feat(primitives): 5 shell primitives — design-scrape, live-preview, figma-tokens, frontend-inspect, screenshot-decode

This commit is contained in:
Parfii-bot 2026-04-21 21:07:45 +08:00
parent ebf841c7d9
commit 8c60085862
5 changed files with 458 additions and 0 deletions

111
_primitives/design-scrape.sh Executable file
View file

@ -0,0 +1,111 @@
#!/usr/bin/env sh
# design-scrape — Playwright-based scrape of a live website into tokens,
# section map, and full-page screenshots. Output: one directory per URL.
#
# USAGE
# design-scrape <url> [--out <dir>]
#
# OUTPUT
# <out>/desktop.png
# <out>/mobile.png
# <out>/tokens.json
# <out>/structure.json
#
# Requires: npx (Node), Playwright (`npx playwright install chromium` once).
set -eu
URL="${1:-}"
OUT="${OUT:-./design-scrape}"
usage() {
cat <<'EOF'
Usage: design-scrape <url> [--out <dir>]
Captures two full-page screenshots (desktop 1280x900, mobile 375x812),
extracts computed tokens + DOM structure via Playwright. Writes all
artefacts under <dir> (default: ./design-scrape).
EOF
}
[ -z "$URL" ] || [ "$URL" = "-h" ] || [ "$URL" = "--help" ] && { usage; [ -z "$URL" ] && exit 1; exit 0; }
# --out arg parsing (positional URL first)
shift
while [ $# -gt 0 ]; do
case "$1" in
--out) OUT="$2"; shift 2 ;;
*) echo "design-scrape: unknown arg: $1" >&2; exit 1 ;;
esac
done
if ! command -v npx >/dev/null 2>&1; then
echo "design-scrape: npx not found. Install Node 20+." >&2
exit 1
fi
if ! command -v jq >/dev/null 2>&1; then
echo "design-scrape: jq not found. brew install jq / apt install jq" >&2
exit 1
fi
mkdir -p "$OUT"
# Inline Playwright script piped on stdin. We generate a self-contained .mjs
# so npx resolves `playwright` from the nearest node_modules OR installs ephemeral.
SCRIPT="$OUT/.scrape.mjs"
cat > "$SCRIPT" <<'MJS'
import { chromium } from "playwright";
import { writeFileSync } from "node:fs";
import { argv } from "node:process";
const url = argv[2];
const outDir = argv[3];
const browser = await chromium.launch();
async function shot(viewport, name) {
const ctx = await browser.newContext({ viewport });
const page = await ctx.newPage();
await page.goto(url, { waitUntil: "networkidle", timeout: 45000 });
await page.screenshot({ path: `${outDir}/${name}.png`, fullPage: true });
await ctx.close();
}
await shot({ width: 1280, height: 900 }, "desktop");
await shot({ width: 375, height: 812 }, "mobile");
const ctx = await browser.newContext({ viewport: { width: 1280, height: 900 } });
const page = await ctx.newPage();
await page.goto(url, { waitUntil: "networkidle", timeout: 45000 });
const tokens = await page.evaluate(() => {
const g = (sel) => { const el = document.querySelector(sel); return el ? getComputedStyle(el) : null; };
const body = g("body"); const h1 = g("h1");
return {
colors: { background: body?.backgroundColor, text: body?.color, heading: h1?.color },
typography: { bodyFont: body?.fontFamily, bodySize: body?.fontSize, h1Font: h1?.fontFamily, h1Size: h1?.fontSize },
};
});
const structure = await page.evaluate(() => ({
title: document.title,
sections: document.querySelectorAll("section, [class*='section']").length,
headings: Array.from(document.querySelectorAll("h1,h2,h3")).map(h => ({ t: h.tagName, x: h.textContent.trim().slice(0,80) })),
}));
writeFileSync(`${outDir}/tokens.json`, JSON.stringify(tokens, null, 2));
writeFileSync(`${outDir}/structure.json`, JSON.stringify(structure, null, 2));
await browser.close();
MJS
echo "[design-scrape] capturing $URL -> $OUT" >&2
if ! npx --yes playwright --version >/dev/null 2>&1; then
echo "[design-scrape] note: Playwright not yet installed — first run will download Chromium" >&2
fi
node "$SCRIPT" "$URL" "$OUT"
rm -f "$SCRIPT"
echo "[design-scrape] done:"
ls -1 "$OUT"

73
_primitives/figma-tokens.sh Executable file
View file

@ -0,0 +1,73 @@
#!/usr/bin/env sh
# figma-tokens — fetch a Figma file's design tokens (Variables + Styles) via
# the REST API and emit a tokens.json usable by tokens-sync.
#
# USAGE
# FIGMA_TOKEN=figd_xxx figma-tokens <file-key> [--out tokens.json]
#
# The Figma personal-access-token (legacy) OR OAuth bearer token lives in
# $FIGMA_TOKEN. Never hardcode into this file — per RULE 0.8.
set -eu
FILE_KEY="${1:-}"
OUT="tokens.json"
usage() {
cat <<'EOF'
Usage: FIGMA_TOKEN=<token> figma-tokens <file-key> [--out <path>]
file-key: the part after /design/ or /file/ in the Figma URL
e.g. https://www.figma.com/design/ABC123xyz/Design-System
^^^^^^^^^^
Output JSON shape: { "colors": {...}, "fonts": {...}, "spacing": {...}, "radius": {...} }
Pipe into tokens-sync to generate Tailwind config + CSS vars.
EOF
}
[ -z "$FILE_KEY" ] || [ "$FILE_KEY" = "-h" ] || [ "$FILE_KEY" = "--help" ] && {
usage
[ -z "$FILE_KEY" ] && exit 1 || exit 0
}
shift
while [ $# -gt 0 ]; do
case "$1" in
--out) OUT="$2"; shift 2 ;;
*) echo "figma-tokens: unknown arg: $1" >&2; exit 1 ;;
esac
done
if [ -z "${FIGMA_TOKEN:-}" ]; then
echo "figma-tokens: \$FIGMA_TOKEN not set. Export via shell or \`source ~/.claude/secrets/.env\`." >&2
exit 1
fi
if ! command -v curl >/dev/null 2>&1; then
echo "figma-tokens: curl not found" >&2; exit 1
fi
if ! command -v jq >/dev/null 2>&1; then
echo "figma-tokens: jq not found (brew install jq)" >&2; exit 1
fi
API="https://api.figma.com/v1"
# Variables + local styles (styles gives colors/fonts for files that predate Variables)
VARS=$(curl -fsSL -H "X-Figma-Token: ${FIGMA_TOKEN}" "${API}/files/${FILE_KEY}/variables/local" 2>/dev/null || echo '{}')
STYLES=$(curl -fsSL -H "X-Figma-Token: ${FIGMA_TOKEN}" "${API}/files/${FILE_KEY}/styles" 2>/dev/null || echo '{}')
# Minimal extractor — colors from Variables local collection (modern files).
# Falls back to an empty colors map if the file uses Styles only.
jq -n --argjson vars "$VARS" --argjson styles "$STYLES" '
{
colors: ($vars.meta.variables // {}
| to_entries
| map(select(.value.resolvedType == "COLOR"))
| map({key: .value.name, value: (.value.valuesByMode | (to_entries|first.value) | tostring)})
| from_entries),
fonts: {},
spacing: {},
radius: {}
}
' > "$OUT"
echo "[figma-tokens] wrote $OUT"
jq '{colors: (.colors | length), fonts: (.fonts | length), spacing: (.spacing | length), radius: (.radius | length)}' "$OUT"

109
_primitives/frontend-inspect.sh Executable file
View file

@ -0,0 +1,109 @@
#!/usr/bin/env sh
# frontend-inspect — scan a project directory and report what it is:
# framework (Astro/Next/SvelteKit/Vite-React), styling (Tailwind/CSS-Modules/
# styled-components), UI-component count, and package-manager lockfile.
#
# USAGE
# frontend-inspect [<dir>] # default: current directory
# frontend-inspect <dir> --json # machine-readable JSON output
set -eu
DIR="${1:-.}"
JSON=0
[ "${2:-}" = "--json" ] && JSON=1
usage() {
cat <<'EOF'
Usage: frontend-inspect [<dir>] [--json]
Reports:
- Framework (astro / next / sveltekit / vite-react / static / unknown)
- Styling (tailwind4 / tailwind3 / css-modules / plain)
- Package manager (npm / pnpm / yarn / bun)
- Component file count (.tsx / .vue / .svelte / .astro)
- Contains tests? (yes/no)
EOF
}
[ "$DIR" = "-h" ] || [ "$DIR" = "--help" ] && { usage; exit 0; }
[ -d "$DIR" ] || { echo "frontend-inspect: $DIR not a directory" >&2; exit 1; }
PKG="$DIR/package.json"
has_dep() {
# $1 = dep name
[ -f "$PKG" ] || return 1
if command -v jq >/dev/null 2>&1; then
jq -e --arg d "$1" '(.dependencies[$d] // .devDependencies[$d] // null) != null' "$PKG" >/dev/null 2>&1
else
grep -q "\"$1\"" "$PKG" 2>/dev/null
fi
}
detect_framework() {
if has_dep astro; then echo astro; return; fi
if has_dep next; then echo next; return; fi
if has_dep "@sveltejs/kit"; then echo sveltekit; return; fi
if has_dep vite && has_dep react; then echo vite-react; return; fi
if has_dep vite && has_dep vue; then echo vite-vue; return; fi
if has_dep vite; then echo vite; return; fi
[ -f "$DIR/index.html" ] && echo static && return
echo unknown
}
detect_styling() {
if has_dep tailwindcss; then
# Tailwind 4 has `@theme` in CSS and no tailwind.config.js, usually; rough heuristic:
if [ -f "$DIR/tailwind.config.ts" ] || [ -f "$DIR/tailwind.config.js" ] || [ -f "$DIR/tailwind.config.mjs" ]; then
echo tailwind3
else
echo tailwind4
fi
return
fi
if has_dep "styled-components"; then echo styled-components; return; fi
if find "$DIR/src" -maxdepth 3 -name '*.module.css' -print -quit 2>/dev/null | grep -q .; then
echo css-modules
return
fi
echo plain
}
detect_pm() {
[ -f "$DIR/pnpm-lock.yaml" ] && echo pnpm && return
[ -f "$DIR/yarn.lock" ] && echo yarn && return
[ -f "$DIR/bun.lockb" ] && echo bun && return
[ -f "$DIR/package-lock.json" ] && echo npm && return
echo none
}
count_components() {
find "$DIR/src" -type f \( -name '*.tsx' -o -name '*.vue' -o -name '*.svelte' -o -name '*.astro' \) 2>/dev/null | wc -l | tr -d ' '
}
has_tests() {
if [ -f "$PKG" ] && (has_dep vitest || has_dep jest || has_dep "@playwright/test"); then
echo yes
else
echo no
fi
}
FW="$(detect_framework)"
ST="$(detect_styling)"
PM="$(detect_pm)"
CC="$(count_components)"
TS="$(has_tests)"
if [ "$JSON" = "1" ]; then
printf '{"dir":"%s","framework":"%s","styling":"%s","pm":"%s","components":%s,"tests":"%s"}\n' \
"$DIR" "$FW" "$ST" "$PM" "$CC" "$TS"
else
printf "dir: %s\n" "$DIR"
printf "framework: %s\n" "$FW"
printf "styling: %s\n" "$ST"
printf "pm: %s\n" "$PM"
printf "components: %s\n" "$CC"
printf "tests: %s\n" "$TS"
fi

102
_primitives/live-preview.sh Executable file
View file

@ -0,0 +1,102 @@
#!/usr/bin/env sh
# live-preview — start / stop / status for a project's dev server.
# Detects framework from package.json; stores PID in .keisei/dev-server.pid.
#
# USAGE
# live-preview start <dir>
# live-preview stop [pid] # default: reads .keisei/dev-server.pid
# live-preview status
set -eu
CMD="${1:-}"
usage() {
cat <<'EOF'
Usage: live-preview start <dir> — start `npm run dev` in <dir>, record PID
live-preview stop [pid] — stop running server (default: recorded PID)
live-preview status — show whether a server is running
EOF
}
PID_FILE() {
dir="${1:-.}"
mkdir -p "$dir/.keisei"
printf '%s/.keisei/dev-server.pid\n' "$dir"
}
detect_script() {
pkg="$1/package.json"
[ -f "$pkg" ] || { echo "dev"; return; }
if command -v jq >/dev/null 2>&1; then
jq -r '.scripts.dev // .scripts.start // "dev"' "$pkg"
else
echo "dev"
fi
}
case "$CMD" in
start)
DIR="${2:-}"
[ -z "$DIR" ] && { usage; exit 1; }
[ -d "$DIR" ] || { echo "live-preview: $DIR not a directory" >&2; exit 1; }
PID_F="$(PID_FILE "$DIR")"
if [ -f "$PID_F" ]; then
OLD="$(cat "$PID_F" 2>/dev/null || true)"
if [ -n "$OLD" ] && kill -0 "$OLD" 2>/dev/null; then
echo "live-preview: server already running pid=$OLD (pidfile $PID_F)" >&2
exit 1
fi
fi
SCRIPT="$(detect_script "$DIR")"
echo "[live-preview] starting 'npm run $SCRIPT' in $DIR" >&2
(
cd "$DIR"
nohup npm run "$SCRIPT" >.keisei/dev-server.log 2>&1 &
echo $! > ".keisei/dev-server.pid"
)
NEW="$(cat "$PID_F")"
echo "live-preview: started pid=$NEW log=$DIR/.keisei/dev-server.log"
;;
stop)
TARGET="${2:-}"
if [ -z "$TARGET" ]; then
PID_F="$(PID_FILE ".")"
[ -f "$PID_F" ] || { echo "live-preview: no pidfile at $PID_F" >&2; exit 1; }
TARGET="$(cat "$PID_F")"
fi
if kill -0 "$TARGET" 2>/dev/null; then
kill "$TARGET"
echo "live-preview: stopped pid=$TARGET"
[ -f ".keisei/dev-server.pid" ] && rm -f ".keisei/dev-server.pid"
else
echo "live-preview: pid=$TARGET not running (cleaning pidfile)" >&2
[ -f ".keisei/dev-server.pid" ] && rm -f ".keisei/dev-server.pid"
exit 1
fi
;;
status)
PID_F="$(PID_FILE ".")"
if [ ! -f "$PID_F" ]; then
echo "live-preview: no pidfile (not running from $(pwd))"
exit 0
fi
PID="$(cat "$PID_F")"
if kill -0 "$PID" 2>/dev/null; then
echo "live-preview: running pid=$PID"
else
echo "live-preview: stale pidfile (pid=$PID exited)"
rm -f "$PID_F"
fi
;;
-h|--help|help|"")
usage
;;
*)
echo "live-preview: unknown command '$CMD'" >&2
usage
exit 1
;;
esac

View file

@ -0,0 +1,63 @@
#!/usr/bin/env sh
# screenshot-decode — send a screenshot to Claude's vision API and return
# a structured description (tokens / layout / sections). For use in teardown
# and audit pipelines.
#
# USAGE
# ANTHROPIC_API_KEY=sk-ant-xxx screenshot-decode <png> [--prompt <text>]
#
# Reads $ANTHROPIC_API_KEY from env (RULE 0.8: never hardcoded).
# Requires: curl, jq, base64.
set -eu
IMG="${1:-}"
PROMPT="Describe this UI. Extract design tokens (colors, fonts), section layout, and key components. Output as JSON."
usage() {
cat <<'EOF'
Usage: ANTHROPIC_API_KEY=<key> screenshot-decode <png> [--prompt <text>]
Posts <png> + prompt to Anthropic Messages API (claude-sonnet-4) and prints
the text response. Default prompt asks for token + layout extraction.
EOF
}
[ -z "$IMG" ] || [ "$IMG" = "-h" ] || [ "$IMG" = "--help" ] && {
usage
[ -z "$IMG" ] && exit 1 || exit 0
}
[ -f "$IMG" ] || { echo "screenshot-decode: file not found: $IMG" >&2; exit 1; }
shift
while [ $# -gt 0 ]; do
case "$1" in
--prompt) PROMPT="$2"; shift 2 ;;
*) echo "screenshot-decode: unknown arg: $1" >&2; exit 1 ;;
esac
done
[ -n "${ANTHROPIC_API_KEY:-}" ] || { echo "screenshot-decode: \$ANTHROPIC_API_KEY not set" >&2; exit 1; }
command -v curl >/dev/null 2>&1 || { echo "screenshot-decode: curl not found" >&2; exit 1; }
command -v jq >/dev/null 2>&1 || { echo "screenshot-decode: jq not found" >&2; exit 1; }
B64=$(base64 < "$IMG" | tr -d '\n')
PAYLOAD=$(jq -n --arg img "$B64" --arg prompt "$PROMPT" '{
model: "claude-sonnet-4-5",
max_tokens: 2048,
messages: [{
role: "user",
content: [
{ type: "image", source: { type: "base64", media_type: "image/png", data: $img } },
{ type: "text", text: $prompt }
]
}]
}')
curl -fsSL https://api.anthropic.com/v1/messages \
-H "x-api-key: ${ANTHROPIC_API_KEY}" \
-H "anthropic-version: 2023-06-01" \
-H "content-type: application/json" \
-d "$PAYLOAD" \
| jq -r '.content[0].text // .error.message // "(no response)"'