diff --git a/_primitives/design-scrape.sh b/_primitives/design-scrape.sh new file mode 100755 index 0000000..74e9f22 --- /dev/null +++ b/_primitives/design-scrape.sh @@ -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 [--out ] +# +# OUTPUT +# /desktop.png +# /mobile.png +# /tokens.json +# /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 [--out ] + +Captures two full-page screenshots (desktop 1280x900, mobile 375x812), +extracts computed tokens + DOM structure via Playwright. Writes all +artefacts under (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" diff --git a/_primitives/figma-tokens.sh b/_primitives/figma-tokens.sh new file mode 100755 index 0000000..9b345e4 --- /dev/null +++ b/_primitives/figma-tokens.sh @@ -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 [--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= figma-tokens [--out ] + +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" diff --git a/_primitives/frontend-inspect.sh b/_primitives/frontend-inspect.sh new file mode 100755 index 0000000..d9d91a3 --- /dev/null +++ b/_primitives/frontend-inspect.sh @@ -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 [] # default: current directory +# frontend-inspect --json # machine-readable JSON output + +set -eu + +DIR="${1:-.}" +JSON=0 +[ "${2:-}" = "--json" ] && JSON=1 + +usage() { + cat <<'EOF' +Usage: frontend-inspect [] [--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 diff --git a/_primitives/live-preview.sh b/_primitives/live-preview.sh new file mode 100755 index 0000000..4b1c74f --- /dev/null +++ b/_primitives/live-preview.sh @@ -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 +# live-preview stop [pid] # default: reads .keisei/dev-server.pid +# live-preview status + +set -eu + +CMD="${1:-}" + +usage() { + cat <<'EOF' +Usage: live-preview start — start `npm run dev` in , 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 diff --git a/_primitives/screenshot-decode.sh b/_primitives/screenshot-decode.sh new file mode 100755 index 0000000..48a1e35 --- /dev/null +++ b/_primitives/screenshot-decode.sh @@ -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 [--prompt ] +# +# 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= screenshot-decode [--prompt ] + +Posts + 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)"'