skilldrop-cli 0.13.4 → 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 +32 -555
- 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/dev-team/skills/contribution-wizard/SKILL.md +6 -6
- package/packs/dev-team/skills/contribution-wizard/manifest.json +2 -2
- 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,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,11 +1,11 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: contribution-wizard
|
|
3
|
-
description: Guides an author through creating a new skilldrop skill from scratch — generates the manifest, SKILL.md, eval cases, and
|
|
3
|
+
description: Guides an author through creating a new skilldrop skill from scratch — generates the manifest, SKILL.md, eval cases, and skill-catalogue entry from a plain-language description of the skill's purpose. Use when authoring a new skill for this repo or a private fork, ensuring a new skill meets the schema and validation requirements before submission.
|
|
4
4
|
---
|
|
5
5
|
|
|
6
6
|
# contribution-wizard
|
|
7
7
|
|
|
8
|
-
Walks an author through the full arc of creating a new skilldrop skill: intake → manifest + SKILL.md + evals +
|
|
8
|
+
Walks an author through the full arc of creating a new skilldrop skill: intake → manifest + SKILL.md + evals + skill-catalogue entry. The output is copy-paste-ready — the author drops the files into `packs/<pack>/skills/<name>/` and runs `python3 validate.py` to confirm before opening a PR.
|
|
9
9
|
|
|
10
10
|
## How to respond
|
|
11
11
|
|
|
@@ -89,12 +89,12 @@ Eight eval cases covering: 2 happy-path, 2 minimal/edge input, 2 anti-pattern (t
|
|
|
89
89
|
]
|
|
90
90
|
```
|
|
91
91
|
|
|
92
|
-
####
|
|
92
|
+
#### Skill catalogue entry
|
|
93
93
|
|
|
94
|
-
One row for the
|
|
94
|
+
One row for `guides/reference/skill-catalogue.md`, under the right category, matching the format of existing rows:
|
|
95
95
|
|
|
96
96
|
```
|
|
97
|
-
| [`<skill-name>`](packs/<pack>/skills/<skill-name>/SKILL.md) | <One sentence describing what the skill does and the output it produces.> |
|
|
97
|
+
| [`<skill-name>`](../../packs/<pack>/skills/<skill-name>/SKILL.md) | <One sentence describing what the skill does and the output it produces.> |
|
|
98
98
|
```
|
|
99
99
|
|
|
100
100
|
#### Pack and outcome
|
|
@@ -110,7 +110,7 @@ Next steps:
|
|
|
110
110
|
1. mkdir -p packs/<pack>/skills/<skill-name> && cd packs/<pack>/skills/<skill-name>
|
|
111
111
|
2. Drop in manifest.json, SKILL.md, and evals/cases.json
|
|
112
112
|
3. python3 validate.py # must pass before opening a PR
|
|
113
|
-
4. Add the
|
|
113
|
+
4. Add the row to guides/reference/skill-catalogue.md under the correct category
|
|
114
114
|
5. Add the skill to an outcome in catalogue.json
|
|
115
115
|
6. If the skill has a how-to guide, add it to guides/how-to/ and guides/README.md
|
|
116
116
|
```
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "contribution-wizard",
|
|
3
|
-
"version": "1.0.
|
|
4
|
-
"description": "Guides an author through creating a new skilldrop skill from scratch — generates the manifest, SKILL.md, eval cases, and
|
|
3
|
+
"version": "1.0.2",
|
|
4
|
+
"description": "Guides an author through creating a new skilldrop skill from scratch — generates the manifest, SKILL.md, eval cases, and skill-catalogue entry from a plain-language description of the skill's purpose. Use when authoring a new skill for this repo or a private fork, ensuring a new skill meets the schema and validation requirements before submission.",
|
|
5
5
|
"entrypoint": "SKILL.md",
|
|
6
6
|
"deps": { "npm": [], "pip": [] },
|
|
7
7
|
"env": { "required": [], "optional": [] },
|
|
@@ -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
|