diff --git a/.claude/hooks/route-skills.sh b/.claude/hooks/route-skills.sh index 54b720898..7bbf89fdc 100755 --- a/.claude/hooks/route-skills.sh +++ b/.claude/hooks/route-skills.sh @@ -163,6 +163,17 @@ if has '(write|draft|author|compose).{0,24}(a |an |the )?(seo )?blog' \ add_match "webjs-blog-write: the request is to write or substantially edit a blog post. Invoke the webjs-blog-write skill BEFORE writing prose. It requires analyzing the author's voice by reading all blog/*.md first, and carries the hard rules (no em-dashes, no internal #NNN numbers in prose, no process tells), the SEO topic + front-matter conventions, the de-duplication check against every existing post, and the mandatory dogfood verification of every factual claim in a feature post." fi +# --- webjs-instagram-post: publish an SEO post to Instagram ------------- +# Triggers: post to Instagram, publish an Instagram post, share/promote a +# page or feature on Instagram. Every post is SEO-only and MUST ship a +# freshly created branded image plus a keyword-rich caption. The skill +# holds the account id, the credential location (never printed), the image +# recipe, the public-hosting requirement, and the confirm-first publish. +if printf '%s' "$lc" | grep -q 'instagram' \ + && has '(post|publish|share|promote|upload|announce|put up|schedule)'; then + add_match "webjs-instagram-post: the request is to publish to the WebJs Instagram account. Invoke the webjs-instagram-post skill. Every post is SEO-only, so ALWAYS create a fresh branded image plus a keyword-rich caption, host the JPEG at a public HTTPS URL, and CONFIRM the image and caption with the user before the public publish. Never print or commit the access token." +fi + # --- code-review: review the diff before a PR is ready ------------------ # Triggers: review the PR/diff/branch/changes, code review, look it over # for bugs. Reviewing every change before it is marked ready is a standing diff --git a/.claude/skills/webjs-instagram-post/SKILL.md b/.claude/skills/webjs-instagram-post/SKILL.md new file mode 100644 index 000000000..e7e0f850a --- /dev/null +++ b/.claude/skills/webjs-instagram-post/SKILL.md @@ -0,0 +1,207 @@ +--- +name: webjs-instagram-post +description: > + Publish an SEO post to the WebJs Instagram account (@webjs_dev). Use this + skill whenever the user asks to post to Instagram, publish an Instagram + post, share something on Instagram, or promote a page/feature/blog on + Instagram for SEO. Every post is strictly for SEO reach, so every post + MUST ship a freshly created branded image plus a keyword-rich caption. + The skill holds the account id, the credential location, the image recipe, + the public-hosting requirement, and the two-step publish flow. It never + prints or commits the access token. +allowed-tools: Bash(curl:*), Bash(magick:*), Bash(convert:*), Bash(identify:*), Bash(cp:*), Bash(mkdir:*), Bash(ls:*), Bash(git:*), Read, Write +--- + +# Post to Instagram for SEO + +Every Instagram post on the WebJs account exists for SEO reach, not for +personal updates. So each post carries two things you build here: a freshly +created branded image, and a keyword-rich caption that names the capability +and links back to a webjs.dev page. Never publish a plain-text post and never +reuse a stale image. + +## Account and API + +- **Account** is the `@webjs_dev` professional (BUSINESS) account. +- **Numeric user id** is `17841441038146182`. On `graph.instagram.com` you can + also address it as `me`. +- **API flavor** is the Instagram API with Instagram Login, so every call goes + to `https://graph.instagram.com/v25.0/...` with a long-lived USER access + token. An app token (the `appid|secret` shape) can never post. + +## Credentials (never printed, never committed) + +The token lives in a gitignored file OUTSIDE the repo: + +``` +~/.config/webjs/instagram.env +``` + +with `IG_TOKEN`, `IG_USER_ID`, and `IG_APP_SECRET`. Source it inside a script +and reference `${IG_TOKEN}`. NEVER `cat`, `echo`, or otherwise print the file, +and NEVER copy the token into the repo, a commit, or a chat message. If the +file is missing, ask the user to generate a long-lived user token from the +Meta dashboard and place it there. + +```sh +set -a; . ~/.config/webjs/instagram.env; set +a +``` + +## Step 1: create the branded SEO image (required, JPEG) + +The Instagram publishing API accepts a **JPEG only**, pulled from a public URL. +`build-post.sh` renders at 2x supersample and outputs a sharpened **1440x1440** +JPEG (quality 95) by default. This matters: a flat 1080 render looks soft in +Instagram's DESKTOP full-size view on a high-DPI screen after IG re-compresses +it, and the supersampled 1440 stays crisp. Do not drop back to a 1x 1080 render. + +Match the webjs.dev OG theme, which is a NEAR-BLACK card with a soft warm glow +in the top-right corner, the orange squircle mark plus the white `webjs` +wordmark, a bold white headline with ONE orange-highlighted phrase, gray +subtext, and a small-caps footer meta row with a `webjs.dev` URL. It is a dark +card with orange accents, NOT an orange background. + +`assets/build-post.sh` produces exactly this, parameterised by env var. Wrap the +key headline phrase in an orange span, and bold a few subtext words in white: + +```sh +OUT=/tmp/post.jpg \ +HEADLINE='The web framework for AI agents' \ +SUBTEXT='No build step. Built on web components, SSR, and progressive enhancement. Standards that outlast frameworks.' \ +KICKER='BUILT FOR THE AI ERA' \ +META='NO BUILD · WEB COMPONENTS · SSR' \ + bash "$(dirname "$0")/assets/build-post.sh" +``` + +It needs ImageMagick with Pango. Always read `$OUT` back and eyeball it before +publishing: check the headline does not collide with the subtext (the script +stacks them dynamically, but a very long headline can still crowd), and that +the footer meta does not overrun the `webjs.dev` URL. + +### Verify the thumbnail crop (do NOT skip this) + +The Instagram **profile grid** crops a 1:1 post to a **3:4 portrait**, keeping +full height but trimming roughly 135px off EACH side of a 1080-wide image. So +text that looks fine on the square card gets clipped, or sticks to the edge, in +the grid thumbnail. `build-post.sh` already keeps every element inside a 200px +safe margin for this reason, but ALWAYS confirm by simulating the crop and +eyeballing it: + +```sh +# 3:4 crop of the default 1440 output (keep full height, center 75% width) +magick "$OUT" -gravity center -crop 1080x1440+0+0 +repage /tmp/grid-crop.jpg +# open /tmp/grid-crop.jpg: no text may touch an edge, padding on both sides +``` + +If anything is tight, widen `PAD` in `build-post.sh` and shrink the headline / +subtext `-size` widths, then rebuild and re-check. This took several iterations +in practice, so treat the crop check as mandatory, not optional. + +## Step 2: host the image at a public HTTPS URL + +Meta fetches the image server-side, so it MUST sit at a public HTTPS URL before +you publish. The on-brand, SEO-positive route is to commit it into the website +and deploy, which also lands a real asset on webjs.dev: + +1. Copy the JPEG to `website/public/social/.jpg` in a worktree. +2. Commit, push, open a PR, and after merge Railway serves it at + `https://webjs.dev/public/social/.jpg`. +3. Confirm the URL returns `200` with `content-type: image/jpeg` before Step 4. + +For a quick one-off where a deploy is too heavy, any public HTTPS host works, +as long as the final URL is reachable and serves real JPEG bytes. + +ALWAYS verify the hosted URL round-trips real bytes BEFORE publishing: + +```sh +curl -s "$IMG" -o /tmp/verify.jpg && identify /tmp/verify.jpg +``` + +A host can return a URL yet store an EMPTY file (catbox did this in testing), +and Instagram then fails the create-container call with a useless generic +`{"error":{"code":1,"message":"An unknown error has occurred."}}`. If you see +code 1, suspect the image URL first: confirm it downloads a valid JPEG of the +right dimensions, and re-host on a different provider if not. + +## Step 3: write the SEO caption + +- First line names the capability with the primary keyword, since the first + line is what search and the feed show. +- Add a short body that expands the value in plain words. +- Close with a bare `webjs.dev/` reference (Instagram captions are not + clickable, but the text is still indexable and it drives to the bio link). +- Append a focused hashtag set. Reuse a core set and add a few per-topic ones, + for example `#webdevelopment #javascript #webcomponents #framework #ssr + #frontend #buildless #aicoding`. + +Keep it honest about what WebJs does. Do not oversell, and do not frame a +WebJs feature as another tool's branded feature. + +## Step 4: publish (confirm first, it is public) + +Publishing is public and hard to undo, so ALWAYS confirm the exact image and +caption with the user before this step. Check the quota first (limit is 25 per +rolling 24 hours). + +```sh +set -a; . ~/.config/webjs/instagram.env; set +a +IMG="https://webjs.dev/public/social/why-webjs.jpg" +CAP="Build on the platform, not against it. WebJs is a no-build, web-components-first framework. webjs.dev/why #webdevelopment #javascript #webcomponents" + +# quota +curl -s "https://graph.instagram.com/v25.0/me/content_publishing_limit?access_token=${IG_TOKEN}" + +# 1) create the media container +CREATE=$(curl -s "https://graph.instagram.com/v25.0/me/media" \ + --data-urlencode "image_url=${IMG}" \ + --data-urlencode "caption=${CAP}" \ + --data-urlencode "access_token=${IG_TOKEN}") +echo "$CREATE" +CREATION_ID=$(printf '%s' "$CREATE" | grep -oE '"id":"[0-9]+"' | head -1 | grep -oE '[0-9]+') + +# 2) WAIT until the container is FINISHED. Publishing too fast fails with +# code 9007 "Media ID is not available" even for a static image. +for i in $(seq 1 25); do + ST=$(curl -s "https://graph.instagram.com/v25.0/${CREATION_ID}?fields=status_code&access_token=${IG_TOKEN}") + printf '%s' "$ST" | grep -q '"status_code":"FINISHED"' && break + printf '%s' "$ST" | grep -q '"status_code":"ERROR"' && { echo "container ERROR: $ST"; break; } +done + +# 3) publish the container +curl -s "https://graph.instagram.com/v25.0/me/media_publish" \ + --data-urlencode "creation_id=${CREATION_ID}" \ + --data-urlencode "access_token=${IG_TOKEN}" +echo +``` + +Only the API JSON is ever printed, so the token stays out of the transcript. +A successful publish returns the new media id. Report that id to the user. + +## Token refresh (keeps the account posting) + +A long-lived token lasts 60 days and is refreshable any time after it is 24 +hours old. Refresh on a schedule so it never lapses: + +```sh +set -a; . ~/.config/webjs/instagram.env; set +a +curl -s "https://graph.instagram.com/v25.0/refresh_access_token?grant_type=ig_refresh_token&access_token=${IG_TOKEN}" +``` + +Then write the returned token back into `~/.config/webjs/instagram.env` without +printing it. + +## The profile avatar asset + +`assets/webjs-instagram-avatar.png` is the 1080x1080 profile picture for the +account. It is full-bleed brand orange with the white `webjs` wordmark, so +Instagram's circular crop yields a clean filled circle rather than the clipped +squircle the old favicon produced. It is set manually in the Instagram app (the +publishing API cannot change a profile picture). Regenerate it with: + +```sh +magick -size 1080x1080 -define gradient:angle=135 gradient:'#FF8C3A'-'#DE5F10' base.png +magick -background none -fill white -font Adwaita-Sans-ExtraBold -kerning -8 \ + -size 800x -gravity center label:'webjs' wordmark.png +magick base.png wordmark.png -gravity center -composite \ + assets/webjs-instagram-avatar.png +``` diff --git a/.claude/skills/webjs-instagram-post/assets/build-post.sh b/.claude/skills/webjs-instagram-post/assets/build-post.sh new file mode 100755 index 000000000..d1173466d --- /dev/null +++ b/.claude/skills/webjs-instagram-post/assets/build-post.sh @@ -0,0 +1,102 @@ +#!/usr/bin/env bash +# +# Build one on-theme WebJs Instagram post image that matches the webjs.dev OG +# cards: near-black background, a soft warm glow in the top-right corner, the +# orange squircle mark plus the white "webjs" wordmark, a bold white headline +# with one orange-highlighted phrase, gray subtext, and a small-caps footer +# meta row with a "webjs.dev" URL. +# +# Sharpness: the whole card is rendered at SS x supersample (default 2, so a +# 2160px canvas) and downscaled with Lanczos + a light unsharp to the final +# size. This keeps the logo and text crisp in Instagram's DESKTOP full-size +# view (a flat 1080 render looks soft there on a high-DPI screen after IG +# re-compresses it). Output defaults to 1440x1440 at JPEG quality 95. +# +# Override the copy per post via env vars (all optional): +# HEADLINE pango markup, wrap the key phrase in +# SUBTEXT pango markup, use +# KICKER top-right label text (uppercase reads best) +# META footer tags, " · " separated +# OUT output path (default ./post.jpg) +# OUT_SIZE final square px (default 1440) +# SS supersample factor (default 2) +# +# Requires ImageMagick with Pango (magick -list format | grep -i pango). +set -euo pipefail +cd "$(dirname "$0")" + +OUT="${OUT:-post.jpg}" +OUT_SIZE="${OUT_SIZE:-1440}" +SS="${SS:-2}" +KICKER="${KICKER:-BUILT FOR THE AI ERA}" +META="${META:-NO BUILD · WEB COMPONENTS · SSR}" +HEADLINE="${HEADLINE:-The web framework for AI agents}" +SUBTEXT="${SUBTEXT:-No build step. Built on web components, SSR, and progressive enhancement. Standards that outlast frameworks.}" + +# Everything below is expressed in 1080-design units, multiplied by SS so the +# card renders on an (1080*SS) canvas and is downsized to OUT_SIZE at the end. +s() { echo $(( $1 * SS )); } # scale one integer +CANVAS=$(s 1080) + +W=/tmp/webjs-ig-post +mkdir -p "$W" + +# background: dim orange radial cropped so the core sits in the top-right corner +magick -size $(s 2160)x$(s 2160) radial-gradient:'#4a2a14'-'#0a0a0a' \ + -crop ${CANVAS}x${CANVAS}+0+${CANVAS} +repage "$W/bg.png" + +LOGO=$(s 76); LR=$(( LOGO - 1 )); LRAD=$(s 22) +magick -size ${LOGO}x${LOGO} -define gradient:angle=135 gradient:'#FF9A45'-'#E06110' \ + \( -size ${LOGO}x${LOGO} xc:none -fill white -draw "roundrectangle 0,0,${LR},${LR},${LRAD},${LRAD}" \) \ + -alpha off -compose CopyOpacity -composite "$W/logo.png" + +magick -background none -fill white -font Adwaita-Sans-ExtraBold -pointsize $(s 42) \ + -kerning $(s -1) label:'webjs' "$W/wordmark.png" + +magick -background none \ + pango:"${KICKER}" \ + "$W/kicker.png" + +# Text sits inside the center-safe column so Instagram's 3:4 profile-grid crop +# (trims ~12.5% off each side) never clips it. +magick -background none -size $(s 660)x \ + pango:"${HEADLINE}" \ + "$W/headline.png" + +magick -background none -size $(s 640)x \ + pango:"${SUBTEXT}" \ + "$W/subtext.png" + +magick -background none \ + pango:" ${META}" \ + "$W/footer-left.png" +magick -background none \ + pango:"webjs.dev" \ + "$W/footer-right.png" + +magick -size $(s 660)x$(s 2) xc:'#282828' "$W/divider.png" + +# 200px left/right safe margin (in design units) so the grid crop leaves +# comfortable padding, not text touching the thumbnail edge. +PAD=$(s 200) +HY=$(s 336) +H_head=$(identify -format '%h' "$W/headline.png") +SY=$(( HY + H_head + $(s 36) )) + +magick "$W/bg.png" \ + "$W/logo.png" -gravity northwest -geometry +${PAD}+$(s 86) -composite \ + "$W/wordmark.png" -gravity northwest -geometry +$(( PAD + $(s 92) ))+$(s 104) -composite \ + "$W/kicker.png" -gravity northeast -geometry +${PAD}+$(s 112) -composite \ + "$W/headline.png" -gravity northwest -geometry +${PAD}+${HY} -composite \ + "$W/subtext.png" -gravity northwest -geometry +$(( PAD + $(s 2) ))+${SY} -composite \ + "$W/divider.png" -gravity northwest -geometry +${PAD}+$(s 902) -composite \ + "$W/footer-left.png" -gravity northwest -geometry +${PAD}+$(s 936) -composite \ + "$W/footer-right.png" -gravity northeast -geometry +${PAD}+$(s 940) -composite \ + "$W/composed.png" + +# Downscale with Lanczos + a light unsharp so glyph edges stay crisp after +# Instagram re-compresses, and output a high-quality JPEG. +magick "$W/composed.png" -filter Lanczos -resize ${OUT_SIZE}x${OUT_SIZE} \ + -unsharp 0x0.6+0.6+0 -colorspace sRGB -strip -quality 95 "$OUT" + +identify -format 'built %f %wx%h %m %b\n' "$OUT" diff --git a/.claude/skills/webjs-instagram-post/assets/webjs-instagram-avatar.png b/.claude/skills/webjs-instagram-post/assets/webjs-instagram-avatar.png new file mode 100644 index 000000000..d6faf02c7 Binary files /dev/null and b/.claude/skills/webjs-instagram-post/assets/webjs-instagram-avatar.png differ diff --git a/test/hooks/route-skills.test.mjs b/test/hooks/route-skills.test.mjs index 653d5e0da..7cf53ea9d 100644 --- a/test/hooks/route-skills.test.mjs +++ b/test/hooks/route-skills.test.mjs @@ -135,6 +135,23 @@ test('webjs-blog-write stays quiet on a docs-sync prompt', () => { assert.ok(!routed(ctx, 'webjs-blog-write'), 'doc-sync prompt must not route to blog-write'); }); +test('webjs-instagram-post routes on instagram-posting phrases', () => { + for (const p of [ + 'post this to instagram', + 'publish an instagram post about the new feature', + 'share the why page on instagram for SEO', + 'promote the streaming actions on instagram', + ]) { + const { ctx } = run(p); + assert.ok(routed(ctx, 'webjs-instagram-post'), `expected instagram route for: ${p}`); + } +}); + +test('webjs-instagram-post stays quiet without instagram intent', () => { + const { ctx } = run('publish the release notes to the changelog'); + assert.ok(!routed(ctx, 'webjs-instagram-post'), 'non-instagram publish must not route to instagram'); +}); + test('code-review routes on review phrases', () => { for (const p of [ 'review the PR',