From content-creation
Maintains LovStudio website partners section and aligns logos on event posters/hero strips: scrapes/normalizes logos to 240px retina canvas, rasterizes SVGs via rsvg-convert, strips backgrounds, composites icon+wordmark, wraps in grid boxes, adds/replaces partners with i18n taglines, audits dead links/files/translations.
npx claudepluginhub lovstudio/skills --plugin content-creationThis skill uses the workspace's default tool permissions.
Operates on `/Users/mark/lovstudio/coding/web` (the LovStudio website repo).
Generates custom SVG logos and brand kits by scanning project context (README, package.json, CSS, assets). Exports favicons (16-512px + ICO), social images (OG/Twitter), dark/light/mono variants, and HTML preview.
Designs and iterates on SVG logos via structured interviews for format, style, colors, and sizes. Generates side-by-side previews and PNG exports at standard sizes.
Guides logo placement (top-left optimal), linking to homepage, alt text, sizing, clear space, responsive implementation, and variants on websites for brand recall and navigation.
Share bugs, ideas, or general feedback.
Operates on /Users/mark/lovstudio/coding/web (the LovStudio website repo).
The partners strip lives in app/(main)/(home)/WorkshopDispatch.tsx as a
PARTNERS: Partner[] array; logos in public/partners/<slug>/logo.png;
taglines in src/i18n/messages/{zh-CN,en,ja,th}.json under dispatch.partner*Tagline.
height: 32px ≈ 2.5× density, sharp enough),
240px for event posters or any retina export at scale: 2 or higher.--show-name
when adding so the brand name renders next to the icon.<品牌名> · <一句话定位> in Chinese; mirror style in en/ja/th.AskUserQuestion.python3 ~/.claude/skills/lovstudio-maintain-partners/scripts/scrape_logo.py \
--url <URL> --download /tmp/<slug>.png
If empty, retry with --js. If still empty, ask the user for a direct logo URL or local file.python3 ~/.claude/skills/lovstudio-maintain-partners/scripts/normalize_logo.py \
--src /tmp/<slug>.png \
--dst /Users/mark/lovstudio/coding/web/public/partners/<slug>/logo.png \
--invert auto
python3 ~/.claude/skills/lovstudio-maintain-partners/scripts/add_partner.py \
--name "<显示名>" --href "<URL>" \
--logo "/partners/<slug>/logo.png" \
--key partner<Slug>Tagline \
--zh "..." --en "..." --ja "..." --th "..." \
[--show-name]
python3 ~/.claude/skills/lovstudio-maintain-partners/scripts/normalize_logo.py \
--src public/partners/<slug>/logo.png \
--dst public/partners/<slug>/logo.png \
--invert auto
Re-read after to verify.
User typically provides a path under ~/lovstudio/partners/<品牌>/<file>.
python3 ~/.claude/skills/lovstudio-maintain-partners/scripts/normalize_logo.py \
--src "<user-provided path>" \
--dst /Users/mark/lovstudio/coding/web/public/partners/<slug>/logo.png \
--invert auto
JPEG inputs auto-strip near-white background to transparent before crop.
python3 ~/.claude/skills/lovstudio-maintain-partners/scripts/audit_partners.py
# add --probe to also HTTP-check every href (slow, requires proxy)
Reports: missing logo files, missing i18n keys per locale, dead URLs.
When: putting 3+ partner logos in a single horizontal strip and they look
different sizes despite having the same CSS height. Common in event posters,
hero sections, "联办 / co-host" rows.
Root cause: each source file has different internal padding (designer
canvas margin), so two PNGs both set to height: 24px render at different
visible heights because their content occupies different fractions of the
canvas. Per-logo CSS height tweaks based on eyeballed content ratios are
unstable—different displays / scaling will diverge again.
Reliable fix — trim at file level, uniform CSS box:
Normalize every logo to identical content height. Default raster file
target is 240px (3× density for retina poster export at scale: 2;
80px gives only 1.7× and looks soft after PNG export). Use --invert off
if the source is already light-on-transparent (don't double-invert):
for f in lujiazui juanyi citic-bookstore citic-thinker-lab; do
python3 ~/.claude/skills/lovstudio-maintain-partners/scripts/normalize_logo.py \
--src ~/lovstudio/partners/<brand>/<file>.png \
--dst <event-assets>/partners/$f.png \
--height 240 --invert auto
done
Always normalize from the original source, never from a previously normalized 80px file (upscaling = blurry — burned by this on juanyi).
For SVG sources, rasterize first. normalize_logo.py operates on
raster pixels and cannot crop SVG viewBox padding. Without this step
an SVG always renders smaller than rasterized PNG siblings:
rsvg-convert -h 720 brand.svg -o /tmp/brand-raw.png # 3× of 240
python3 ~/.claude/skills/lovstudio-maintain-partners/scripts/normalize_logo.py \
--src /tmp/brand-raw.png --dst <event-assets>/partners/brand.png \
--height 240 --invert off
rsvg-convert ships with librsvg (brew install librsvg).
For SVG with embedded background rect (icon wrapped in a black/colored
rounded square — common in app-icon-style SVGs from find-logo), strip
the background before rasterizing, otherwise filter brightness(0) invert(1) flattens it into a solid white block that hides the icon:
# Drop the outer <rect fill="#000"...> wrapper
sed -E 's|<rect[^/]*fill="#0+"[^/]*/>||' brand.svg > /tmp/brand-clean.svg
rsvg-convert -h 720 /tmp/brand-clean.svg -o /tmp/brand-raw.png
Wrap each logo in a fixed-size box (recommended over auto-width flex):
<span class="ps-logo-box"><img src="..." class="ps-logo"></span>
.ps-logo-box {
width: 96px; height: 30px; /* fixed grid cell */
display: inline-flex;
align-items: center; justify-content: center;
border: 1px solid rgba(255,255,255,0.10);
border-radius: 4px;
padding: 3px 6px;
box-sizing: border-box;
}
.ps-logo { max-width: 100%; max-height: 100%; width: auto; height: auto; display: block; }
Fixed boxes give a stable matrix look — narrow logos (icon-only) and wide logos (icon + wordmark) all occupy the same footprint, with the asset scaled to fit. Auto-width flex (the older recipe) makes per-row total widths unpredictable as logos get added/removed.
Dark-background unification — when the row sits on a dark canvas (e.g. event poster), most brand logos are designed for white BG and look inconsistent (some have black text, some have brand-colored marks). The stable recipe:
.ps-logo { filter: brightness(0) invert(1) opacity(0.88); }
/* logos already white-on-transparent — opt out of inversion */
.ps-logo.ps-logo-original { filter: opacity(0.88); }
brightness(0) flattens all colors to black, then invert(1) produces
uniform white at the configured opacity. The .ps-logo-original escape
hatch is for source files that are already white-on-transparent (white
SVG variants from a brand kit) so you don't double-process them into
invisible black-on-dark.
Icon-only SVG → composite icon + wordmark — if the brand SVG only has an icon (no "BrandName" wordmark beside it), don't ship just the icon in a 96×30 box (it'll look like an unidentified mark). Compose the wordmark with PIL using the brand's own font when possible:
from PIL import Image, ImageDraw, ImageFont, ImageOps
# 1. rasterize cleaned SVG, invert white→black so default filter works
icon = Image.open('/tmp/brand-icon.png').convert('RGBA')
r, g, b, a = icon.split()
inv = Image.merge('RGB', (ImageOps.invert(r), ImageOps.invert(g), ImageOps.invert(b)))
icon = Image.merge('RGBA', (*inv.split(), a))
icon = icon.crop(icon.getbbox())
target_h = 240
icon = icon.resize((int(icon.width * target_h / icon.height), target_h), Image.LANCZOS)
# 2. render wordmark in brand font (find-logo bundles fonts/ when found)
font = ImageFont.truetype('partners/<brand>/fonts/<Family>.ttf', 150)
# 3. compose icon + gap + text on transparent canvas
The PNG goes through the same brightness(0) invert(1) filter as raster
logos — match colors with all other entries automatically. Use the brand's
own font (often shipped under <brand>/fonts/ by the find-logo skill);
fall back to system SF / Helvetica only if no brand font is available.
Anti-pattern — do not try to fix alignment by setting per-logo
heights like .ps-logo-juanyi { height: 26px }. It's brittle (every new
logo needs another magic number), unstable across browsers, and breaks
the moment a designer reships the source asset with different padding.
| Flag | Default | Notes |
|---|---|---|
--src | required | input image (PNG/JPG/rasterized SVG) |
--dst | required | output PNG path; parent dirs auto-created |
--height | 80 | target content height. Use 240 for retina poster export (scale: 2) — 80 looks soft after 2× downscale. |
--invert | auto | auto / off / full / selective (selective preserves colored icons) |
| Flag | Default | Notes |
|---|---|---|
--url | required | brand homepage |
--js | off | use Playwright headless Chromium for SPAs |
--download | off | save first candidate to this path |
| Flag | Notes |
|---|---|
--name | display name (CJK ok) |
--href | brand URL |
--logo | path under /public, e.g. /partners/foo/logo.png |
--key | i18n key, e.g. partnerFooTagline |
--zh / --en / --ja / --th | tagline strings (all required) |
--show-name | render name next to icon for narrow logos |
| Flag | Notes |
|---|---|
--probe | HTTP-probe every href (slow, needs proxy env vars) |
Sandbox child processes don't inherit the system ClashX proxy. Before scraping or probing, export:
export https_proxy=http://127.0.0.1:7890 \
http_proxy=http://127.0.0.1:7890 \
all_proxy=socks5://127.0.0.1:7891
scrape_logo.py and audit_partners.py already inject these for curl /
Playwright invocations.
pip install Pillow --break-system-packages
# Optional, for JS-rendered SPAs:
pip install playwright --break-system-packages && playwright install chromium