@gradial/aci 0.1.52-rc.29 → 0.1.52-rc.34-e2e.1

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 (55) hide show
  1. package/CHANGELOG.md +73 -0
  2. package/README.md +4 -4
  3. package/dist/audience-data-models.d.ts +201 -372
  4. package/dist/audience-data-models.js +283 -120
  5. package/dist/cli/compile-registry.mjs +0 -2
  6. package/dist/collect/aci.js +1 -1
  7. package/dist/collect/aci.js.sha256 +1 -1
  8. package/dist/collect/gapbuffer.js +11 -6
  9. package/dist/internal/platform/collectionstore/errors.d.ts +11 -0
  10. package/dist/internal/platform/collectionstore/errors.js +20 -0
  11. package/dist/next/error-pages.d.ts +4 -0
  12. package/dist/next/error-pages.js +4 -0
  13. package/dist/next/release-context.d.ts +29 -0
  14. package/dist/next/release-context.js +55 -0
  15. package/dist/next/server.d.ts +8 -0
  16. package/dist/next/server.js +14 -1
  17. package/dist/react/VideoPlayer.d.ts +1 -1
  18. package/dist/react/VideoPlayer.js +2 -2
  19. package/dist/react/index.d.ts +2 -2
  20. package/dist/react/index.js +2 -3
  21. package/dist/testing/conformance/preset.js +3 -0
  22. package/dist/testing/conformance/root-identity.js +7 -26
  23. package/dist/testing/conformance/types.d.ts +3 -0
  24. package/dist/types/image.d.ts +54 -17
  25. package/dist/types/image.js +102 -18
  26. package/dist/types/media.d.ts +14 -6
  27. package/dist/types/video.d.ts +7 -3
  28. package/migrations/conformance-registry.md +22 -0
  29. package/package.json +8 -7
  30. package/src/cli/compile-registry.mjs +0 -2
  31. package/src/types/image.ts +133 -29
  32. package/src/types/video.ts +1 -1
  33. package/templates/astro/starter-content/src/fixtures/sections.ts +0 -1
  34. package/templates/common/.agents/skills/aci/SKILL.md +4 -3
  35. package/templates/common/.agents/skills/dam-delivery/SKILL.md +182 -193
  36. package/templates/common/.agents/skills/dam-delivery/references/permalink-grammar.md +137 -101
  37. package/templates/common/starter-content/.content/dam/README.md +74 -0
  38. package/templates/common/starter-content/.content/dam/folders/seed/000000000000000000000000101/governance.json +2 -1
  39. package/templates/common/starter-content/.content/dam/folders/seed/000000000000000000000000102/governance.json +2 -1
  40. package/templates/common/starter-content/.content/patterns/components/button.json +2 -2
  41. package/templates/common/starter-content/.content/patterns/components/container.json +2 -2
  42. package/templates/common/starter-content/.content/patterns/components/feature_card.json +2 -2
  43. package/templates/common/starter-content/.content/patterns/components/feature_grid.json +2 -2
  44. package/templates/common/starter-content/.content/patterns/components/home_hero.json +16 -7
  45. package/templates/common/starter-content/.content/patterns/components/personalization_choice.json +2 -2
  46. package/templates/common/starter-content/.content/patterns/components/site-footer.json +2 -2
  47. package/templates/common/starter-content/.content/patterns/components/site-navigation.json +2 -2
  48. package/templates/common/starter-content/.content/patterns/components/stats_bar.json +2 -2
  49. package/templates/nextjs/template/src/app/fonts/inter/OFL.txt +93 -0
  50. package/templates/nextjs/template/src/app/fonts/inter/inter-latin.woff2 +0 -0
  51. package/templates/nextjs/template/src/app/fonts/space-grotesk/OFL.txt +93 -0
  52. package/templates/nextjs/template/src/app/fonts/space-grotesk/space-grotesk-latin.woff2 +0 -0
  53. package/templates/nextjs/template/src/app/layout.tsx +10 -7
  54. package/templates/nextjs/template/tests/conformance.test.ts.tmpl +1 -0
  55. package/templates/common/.agents/skills/dam-delivery/references/parameter-contract.md +0 -107
@@ -1,13 +1,19 @@
1
1
  import { z } from 'zod';
2
2
  export interface ImageSource {
3
- /** Primary derivative URL for this rendition candidate. */
4
- src: string;
5
- /** Intrinsic width in pixels (0 when the format carries none, e.g. SVG). */
3
+ /** Authored asset path — static sources only; DAM sources never set it. */
4
+ src?: string;
5
+ /** Declared candidate widths for the srcset (DAM sources). Empty or absent
6
+ * when the slot declares no outputs: the SDK renders the bare permalink
7
+ * (no params, original bytes). */
8
+ widths?: number[];
9
+ /** Layout width in pixels — the largest declared width, or the intrinsic
10
+ * width for static/original-aspect sources. */
6
11
  width?: number;
7
- /** Intrinsic height in pixels (0 when the format carries none). */
12
+ /** Layout height in pixels — the height at `width` (aspect carrier). */
8
13
  height?: number;
9
- /** Optional width-descriptor rendition candidates. */
10
- srcset?: string;
14
+ /** Transform fit for DAM sources: 'max' fits inside the box; 'crop' fills
15
+ * the declared aspect and emits an h param per srcset width. */
16
+ fit?: 'max' | 'crop';
11
17
  /** sizes attribute from the slot contract. */
12
18
  sizes?: string;
13
19
  /** Media query scoping this source (art direction). */
@@ -20,8 +26,9 @@ export interface Image {
20
26
  $type: 'gradial.image';
21
27
  /** DAM asset identifier (or "static:<path>" for non-DAM assets). */
22
28
  assetId: string;
23
- /** DAM version identifier (or "static" for non-DAM assets). */
24
- versionId: string;
29
+ /** DAM version identifier — absent for DAM images, which compile to the
30
+ * versionless live URL /dam/{assetId}; never set for static images. */
31
+ versionId?: string;
25
32
  /** Accessible description of the image content. */
26
33
  alt: string;
27
34
  /** <source> candidates produced for the asset slot. */
@@ -38,10 +45,14 @@ export interface SlotOutput {
38
45
  media?: string;
39
46
  }
40
47
  export declare const ImageSourceSchema: z.ZodObject<{
41
- src: z.ZodString;
42
- srcset: z.ZodOptional<z.ZodString>;
48
+ src: z.ZodOptional<z.ZodString>;
49
+ widths: z.ZodOptional<z.ZodArray<z.ZodNumber>>;
43
50
  width: z.ZodOptional<z.ZodNumber>;
44
51
  height: z.ZodOptional<z.ZodNumber>;
52
+ fit: z.ZodOptional<z.ZodEnum<{
53
+ max: "max";
54
+ crop: "crop";
55
+ }>>;
45
56
  sizes: z.ZodOptional<z.ZodString>;
46
57
  media: z.ZodOptional<z.ZodString>;
47
58
  type: z.ZodString;
@@ -49,19 +60,43 @@ export declare const ImageSourceSchema: z.ZodObject<{
49
60
  export declare const ImageSchema: z.ZodObject<{
50
61
  $type: z.ZodLiteral<"gradial.image">;
51
62
  assetId: z.ZodString;
52
- versionId: z.ZodString;
63
+ versionId: z.ZodOptional<z.ZodString>;
53
64
  alt: z.ZodString;
54
65
  sources: z.ZodArray<z.ZodObject<{
55
- src: z.ZodString;
56
- srcset: z.ZodOptional<z.ZodString>;
66
+ src: z.ZodOptional<z.ZodString>;
67
+ widths: z.ZodOptional<z.ZodArray<z.ZodNumber>>;
57
68
  width: z.ZodOptional<z.ZodNumber>;
58
69
  height: z.ZodOptional<z.ZodNumber>;
70
+ fit: z.ZodOptional<z.ZodEnum<{
71
+ max: "max";
72
+ crop: "crop";
73
+ }>>;
59
74
  sizes: z.ZodOptional<z.ZodString>;
60
75
  media: z.ZodOptional<z.ZodString>;
61
76
  type: z.ZodString;
62
77
  }, z.core.$strip>>;
63
78
  }, z.core.$strip>;
79
+ /** Default DAM delivery quality, matching the edge's DEFAULT_QUALITY. */
80
+ export declare const DAM_IMAGE_QUALITY = 82;
81
+ /**
82
+ * Configures the origin for DAM permalinks. The default is the empty string:
83
+ * relative same-origin URLs (`/dam/{assetId}`), served by the site's own
84
+ * hosting edge. Set a base (e.g. `https://assets.example.com`) when images
85
+ * are consumed off-origin — email, external CMS fields, social cards — or
86
+ * when delivery is hosted on a dedicated domain. Call once during app setup,
87
+ * before the first render.
88
+ */
89
+ export declare function configureDamAssets(options?: {
90
+ baseUrl?: string;
91
+ }): void;
92
+ /**
93
+ * The DAM permalink for an asset: `{base}/dam/{assetId}`. No params means
94
+ * original bytes.
95
+ */
96
+ export declare function damAssetUrl(assetId: string): string;
64
97
  export type ImageAttrs = Record<string, unknown>;
98
+ /** True when the image resolves to authored static bytes, not a DAM asset. */
99
+ export declare function isStaticImage(image: Image): boolean;
65
100
  /**
66
101
  * The primary rendition of an image — its first source entry.
67
102
  */
@@ -70,9 +105,11 @@ export declare function primaryImageSource(image: Image): ImageSource;
70
105
  * Computes the full set of <img> attributes for an Image.
71
106
  *
72
107
  * Framework components (React <Picture>, Astro <img>, Svelte <img>) call this
73
- * and spread the result into their native element. No HTML string
74
- * serialization. Media-scoped sources need a <picture> wrapper and are not
75
- * representable as plain <img> attributes, so srcset/sizes are taken from the
76
- * first unscoped source only.
108
+ * and spread the result into the native element. No HTML string
109
+ * serialization. DAM images construct their /dam/{assetId} permalink and
110
+ * srcset here from the slot-declared widths; static images pass their
111
+ * authored src through. Media-scoped sources need a <picture> wrapper and
112
+ * are not representable as plain <img> attributes, so srcset/sizes are taken
113
+ * from the first unscoped source only.
77
114
  */
78
115
  export declare function imageAttrs(image: Image, overrides?: ImageAttrs): ImageAttrs;
@@ -3,10 +3,11 @@ import { z } from 'zod';
3
3
  // Zod schema
4
4
  // ---------------------------------------------------------------------------
5
5
  export const ImageSourceSchema = z.object({
6
- src: z.string().min(1),
7
- srcset: z.string().min(1).optional(),
6
+ src: z.string().min(1).optional(),
7
+ widths: z.array(z.number().int().positive()).optional(),
8
8
  width: z.number().int().nonnegative().optional(),
9
9
  height: z.number().int().nonnegative().optional(),
10
+ fit: z.enum(['max', 'crop']).optional(),
10
11
  sizes: z.string().min(1).optional(),
11
12
  media: z.string().min(1).optional(),
12
13
  type: z.string().min(1),
@@ -14,10 +15,97 @@ export const ImageSourceSchema = z.object({
14
15
  export const ImageSchema = z.object({
15
16
  $type: z.literal('gradial.image'),
16
17
  assetId: z.string().min(1),
17
- versionId: z.string().min(1),
18
+ versionId: z.string().min(1).optional(),
18
19
  alt: z.string(),
19
20
  sources: z.array(ImageSourceSchema).min(1),
20
21
  });
22
+ // ---------------------------------------------------------------------------
23
+ // DAM permalink construction (ADR 0062)
24
+ // ---------------------------------------------------------------------------
25
+ /** Default DAM delivery quality, matching the edge's DEFAULT_QUALITY. */
26
+ export const DAM_IMAGE_QUALITY = 82;
27
+ let damAssetBase = '';
28
+ /**
29
+ * Configures the origin for DAM permalinks. The default is the empty string:
30
+ * relative same-origin URLs (`/dam/{assetId}`), served by the site's own
31
+ * hosting edge. Set a base (e.g. `https://assets.example.com`) when images
32
+ * are consumed off-origin — email, external CMS fields, social cards — or
33
+ * when delivery is hosted on a dedicated domain. Call once during app setup,
34
+ * before the first render.
35
+ */
36
+ export function configureDamAssets(options = {}) {
37
+ damAssetBase = (options.baseUrl ?? '').replace(/\/+$/, '');
38
+ }
39
+ /**
40
+ * The DAM permalink for an asset: `{base}/dam/{assetId}`. No params means
41
+ * original bytes.
42
+ */
43
+ export function damAssetUrl(assetId) {
44
+ return `${damAssetBase}/dam/${assetId}`;
45
+ }
46
+ const FORMAT_PARAMS = {
47
+ 'image/avif': 'avif',
48
+ 'image/webp': 'webp',
49
+ 'image/jpeg': 'jpg',
50
+ 'image/png': 'png',
51
+ };
52
+ function sourceFormatParams(source) {
53
+ const fm = FORMAT_PARAMS[source.type] ?? 'webp';
54
+ return `fm=${fm}&q=${DAM_IMAGE_QUALITY}`;
55
+ }
56
+ function srcsetWidth(assetId, source, width) {
57
+ const params = [`w=${width}`];
58
+ if (source.fit === 'crop' && source.width && source.height) {
59
+ const height = Math.round((width * source.height) / source.width);
60
+ params.push(`h=${height}`, 'fit=crop');
61
+ }
62
+ else {
63
+ params.push('fit=max');
64
+ }
65
+ params.push(sourceFormatParams(source));
66
+ return `${damAssetUrl(assetId)}?${params.join('&')} ${width}w`;
67
+ }
68
+ function damImageAttrs(image, primary) {
69
+ const base = {
70
+ src: damAssetUrl(image.assetId),
71
+ alt: image.alt,
72
+ width: primary.width && primary.width > 0 ? primary.width : undefined,
73
+ height: primary.height && primary.height > 0 ? primary.height : undefined,
74
+ loading: 'lazy',
75
+ decoding: 'async',
76
+ };
77
+ if (primary.widths && primary.widths.length > 0) {
78
+ // Primary src at the largest declared width; srcset covers them all.
79
+ const largest = primary.widths[primary.widths.length - 1];
80
+ const params = [`w=${largest}`];
81
+ if (primary.fit === 'crop' && primary.width && primary.height) {
82
+ params.push(`h=${Math.round((largest * primary.height) / primary.width)}`, 'fit=crop');
83
+ }
84
+ else {
85
+ params.push('fit=max');
86
+ }
87
+ params.push(sourceFormatParams(primary));
88
+ base.src = `${damAssetUrl(image.assetId)}?${params.join('&')}`;
89
+ if (primary.widths.length > 1) {
90
+ base.srcSet = primary.widths.map((w) => srcsetWidth(image.assetId, primary, w)).join(', ');
91
+ }
92
+ }
93
+ return base;
94
+ }
95
+ function staticImageAttrs(image, primary) {
96
+ return {
97
+ src: primary.src ?? '',
98
+ alt: image.alt,
99
+ width: primary.width && primary.width > 0 ? primary.width : undefined,
100
+ height: primary.height && primary.height > 0 ? primary.height : undefined,
101
+ loading: 'lazy',
102
+ decoding: 'async',
103
+ };
104
+ }
105
+ /** True when the image resolves to authored static bytes, not a DAM asset. */
106
+ export function isStaticImage(image) {
107
+ return image.assetId.startsWith('static:');
108
+ }
21
109
  /**
22
110
  * The primary rendition of an image — its first source entry.
23
111
  */
@@ -28,23 +116,19 @@ export function primaryImageSource(image) {
28
116
  * Computes the full set of <img> attributes for an Image.
29
117
  *
30
118
  * Framework components (React <Picture>, Astro <img>, Svelte <img>) call this
31
- * and spread the result into their native element. No HTML string
32
- * serialization. Media-scoped sources need a <picture> wrapper and are not
33
- * representable as plain <img> attributes, so srcset/sizes are taken from the
34
- * first unscoped source only.
119
+ * and spread the result into the native element. No HTML string
120
+ * serialization. DAM images construct their /dam/{assetId} permalink and
121
+ * srcset here from the slot-declared widths; static images pass their
122
+ * authored src through. Media-scoped sources need a <picture> wrapper and
123
+ * are not representable as plain <img> attributes, so srcset/sizes are taken
124
+ * from the first unscoped source only.
35
125
  */
36
126
  export function imageAttrs(image, overrides = {}) {
37
127
  const primary = image.sources[0];
38
128
  const unscoped = image.sources.find((s) => !s.media) ?? primary;
39
- return {
40
- src: primary.src,
41
- alt: image.alt,
42
- width: primary.width && primary.width > 0 ? primary.width : undefined,
43
- height: primary.height && primary.height > 0 ? primary.height : undefined,
44
- srcSet: unscoped?.srcset || undefined,
45
- sizes: unscoped?.sizes || undefined,
46
- loading: 'lazy',
47
- decoding: 'async',
48
- ...overrides,
49
- };
129
+ const base = isStaticImage(image) ? staticImageAttrs(image, primary) : damImageAttrs(image, primary);
130
+ if (unscoped?.sizes) {
131
+ base.sizes = unscoped.sizes;
132
+ }
133
+ return { ...base, ...overrides };
50
134
  }
@@ -7,13 +7,17 @@ export type Asset = Image | Video;
7
7
  export declare const AssetSchema: z.ZodUnion<readonly [z.ZodObject<{
8
8
  $type: z.ZodLiteral<"gradial.image">;
9
9
  assetId: z.ZodString;
10
- versionId: z.ZodString;
10
+ versionId: z.ZodOptional<z.ZodString>;
11
11
  alt: z.ZodString;
12
12
  sources: z.ZodArray<z.ZodObject<{
13
- src: z.ZodString;
14
- srcset: z.ZodOptional<z.ZodString>;
13
+ src: z.ZodOptional<z.ZodString>;
14
+ widths: z.ZodOptional<z.ZodArray<z.ZodNumber>>;
15
15
  width: z.ZodOptional<z.ZodNumber>;
16
16
  height: z.ZodOptional<z.ZodNumber>;
17
+ fit: z.ZodOptional<z.ZodEnum<{
18
+ max: "max";
19
+ crop: "crop";
20
+ }>>;
17
21
  sizes: z.ZodOptional<z.ZodString>;
18
22
  media: z.ZodOptional<z.ZodString>;
19
23
  type: z.ZodString;
@@ -30,13 +34,17 @@ export declare const AssetSchema: z.ZodUnion<readonly [z.ZodObject<{
30
34
  poster: z.ZodOptional<z.ZodObject<{
31
35
  $type: z.ZodLiteral<"gradial.image">;
32
36
  assetId: z.ZodString;
33
- versionId: z.ZodString;
37
+ versionId: z.ZodOptional<z.ZodString>;
34
38
  alt: z.ZodString;
35
39
  sources: z.ZodArray<z.ZodObject<{
36
- src: z.ZodString;
37
- srcset: z.ZodOptional<z.ZodString>;
40
+ src: z.ZodOptional<z.ZodString>;
41
+ widths: z.ZodOptional<z.ZodArray<z.ZodNumber>>;
38
42
  width: z.ZodOptional<z.ZodNumber>;
39
43
  height: z.ZodOptional<z.ZodNumber>;
44
+ fit: z.ZodOptional<z.ZodEnum<{
45
+ max: "max";
46
+ crop: "crop";
47
+ }>>;
40
48
  sizes: z.ZodOptional<z.ZodString>;
41
49
  media: z.ZodOptional<z.ZodString>;
42
50
  type: z.ZodString;
@@ -50,13 +50,17 @@ export declare const VideoSchema: z.ZodObject<{
50
50
  poster: z.ZodOptional<z.ZodObject<{
51
51
  $type: z.ZodLiteral<"gradial.image">;
52
52
  assetId: z.ZodString;
53
- versionId: z.ZodString;
53
+ versionId: z.ZodOptional<z.ZodString>;
54
54
  alt: z.ZodString;
55
55
  sources: z.ZodArray<z.ZodObject<{
56
- src: z.ZodString;
57
- srcset: z.ZodOptional<z.ZodString>;
56
+ src: z.ZodOptional<z.ZodString>;
57
+ widths: z.ZodOptional<z.ZodArray<z.ZodNumber>>;
58
58
  width: z.ZodOptional<z.ZodNumber>;
59
59
  height: z.ZodOptional<z.ZodNumber>;
60
+ fit: z.ZodOptional<z.ZodEnum<{
61
+ max: "max";
62
+ crop: "crop";
63
+ }>>;
60
64
  sizes: z.ZodOptional<z.ZodString>;
61
65
  media: z.ZodOptional<z.ZodString>;
62
66
  type: z.ZodString;
@@ -0,0 +1,22 @@
1
+ # Designate the render registry for Next conformance
2
+
3
+ Set `registry` in the site's conformance options to the module its page entry
4
+ uses to create the ACI page:
5
+
6
+ ```ts
7
+ import { conformancePreset } from '@gradial/aci/testing';
8
+
9
+ conformancePreset({
10
+ framework: 'next',
11
+ registry: 'src/cms/registry.ts',
12
+ });
13
+ ```
14
+
15
+ Paths resolve against `rootDir`, which defaults to the test process's current
16
+ working directory. Absolute paths also work. Use the app's full render registry,
17
+ including every registered component. The named module must export the registry.
18
+
19
+ Run the site's `npm test`. The `rootIdentity` check renders components from the
20
+ named module and fails if a rendered root omits its ACI identity attributes.
21
+ Missing configuration or a missing module fails before importing site code.
22
+ Astro's existing authored-block scan does not require this option.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gradial/aci",
3
- "version": "0.1.52-rc.29",
3
+ "version": "0.1.52-rc.34-e2e.1",
4
4
  "repository": {
5
5
  "type": "git",
6
6
  "url": "https://github.com/gradial/bare-metal-go.git",
@@ -20,6 +20,7 @@
20
20
  "templates/nextjs",
21
21
  "templates/astro",
22
22
  "CHANGELOG.md",
23
+ "migrations",
23
24
  "README.md"
24
25
  ],
25
26
  "bin": {
@@ -159,11 +160,11 @@
159
160
  "zod": "^4.0.0"
160
161
  },
161
162
  "optionalDependencies": {
162
- "@gradial/aci-cli-darwin-arm64": "0.1.52-rc.29",
163
- "@gradial/aci-cli-darwin-x64": "0.1.52-rc.29",
164
- "@gradial/aci-cli-linux-arm64": "0.1.52-rc.29",
165
- "@gradial/aci-cli-linux-x64": "0.1.52-rc.29",
166
- "@gradial/aci-cli-win32-arm64": "0.1.52-rc.29",
167
- "@gradial/aci-cli-win32-x64": "0.1.52-rc.29"
163
+ "@gradial/aci-cli-darwin-arm64": "0.1.52-rc.34-e2e.1",
164
+ "@gradial/aci-cli-darwin-x64": "0.1.52-rc.34-e2e.1",
165
+ "@gradial/aci-cli-linux-arm64": "0.1.52-rc.34-e2e.1",
166
+ "@gradial/aci-cli-linux-x64": "0.1.52-rc.34-e2e.1",
167
+ "@gradial/aci-cli-win32-arm64": "0.1.52-rc.34-e2e.1",
168
+ "@gradial/aci-cli-win32-x64": "0.1.52-rc.34-e2e.1"
168
169
  }
169
170
  }
@@ -346,12 +346,10 @@ function isImageSchema(schema) {
346
346
  return compiled?.type === 'object'
347
347
  && properties.$type?.const === 'gradial.image'
348
348
  && properties.assetId?.type === 'string'
349
- && properties.versionId?.type === 'string'
350
349
  && properties.alt?.type === 'string'
351
350
  && properties.sources?.type === 'array'
352
351
  && required.has('$type')
353
352
  && required.has('assetId')
354
- && required.has('versionId')
355
353
  && required.has('alt')
356
354
  && required.has('sources');
357
355
  } catch {
@@ -3,19 +3,28 @@ import { z } from 'zod';
3
3
  // ---------------------------------------------------------------------------
4
4
  // Image source variants
5
5
  //
6
- // Each source provides a primary src and may include a ready-made srcset
7
- // scoped by the optional media/sizes of the slot output that produced it.
6
+ // A DAM source carries the asset identity's slot presentation — the declared
7
+ // candidate widths, the fit, and the MIME format — and NEVER a URL: the SDK
8
+ // constructs the /dam/{assetId} permalink and its srcset at render time
9
+ // (ADR 0062: the compiler emits asset ids only). Static assets keep their
10
+ // authored src passthrough.
8
11
  // ---------------------------------------------------------------------------
9
12
 
10
13
  export interface ImageSource {
11
- /** Primary derivative URL for this rendition candidate. */
12
- src: string;
13
- /** Intrinsic width in pixels (0 when the format carries none, e.g. SVG). */
14
+ /** Authored asset path — static sources only; DAM sources never set it. */
15
+ src?: string;
16
+ /** Declared candidate widths for the srcset (DAM sources). Empty or absent
17
+ * when the slot declares no outputs: the SDK renders the bare permalink
18
+ * (no params, original bytes). */
19
+ widths?: number[];
20
+ /** Layout width in pixels — the largest declared width, or the intrinsic
21
+ * width for static/original-aspect sources. */
14
22
  width?: number;
15
- /** Intrinsic height in pixels (0 when the format carries none). */
23
+ /** Layout height in pixels — the height at `width` (aspect carrier). */
16
24
  height?: number;
17
- /** Optional width-descriptor rendition candidates. */
18
- srcset?: string;
25
+ /** Transform fit for DAM sources: 'max' fits inside the box; 'crop' fills
26
+ * the declared aspect and emits an h param per srcset width. */
27
+ fit?: 'max' | 'crop';
19
28
  /** sizes attribute from the slot contract. */
20
29
  sizes?: string;
21
30
  /** Media query scoping this source (art direction). */
@@ -25,7 +34,7 @@ export interface ImageSource {
25
34
  }
26
35
 
27
36
  // ---------------------------------------------------------------------------
28
- // Image — DAM-backed image asset with compiler-resolved derivatives
37
+ // Image — DAM-backed image asset with compiler-resolved live asset URLs
29
38
  //
30
39
  // Content authors write an asset reference: { "$type": "dam.assetRef", ... }
31
40
  // or for static files: { "$type": "static.assetRef", "src": "/path" }
@@ -37,8 +46,9 @@ export interface Image {
37
46
  $type: 'gradial.image';
38
47
  /** DAM asset identifier (or "static:<path>" for non-DAM assets). */
39
48
  assetId: string;
40
- /** DAM version identifier (or "static" for non-DAM assets). */
41
- versionId: string;
49
+ /** DAM version identifier — absent for DAM images, which compile to the
50
+ * versionless live URL /dam/{assetId}; never set for static images. */
51
+ versionId?: string;
42
52
  /** Accessible description of the image content. */
43
53
  alt: string;
44
54
  /** <source> candidates produced for the asset slot. */
@@ -66,10 +76,11 @@ export interface SlotOutput {
66
76
  // ---------------------------------------------------------------------------
67
77
 
68
78
  export const ImageSourceSchema = z.object({
69
- src: z.string().min(1),
70
- srcset: z.string().min(1).optional(),
79
+ src: z.string().min(1).optional(),
80
+ widths: z.array(z.number().int().positive()).optional(),
71
81
  width: z.number().int().nonnegative().optional(),
72
82
  height: z.number().int().nonnegative().optional(),
83
+ fit: z.enum(['max', 'crop']).optional(),
73
84
  sizes: z.string().min(1).optional(),
74
85
  media: z.string().min(1).optional(),
75
86
  type: z.string().min(1),
@@ -78,17 +89,113 @@ export const ImageSourceSchema = z.object({
78
89
  export const ImageSchema = z.object({
79
90
  $type: z.literal('gradial.image'),
80
91
  assetId: z.string().min(1),
81
- versionId: z.string().min(1),
92
+ versionId: z.string().min(1).optional(),
82
93
  alt: z.string(),
83
94
  sources: z.array(ImageSourceSchema).min(1),
84
95
  });
85
96
 
97
+ // ---------------------------------------------------------------------------
98
+ // DAM permalink construction (ADR 0062)
99
+ // ---------------------------------------------------------------------------
100
+
101
+ /** Default DAM delivery quality, matching the edge's DEFAULT_QUALITY. */
102
+ export const DAM_IMAGE_QUALITY = 82;
103
+
104
+ let damAssetBase = '';
105
+
106
+ /**
107
+ * Configures the origin for DAM permalinks. The default is the empty string:
108
+ * relative same-origin URLs (`/dam/{assetId}`), served by the site's own
109
+ * hosting edge. Set a base (e.g. `https://assets.example.com`) when images
110
+ * are consumed off-origin — email, external CMS fields, social cards — or
111
+ * when delivery is hosted on a dedicated domain. Call once during app setup,
112
+ * before the first render.
113
+ */
114
+ export function configureDamAssets(options: { baseUrl?: string } = {}): void {
115
+ damAssetBase = (options.baseUrl ?? '').replace(/\/+$/, '');
116
+ }
117
+
118
+ /**
119
+ * The DAM permalink for an asset: `{base}/dam/{assetId}`. No params means
120
+ * original bytes.
121
+ */
122
+ export function damAssetUrl(assetId: string): string {
123
+ return `${damAssetBase}/dam/${assetId}`;
124
+ }
125
+
126
+ const FORMAT_PARAMS: Record<string, string> = {
127
+ 'image/avif': 'avif',
128
+ 'image/webp': 'webp',
129
+ 'image/jpeg': 'jpg',
130
+ 'image/png': 'png',
131
+ };
132
+
133
+ function sourceFormatParams(source: ImageSource): string {
134
+ const fm = FORMAT_PARAMS[source.type] ?? 'webp';
135
+ return `fm=${fm}&q=${DAM_IMAGE_QUALITY}`;
136
+ }
137
+
138
+ function srcsetWidth(assetId: string, source: ImageSource, width: number): string {
139
+ const params = [`w=${width}`];
140
+ if (source.fit === 'crop' && source.width && source.height) {
141
+ const height = Math.round((width * source.height) / source.width);
142
+ params.push(`h=${height}`, 'fit=crop');
143
+ } else {
144
+ params.push('fit=max');
145
+ }
146
+ params.push(sourceFormatParams(source));
147
+ return `${damAssetUrl(assetId)}?${params.join('&')} ${width}w`;
148
+ }
149
+
150
+ function damImageAttrs(image: Image, primary: ImageSource): ImageAttrs {
151
+ const base: ImageAttrs = {
152
+ src: damAssetUrl(image.assetId),
153
+ alt: image.alt,
154
+ width: primary.width && primary.width > 0 ? primary.width : undefined,
155
+ height: primary.height && primary.height > 0 ? primary.height : undefined,
156
+ loading: 'lazy',
157
+ decoding: 'async',
158
+ };
159
+ if (primary.widths && primary.widths.length > 0) {
160
+ // Primary src at the largest declared width; srcset covers them all.
161
+ const largest = primary.widths[primary.widths.length - 1];
162
+ const params = [`w=${largest}`];
163
+ if (primary.fit === 'crop' && primary.width && primary.height) {
164
+ params.push(`h=${Math.round((largest * primary.height) / primary.width)}`, 'fit=crop');
165
+ } else {
166
+ params.push('fit=max');
167
+ }
168
+ params.push(sourceFormatParams(primary));
169
+ base.src = `${damAssetUrl(image.assetId)}?${params.join('&')}`;
170
+ if (primary.widths.length > 1) {
171
+ base.srcSet = primary.widths.map((w) => srcsetWidth(image.assetId, primary, w)).join(', ');
172
+ }
173
+ }
174
+ return base;
175
+ }
176
+
177
+ function staticImageAttrs(image: Image, primary: ImageSource): ImageAttrs {
178
+ return {
179
+ src: primary.src ?? '',
180
+ alt: image.alt,
181
+ width: primary.width && primary.width > 0 ? primary.width : undefined,
182
+ height: primary.height && primary.height > 0 ? primary.height : undefined,
183
+ loading: 'lazy',
184
+ decoding: 'async',
185
+ };
186
+ }
187
+
86
188
  // ---------------------------------------------------------------------------
87
189
  // Shared attrs helper — consumed by React, Astro, and Svelte components
88
190
  // ---------------------------------------------------------------------------
89
191
 
90
192
  export type ImageAttrs = Record<string, unknown>;
91
193
 
194
+ /** True when the image resolves to authored static bytes, not a DAM asset. */
195
+ export function isStaticImage(image: Image): boolean {
196
+ return image.assetId.startsWith('static:');
197
+ }
198
+
92
199
  /**
93
200
  * The primary rendition of an image — its first source entry.
94
201
  */
@@ -100,24 +207,21 @@ export function primaryImageSource(image: Image): ImageSource {
100
207
  * Computes the full set of <img> attributes for an Image.
101
208
  *
102
209
  * Framework components (React <Picture>, Astro <img>, Svelte <img>) call this
103
- * and spread the result into their native element. No HTML string
104
- * serialization. Media-scoped sources need a <picture> wrapper and are not
105
- * representable as plain <img> attributes, so srcset/sizes are taken from the
106
- * first unscoped source only.
210
+ * and spread the result into the native element. No HTML string
211
+ * serialization. DAM images construct their /dam/{assetId} permalink and
212
+ * srcset here from the slot-declared widths; static images pass their
213
+ * authored src through. Media-scoped sources need a <picture> wrapper and
214
+ * are not representable as plain <img> attributes, so srcset/sizes are taken
215
+ * from the first unscoped source only.
107
216
  */
108
217
  export function imageAttrs(image: Image, overrides: ImageAttrs = {}): ImageAttrs {
109
218
  const primary = image.sources[0];
110
219
  const unscoped = image.sources.find((s) => !s.media) ?? primary;
111
220
 
112
- return {
113
- src: primary.src,
114
- alt: image.alt,
115
- width: primary.width && primary.width > 0 ? primary.width : undefined,
116
- height: primary.height && primary.height > 0 ? primary.height : undefined,
117
- srcSet: unscoped?.srcset || undefined,
118
- sizes: unscoped?.sizes || undefined,
119
- loading: 'lazy',
120
- decoding: 'async',
121
- ...overrides,
122
- };
221
+ const base = isStaticImage(image) ? staticImageAttrs(image, primary) : damImageAttrs(image, primary);
222
+
223
+ if (unscoped?.sizes) {
224
+ base.sizes = unscoped.sizes;
225
+ }
226
+ return { ...base, ...overrides };
123
227
  }
@@ -11,7 +11,7 @@ export interface VideoSource {
11
11
  }
12
12
 
13
13
  // ---------------------------------------------------------------------------
14
- // Video — DAM-backed video asset with compiler-resolved derivatives
14
+ // Video — DAM-backed video asset with compiler-resolved live asset URLs
15
15
  //
16
16
  // Content authors write: { "$type": "dam.assetRef", ... }
17
17
  // or for static files: { "$type": "static.assetRef", "src": "/path" }
@@ -26,7 +26,6 @@ export const sectionStoryFixtures: SectionStoryFixture[] = [
26
26
  image: {
27
27
  $type: 'gradial.image',
28
28
  assetId: '3Ih26etWBTYfnAd0EjD0pP2h902',
29
- versionId: 'v0001',
30
29
  alt: 'Seattle skyline at dusk',
31
30
  sources: [
32
31
  { src: '/assets/seattle.avif', width: 1920, height: 1280, sizes: '100vw', type: 'image/avif' }
@@ -262,6 +262,7 @@ site or design into ACI.
262
262
 
263
263
  ## Deep Dive: DAM Delivery
264
264
 
265
- Read `.agents/skills/dam-delivery/SKILL.md` for signed on-demand image
266
- transform URLs: when to mint them, the closed parameter contract, expiry
267
- semantics, and why they must never be baked into published payloads.
265
+ Read `.agents/skills/dam-delivery/SKILL.md` for public DAM asset links
266
+ (`/dam/{assetId}`): how to shape an image with the `w`, `h`, `fit`, `fm`,
267
+ `q` query, what makes a link serve or stop serving, and when to use a
268
+ link instead of a pre-baked derivative.