skilldrop-cli 0.13.5 → 0.13.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +3 -2
- package/catalogue.json +19 -1
- package/contracts/catalogue.schema.json +1 -0
- package/model-routing.json +8 -0
- package/package.json +1 -1
- package/packs/design/pack.json +22 -0
- package/packs/design/skills/brand-kit/SKILL.md +105 -0
- package/packs/design/skills/brand-kit/evals/eval_queries.json +30 -0
- package/packs/design/skills/brand-kit/evals/evals.json +18 -0
- package/packs/design/skills/brand-kit/examples/northwind-brand.md +48 -0
- package/packs/design/skills/brand-kit/manifest.json +44 -0
- package/packs/design/skills/brand-kit/scripts/check_brand.py +114 -0
- package/packs/design/skills/brand-kit/templates/brand.json +54 -0
- package/packs/{stakeholder-comms → design}/skills/deck-builder/SKILL.md +27 -9
- package/packs/{stakeholder-comms → design}/skills/deck-builder/manifest.json +2 -2
- package/packs/{stakeholder-comms → design}/skills/deck-builder/scripts/build_deck.py +105 -3
- package/packs/{stakeholder-comms → design}/skills/deck-builder/templates/deck-spec.json +4 -1
- package/packs/design/skills/marketing-flyer/SKILL.md +113 -0
- package/packs/design/skills/marketing-flyer/evals/eval_queries.json +30 -0
- package/packs/design/skills/marketing-flyer/evals/evals.json +18 -0
- package/packs/design/skills/marketing-flyer/examples/community-health-fair.md +68 -0
- package/packs/design/skills/marketing-flyer/manifest.json +39 -0
- package/packs/design/skills/marketing-flyer/reference.md +54 -0
- package/packs/design/skills/marketing-flyer/scripts/build_flyer.py +323 -0
- package/packs/design/skills/marketing-flyer/templates/flyer-spec.json +26 -0
- package/packs/stakeholder-comms/pack.json +1 -1
- package/profiles.json +1 -1
- /package/packs/{stakeholder-comms → design}/skills/deck-builder/evals/eval_queries.json +0 -0
- /package/packs/{stakeholder-comms → design}/skills/deck-builder/evals/evals.json +0 -0
- /package/packs/{stakeholder-comms → design}/skills/deck-builder/examples/exec-board-update.md +0 -0
- /package/packs/{stakeholder-comms → design}/skills/deck-builder/examples/templated-brand-deck.md +0 -0
- /package/packs/{stakeholder-comms → design}/skills/deck-builder/reference.md +0 -0
- /package/packs/{stakeholder-comms → design}/skills/deck-builder/requirements.txt +0 -0
- /package/packs/{stakeholder-comms → design}/skills/deck-builder/templates/palettes.json +0 -0
- /package/packs/{stakeholder-comms → design}/skills/slide-outliner/SKILL.md +0 -0
- /package/packs/{stakeholder-comms → design}/skills/slide-outliner/evals/eval_queries.json +0 -0
- /package/packs/{stakeholder-comms → design}/skills/slide-outliner/evals/evals.json +0 -0
- /package/packs/{stakeholder-comms → design}/skills/slide-outliner/manifest.json +0 -0
- /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,
|
|
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
|
}
|
|
File without changes
|
|
File without changes
|
/package/packs/{stakeholder-comms → design}/skills/deck-builder/examples/exec-board-update.md
RENAMED
|
File without changes
|
/package/packs/{stakeholder-comms → design}/skills/deck-builder/examples/templated-brand-deck.md
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|