feat(primitives): 5 shell primitives — design-scrape, live-preview, figma-tokens, frontend-inspect, screenshot-decode
This commit is contained in:
parent
ebf841c7d9
commit
8c60085862
5 changed files with 458 additions and 0 deletions
111
_primitives/design-scrape.sh
Executable file
111
_primitives/design-scrape.sh
Executable 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
73
_primitives/figma-tokens.sh
Executable 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
109
_primitives/frontend-inspect.sh
Executable 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
102
_primitives/live-preview.sh
Executable 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
|
||||
63
_primitives/screenshot-decode.sh
Executable file
63
_primitives/screenshot-decode.sh
Executable 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)"'
|
||||
Loading…
Reference in a new issue