skilldrop-cli 0.13.5 → 0.13.7

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 (40) hide show
  1. package/README.md +3 -2
  2. package/bin/skilldrop.js +17 -5
  3. package/catalogue.json +19 -1
  4. package/contracts/catalogue.schema.json +1 -0
  5. package/model-routing.json +8 -0
  6. package/package.json +1 -1
  7. package/packs/design/pack.json +22 -0
  8. package/packs/design/skills/brand-kit/SKILL.md +105 -0
  9. package/packs/design/skills/brand-kit/evals/eval_queries.json +30 -0
  10. package/packs/design/skills/brand-kit/evals/evals.json +18 -0
  11. package/packs/design/skills/brand-kit/examples/northwind-brand.md +48 -0
  12. package/packs/design/skills/brand-kit/manifest.json +44 -0
  13. package/packs/design/skills/brand-kit/scripts/check_brand.py +114 -0
  14. package/packs/design/skills/brand-kit/templates/brand.json +54 -0
  15. package/packs/{stakeholder-comms → design}/skills/deck-builder/SKILL.md +27 -9
  16. package/packs/{stakeholder-comms → design}/skills/deck-builder/manifest.json +2 -2
  17. package/packs/{stakeholder-comms → design}/skills/deck-builder/scripts/build_deck.py +105 -3
  18. package/packs/{stakeholder-comms → design}/skills/deck-builder/templates/deck-spec.json +4 -1
  19. package/packs/design/skills/marketing-flyer/SKILL.md +113 -0
  20. package/packs/design/skills/marketing-flyer/evals/eval_queries.json +30 -0
  21. package/packs/design/skills/marketing-flyer/evals/evals.json +18 -0
  22. package/packs/design/skills/marketing-flyer/examples/community-health-fair.md +68 -0
  23. package/packs/design/skills/marketing-flyer/manifest.json +39 -0
  24. package/packs/design/skills/marketing-flyer/reference.md +54 -0
  25. package/packs/design/skills/marketing-flyer/scripts/build_flyer.py +323 -0
  26. package/packs/design/skills/marketing-flyer/templates/flyer-spec.json +26 -0
  27. package/packs/stakeholder-comms/pack.json +1 -1
  28. package/profiles.json +1 -1
  29. /package/packs/{stakeholder-comms → design}/skills/deck-builder/evals/eval_queries.json +0 -0
  30. /package/packs/{stakeholder-comms → design}/skills/deck-builder/evals/evals.json +0 -0
  31. /package/packs/{stakeholder-comms → design}/skills/deck-builder/examples/exec-board-update.md +0 -0
  32. /package/packs/{stakeholder-comms → design}/skills/deck-builder/examples/templated-brand-deck.md +0 -0
  33. /package/packs/{stakeholder-comms → design}/skills/deck-builder/reference.md +0 -0
  34. /package/packs/{stakeholder-comms → design}/skills/deck-builder/requirements.txt +0 -0
  35. /package/packs/{stakeholder-comms → design}/skills/deck-builder/templates/palettes.json +0 -0
  36. /package/packs/{stakeholder-comms → design}/skills/slide-outliner/SKILL.md +0 -0
  37. /package/packs/{stakeholder-comms → design}/skills/slide-outliner/evals/eval_queries.json +0 -0
  38. /package/packs/{stakeholder-comms → design}/skills/slide-outliner/evals/evals.json +0 -0
  39. /package/packs/{stakeholder-comms → design}/skills/slide-outliner/manifest.json +0 -0
  40. /package/packs/{stakeholder-comms → design}/skills/slide-outliner/templates/deck-outline.md +0 -0
@@ -0,0 +1,54 @@
1
+ # marketing-flyer reference
2
+
3
+ ## Sizes
4
+
5
+ | `size` | Dimensions | Use |
6
+ |---|---|---|
7
+ | `letter` | 8.5 × 11 in | US noticeboards and handouts (default) |
8
+ | `a4` | 210 × 297 mm | Everywhere outside the US |
9
+ | `a5` | 148 × 210 mm | Leaflets, counter cards, two to a sheet |
10
+ | `square` | 1080 × 1080 px | Instagram and LinkedIn posts; export with `--png` |
11
+ | `story` | 1080 × 1920 px | Stories and phone screens; export with `--png` |
12
+
13
+ `"orientation": "landscape"` swaps width and height. The HTML sets `@page` to the exact size
14
+ with no margins and turns on exact colour printing, so a browser's *Print → Save as PDF* at
15
+ 100% scale reproduces it. Home and office printers can't print to the edge. For a flyer
16
+ printed on a desk printer, keep important content at least 0.25 in (6 mm) from the edge,
17
+ which every layout already does except for the full-width image and band. For professional
18
+ printing with bleed, export the PDF and ask the printer for their bleed requirements.
19
+
20
+ ## Layouts
21
+
22
+ | `layout` | Looks like | Use when |
23
+ |---|---|---|
24
+ | `hero` | Logo band, a full-width photo, then the copy | There's a strong photo, and the thing itself is the draw |
25
+ | `split` | A photo (or brand-colour block) down the left, copy on the right | The photo is portrait, or the copy is a list of points |
26
+ | `event` | Logo band, a large date block beside the headline, then photo and details | The date is the most important fact |
27
+ | `minimal` | Logo, headline, a rounded photo, copy on white | Offers and announcements where restraint reads as quality |
28
+
29
+ The first `details` entry is the date block in `event`; put the date there.
30
+
31
+ ## Copy budgets
32
+
33
+ | Element | Budget | Why |
34
+ |---|---|---|
35
+ | Headline | ≤ 10 words | Read from across a room in two seconds |
36
+ | Subhead | One line | Who it's for, or why it's worth it |
37
+ | Points | ≤ 4, one line each | A flyer is scanned, not read |
38
+ | Body | ≤ 60 words, usually none | A paragraph is skipped on a noticeboard |
39
+ | Call to action | Exactly one, a verb | Two choices means neither gets made |
40
+
41
+ ## Colour and contrast
42
+
43
+ - **Body text** must reach 4.5:1 against the background. The script refuses to build below that.
44
+ - **Header band and call to action:** the script prints white or the brand's text colour,
45
+ whichever contrasts more with the fill. A pale accent therefore gets dark text. Don't
46
+ override this.
47
+ - **Points' markers use the accent.** Keep the accent for shapes and highlights, not long text.
48
+
49
+ ## Fonts
50
+
51
+ The HTML names the brand's fonts with a fallback, such as `'Georgia', serif`. A font the
52
+ viewer doesn't have installed falls back to the generic family, so the PDF uses whatever is
53
+ installed on the machine that printed it. For an exact match, export the PDF on a machine
54
+ that has the brand fonts installed.
@@ -0,0 +1,323 @@
1
+ #!/usr/bin/env python3
2
+ """Build a print-ready, on-brand flyer as one self-contained HTML file. Stdlib only.
3
+
4
+ The spec (see templates/flyer-spec.json) gives the content; a brand.json (the brand-kit
5
+ skill writes one) gives the logo, colours and fonts. Images are embedded as data URIs, so the
6
+ .html file is the whole flyer: open it and print, or save as PDF, at 100% scale.
7
+
8
+ Usage:
9
+ python3 build_flyer.py spec.json -o flyer.html [--brand brand.json] [--pdf flyer.pdf] [--png flyer.png]
10
+
11
+ --pdf and --png use a local Chrome or Chromium in headless mode when one is installed; without
12
+ one the script says so and the HTML is still the deliverable.
13
+ """
14
+ import argparse
15
+ import base64
16
+ import html
17
+ import json
18
+ import mimetypes
19
+ import os
20
+ import re
21
+ import shutil
22
+ import subprocess
23
+ import sys
24
+ from pathlib import Path
25
+
26
+ SIZES = { # (width, height) in CSS units, portrait
27
+ "letter": ("8.5in", "11in"), "a4": ("210mm", "297mm"), "a5": ("148mm", "210mm"),
28
+ "square": ("1080px", "1080px"), "story": ("1080px", "1920px"),
29
+ }
30
+ PX = {"letter": (816, 1056), "a4": (794, 1123), "a5": (559, 794), "square": (1080, 1080), "story": (1080, 1920)}
31
+ LAYOUTS = ("hero", "split", "event", "minimal")
32
+ DEFAULTS = {"primary": "#1A2A6C", "secondary": "#4A5A8C", "accent": "#FDBB2D", "background": "#FFFFFF", "text": "#222222"}
33
+ WARNINGS = []
34
+
35
+
36
+ def warn(msg):
37
+ WARNINGS.append(msg)
38
+ print(f"WARN {msg}", file=sys.stderr)
39
+
40
+
41
+ def die(msg):
42
+ print(f"ERROR {msg}", file=sys.stderr)
43
+ sys.exit(1)
44
+
45
+
46
+ def lum(hex_str):
47
+ h = hex_str.lstrip("#")
48
+ def ch(v):
49
+ v = int(v, 16) / 255
50
+ return v / 12.92 if v <= 0.03928 else ((v + 0.055) / 1.055) ** 2.4
51
+ r, g, b = (ch(h[i:i + 2]) for i in (0, 2, 4))
52
+ return 0.2126 * r + 0.7152 * g + 0.0722 * b
53
+
54
+
55
+ def contrast(a, b):
56
+ la, lb = sorted((lum(a), lum(b)), reverse=True)
57
+ return (la + 0.05) / (lb + 0.05)
58
+
59
+
60
+ def ink_on(bg, text, light="#FFFFFF"):
61
+ """The readable text colour on a filled band: white or the brand text colour, whichever
62
+ contrasts more — so a pale accent gets dark text instead of illegible white."""
63
+ return light if contrast(light, bg) >= contrast(text, bg) else text
64
+
65
+
66
+ def data_uri(path, label):
67
+ if not path:
68
+ return None
69
+ p = Path(path)
70
+ if not p.exists():
71
+ warn(f"{label} not found: {p} — the flyer is built without it")
72
+ return None
73
+ mime = mimetypes.guess_type(p.name)[0] or "application/octet-stream"
74
+ if not mime.startswith("image/"):
75
+ warn(f"{label} {p.name} is not an image ({mime})")
76
+ return None
77
+ return f"data:{mime};base64,{base64.b64encode(p.read_bytes()).decode()}"
78
+
79
+
80
+ def load_json(path):
81
+ try:
82
+ return json.loads(Path(path).read_text())
83
+ except (OSError, json.JSONDecodeError) as e:
84
+ die(f"cannot read {path}: {e}")
85
+
86
+
87
+ def resolve(base, ref):
88
+ return None if not ref else (Path(ref) if Path(ref).is_absolute() else (base / ref))
89
+
90
+
91
+ def font_stack(f, generic):
92
+ if not f:
93
+ return generic
94
+ fam = f.get("family") if isinstance(f, dict) else f
95
+ fb = (f.get("fallback") if isinstance(f, dict) else None) or generic
96
+ return f"'{fam}', {fb}" if fam else fb
97
+
98
+
99
+ def esc(s):
100
+ return html.escape(str(s or ""), quote=True)
101
+
102
+
103
+ def build(spec_path, out_path, brand_ref=None):
104
+ spec_path = Path(spec_path).resolve()
105
+ base = spec_path.parent
106
+ spec = load_json(spec_path)
107
+
108
+ brand, bbase = {}, base
109
+ ref = brand_ref or spec.get("brand")
110
+ if isinstance(ref, dict):
111
+ brand = ref
112
+ elif ref:
113
+ bp = resolve(base, ref) if not brand_ref else Path(ref).resolve()
114
+ if bp.exists():
115
+ brand, bbase = load_json(bp), bp.parent
116
+ else:
117
+ warn(f"brand file not found: {bp} — using default colours and no logo")
118
+ if not brand:
119
+ warn("no brand supplied — the flyer uses default colours. Run brand-kit, or pass --brand")
120
+
121
+ c = {**DEFAULTS, **{k: v for k, v in (brand.get("colors") or {}).items() if k in DEFAULTS and v},
122
+ **{k: v for k, v in (spec.get("colors") or {}).items() if k in DEFAULTS and v}}
123
+ for k, v in c.items():
124
+ if not re.match(r"^#[0-9A-Fa-f]{6}$", v):
125
+ die(f"colour {k} is {v!r}, not a 6-digit hex code")
126
+ if contrast(c["text"], c["background"]) < 4.5:
127
+ die(f"text on background contrast is {contrast(c['text'], c['background']):.2f}:1, below 4.5:1 — fix the brand colours")
128
+ on_primary, on_accent = ink_on(c["primary"], c["text"]), ink_on(c["accent"], c["text"])
129
+ fonts = brand.get("fonts") or {}
130
+ head_font, body_font = font_stack(fonts.get("heading"), "Georgia, serif"), font_stack(fonts.get("body"), "Helvetica, Arial, sans-serif")
131
+
132
+ logo = brand.get("logo") or {}
133
+ if isinstance(logo, str):
134
+ logo = {"primary": logo}
135
+ logo_light = data_uri(resolve(bbase, logo.get("primary")), "logo")
136
+ logo_dark = data_uri(resolve(bbase, logo.get("on_dark")), "on-dark logo") or logo_light
137
+ if not logo_light and not logo_dark:
138
+ warn("no logo — the brand name is set as text instead")
139
+ name = brand.get("name") or spec.get("name") or ""
140
+
141
+ size = str(spec.get("size", "letter")).lower()
142
+ if size not in SIZES:
143
+ warn(f"unknown size {size!r}; using letter")
144
+ size = "letter"
145
+ w, h = SIZES[size]
146
+ landscape = str(spec.get("orientation", "portrait")).lower() == "landscape"
147
+ if landscape:
148
+ w, h = h, w
149
+ layout = str(spec.get("layout", "hero")).lower()
150
+ if layout not in LAYOUTS:
151
+ warn(f"unknown layout {layout!r}; using hero")
152
+ layout = "hero"
153
+
154
+ headline = spec.get("headline") or die("spec needs a headline")
155
+ if len(headline.split()) > 10:
156
+ warn(f"headline is {len(headline.split())} words; ten or fewer read from across a room")
157
+ points = spec.get("points") or []
158
+ if len(points) > 4:
159
+ warn(f"{len(points)} points; a flyer carries four at most — the rest belong on the linked page")
160
+ body = spec.get("body") or ""
161
+ if len(body.split()) > 60:
162
+ warn(f"body is {len(body.split())} words; keep it under 60")
163
+ cta = spec.get("cta")
164
+ if isinstance(cta, list):
165
+ warn("more than one call to action; a flyer gets one — using the first")
166
+ cta = cta[0] if cta else None
167
+ if not cta:
168
+ warn("no call to action — what should a reader do next?")
169
+ img = spec.get("image") or {}
170
+ hero = data_uri(resolve(base, img.get("path")), "image") if isinstance(img, dict) else data_uri(resolve(base, img), "image")
171
+ if hero and isinstance(img, dict) and not img.get("alt"):
172
+ warn("image has no alt text")
173
+ qr = data_uri(resolve(base, spec.get("qr")), "QR code")
174
+ contact = {**(brand.get("contact") or {}), **(spec.get("contact") or {})}
175
+ footer = spec.get("footer") or (brand.get("legal") or {}).get("footer") or ""
176
+ details = spec.get("details") or []
177
+
178
+ def logo_img(dark):
179
+ src = logo_dark if dark else logo_light
180
+ return f'<img class="logo" src="{src}" alt="{esc(name)}">' if src else f'<span class="wordmark">{esc(name)}</span>'
181
+
182
+ pts = "".join(f"<li>{esc(p)}</li>" for p in points[:4])
183
+ det = "".join(f'<div class="det"><span>{esc(d.get("label"))}</span><b>{esc(d.get("value"))}</b></div>' for d in details)
184
+ cta_html = (f'<div class="cta"><b>{esc(cta.get("text"))}</b>'
185
+ + (f'<span>{esc(cta.get("url"))}</span>' if cta.get("url") else "")
186
+ + (f'<img class="qr" src="{qr}" alt="QR code for {esc(cta.get("url") or cta.get("text"))}">' if qr else "")
187
+ + "</div>") if cta else ""
188
+ contact_line = " · ".join(esc(v) for k, v in contact.items() if v and k in ("website", "email", "phone", "address"))
189
+ img_html = f'<div class="img" style="background-image:url({hero})" role="img" aria-label="{esc(img.get("alt", ""))}"></div>' if hero else ""
190
+
191
+ if layout == "split":
192
+ main = f"""<div class="split"><div class="side">{img_html or f'<div class="sideblock">{logo_img(True)}</div>'}</div>
193
+ <div class="col">{'' if not img_html else logo_img(False)}<h1>{esc(headline)}</h1>
194
+ {f'<p class="sub">{esc(spec.get("subhead"))}</p>' if spec.get("subhead") else ''}
195
+ {f'<p class="body">{esc(body)}</p>' if body else ''}<ul class="pts">{pts}</ul><div class="dets">{det}</div>{cta_html}</div></div>"""
196
+ elif layout == "event":
197
+ first = details[0] if details else {}
198
+ main = f"""<header class="band">{logo_img(True)}</header>
199
+ <div class="eventtop"><div class="date"><span>{esc(first.get("label", ""))}</span><b>{esc(first.get("value", ""))}</b></div>
200
+ <div><h1>{esc(headline)}</h1>{f'<p class="sub">{esc(spec.get("subhead"))}</p>' if spec.get("subhead") else ''}</div></div>
201
+ {img_html}<div class="col">{f'<p class="body">{esc(body)}</p>' if body else ''}<ul class="pts">{pts}</ul>
202
+ <div class="dets">{"".join(f'<div class="det"><span>{esc(d.get("label"))}</span><b>{esc(d.get("value"))}</b></div>' for d in details[1:])}</div>{cta_html}</div>"""
203
+ elif layout == "minimal":
204
+ main = f"""<div class="col min">{logo_img(False)}<h1>{esc(headline)}</h1>
205
+ {f'<p class="sub">{esc(spec.get("subhead"))}</p>' if spec.get("subhead") else ''}{img_html}
206
+ {f'<p class="body">{esc(body)}</p>' if body else ''}<ul class="pts">{pts}</ul><div class="dets">{det}</div>{cta_html}</div>"""
207
+ else: # hero
208
+ main = f"""<header class="band">{logo_img(True)}</header>{img_html}
209
+ <div class="col"><h1>{esc(headline)}</h1>{f'<p class="sub">{esc(spec.get("subhead"))}</p>' if spec.get("subhead") else ''}
210
+ {f'<p class="body">{esc(body)}</p>' if body else ''}<ul class="pts">{pts}</ul><div class="dets">{det}</div>{cta_html}</div>"""
211
+
212
+ unit = "px" if size in ("square", "story") else "pt"
213
+ # Social sizes are set in px. A square has a third less height than a page, so its type is
214
+ # smaller; a story is tall and takes larger type.
215
+ scale = {"square": 1.35, "story": 2.1}.get(size, 1.0)
216
+ doc = f"""<!doctype html>
217
+ <html lang="en"><head><meta charset="utf-8">
218
+ <title>{esc(headline)} — {esc(name)}</title>
219
+ <meta name="viewport" content="width=device-width, initial-scale=1">
220
+ <style>
221
+ @page {{ size: {w} {h}; margin: 0; }}
222
+ * {{ box-sizing: border-box; }}
223
+ html, body {{ margin: 0; padding: 0; background: #e9e9e6; }}
224
+ body {{ -webkit-print-color-adjust: exact; print-color-adjust: exact; }}
225
+ .page {{ width: {w}; height: {h}; margin: {'0' if unit == 'px' else '24px'} auto; background: {c['background']}; color: {c['text']};
226
+ font-family: {body_font}; position: relative; overflow: hidden; display: flex; flex-direction: column;
227
+ box-shadow: 0 6px 30px rgba(0,0,0,.18); font-size: {11 * scale}{unit}; line-height: 1.45; }}
228
+ @media print {{ html, body {{ background: none; }} .page {{ margin: 0; box-shadow: none; }} }}
229
+ h1 {{ font-family: {head_font}; color: {c['primary']}; font-size: {40 * scale}{unit}; line-height: 1.05; margin: 0 0 .35em; letter-spacing: -.01em; }}
230
+ .sub {{ font-size: {16 * scale}{unit}; margin: 0 0 .9em; color: {c['secondary'] if contrast(c['secondary'], c['background']) >= 4.5 else c['text']}; }}
231
+ .body {{ margin: 0 0 .9em; }}
232
+ .band {{ background: {c['primary']}; color: {on_primary}; padding: {18 * scale}{unit} {36 * scale}{unit}; display: flex; align-items: center; }}
233
+ .logo {{ max-height: {44 * scale}{unit}; max-width: 55%; }}
234
+ .wordmark {{ font-family: {head_font}; font-weight: 700; font-size: {20 * scale}{unit}; }}
235
+ .band .wordmark {{ color: {on_primary}; }}
236
+ .img {{ height: {'26%' if size == 'square' else '34%'}; background-size: cover; background-position: center; }}
237
+ .col {{ padding: {28 * scale}{unit} {36 * scale}{unit}; flex: 1; display: flex; flex-direction: column; }}
238
+ .col .logo {{ margin-bottom: {18 * scale}{unit}; align-self: flex-start; }}
239
+ .pts {{ list-style: none; padding: 0; margin: 0 0 1em; }}
240
+ .pts li {{ padding-left: 1.2em; position: relative; margin: .35em 0; font-size: {13 * scale}{unit}; }}
241
+ .pts li::before {{ content: ""; position: absolute; left: 0; top: .45em; width: .55em; height: .55em; background: {c['accent']}; border-radius: 2px; }}
242
+ .dets {{ display: grid; grid-template-columns: repeat(auto-fit, minmax(30%, 1fr)); gap: .6em 1.2em; margin: 0 0 1.2em; }}
243
+ .det span {{ display: block; font-size: {9 * scale}{unit}; text-transform: uppercase; letter-spacing: .08em; color: {c['secondary'] if contrast(c['secondary'], c['background']) >= 4.5 else c['text']}; }}
244
+ .det b {{ font-size: {13 * scale}{unit}; }}
245
+ .cta {{ margin-top: auto; background: {c['accent']}; color: {on_accent}; border-radius: 10px; padding: {16 * scale}{unit} {20 * scale}{unit};
246
+ display: grid; grid-template-columns: 1fr auto; align-items: center; gap: .2em 1em; }}
247
+ .cta b {{ font-family: {head_font}; font-size: {20 * scale}{unit}; }}
248
+ .cta span {{ grid-column: 1; font-size: {12 * scale}{unit}; }}
249
+ .cta .qr {{ grid-column: 2; grid-row: 1 / span 2; width: {64 * scale}{unit}; height: {64 * scale}{unit}; background: #fff; padding: 4px; border-radius: 4px; }}
250
+ .split {{ display: grid; grid-template-columns: 42% 1fr; flex: 1; }}
251
+ .split .side .img {{ height: 100%; }}
252
+ .sideblock {{ background: {c['primary']}; height: 100%; display: flex; align-items: flex-start; padding: {36 * scale}{unit} {28 * scale}{unit}; }}
253
+ .eventtop {{ display: grid; grid-template-columns: auto 1fr; gap: 1.4em; align-items: center; padding: {28 * scale}{unit} {36 * scale}{unit} 0; }}
254
+ .date {{ background: {c['accent']}; color: {on_accent}; border-radius: 10px; padding: .8em 1em; text-align: center; min-width: 7em; }}
255
+ .date span {{ display: block; font-size: {9 * scale}{unit}; text-transform: uppercase; letter-spacing: .08em; }}
256
+ .date b {{ font-family: {head_font}; font-size: {18 * scale}{unit}; line-height: 1.15; }}
257
+ .eventtop + .img {{ margin-top: {20 * scale}{unit}; }}
258
+ .min {{ justify-content: flex-start; }}
259
+ .min .img {{ height: 30%; border-radius: 10px; margin: 0 0 1em; }}
260
+ .foot {{ padding: {12 * scale}{unit} {36 * scale}{unit}; font-size: {9 * scale}{unit}; color: {c['text']}; border-top: 1px solid {c['secondary']}33; display: flex; justify-content: space-between; gap: 1em; }}
261
+ </style></head>
262
+ <body><main class="page">
263
+ {main}
264
+ <footer class="foot"><span>{contact_line}</span><span>{esc(footer)}</span></footer>
265
+ </main></body></html>
266
+ """
267
+ out_path = Path(out_path).resolve()
268
+ out_path.parent.mkdir(parents=True, exist_ok=True)
269
+ out_path.write_text(doc, encoding="utf-8")
270
+ print(f"wrote {out_path} ({layout}, {size}{', landscape' if landscape else ''}; "
271
+ f"brand: {'yes' if brand else 'none'}; logo: {'yes' if (logo_light or logo_dark) else 'no'})")
272
+ print(f" contrast: text {contrast(c['text'], c['background']):.1f}:1 · band {contrast(on_primary, c['primary']):.1f}:1 · "
273
+ f"call to action {contrast(on_accent, c['accent']):.1f}:1")
274
+ return out_path, size, landscape
275
+
276
+
277
+ def chrome():
278
+ for p in ("/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
279
+ "/Applications/Chromium.app/Contents/MacOS/Chromium"):
280
+ if os.path.exists(p):
281
+ return p
282
+ for n in ("google-chrome", "google-chrome-stable", "chromium", "chromium-browser", "chrome"):
283
+ if shutil.which(n):
284
+ return shutil.which(n)
285
+ return None
286
+
287
+
288
+ def export(html_path, size, pdf=None, png=None, landscape=False):
289
+ exe = chrome()
290
+ if not exe:
291
+ print("No Chrome or Chromium found: open the .html in a browser and print to PDF at 100% scale, margins none.")
292
+ return
293
+ url = Path(html_path).resolve().as_uri()
294
+ if pdf:
295
+ subprocess.run([exe, "--headless=new", "--disable-gpu", "--no-pdf-header-footer",
296
+ f"--print-to-pdf={Path(pdf).resolve()}", url], check=False, capture_output=True)
297
+ print(f"wrote {Path(pdf).resolve()}" if Path(pdf).exists() else "PDF export failed — print from the browser instead")
298
+ if png:
299
+ pw, ph = PX[size][::-1] if landscape else PX[size]
300
+ subprocess.run([exe, "--headless=new", "--disable-gpu", "--hide-scrollbars", f"--window-size={pw},{ph}",
301
+ f"--screenshot={Path(png).resolve()}", url], check=False, capture_output=True)
302
+ print(f"wrote {Path(png).resolve()}" if Path(png).exists() else "PNG export failed")
303
+
304
+
305
+ def main(argv):
306
+ ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
307
+ ap.add_argument("spec")
308
+ ap.add_argument("-o", "--output", default=None, help="output .html (default: alongside the spec)")
309
+ ap.add_argument("--brand", default=None, help="brand.json from brand-kit; overrides the spec's brand field")
310
+ ap.add_argument("--pdf", default=None, help="also write a PDF through headless Chrome, when installed")
311
+ ap.add_argument("--png", default=None, help="also write a PNG (for social sizes) through headless Chrome")
312
+ a = ap.parse_args(argv)
313
+ out = a.output or str(Path(a.spec).with_suffix(".html"))
314
+ html_path, size, landscape = build(a.spec, out, a.brand)
315
+ if a.pdf or a.png:
316
+ export(html_path, size, a.pdf, a.png, landscape)
317
+ if WARNINGS:
318
+ print(f"{len(WARNINGS)} warning(s) — fix them or tell the user before sharing the flyer")
319
+ return 0
320
+
321
+
322
+ if __name__ == "__main__":
323
+ sys.exit(main(sys.argv[1:]))
@@ -0,0 +1,26 @@
1
+ {
2
+ "$comment": "marketing-flyer spec. Paths are relative to this file. brand may be a path to brand.json (from brand-kit) or the same object inline; --brand on the command line overrides it. colors overrides individual brand colours for this flyer only.",
3
+ "brand": "./brand/brand.json",
4
+ "size": "letter",
5
+ "orientation": "portrait",
6
+ "layout": "event",
7
+ "headline": "Free community health fair",
8
+ "subhead": "Checkups, flu shots and healthy cooking — for the whole family",
9
+ "body": "",
10
+ "points": [
11
+ "Free blood pressure and glucose checks",
12
+ "Flu shots, no appointment needed",
13
+ "Kids' corner and healthy cooking demos"
14
+ ],
15
+ "details": [
16
+ { "label": "When", "value": "Sat 14 Nov, 10am–2pm" },
17
+ { "label": "Where", "value": "Riverside Park pavilion" },
18
+ { "label": "Cost", "value": "Free" }
19
+ ],
20
+ "cta": { "text": "Save your spot", "url": "northwindhealth.example/fair" },
21
+ "qr": "./qr-fair.png",
22
+ "image": { "path": "./riverside-park.jpg", "alt": "Families on the lawn at Riverside Park" },
23
+ "contact": { "phone": "" },
24
+ "footer": "© 2026 Northwind Health",
25
+ "colors": {}
26
+ }
@@ -1,5 +1,5 @@
1
1
  {
2
- "description": "Non-technical audiences: audience profiling, deck outlines and real .pptx decks, exec summaries, decision logs, and guides.",
2
+ "description": "Non-technical audiences: audience profiling, exec summaries, decision logs, and guides. Decks live in the design pack.",
3
3
  "display_name": "Stakeholder comms",
4
4
  "keywords": ["exec-summary", "deck", "audience", "decision-log"],
5
5
  "links": {
package/profiles.json CHANGED
@@ -16,7 +16,7 @@
16
16
  },
17
17
  "full": {
18
18
  "description": "The complete catalogue: every pack, every reviewer agent, every loop.",
19
- "packs": ["core", "solution-architect", "product-manager", "dev-team", "sre-oncall", "stakeholder-comms", "ai-engineering", "claude-api"],
19
+ "packs": ["core", "solution-architect", "product-manager", "dev-team", "sre-oncall", "stakeholder-comms", "design", "ai-engineering", "claude-api"],
20
20
  "agents": ["devils-advocate", "security-reviewer", "code-quality"],
21
21
  "loops": ["discover", "design", "build", "release", "operate", "ship-a-draft"]
22
22
  }