@cliangdev/conductor 0.20.6 → 0.22.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 (67) hide show
  1. package/assets/claude/skills/conductor-creative/SKILL.md +144 -0
  2. package/assets/claude/skills/conductor-publisher/SKILL.md +4 -0
  3. package/assets/cli-manifest.json +100 -0
  4. package/dist/commands/creative.d.ts +3 -0
  5. package/dist/commands/creative.d.ts.map +1 -0
  6. package/dist/commands/creative.js +60 -0
  7. package/dist/commands/creative.js.map +1 -0
  8. package/dist/creative/assertions.js +221 -0
  9. package/dist/creative/font.js +39 -0
  10. package/dist/creative/frame.css +126 -0
  11. package/dist/creative/frame.html +29 -0
  12. package/dist/creative/frame.js +76 -0
  13. package/dist/creative/job/render.mjs +203 -0
  14. package/dist/creative/job/server.mjs +72 -0
  15. package/dist/creative/job/transport.mjs +167 -0
  16. package/dist/creative/layouts/bleed/layout.css +47 -0
  17. package/dist/creative/layouts/bleed/layout.json +17 -0
  18. package/dist/creative/layouts/card/layout.css +24 -0
  19. package/dist/creative/layouts/card/layout.json +17 -0
  20. package/dist/creative/layouts/index.js +62 -0
  21. package/dist/creative/layouts/index.json +1 -0
  22. package/dist/creative/layouts/split/layout.css +22 -0
  23. package/dist/creative/layouts/split/layout.json +17 -0
  24. package/dist/creative/layouts/stacked/layout.css +40 -0
  25. package/dist/creative/layouts/stacked/layout.json +23 -0
  26. package/dist/creative/placements.js +200 -0
  27. package/dist/creative/placements.json +232 -0
  28. package/dist/creative/render.js +457 -0
  29. package/dist/creative/sheet.html +27 -0
  30. package/dist/creative/sheet.js +94 -0
  31. package/dist/creative/styles.css +314 -0
  32. package/dist/creative/tokens.css +33 -0
  33. package/dist/index.js +2 -0
  34. package/dist/index.js.map +1 -1
  35. package/dist/lib/creative-browser.d.ts +25 -0
  36. package/dist/lib/creative-browser.d.ts.map +1 -0
  37. package/dist/lib/creative-browser.js +36 -0
  38. package/dist/lib/creative-browser.js.map +1 -0
  39. package/dist/lib/creative-id.d.ts +8 -0
  40. package/dist/lib/creative-id.d.ts.map +1 -0
  41. package/dist/lib/creative-id.js +14 -0
  42. package/dist/lib/creative-id.js.map +1 -0
  43. package/dist/lib/creative-render.d.ts +32 -0
  44. package/dist/lib/creative-render.d.ts.map +1 -0
  45. package/dist/lib/creative-render.js +71 -0
  46. package/dist/lib/creative-render.js.map +1 -0
  47. package/dist/lib/image-dimensions.d.ts +20 -0
  48. package/dist/lib/image-dimensions.d.ts.map +1 -0
  49. package/dist/lib/image-dimensions.js +115 -0
  50. package/dist/lib/image-dimensions.js.map +1 -0
  51. package/dist/lib/plugin-assets.d.ts.map +1 -1
  52. package/dist/lib/plugin-assets.js +1 -0
  53. package/dist/lib/plugin-assets.js.map +1 -1
  54. package/dist/mcp/index.d.ts +18 -0
  55. package/dist/mcp/index.d.ts.map +1 -1
  56. package/dist/mcp/index.js +301 -0
  57. package/dist/mcp/index.js.map +1 -1
  58. package/dist/mcp/tools/creatives.d.ts +93 -0
  59. package/dist/mcp/tools/creatives.d.ts.map +1 -0
  60. package/dist/mcp/tools/creatives.js +291 -0
  61. package/dist/mcp/tools/creatives.js.map +1 -0
  62. package/dist/mcp/tools/insights.d.ts +11 -0
  63. package/dist/mcp/tools/insights.d.ts.map +1 -0
  64. package/dist/mcp/tools/insights.js +85 -0
  65. package/dist/mcp/tools/insights.js.map +1 -0
  66. package/package.json +4 -3
  67. package/scripts/build-creative.mjs +93 -0
@@ -0,0 +1,144 @@
1
+ ---
2
+ name: conductor-creative
3
+ description: Creates on-brand ad creative through the Conductor MCP tools — a photo plus headline rendered at every enabled placement (e.g. TikTok/Reels 9:16, Instagram feed 4:5, Facebook 1:1, plus any opt-in placements) from a workspace's Brand Kit, rendered locally on this machine and checked by eye before it's called done. Use when asked to make, design, write or export a social ad, an ad creative, a promo image, a static ad, a story or carousel sequence, or a new lettered variant (headline test) of one.
4
+ user-invocable: true
5
+ allowed-tools: mcp__conductor__*, AskUserQuestion, Read, Glob, Grep
6
+ ---
7
+
8
+ # Conductor Creative
9
+
10
+ Turns one concept — a photo and a headline — into upload-ready artwork at every placement a
11
+ workspace's Brand Kit enables. This is the front half of Conductor's marketing pillar: what this
12
+ skill produces becomes a Post's media, and `conductor-publisher` takes it from there through review
13
+ and out to each destination. You write the ad here; you do not post it.
14
+
15
+ You are writing the ad, not just running a tool. The copy and the visual language both come from
16
+ things that already exist for this workspace — a Brand Kit (data: tokens, copy rules, approved
17
+ lines) and a Knowledge page (prose: positioning, voice, facts). Read them rather than inventing.
18
+
19
+ Rendering happens locally, through this MCP server, with a browser on this machine — there is no
20
+ separate render service to wait on. `render_creative` typically finishes in well under a minute.
21
+
22
+ ## How to make a Creative
23
+
24
+ ### 1. Read the brand
25
+
26
+ Call `get_brand_kit` (a project may have more than one — pass `kitId` if the person named a
27
+ specific brand, otherwise the default). It gives you the colour tokens, font, logo/wordmark/badge
28
+ URLs, the CTA claim, the copy rules (each a regex, a message, and which fields it applies to), the
29
+ approved lines, the enabled placements, and `knowledgePagePath` — the wiki page holding this
30
+ brand's positioning, message hierarchy, approved verbatim lines and voice in prose. Read that page
31
+ with `read_knowledge_pages` before writing anything. Quote its approved lines rather than
32
+ paraphrasing them; they were worded and cleared on purpose.
33
+
34
+ ### 2. Pick the angle
35
+
36
+ Lead with whatever the brand's own message hierarchy puts first — the job the product does end to
37
+ end, not a secondary feature. A supporting capability is proof, not the headline.
38
+
39
+ ### 3. Write the headline
40
+
41
+ Prefer an approved line from the Knowledge page verbatim. Otherwise write one that names something
42
+ real and specific, not a generic claim.
43
+
44
+ If the Brand Kit's `accentPhraseRequired` is true, mark exactly one phrase per headline with
45
+ asterisks: `Every saved link, *finally usable*.` Never zero, never two — `create_creative` and
46
+ `update_creative` refuse a headline that gets this wrong, naming the rule.
47
+
48
+ Keep it short — checked at thumbnail size, not read up close.
49
+
50
+ ### 4. Obey the brand's copy rules
51
+
52
+ Every rule in `copyRules` is data (a pattern, a message, and which fields it checks), not something
53
+ to remember — the backend enforces it on every write and hands back the rule's own message on a
54
+ violation, listing every rule the copy tripped. Fix the copy and retry rather than working around
55
+ it. A kit with no rules configured is not a bug; it simply never fails a save on that account.
56
+
57
+ ### 5. Choose the photo and look at it
58
+
59
+ Call `list_creatives`/an existing photo library read, or check what photos are already on hand
60
+ before uploading a new one. A photo needs `checked: true` and not `blocked` before a Creative can
61
+ go to `READY` — see the photo library's own listing for that state.
62
+
63
+ A new photo goes through `upload_creative_photo` (local path or URL), which requires `source` (a
64
+ URL or a short provenance note, e.g. "Generated with a named model on a given date") and `licence`
65
+ (e.g. "Own work", "Unsplash Licence", "Generated, house use"). This closes a real legal exposure: an
66
+ unattributed or unlicensed photo running as paid or organic media.
67
+
68
+ **Open the photo and look at it before using it.** A picture can break every copy rule with text,
69
+ a logo or a claim burned into its pixels, and no automated check can see that — this is the first
70
+ of this skill's silent failure modes (the full list is below). A human or a prior pass marks a bad
71
+ photo `blocked`; if you notice one that should be, say so rather than using it anyway.
72
+
73
+ Match the crop to the subject: a layout that lays copy over the photo (e.g. `bleed`) puts a face in
74
+ the top third in collision with the headline — prefer a layout that keeps the photo and copy on
75
+ separate panels (e.g. `stacked`) for those.
76
+
77
+ ### 6. Create, then render
78
+
79
+ `create_creative` with the Brand Kit (if not the default), layout, theme, photo, headline, body,
80
+ caption and alt text. To test a headline variant of an existing concept, pass `variantOf` with that
81
+ Creative's id instead of starting fresh — it inherits everything else and gets the next letter.
82
+ Then call `get_creative` to verify what was actually stored.
83
+
84
+ `render_creative` (previewOnly: true first, for a quick contact sheet) runs the render locally and
85
+ returns the render id, state, frame URLs and any warnings. Then `preview_creative` to actually look
86
+ at it — it returns the contact sheet as an image directly, not just a URL to trust.
87
+
88
+ ### 7. Look at the result honestly
89
+
90
+ **A successful render proves the frame is structurally correct, not that the ad is good.** Its
91
+ in-page checks catch text spilling out of a safe zone, a font that failed to load, a photo that
92
+ never decoded, or contrast below the accessible minimum — not whether the headline reads at a
93
+ glance, whether the accent phrase falls on the words that carry the meaning, or whether the copy is
94
+ sitting on the subject's face. Judge those yourself from the image `preview_creative` hands back. If
95
+ the copy collides with the photo, switch layout (see step 5) and re-render rather than shrinking
96
+ text until it technically fits.
97
+
98
+ ### 8. Check readiness before calling it done
99
+
100
+ `get_creative` includes a `readiness` block: caption, alt text, the photo's checked/blocked state
101
+ and provenance, and the Creative's own state (not `DRAFT`) are blocking; an AI-generation disclosure
102
+ is informational only. Do not call a Creative done while `ready` is false.
103
+
104
+ ### 9. Render for real, then hand off
105
+
106
+ Once the preview looks right, `render_creative` again with `previewOnly: false` (or omitted) for the
107
+ full-size placement frames, then `attach_creative_to_post` with the Post's Work Item id — it maps
108
+ each placement to the right destinations (9:16 to TikTok/Reels, 4:5 to Instagram feed, 1:1 to
109
+ Facebook, story to story targets) for every destination that has not set its own custom media, and
110
+ never touches one that has. Verify with `get_post_status`. From here, use the `conductor-publisher`
111
+ skill for scheduling, review and publishing — this skill's job ends at attaching the artwork.
112
+
113
+ ## Making a sequence instead of a single ad
114
+
115
+ For a platform surface that's tapped or swiped through, set `sequenceKind` to `story` (2 to 7
116
+ beats, aim for 3 to 5) or `carousel` (2 to 10 cards, 5 to 7 performs best) on `create_creative` or
117
+ `update_creative`, with a `sequence` array. Each beat inherits the Creative and overrides only its
118
+ own `headline`, and optionally `body`, `photoId` or `cta`. Structure a story hook, mechanism,
119
+ payoff — the first beat has to earn the next tap on its own, since that's where the drop-off is
120
+ worst. A logo lockup and CTA claim land on the last beat only by default; `cta: true` on an earlier
121
+ beat overrides that. A carousel renders every card at one shared aspect ratio (`carouselRatio`),
122
+ since the destination platforms require that.
123
+
124
+ ## Failures that are silent (carried over from this skill's predecessor)
125
+
126
+ Automated checks are real but incomplete. These are the ones worth remembering to look for by eye:
127
+
128
+ - **A photo with text, a logo or a claim burned into its pixels** passes every copy-rule and
129
+ render check, because none of them look at pixels for meaning — only step 5's "open and look at
130
+ it" catches this.
131
+ - **A structurally valid render is not necessarily a good one.** The render's own assertions check
132
+ spill, safe zones, fonts and contrast — never whether the headline reads at a glance or the copy
133
+ sits on the subject's face. Step 7 exists because of this gap.
134
+ - **A Brand Kit with no copy rules, or no accent-phrase requirement, never fails a save on that
135
+ account.** That is permissive by design, not a bug — but it means the model, not the platform, is
136
+ the only check on brand voice for that workspace until someone configures rules.
137
+ - **A stale write is refused, not silently overwritten.** `update_creative` requires the Creative's
138
+ current `version`; a mismatch comes back as a 409 naming the conflict rather than clobbering a
139
+ concurrent edit — reread with `get_creative` and reapply, don't retry blind.
140
+
141
+ ## Related skills
142
+
143
+ - `conductor-publisher`: schedules and tracks the Post this Creative's artwork attaches to, through
144
+ review and out to each destination. This skill hands off to it, never the reverse.
@@ -17,6 +17,10 @@ are. One instance of it is a MARKETING Post going to Instagram and TikTok; anoth
17
17
  own lifecycle that publishes somewhere else. Read the rules below as rules about the pipeline, and
18
18
  the platform names as examples.
19
19
 
20
+ If the destination needs artwork that does not exist yet — a photo turned into on-brand ad
21
+ creative, not just an attached file — make it first with the `conductor-creative` skill, then come
22
+ back here with the rendered frames to attach and schedule.
23
+
20
24
  ## Step 1 — Gather three things, and ask rather than guess
21
25
 
22
26
  1. **The copy** — inline, or a file path you `Read`. Keep it exactly as given.
@@ -127,6 +127,26 @@
127
127
  "syntax": "conductor config set-url <url>",
128
128
  "description": "Update the API base URL. Use when running a self-hosted backend.",
129
129
  "category": "config"
130
+ },
131
+ {
132
+ "name": "creative render",
133
+ "syntax": "conductor creative render <creativeId>",
134
+ "description": "Render a Creative locally with a Playwright-driven browser to upload-ready JPEG frames, or a preview contact sheet. Prints the render id, state and frame URLs; exits non-zero on failure.",
135
+ "category": "marketing",
136
+ "options": [
137
+ {
138
+ "flag": "--preview",
139
+ "description": "Render a preview contact sheet instead of full-size frames"
140
+ },
141
+ {
142
+ "flag": "--renderer <name>",
143
+ "description": "Attribution tag stored on the render (default: cli)"
144
+ },
145
+ {
146
+ "flag": "--workflow-run-id <id>",
147
+ "description": "Attribute this render to a Workflow run"
148
+ }
149
+ ]
130
150
  }
131
151
  ],
132
152
  "claudeIntegration": {
@@ -168,6 +188,10 @@
168
188
  {
169
189
  "name": "conductor-publisher",
170
190
  "description": "Schedules and tracks a Post through the publishing pipeline: gathers copy, destinations and timing, checks content, creates it with create_post, reports the confirmation table, follows it through review and publishing."
191
+ },
192
+ {
193
+ "name": "conductor-creative",
194
+ "description": "Makes a Creative end to end: reads the Brand Kit and its Knowledge page, picks a photo and an angle, creates the Creative, renders and looks at the result, fixes what's wrong, then hands it to conductor-publisher."
171
195
  }
172
196
  ]
173
197
  },
@@ -667,6 +691,12 @@
667
691
  "category": "marketing",
668
692
  "requiredParams": []
669
693
  },
694
+ {
695
+ "name": "get_marketing_insights",
696
+ "description": "What is working across the project's published Posts: totals, engagement rate by platform, format and time, best and worst posts, and movers vs the prior window.",
697
+ "category": "marketing",
698
+ "requiredParams": []
699
+ },
670
700
  {
671
701
  "name": "submit_review",
672
702
  "description": "Record an approve / request_changes verdict on a Post as this API key's user (an assigned REVIEWER). Approval schedules the Post.",
@@ -689,6 +719,76 @@
689
719
  "description": "The project's best-performing published destinations ranked by one metric.",
690
720
  "category": "marketing",
691
721
  "requiredParams": []
722
+ },
723
+ {
724
+ "name": "get_brand_kit",
725
+ "description": "Read a workspace Brand Kit: colour tokens, font, logo/wordmark/badge URLs, CTA claim, copy rules, approved lines, enabled placements, and the Knowledge page path holding its prose context.",
726
+ "category": "marketing"
727
+ },
728
+ {
729
+ "name": "list_creatives",
730
+ "description": "List the project's Creatives (photo + headline + layout), newest display number first. Filter by state or brandKitId.",
731
+ "category": "marketing"
732
+ },
733
+ {
734
+ "name": "get_creative",
735
+ "description": "One Creative in full: its fields, readiness checklist, and the other lettered variants in its family.",
736
+ "category": "marketing",
737
+ "requiredParams": [
738
+ "creativeId"
739
+ ]
740
+ },
741
+ {
742
+ "name": "create_creative",
743
+ "description": "Create a Creative, or with variantOf cut a lettered variant of an existing one. Refused with the failing rule's message on a validation or readiness error.",
744
+ "category": "marketing"
745
+ },
746
+ {
747
+ "name": "update_creative",
748
+ "description": "Patch a Creative's fields. version must match the current value or the write is refused with a 409 conflict.",
749
+ "category": "marketing",
750
+ "requiredParams": [
751
+ "creativeId",
752
+ "version"
753
+ ]
754
+ },
755
+ {
756
+ "name": "upload_creative_photo",
757
+ "description": "Upload a photo into the project's photo library from a local file or a public URL, recording its provenance for the readiness checklist. Width/height are read from the file.",
758
+ "category": "marketing"
759
+ },
760
+ {
761
+ "name": "render_creative",
762
+ "description": "Render a Creative locally (Playwright on this machine) to upload-ready JPEG frames, or a contact sheet with previewOnly. Runs synchronously and returns the render id, state and frame URLs.",
763
+ "category": "marketing",
764
+ "requiredParams": [
765
+ "creativeId"
766
+ ]
767
+ },
768
+ {
769
+ "name": "preview_creative",
770
+ "description": "Look at a Creative: returns its latest contact sheet as an image, rendering a fresh preview first if none exists yet.",
771
+ "category": "marketing",
772
+ "requiredParams": [
773
+ "creativeId"
774
+ ]
775
+ },
776
+ {
777
+ "name": "attach_creative_to_post",
778
+ "description": "Attach a Creative's rendered frames to a Post as its media, placement to platform, for every destination without its own custom media. Call get_post_status (postId) to verify.",
779
+ "category": "marketing",
780
+ "requiredParams": [
781
+ "creativeId",
782
+ "postId"
783
+ ]
784
+ },
785
+ {
786
+ "name": "create_experiment",
787
+ "description": "Start a hook experiment comparing every lettered variant of a Creative's family; settles itself once every variant has published and reported.",
788
+ "category": "marketing",
789
+ "requiredParams": [
790
+ "creativeId"
791
+ ]
692
792
  }
693
793
  ]
694
794
  }
@@ -0,0 +1,3 @@
1
+ import { Command } from 'commander';
2
+ export declare function registerCreative(program: Command): void;
3
+ //# sourceMappingURL=creative.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"creative.d.ts","sourceRoot":"","sources":["../../src/commands/creative.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,OAAO,EAAE,MAAM,WAAW,CAAA;AAKnC,wBAAgB,gBAAgB,CAAC,OAAO,EAAE,OAAO,GAAG,IAAI,CAiEvD"}
@@ -0,0 +1,60 @@
1
+ import { getConfig, resolveProject } from '../mcp/config.js';
2
+ import { resolveCreativeId } from '../lib/creative-id.js';
3
+ import { renderCreative } from '../lib/creative-render.js';
4
+ export function registerCreative(program) {
5
+ const creative = program.command('creative').description('Render Conductor Creatives locally with Playwright');
6
+ creative
7
+ .command('render <creativeId>')
8
+ .description('Render a Creative to upload-ready JPEG frames (or a preview contact sheet) using a local browser')
9
+ .option('--preview', 'Render a preview contact sheet instead of full-size frames')
10
+ .option('--renderer <name>', 'Attribution tag stored on the render (default: cli)')
11
+ .option('--workflow-run-id <id>', 'Attribute this render to a Workflow run')
12
+ .addHelpText('after', `
13
+ Examples:
14
+ conductor creative render 12a
15
+ conductor creative render 12a --preview
16
+ conductor creative render 12a --renderer workflow --workflow-run-id run_123`)
17
+ .action(async (creativeId, options) => {
18
+ let config;
19
+ try {
20
+ config = getConfig();
21
+ }
22
+ catch (err) {
23
+ console.error(err instanceof Error ? err.message : String(err));
24
+ process.exitCode = 1;
25
+ return;
26
+ }
27
+ const resolution = resolveProject(config);
28
+ if (resolution.mismatch) {
29
+ console.error(`Refusing to act: the current directory (${process.cwd()}) is a git repository not linked to any ` +
30
+ 'configured Conductor project. Run `conductor init` here, or set CONDUCTOR_PROJECT_ID.');
31
+ process.exitCode = 1;
32
+ return;
33
+ }
34
+ config.projectId = resolution.projectId;
35
+ try {
36
+ const result = await renderCreative({
37
+ creativeId: await resolveCreativeId(creativeId, config),
38
+ previewOnly: !!options.preview,
39
+ renderer: options.renderer ?? 'cli',
40
+ workflowRunId: options.workflowRunId,
41
+ }, config, (...args) => console.log(...args));
42
+ console.log(`render ${result.renderId ?? '(none)'}: ${result.state ?? (result.ok ? 'SUCCEEDED' : 'FAILED')}`);
43
+ for (const frame of result.frames) {
44
+ const label = frame.sequenceIndex != null ? `${frame.placementKey}[${frame.sequenceIndex}]` : frame.placementKey;
45
+ const size = frame.width && frame.height ? `${frame.width}x${frame.height}` : '';
46
+ console.log(` ${label} ${size} ${frame.url ?? ''}`.trimEnd());
47
+ }
48
+ if (result.error)
49
+ console.error(result.error);
50
+ if (!result.ok || result.state === 'FAILED') {
51
+ process.exitCode = 1;
52
+ }
53
+ }
54
+ catch (err) {
55
+ console.error(err instanceof Error ? err.message : String(err));
56
+ process.exitCode = 1;
57
+ }
58
+ });
59
+ }
60
+ //# sourceMappingURL=creative.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"creative.js","sourceRoot":"","sources":["../../src/commands/creative.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,221 @@
1
+ /* assertions.js — the in-page checks that refuse a wrong render, ported from
2
+ * nexus-marketing/social/export-png.mjs and made brand-agnostic: the accent
3
+ * check reads brand.tokens.accent (via the board's own resolved --cc-accent
4
+ * custom property) instead of a hardcoded Rexipe coral, and the font check
5
+ * reads brand.fontFamily instead of a hardcoded "DM Sans". A brand kit with
6
+ * neither set still gets a meaningful check — the board's actually-applied
7
+ * value is what is verified, not a specific brand's value.
8
+ *
9
+ * Split in two for testability:
10
+ * - pure math (relativeLuminance, parseRgb, contrastRatio, spillDetect,
11
+ * safeZoneIntrusion): plain data in, plain data out, no DOM. Exercised
12
+ * directly with fixture rects in test/assertions.test.mjs.
13
+ * - runAssertions(board, placement, brand): reads the live DOM
14
+ * (getBoundingClientRect, getComputedStyle, document.fonts, Image
15
+ * loading) and calls the pure functions above. Only runs in a real
16
+ * browser (Playwright); this is what frame.js calls after fitBoard.
17
+ *
18
+ * These are the same checks fitBoard()'s own `fits()` helper in render.js
19
+ * uses to decide whether type fits (spill + the bottom safe zone) — this
20
+ * module reports the same two conditions as errors with a human-readable
21
+ * message, plus checks fits() has no opinion on: fonts loaded, images
22
+ * loaded (including a bleed layout's CSS background photo, invisible to a
23
+ * plain <img> check), headline contrast, the accent colour, and the
24
+ * rendered box size.
25
+ */
26
+
27
+ /* ── pure math ─────────────────────────────────────────────────────────── */
28
+
29
+ /** WCAG relative luminance of an [r, g, b] (0-255) triplet. */
30
+ export function relativeLuminance([r, g, b]) {
31
+ const [R, G, B] = [r, g, b].map((v) => {
32
+ v /= 255;
33
+ return v <= 0.03928 ? v / 12.92 : Math.pow((v + 0.055) / 1.055, 2.4);
34
+ });
35
+ return 0.2126 * R + 0.7152 * G + 0.0722 * B;
36
+ }
37
+
38
+ /** "rgb(1, 2, 3)" / "rgba(1, 2, 3, 0.5)" -> [1, 2, 3]; anything else -> null. */
39
+ export function parseRgb(str) {
40
+ // Anchored, bounded and limited to digits, dots, commas and spaces, so no input can make it backtrack.
41
+ const s = String(str || '').trim();
42
+ if (s.length > 64) return null;
43
+ const m = /^rgba?\(([0-9., ]+)\)$/.exec(s);
44
+ if (!m) return null;
45
+ const parts = m[1].split(',').map((s) => parseFloat(s));
46
+ return parts.length >= 3 && parts.slice(0, 3).every((n) => !Number.isNaN(n)) ? parts.slice(0, 3) : null;
47
+ }
48
+
49
+ /** WCAG contrast ratio between two [r, g, b] triplets, rounded to 1dp. 3:1 is
50
+ * the floor for large text, and every headline this engine renders is large. */
51
+ export function contrastRatio(fgRgb, bgRgb) {
52
+ const l1 = relativeLuminance(fgRgb);
53
+ const l2 = relativeLuminance(bgRgb);
54
+ const ratio = (Math.max(l1, l2) + 0.05) / (Math.min(l1, l2) + 0.05);
55
+ return Math.round(ratio * 10) / 10;
56
+ }
57
+
58
+ /** Nodes whose box crosses the artboard's own edges — a wrapped headline or a
59
+ * too-tall CTA row bleeding off the visible artboard. `nodes` is
60
+ * `[{ name, rect: {top,bottom,right} }]`; `boardRect` is the artboard's own
61
+ * `{top,bottom,right}`. Returns human-readable messages, empty when clean. */
62
+ export function spillDetect(boardRect, nodes) {
63
+ const issues = [];
64
+ for (const { name, rect } of nodes) {
65
+ if (rect.top < boardRect.top - 1) issues.push(`${name} over top by ${Math.round(boardRect.top - rect.top)}px`);
66
+ if (rect.bottom > boardRect.bottom + 1) issues.push(`${name} past bottom by ${Math.round(rect.bottom - boardRect.bottom)}px`);
67
+ if (rect.right > boardRect.right + 1) issues.push(`${name} past right by ${Math.round(rect.right - boardRect.right)}px`);
68
+ }
69
+ return issues;
70
+ }
71
+
72
+ /** Copy intruding on the placement's reserved bottom safe zone (where a
73
+ * platform's own UI — Instagram's story CTA, TikTok's caption — sits).
74
+ * Mirrors render.js's own `fits()` bottom check exactly, so fitting and this
75
+ * safety net can never disagree. Only the bottom zone is enforced: the top
76
+ * reserve varies far more per platform and is documented, not asserted, the
77
+ * same call nexus-marketing's export-png.mjs made. */
78
+ export function safeZoneIntrusion(boardRect, nodes, safe) {
79
+ const issues = [];
80
+ const bottom = (safe && safe.bottom) || 0;
81
+ if (!bottom) return issues;
82
+ for (const { name, rect } of nodes) {
83
+ const over = rect.bottom - (boardRect.bottom - bottom);
84
+ if (over > 1) issues.push(`${name} intrudes ${Math.round(over)}px into the bottom safe zone (reserves ${bottom}px)`);
85
+ }
86
+ return issues;
87
+ }
88
+
89
+ /* ── DOM-dependent (real browser only) ────────────────────────────────────── */
90
+
91
+ const CHECKED_SELECTOR = '.cc-headline, .cc-body, .cc-cta, .cc-lockup';
92
+
93
+ function rectOf(node) {
94
+ const r = node.getBoundingClientRect();
95
+ return { top: r.top, bottom: r.bottom, right: r.right };
96
+ }
97
+
98
+ /* Resolves any valid CSS color string (hex, rgb(), a named color, or a
99
+ * var() the browser has already inlined) to an [r,g,b] triplet by letting
100
+ * the browser's own parser do the work via a throwaway element — the only
101
+ * reliable way to normalize an arbitrary brand token value. */
102
+ function resolveCssColor(value) {
103
+ const probe = document.createElement('span');
104
+ probe.style.display = 'none';
105
+ probe.style.color = value;
106
+ document.body.appendChild(probe);
107
+ const rgb = parseRgb(getComputedStyle(probe).color);
108
+ probe.remove();
109
+ return rgb;
110
+ }
111
+
112
+ function sameRgb(a, b) {
113
+ return Boolean(a && b && a[0] === b[0] && a[1] === b[1] && a[2] === b[2]);
114
+ }
115
+
116
+ /* Runs every check against a live, fitted `.cc-board` and returns
117
+ * `{ errors, warnings, width, height }`. `placement` is this placement's own
118
+ * registry entry (pixel size, safe zone); `brand` is the same object handed
119
+ * to renderBoard. An empty `errors` array means the frame is safe to ship;
120
+ * `warnings` are non-fatal (e.g. a soft, upscaled photo). */
121
+ export async function runAssertions(board, placement, brand) {
122
+ const errors = [];
123
+ const warnings = [];
124
+ const br = rectOf(board);
125
+
126
+ const nodes = [...board.querySelectorAll(CHECKED_SELECTOR)].map((n) => ({ name: n.className, rect: rectOf(n) }));
127
+
128
+ const spill = spillDetect(br, nodes);
129
+ if (spill.length) errors.push(`text spill: ${spill.join('; ')}`);
130
+
131
+ const safe = {
132
+ top: Number(board.dataset.safeTop) || 0,
133
+ bottom: Number(board.dataset.safeBottom) || 0,
134
+ right: Number(board.dataset.safeRight) || 0,
135
+ };
136
+ const intrusion = safeZoneIntrusion(br, nodes, safe);
137
+ if (intrusion.length) errors.push(`safe-zone intrusion: ${intrusion.join('; ')}`);
138
+
139
+ if (brand && brand.fontFamily) {
140
+ const headlineEl = board.querySelector('.cc-headline');
141
+ const size = Math.round(parseFloat(getComputedStyle(headlineEl).fontSize)) || 16;
142
+ if (!document.fonts.check(`800 ${size}px "${brand.fontFamily}"`)) {
143
+ errors.push(`font "${brand.fontFamily}" did not load (would export in a fallback face)`);
144
+ }
145
+ }
146
+
147
+ const imgs = [...board.querySelectorAll('img')];
148
+ const broken = imgs.filter((i) => !i.complete || i.naturalWidth === 0).map((i) => i.getAttribute('src'));
149
+ if (broken.length) errors.push(`image(s) failed to load: ${broken.join(', ')}`);
150
+
151
+ // A bleed/card layout paints its photo as a CSS background, invisible to
152
+ // the <img> check above — probe it directly, the same way a typo'd path
153
+ // would otherwise export a flat, photo-less board and pass everything else.
154
+ const bgImage = getComputedStyle(board).backgroundImage;
155
+ const bgUrlMatch = /url\("?(.+?)"?\)/.exec(bgImage || '');
156
+ if (bgUrlMatch && bgUrlMatch[1]) {
157
+ const probe = await new Promise((done) => {
158
+ const img = new Image();
159
+ img.onload = () => done({ ok: true, w: img.naturalWidth, h: img.naturalHeight });
160
+ img.onerror = () => done({ ok: false });
161
+ img.src = bgUrlMatch[1];
162
+ });
163
+ if (!probe.ok) {
164
+ errors.push(`background photo failed to load: ${bgUrlMatch[1]}`);
165
+ } else if (placement && probe.w < placement.w * 2 && probe.h < placement.h * 2) {
166
+ // Soft photography check: a source smaller than the 2x frame is
167
+ // upscaled, so the photo goes soft while the text stays crisp.
168
+ warnings.push(`photo is ${probe.w}x${probe.h}, upscaled to fit ${placement.w * 2}x${placement.h * 2}`);
169
+ }
170
+ }
171
+
172
+ const headlineEl = board.querySelector('.cc-headline');
173
+ const em = headlineEl && headlineEl.querySelector('em');
174
+ if (em) {
175
+ const emRgb = parseRgb(getComputedStyle(em).color);
176
+ const expectedRaw = getComputedStyle(board).getPropertyValue('--cc-accent').trim();
177
+ const expectedRgb = expectedRaw ? resolveCssColor(expectedRaw) : null;
178
+ if (!emRgb) {
179
+ errors.push('accent colour did not resolve on the headline');
180
+ } else if (expectedRgb && !sameRgb(emRgb, expectedRgb)) {
181
+ errors.push(`accent colour unresolved: headline em is ${getComputedStyle(em).color}, brand accent resolves to ${expectedRaw}`);
182
+ }
183
+ }
184
+
185
+ let contrast = null;
186
+ if (headlineEl) {
187
+ const fgRgb = parseRgb(getComputedStyle(headlineEl).color);
188
+ let bgRgb = null;
189
+ if (fgRgb) {
190
+ for (let n = headlineEl; n && n !== document.body; n = n.parentElement) {
191
+ const cs = getComputedStyle(n);
192
+ if (cs.backgroundImage && cs.backgroundImage !== 'none') {
193
+ const stops = (cs.backgroundImage.match(/rgba?\([^)]+\)/g) || []).map(parseRgb).filter(Boolean);
194
+ if (stops.length) {
195
+ // A scrim gradient: its darkest declared stop is the worst case.
196
+ bgRgb = stops.reduce((worst, c) => (relativeLuminance(c) < relativeLuminance(worst) ? c : worst));
197
+ break;
198
+ }
199
+ }
200
+ const col = cs.backgroundColor;
201
+ if (col && !/rgba?\(0,\s*0,\s*0,\s*0\)|transparent/.test(col)) {
202
+ const rgb = parseRgb(col);
203
+ if (rgb) { bgRgb = rgb; break; }
204
+ }
205
+ }
206
+ }
207
+ if (fgRgb && bgRgb) contrast = contrastRatio(fgRgb, bgRgb);
208
+ }
209
+ if (contrast !== null && contrast < 3) {
210
+ errors.push(`headline contrast is ${contrast}:1 against its background, below the 3:1 floor for large text`);
211
+ }
212
+
213
+ const boxRect = board.getBoundingClientRect();
214
+ const width = Math.round(boxRect.width);
215
+ const height = Math.round(boxRect.height);
216
+ if (placement && (width !== placement.w || height !== placement.h)) {
217
+ errors.push(`artboard is ${width}x${height}, expected ${placement.w}x${placement.h}`);
218
+ }
219
+
220
+ return { errors, warnings, contrast, width, height };
221
+ }
@@ -0,0 +1,39 @@
1
+ /* Brand font loading shared by the editor (mount.js) and the headless render
2
+ * pages (frame.js, sheet.js). Browser-only; a no-op outside a document. */
3
+
4
+ /* Loads `brand.fontUrl` as a <link rel="stylesheet"> (a Google Fonts URL or a
5
+ * self-hosted @font-face sheet both work this way) and resolves once the
6
+ * browser has it, or immediately if there is no fontUrl. Idempotent: calling
7
+ * it twice with the same URL does not insert a second <link>. Does not touch
8
+ * `document.fonts.ready` itself — callers that need the font *painted*, not
9
+ * just requested, should await that separately (mountBoard does). */
10
+ const loadedFontUrls = new Set();
11
+ export function loadFont(brand) {
12
+ const href = brand && brand.fontUrl;
13
+ if (!href) return Promise.resolve();
14
+ if (loadedFontUrls.has(href)) return Promise.resolve();
15
+ if (typeof document === 'undefined') return Promise.resolve();
16
+ const existing = Array.from(document.querySelectorAll('link[rel="stylesheet"]')).find((l) => l.href === href);
17
+ if (existing) {
18
+ loadedFontUrls.add(href);
19
+ return Promise.resolve();
20
+ }
21
+ return new Promise((resolve, reject) => {
22
+ const link = document.createElement('link');
23
+ link.rel = 'stylesheet';
24
+ link.href = href;
25
+ link.onload = () => { loadedFontUrls.add(href); resolve(); };
26
+ link.onerror = () => reject(new Error('failed to load font stylesheet: ' + href));
27
+ document.head.appendChild(link);
28
+ });
29
+ }
30
+
31
+ /* Loads the brand font and waits until its face is actually available, so
32
+ * fitting measures the real glyphs. Safe when the brand has no font. */
33
+ export async function ensureBrandFont(brand) {
34
+ await loadFont(brand);
35
+ const family = brand && brand.fontFamily;
36
+ if (family && typeof document !== 'undefined' && document.fonts && document.fonts.load) {
37
+ await Promise.all([400, 700].map((w) => document.fonts.load(`${w} 32px "${family}"`).catch(() => [])));
38
+ }
39
+ }