@salesforce/afv-skills 1.56.0 → 1.57.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 (55) hide show
  1. package/package.json +1 -1
  2. package/skills/commerce-b2b-open-code-components-integrate/SKILL.md +1 -1
  3. package/skills/commerce-b2b-open-code-components-replace/SKILL.md +16 -16
  4. package/skills/dx-devops-project-manage/SKILL.md +1 -1
  5. package/skills/experience-cms-content-generate/SKILL.md +1 -1
  6. package/skills/experience-cms-content-render/SKILL.md +3 -3
  7. package/skills/experience-cms-content-render/assets/angular/MediaRenderer.component.ts +35 -7
  8. package/skills/experience-cms-content-render/assets/angular/cms-item.service.ts +13 -4
  9. package/skills/experience-cms-content-render/assets/react/MediaRenderer.tsx +30 -8
  10. package/skills/experience-cms-content-render/assets/react/useCmsItem.ts +14 -4
  11. package/skills/experience-cms-content-render/assets/shared/cmsCore.types.ts +11 -1
  12. package/skills/experience-cms-content-render/assets/shared/externalRefs.ts +1 -1
  13. package/skills/experience-cms-content-render/assets/shared/mediaLabels.ts +62 -0
  14. package/skills/experience-cms-content-render/references/init-scaffold.md +13 -6
  15. package/skills/experience-content-media-stock-image-search/SKILL.md +0 -1
  16. package/skills/experience-lwc-design-generate/SKILL.md +1 -1
  17. package/skills/experience-lwc-legacy-migrate/SKILL.md +212 -0
  18. package/skills/experience-lwc-legacy-migrate/assets/lo20-host-page-template.html +192 -0
  19. package/skills/experience-lwc-legacy-migrate/references/aura-to-lwc-completeness-checklist.md +188 -0
  20. package/skills/experience-lwc-legacy-migrate/references/lightning-out-2-system-reference.md +495 -0
  21. package/skills/experience-lwc-legacy-migrate/references/lightning-out-beta-to-2-migration.md +824 -0
  22. package/skills/experience-lwc-legacy-migrate/scripts/convert-lo-names.py +94 -0
  23. package/skills/experience-lwc-legacy-migrate/scripts/validate-lo20-page.py +133 -0
  24. package/skills/experience-ui-bundle-project-generate/SKILL.md +1 -1
  25. package/skills/integration-connectivity-connected-app-configure/SKILL.md +5 -5
  26. package/skills/integration-connectivity-generate/SKILL.md +10 -10
  27. package/skills/integration-connectivity-generate/scripts/configure-named-credential.sh +2 -2
  28. package/skills/platform-apex-test-generate/SKILL.md +1 -1
  29. package/skills/platform-apex-test-run/SKILL.md +1 -1
  30. package/skills/platform-custom-field-generate/SKILL.md +1 -1
  31. package/skills/platform-custom-lightning-type-generate/SKILL.md +1 -1
  32. package/skills/platform-custom-metadata-type-generate/SKILL.md +1 -1
  33. package/skills/platform-custom-object-generate/SKILL.md +1 -1
  34. package/skills/platform-custom-report-type-generate/SKILL.md +1 -1
  35. package/skills/platform-custom-setting-generate/SKILL.md +1 -1
  36. package/skills/platform-data-and-tooling-api-context-get/SKILL.md +1 -1
  37. package/skills/platform-data-manage/SKILL.md +10 -10
  38. package/skills/platform-flexipage-generate/SKILL.md +1 -1
  39. package/skills/platform-lightning-app-coordinate/SKILL.md +1 -1
  40. package/skills/platform-metadata-api-context-get/SKILL.md +1 -1
  41. package/skills/platform-metadata-retrieve/SKILL.md +1 -1
  42. package/skills/platform-permission-set-generate/SKILL.md +1 -1
  43. package/skills/platform-report-generate/SKILL.md +1 -1
  44. package/skills/platform-salesforce-connect-adapter-generate/SKILL.md +4 -4
  45. package/skills/platform-sharing-owd-configure/SKILL.md +1 -1
  46. package/skills/platform-sharing-rules-generate/SKILL.md +1 -1
  47. package/skills/platform-soql-query/SKILL.md +1 -1
  48. package/skills/platform-value-set-generate/SKILL.md +1 -1
  49. package/skills/service-agentforce-human-escalation-configure/SKILL.md +4 -0
  50. package/skills/service-agentforce-human-escalation-configure/scripts/tests/_bootstrap.py +26 -6
  51. package/skills/service-agentforce-human-escalation-configure/scripts/tests/test_escalation_contracts.py +5 -4
  52. package/skills/service-de-waba-integrate/SKILL.md +2 -1
  53. package/skills/service-digital-engagement-deployment-configure/SKILL.md +2 -0
  54. package/skills/service-digital-engagement-deployment-configure/scripts/check-api-version.sh +29 -0
  55. package/skills/service-email-to-case-configure/SKILL.md +1 -1
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@salesforce/afv-skills",
3
- "version": "1.56.0",
3
+ "version": "1.57.0",
4
4
  "description": "Salesforce skills for Agentforce Vibes",
5
5
  "license": "CC-BY-NC-4.0",
6
6
  "files": [
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  name: commerce-b2b-open-code-components-integrate
3
3
  description: "Integrate Salesforce B2B Commerce open source components from GitHub into B2B Commerce stores. Use when users mention \"integrate open code components\", \"open source B2B commerce\", \"add open code components\", \"forcedotcom/b2b-commerce-open-source-components\", or want to add open source commerce components to their store. Copies all components and labels so they become available in Experience Builder."
4
- allowed-tools: Bash(git clone:*) Bash(cp:*) Read
4
+ allowed-tools: Bash(git clone:*) Bash(cp:*) Bash(sf org list:*) Bash(sf config get:*) Bash(sf project retrieve start:*) Read
5
5
  metadata:
6
6
  version: "1.0"
7
7
  domains: ["Commerce", "Experience"]
@@ -3,10 +3,10 @@ name: commerce-b2b-open-code-components-replace
3
3
  description: "Replace OOTB (out-of-the-box) B2B Commerce components with open source equivalents in site metadata content.json files, or look up the equivalent open code `site:` component for OOTB definitions. Use when users mention \"replace OOTB components\", \"replace commerce components with open code\", \"swap OOTB for open source\", \"replace commerce_builder:\", \"replace OOTB in site\", \"replace component in site metadata\", \"replace component definition\", \"find open code equivalent\", \"equivalent open code component\", \"OOTB to open code mapping\", \"what is the site component for\", components \"in this view\" or \"for a given view\", or a specific list of component names — and want to update or only discover mappings in their store metadata."
4
4
  allowed-tools: Bash(grep:*) Bash(ls:*) Read Write
5
5
  metadata:
6
- relatedSkills:
7
- - "commerce-b2b-open-code-components-integrate"
8
6
  version: "1.0"
9
7
  domains: ["Commerce", "Experience"]
8
+ relatedSkills:
9
+ - "commerce-b2b-open-code-components-integrate"
10
10
  ---
11
11
 
12
12
  # Replacing OOTB B2B Commerce Components with Open Code
@@ -94,10 +94,10 @@ Then present the replaceable components as a **multi-select list** so the user c
94
94
  ```text
95
95
  Which components would you like to replace?
96
96
 
97
- ☐ commerce_builder:heading → site:productHeading
98
- ☐ commerce_builder:cartBadge → site:cartBadge
99
- ☐ commerce_builder:searchInput → site:searchInput
100
- ☐ All of the above
97
+ [ ] commerce_builder:heading → site:productHeading
98
+ [ ] commerce_builder:cartBadge → site:cartBadge
99
+ [ ] commerce_builder:searchInput → site:searchInput
100
+ [ ] All of the above
101
101
  ```
102
102
 
103
103
  If user provided specific component name(s) in the original request, pre-filter to those and skip the selection prompt.
@@ -119,7 +119,7 @@ The affected files are already known from Step 1. For each file that contains se
119
119
  ### Step 3: Report
120
120
 
121
121
  ```text
122
- ✅ Replacement Complete!
122
+ Replacement Complete!
123
123
 
124
124
  Replaced X components across Y files:
125
125
  - commerce_builder:heading → site:productHeading (3 files)
@@ -152,9 +152,9 @@ Next Steps:
152
152
  **User:** "Replace OOTB components with open code versions"
153
153
 
154
154
  **Agent:** "Before replacing components, I need to verify that the open code components are set up in your store. Let me check..."
155
- - ✓ Open source repo cloned
156
- - ✓ Store metadata available for My_B2B_Store1
157
- - ✓ Open code components are integrated
155
+ - [done] Open source repo cloned
156
+ - [done] Store metadata available for My_B2B_Store1
157
+ - [done] Open code components are integrated
158
158
 
159
159
  **Agent:** "I'm scanning your store's site metadata to find all OOTB commerce components and checking which have open code equivalents."
160
160
 
@@ -174,11 +174,11 @@ No mapping available (not in mapping table):
174
174
  ```text
175
175
  Which components would you like to replace?
176
176
 
177
- ☐ commerce_builder:heading → site:productHeading
178
- ☐ commerce_builder:cartBadge → site:cartBadge
179
- ☐ commerce_builder:searchInput → site:searchInput
180
- ☐ commerce_builder:cartSummary → site:cartSummary
181
- ☐ All of the above
177
+ [ ] commerce_builder:heading → site:productHeading
178
+ [ ] commerce_builder:cartBadge → site:cartBadge
179
+ [ ] commerce_builder:searchInput → site:searchInput
180
+ [ ] commerce_builder:cartSummary → site:cartSummary
181
+ [ ] All of the above
182
182
  ```
183
183
 
184
184
  **User:** _(selects heading and cartBadge)_
@@ -188,7 +188,7 @@ Which components would you like to replace?
188
188
  _(Files already known from scan — one Read/Write pass per affected file, all replacements batched)_
189
189
 
190
190
  ```text
191
- ✅ Replacement Complete!
191
+ Replacement Complete!
192
192
 
193
193
  Replaced 2 components across 5 files:
194
194
  - commerce_builder:heading → site:productHeading (3 files)
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: dx-devops-project-manage
3
- description: "Use this skill to list, view, or manage DevOps Center projects in a Salesforce org — show all projects, create a new project, or update an existing project's name, description, or active status. Invoke it to run sf devops project list whenever the user wants to show, see, view, display, or list available projects, check which or how many DevOps Center projects exist, or look up a project ID — even a plain listing request should use this skill rather than a raw tool or a direct answer. Also invoke to create a project, set up a new deployment pipeline foundation, initialize DevOps Center for a new feature, update project settings like name and description, or activate/deactivate (archive) a project. Consolidates sf devops project list/create/update operations. DO NOT TRIGGER for work item, pipeline, promotion, or conflict operations — those are separate skills."
3
+ description: "Use to list, view, or manage DevOps Center projects in a Salesforce org — show all projects, create a project, or update an existing project's name, description, or active status. Invoke to run sf devops project list on any listing request, or to create a project, set up a new pipeline foundation, initialize DevOps Center for a feature, update project settings, or activate/deactivate (archive) a project. Consolidates sf devops project list/create/update. DO NOT TRIGGER for work item, pipeline, promotion, or conflict operations — those are separate skills."
4
4
  metadata:
5
5
  version: "1.0"
6
6
  domains: ["Developer Experience"]
@@ -6,8 +6,8 @@ metadata:
6
6
  domains: ["Experience"]
7
7
  minApiVersion: "64.0"
8
8
  relatedSkills:
9
- - "experience-cms-content-type-generate"
10
9
  - "experience-cms-content-render"
10
+ - "experience-cms-content-type-generate"
11
11
  cliTools:
12
12
  - tool: ["sf"]
13
13
  semver: ">=2.0.0"
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: experience-cms-content-render
3
- description: "Renders, embeds, or displays existing Salesforce CMS content in a React or Angular uiBundle app: installs the CMS toolkit package, registers a typed reference, generates a framework-matched renderer from the live schema, and wires the render path (delivery API, contentKey, RichText, image resolution). Use it — and prefer it over general UI-bundle skills like experience-ui-bundle-frontend-generate, which own only the page/layout shell — for the CMS-rendering part of a prompt, even when editing files under src/. Triggers on 'render the CMS news article on the home page', 'embed the CMS blog post', 'display the featured product in the hero', 'show the blog post on a detail page', 'render the CMS video on the home page', 'show the CMS image in the hero'; if a prompt mixes page/layout work with CMS rendering, activate for the CMS part. Do not trigger when only searching for a CMS item, or when defining a new CMS content type/schema (use experience-cms-content-type-generate)."
3
+ description: "Renders, embeds, or displays existing Salesforce CMS content in a React or Angular uiBundle app: installs the CMS toolkit, registers a typed reference, generates a framework-matched renderer from the live schema, and wires the render path (delivery API, contentKey, RichText, image resolution). Prefer over general UI-bundle skills like experience-ui-bundle-frontend-generate (page/layout shell only) for the CMS-rendering part, even under src/. Triggers on prompts like 'render the CMS article on the home page', 'embed this CMS blog post here', 'show the CMS video in the hero', 'display the CMS image in the banner', or 'add this CMS content block to the detail page'; for mixed prompts, activate for the CMS part only. Do not trigger for searching a CMS item or defining a new CMS content type/schema (use experience-cms-content-type-generate)."
4
4
  metadata:
5
5
  version: "2.1"
6
6
  domains: ["Experience"]
@@ -168,6 +168,6 @@ Dynamic feed discovery ("latest 10 news"); cross-ref audit tooling; media/image-
168
168
 
169
169
  ## Files & references
170
170
 
171
- **Templates** — `assets/shared/`: `cmsCore.types.ts`, `externalRefs.ts`, `cmsContentType.ts` (foundation + per-type body). `assets/react/`: `useCmsItem.ts`, `heuristicRenderer.tsx` (renderer + `RenderField` dispatcher), `TypeRenderer.tsx`, `TypeList.tsx`, `DetailPage.tsx`, `MediaRenderer.tsx`. `assets/angular/`: `cms-item.service.ts`, `cms-content.component.ts`, `TypeRenderer.component.ts`, `TypeList.component.ts`, `DetailPage.component.ts`, `MediaRenderer.component.ts`.
171
+ **Templates** — `assets/shared/`: `cmsCore.types.ts`, `externalRefs.ts`, `cmsContentType.ts` (foundation + per-type body), `mediaLabels.ts`. `assets/react/`: `useCmsItem.ts`, `heuristicRenderer.tsx` (renderer + `RenderField` dispatcher), `TypeRenderer.tsx`, `TypeList.tsx`, `DetailPage.tsx`, `MediaRenderer.tsx`. `assets/angular/`: `cms-item.service.ts`, `cms-content.component.ts`, `TypeRenderer.component.ts`, `TypeList.component.ts`, `DetailPage.component.ts`, `MediaRenderer.component.ts`.
172
172
 
173
- **Docs** (`references/`): `package-api.md` (toolkit API + response contract — ground truth), `interaction-model.md` (question wording + terminal stops), `init-scaffold.md` (runtime tree, copy set, foundation sets), `schema-sync.md` (retrieval + regen), `verify.md` (fix policy + report), `codegen-guardrails.md` (full Rule 1–5), `embed-recipes.md` (URL regex + placement), `heuristic-render-rules.md`, `detail-pages.md`, `styling-scopes.md`, `bulk-loading.md`, `failure-modes.md`.
173
+ **Docs** (`references/`): `package-api.md` (toolkit API + contract — ground truth), `interaction-model.md` (wording + terminal stops), `init-scaffold.md` (runtime tree, copy set, foundation sets), `schema-sync.md` (retrieval + regen), `verify.md` (fix policy + report), `codegen-guardrails.md` (full Rule 1–5), `embed-recipes.md` (URL regex + placement), `heuristic-render-rules.md`, `detail-pages.md`, `styling-scopes.md`, `bulk-loading.md`, `failure-modes.md`.
@@ -30,6 +30,7 @@ import {
30
30
  type CmsMediaBody,
31
31
  type CmsMediaType,
32
32
  } from '../shared/cmsCore.types';
33
+ import { deriveDownloadLabel, effectiveAudioLabel } from '../shared/mediaLabels';
33
34
 
34
35
  @Component({
35
36
  selector: 'cms-media',
@@ -48,10 +49,21 @@ import {
48
49
  @if (src) { <img [class]="className" [src]="src" [alt]="alt" loading="lazy" /> }
49
50
  }
50
51
  @case ('audio') {
51
- @if (src) { <audio [class]="className" controls [src]="src" [attr.aria-label]="alt || null"></audio> }
52
+ <!-- [attr.aria-label]="alt || null" would REMOVE the attribute when alt is
53
+ empty (Angular attribute-binding semantics) — audioLabel always has a
54
+ value, so the control never loses its accessible name. -->
55
+ @if (src) { <audio [class]="className" controls [src]="src" [attr.aria-label]="audioLabel"></audio> }
52
56
  }
53
57
  @case ('video') {
54
- @if (src) { <video [class]="className" controls [src]="src" [attr.aria-label]="alt || null"></video> }
58
+ <!-- Captions render only when the ref supplies `captionsSrc` (SKILL.md §B4)
59
+ — there is no fetched-body field to derive real caption text from. -->
60
+ @if (src) {
61
+ <video [class]="className" controls [src]="src" [attr.aria-label]="alt || null">
62
+ @if (captionsSrc) {
63
+ <track kind="captions" [src]="captionsSrc" [label]="captionsLabel" default />
64
+ }
65
+ </video>
66
+ }
55
67
  }
56
68
  @case ('document') {
57
69
  <!-- Download link only — no inline preview (SKILL.md §B3). Named with the
@@ -75,13 +87,25 @@ export class MediaRendererComponent implements OnChanges {
75
87
  /** Resolved src + alt for the template (recomputed after each load). */
76
88
  src: string | undefined;
77
89
  alt = '';
78
- /** Accessible name for the document download link (altText → title → "Download"). */
79
- docLabel = 'Download';
90
+ /** Accessible name for the document download link (altText → title → derived
91
+ * filename → "Download file"; see `deriveDownloadLabel`). */
92
+ docLabel = 'Download file';
93
+ /** Caller-supplied captions-track URL/label for `<video>` (foreign ref only —
94
+ * see `CmsExternalRef.captionsSrc` in cmsCore.types.ts). Absent on the
95
+ * contentKey path, since the fetched media body carries no captions field. */
96
+ captionsSrc: string | undefined;
97
+ captionsLabel = 'Captions';
80
98
 
81
99
  private loadedRefName?: string;
82
100
 
83
101
  constructor(private readonly cms: CmsItemService) {}
84
102
 
103
+ /** Accessible name for `<audio>` when `altText` is empty — never falls back to
104
+ * `null`/removed, unlike `[attr.aria-label]="alt || null"`. */
105
+ get audioLabel(): string {
106
+ return effectiveAudioLabel(this.alt, this.src);
107
+ }
108
+
85
109
  ngOnChanges(): void {
86
110
  const ref = this.ref;
87
111
  // Only (re)load when the ref identity changes (mirrors TypeRendererComponent).
@@ -92,14 +116,18 @@ export class MediaRendererComponent implements OnChanges {
92
116
  this.error = undefined;
93
117
  this.src = undefined;
94
118
  this.alt = '';
95
- this.docLabel = 'Download';
119
+ this.docLabel = 'Download file';
120
+ this.captionsSrc = undefined;
121
+ this.captionsLabel = 'Captions';
96
122
 
97
123
  // Foreign media ref: the unauthenticatedUrl is the asset src directly — no load
98
124
  // (Rule 1). alt/title ride on the ref (search hand-off or Ref Registration ask).
99
125
  if ('url' in ref && typeof ref.url === 'string') {
100
126
  this.src = ref.url;
101
127
  this.alt = ref.altText ?? '';
102
- this.docLabel = ref.altText || ref.title || 'Download';
128
+ this.docLabel = deriveDownloadLabel(ref.url, ref.altText, ref.title);
129
+ this.captionsSrc = ref.captionsSrc;
130
+ this.captionsLabel = ref.captionsLabel || 'Captions';
103
131
  this.loading = false;
104
132
  return;
105
133
  }
@@ -123,7 +151,7 @@ export class MediaRendererComponent implements OnChanges {
123
151
  private resolveSrc(cmsType: CmsMediaType, body: CmsMediaBody): void {
124
152
  const media = body['sfdc_cms:media'];
125
153
  this.alt = body.altText ?? '';
126
- this.docLabel = body.altText || body.title || 'Download';
154
+ this.docLabel = deriveDownloadLabel(media?.url, body.altText, body.title);
127
155
  if (cmsType === 'image') {
128
156
  this.src = resolveCmsImageUrl(media);
129
157
  return;
@@ -18,6 +18,15 @@ import {
18
18
  import type { AnyCmsRef, CmsRef } from '../shared/cmsCore.types';
19
19
  import { CMS_CHANNEL_ID_FALLBACK } from '../shared/externalRefs';
20
20
 
21
+ /** Runtime guard before the cache's `as TBody` cast: a CMS body is always an
22
+ * object/array, so this screens out a stale primitive from a `ref.name` reused
23
+ * under a different, incompatible `TBody` — it cannot verify the object's shape
24
+ * actually matches `TBody` (no schema is available here), so callers must still
25
+ * keep `ref.name` unique per content shape. */
26
+ function isCacheableBody(value: unknown): boolean {
27
+ return value !== null && typeof value === 'object';
28
+ }
29
+
21
30
  /** Resolved state for one ref read — the component maps this onto signals. */
22
31
  export interface CmsItemResult<TBody> {
23
32
  body: TBody | undefined;
@@ -38,9 +47,9 @@ export class CmsItemService {
38
47
  async load<TBody = unknown>(ref: AnyCmsRef): Promise<CmsItemResult<TBody>> {
39
48
  const name = ref.name;
40
49
 
41
- const cached = this.cache.get(name) as TBody | undefined;
42
- if (cached !== undefined) {
43
- return { body: cached, error: undefined };
50
+ const rawCached = this.cache.get(name);
51
+ if (isCacheableBody(rawCached)) {
52
+ return { body: rawCached as TBody, error: undefined };
44
53
  }
45
54
 
46
55
  let promise = this.inflight.get(name) as Promise<TBody> | undefined;
@@ -89,7 +98,7 @@ export class CmsItemService {
89
98
  ) {
90
99
  return err;
91
100
  }
92
- return err instanceof Error ? err : new Error('Unknown CMS fetch error');
101
+ return err instanceof Error ? err : new Error('Unknown CMS fetch error', { cause: err });
93
102
  }
94
103
 
95
104
  /** Test-only escape hatch — reset the cache between test runs. */
@@ -27,6 +27,7 @@ import {
27
27
  type CmsMediaBody,
28
28
  type CmsMediaType,
29
29
  } from '../shared/cmsCore.types';
30
+ import { deriveDownloadLabel, effectiveAudioLabel } from '../shared/mediaLabels';
30
31
 
31
32
  export interface MediaRendererProps {
32
33
  /** A media ref — `cmsType` selects the element; a non-media ref is a compile error. */
@@ -43,16 +44,30 @@ function mediaElement(
43
44
  src: string,
44
45
  alt: string,
45
46
  docLabel: string,
47
+ captionsSrc: string | undefined,
48
+ captionsLabel: string | undefined,
46
49
  className?: string,
47
50
  ): ReactNode {
48
51
  switch (cmsType) {
49
52
  case 'image':
50
53
  return <img className={className} src={src} alt={alt} loading="lazy" />;
51
54
  case 'audio':
52
- // altText is typically absent on audio → aria-label only when present.
53
- return <audio className={className} controls src={src} aria-label={alt || undefined} />;
55
+ // `aria-label={alt || undefined}` would drop the attribute entirely when alt
56
+ // is empty (same JSX-prop-omission semantics as Angular's `|| null` binding) —
57
+ // effectiveAudioLabel always returns a value, so the control never loses its
58
+ // accessible name.
59
+ return <audio className={className} controls src={src} aria-label={effectiveAudioLabel(alt, src)} />;
54
60
  case 'video':
55
- return <video className={className} controls src={src} aria-label={alt || undefined} />;
61
+ // Captions require a caller-supplied `captionsSrc` (SKILL.md §B4) — the CMS
62
+ // media body/ref carries no captions-track field, so there is no data to
63
+ // fabricate a `<track>` from when the caller hasn't provided one.
64
+ return (
65
+ <video className={className} controls src={src} aria-label={alt || undefined}>
66
+ {captionsSrc && (
67
+ <track kind="captions" src={captionsSrc} label={captionsLabel || 'Captions'} default />
68
+ )}
69
+ </video>
70
+ );
56
71
  case 'document':
57
72
  // Download link only — no inline preview (SKILL.md §B3). Name the link so it
58
73
  // identifies WHAT downloads (WCAG 2.4.4), never a bare "Download" that is
@@ -72,11 +87,13 @@ function DirectMedia(props: {
72
87
  url: string;
73
88
  alt: string;
74
89
  title?: string;
90
+ captionsSrc?: string;
91
+ captionsLabel?: string;
75
92
  className?: string;
76
93
  }): ReactNode {
77
- const { cmsType, url, alt, title, className } = props;
78
- const docLabel = alt || title || 'Download';
79
- return mediaElement(cmsType, url, alt, docLabel, className);
94
+ const { cmsType, url, alt, title, captionsSrc, captionsLabel, className } = props;
95
+ const docLabel = deriveDownloadLabel(url, alt, title);
96
+ return mediaElement(cmsType, url, alt, docLabel, captionsSrc, captionsLabel, className);
80
97
  }
81
98
 
82
99
  /**
@@ -93,7 +110,7 @@ function FetchedMedia(props: { ref: AnyCmsRef<CmsMediaType>; className?: string
93
110
 
94
111
  const media = body['sfdc_cms:media'];
95
112
  const alt = body.altText ?? '';
96
- const docLabel = body.altText || body.title || 'Download';
113
+ const docLabel = deriveDownloadLabel(media?.url, body.altText, body.title);
97
114
 
98
115
  // Src resolution SPLITS by medium (Rule 2): image → resolveCmsImageUrl;
99
116
  // audio/video/document → resolveMediaUrl. Both toolkit helpers resolve the
@@ -102,7 +119,10 @@ function FetchedMedia(props: { ref: AnyCmsRef<CmsMediaType>; className?: string
102
119
  ref.cmsType === 'image' ? resolveCmsImageUrl(media) : media?.url ? resolveMediaUrl(media.url) : undefined;
103
120
  if (!src) return null;
104
121
 
105
- return mediaElement(ref.cmsType, src, alt, docLabel, className);
122
+ // No fetched-body field carries a captions-track URL today (SKILL.md §B4), so the
123
+ // contentKey path never has real caption data to pass — only a foreign ref with a
124
+ // caller-supplied `captionsSrc` (see DirectMedia) can render a `<track>`.
125
+ return mediaElement(ref.cmsType, src, alt, docLabel, undefined, undefined, className);
106
126
  }
107
127
 
108
128
  export function MediaRenderer(props: MediaRendererProps): ReactNode {
@@ -117,6 +137,8 @@ export function MediaRenderer(props: MediaRendererProps): ReactNode {
117
137
  url={ref.url}
118
138
  alt={ref.altText ?? ''}
119
139
  title={ref.title}
140
+ captionsSrc={ref.captionsSrc}
141
+ captionsLabel={ref.captionsLabel}
120
142
  className={className}
121
143
  />
122
144
  );
@@ -30,6 +30,15 @@ interface UseCmsItemState<TBody> {
30
30
  const cache = new Map<string, unknown>();
31
31
  const inflight = new Map<string, Promise<unknown>>();
32
32
 
33
+ /** Runtime guard before the cache's `as TBody` cast: a CMS body is always an
34
+ * object/array, so this screens out a stale primitive from a `ref.name` reused
35
+ * under a different, incompatible `TBody` — it cannot verify the object's shape
36
+ * actually matches `TBody` (no schema is available here), so callers must still
37
+ * keep `ref.name` unique per content shape. */
38
+ function isCacheableBody(value: unknown): boolean {
39
+ return value !== null && typeof value === 'object';
40
+ }
41
+
33
42
  /** Shared request for a ref — NO abort signal. `CMS_CHANNEL_ID_FALLBACK` is passed
34
43
  * as `options.channelId` only when set (empty → catalog supplies it). */
35
44
  function createItemRequest<TBody>(ref: AnyCmsRef): Promise<TBody> {
@@ -46,7 +55,8 @@ export function useCmsItem<TBody = unknown>(
46
55
  ref: AnyCmsRef,
47
56
  ): UseCmsItemState<TBody> {
48
57
  const [state, setState] = useState<UseCmsItemState<TBody>>(() => {
49
- const cached = cache.get(ref.name) as TBody | undefined;
58
+ const rawCached = cache.get(ref.name);
59
+ const cached = isCacheableBody(rawCached) ? (rawCached as TBody) : undefined;
50
60
  return {
51
61
  body: cached,
52
62
  loading: cached === undefined,
@@ -64,9 +74,9 @@ export function useCmsItem<TBody = unknown>(
64
74
  let cancelled = false;
65
75
  const name = ref.name;
66
76
 
67
- const cached = cache.get(name) as TBody | undefined;
68
- if (cached !== undefined) {
69
- setState({ body: cached, loading: false, error: undefined });
77
+ const rawCached = cache.get(name);
78
+ if (isCacheableBody(rawCached)) {
79
+ setState({ body: rawCached as TBody, loading: false, error: undefined });
70
80
  return () => {
71
81
  cancelled = true;
72
82
  };
@@ -25,9 +25,19 @@ export interface CmsExternalRef<K extends string = string> {
25
25
  * from. The skill threads these off the search hand-off (or asks the user) at Ref
26
26
  * Registration so the accessible name survives. Ignored on non-media refs (news
27
27
  * reads its title from the fetched envelope). Both optional; `altText` absent → `''`
28
- * (decorative image, WCAG 1.1.1); `title` names the document download link. */
28
+ * (decorative image, WCAG 1.1.1); `title` names the document download link.
29
+ *
30
+ * `captionsSrc` is MEDIA-ONLY (video), also foreign-ref only: a caller who has a
31
+ * captions/subtitles track (e.g. a VTT file) for this video supplies its URL here
32
+ * so MediaRenderer can render an accessible `<track kind="captions">`. There is no
33
+ * fetched-body equivalent for the uiBundle-space (`contentKey`) path today — the
34
+ * predefined media body carries no captions field — so captions render only when
35
+ * a caller populates this on a foreign ref. `captionsLabel` names the track
36
+ * (defaults to "Captions" when omitted). */
29
37
  readonly altText?: string;
30
38
  readonly title?: string;
39
+ readonly captionsSrc?: string;
40
+ readonly captionsLabel?: string;
31
41
  }
32
42
 
33
43
  /**
@@ -4,7 +4,7 @@
4
4
  * src/cms/shared/externalRefs.ts; each embed appends one entry inside the marker block.
5
5
  * Names are EXT_<TITLE_SNAKE>, unique — object-literal keys collide at compile time.
6
6
  *
7
- * ⚠ Do NOT edit the marker block by hand. Add entries via the skill.
7
+ * Warning: do NOT edit the marker block by hand. Add entries via the skill.
8
8
  */
9
9
  // Only `AnyCmsRef` is used in the empty template; Ref Registration adds
10
10
  // CmsExternalRef/CmsRef when it appends an entry (importing them eagerly trips
@@ -0,0 +1,62 @@
1
+ /**
2
+ * Accessible-name derivation for MediaRenderer (FRAMEWORK-AGNOSTIC). Shared by the
3
+ * React and Angular renderers so the document-download label and the audio
4
+ * fallback label are computed identically regardless of framework.
5
+ *
6
+ * The CMS media body/ref (`CmsMediaField`/`CmsExternalRef`) carries no filename
7
+ * field and no captions-track field — only `url`. These helpers derive a label
8
+ * from real, already-present data (the URL itself) rather than fabricating text
9
+ * that isn't there.
10
+ */
11
+
12
+ /** Last path segment of `url`, URL-decoded, kept only when it looks like a filename
13
+ * (has an extension). Returns `undefined` when `url` is absent or unparsable, or
14
+ * when the last segment has no extension to trust as a filename. */
15
+ function deriveFilenameFromUrl(url: string | undefined): string | undefined {
16
+ if (!url) return undefined;
17
+ let pathname: string;
18
+ try {
19
+ pathname = url.includes('://') ? new URL(url).pathname : url.split(/[?#]/)[0];
20
+ } catch {
21
+ return undefined;
22
+ }
23
+ const lastSegment = pathname.split('/').filter(Boolean).pop();
24
+ if (!lastSegment) return undefined;
25
+ let decoded: string;
26
+ try {
27
+ decoded = decodeURIComponent(lastSegment);
28
+ } catch {
29
+ decoded = lastSegment;
30
+ }
31
+ return /\.[a-zA-Z0-9]+$/.test(decoded) ? decoded : undefined;
32
+ }
33
+
34
+ /**
35
+ * Accessible name for the document download link (WCAG 2.4.4) — identifies WHAT
36
+ * downloads instead of a bare, ambiguous "Download". Prefers `altText`/`title`
37
+ * when present; otherwise derives the real filename off the URL; otherwise falls
38
+ * back to the still-more-specific-than-bare-"Download" literal `'Download file'`.
39
+ */
40
+ export function deriveDownloadLabel(
41
+ url: string | undefined,
42
+ altText?: string,
43
+ title?: string,
44
+ ): string {
45
+ if (altText) return altText;
46
+ if (title) return title;
47
+ const filename = deriveFilenameFromUrl(url);
48
+ return filename ? `Download ${filename}` : 'Download file';
49
+ }
50
+
51
+ /**
52
+ * Accessible name for an `<audio>` element when `altText` is absent. Angular's
53
+ * `[attr.aria-label]="alt || null"` REMOVES the attribute when `alt` is falsy
54
+ * (unlike a JSX `undefined` prop, which is the same removal but explicitly
55
+ * intended there); this default keeps the attribute present so the control
56
+ * always has an accessible name.
57
+ */
58
+ export function effectiveAudioLabel(altText: string | undefined, url?: string): string {
59
+ if (altText) return altText;
60
+ const filename = deriveFilenameFromUrl(url);
61
+ return filename ? `Audio: ${filename}` : 'Audio';
62
+ }
@@ -78,21 +78,28 @@ renderer (skip if it already exists — never overwrite):
78
78
  |-------------------------|------------------------------|
79
79
  | `assets/react/MediaRenderer.tsx` *(react)* | `src/cms/react/MediaRenderer.tsx` |
80
80
  | `assets/angular/MediaRenderer.component.ts` *(angular)* | `src/cms/angular/MediaRenderer.component.ts` |
81
+ | `assets/shared/mediaLabels.ts` | `src/cms/shared/mediaLabels.ts` |
82
+
83
+ Both `MediaRenderer.tsx` and `MediaRenderer.component.ts` import `deriveDownloadLabel`/
84
+ `effectiveAudioLabel` from `../shared/mediaLabels`, so `mediaLabels.ts` MUST be copied
85
+ in the same pass as the renderer — copying the renderer without it leaves an
86
+ unresolved import and breaks the app's build. Skip if it already exists, exactly like
87
+ the renderer files.
81
88
 
82
89
  The media types (`CmsMediaBody`, `CmsMediaField`, `CmsMediaType`) ship inside the
83
90
  always-written `shared/cmsCore.types.ts`, so a media embed onto an existing runtime
84
- needs only the one renderer file plus the ref entry — provided the runtime's
91
+ needs only the renderer, `mediaLabels.ts`, and the ref entry — provided the runtime's
85
92
  `cmsCore.types.ts` carries the media exports (a pre-media scaffold that lacks them is
86
93
  Init drift on that file; HALT, don't silently patch). On the `contentKey` path URL
87
94
  resolution is entirely toolkit-owned (`resolveCmsImageUrl` / `resolveMediaUrl`); no
88
95
  local prefix helper ships. On the foreign `url` path the renderer uses `ref.url`
89
96
  directly — no resolver, no fetch (Rule 1/Rule 2).
90
97
 
91
- **The media renderer is NOT in the foundation sets below.** Init/Embed detection is
92
- unchanged: a runtime scaffolded before media existed is still a complete `embed`
93
- runtime, and the media renderer is written lazily on the first media embed exactly
94
- like a per-type `<type>.ts`. Do not add `MediaRenderer.*` to the detection sets, or
95
- every pre-media app would mis-classify as drift.
98
+ **The media renderer and `mediaLabels.ts` are NOT in the foundation sets below.**
99
+ Init/Embed detection is unchanged: a runtime scaffolded before media existed is still
100
+ a complete `embed` runtime, and both files are written lazily on the first media embed
101
+ exactly like a per-type `<type>.ts`. Do not add `MediaRenderer.*` or `mediaLabels.ts`
102
+ to the detection sets, or every pre-media app would mis-classify as drift.
96
103
 
97
104
  ## Foundation sets (used by Init vs Embed detection)
98
105
 
@@ -4,7 +4,6 @@ description: "Searches for and downloads ethically-licensed stock images via the
4
4
  metadata:
5
5
  version: "1.0"
6
6
  domains: ["Experience"]
7
- accessCheck: []
8
7
  relatedSkills:
9
8
  - "experience-search-coordinate"
10
9
  cliTools:
@@ -175,7 +175,7 @@ A new component built from this workflow is "done" only when **every** item belo
175
175
  - Optional: add o11y instrumentation as a separate pass once the component stabilizes.
176
176
  - `experience-lwc-typescript-migrate` — optional once JS is green.
177
177
  - When the new component ships behind a flag, gate it with a feature flag during rollout.
178
- - `experience-lwc-api-docs-generate` — once the public API surface is stable.
178
+ - Generate API-surface documentation once the public API is stable (no dedicated skill for this yet).
179
179
  - Org-aware inputs used by this workflow:
180
180
  - Figma URL + screenshot (and, if available, the developer's Dev Mode metadata export) — Phase 1.2 Figma input.
181
181
  - `experience-lds-data-requirements-generate` owns the org-schema introspection and data-spec validation used in Phase 1 when the component needs org-backed data — hand off to that skill rather than duplicating its work here.