Feat v0.6.16 usability survey gate
Some checks failed
CI/CD / detect-changes (pull_request) Successful in 3s
CI/CD / test-frontend (pull_request) Successful in 4s
CI/CD / build-and-deploy (pull_request) Has been cancelled
CI/CD / test-sqlite (pull_request) Has been cancelled
CI/CD / test-go-pg (pull_request) Has been cancelled

Machine-auditable UI quality gate: four audit scripts, structured survey
prompt, WCAG contrast fixes, mobile touch targets, focus indicators, and
Docker Hub documentation correction.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-01 14:46:12 +00:00
parent d9802df2af
commit 0353debc11
12 changed files with 1146 additions and 35 deletions

146
scripts/audit-touch-targets.sh Executable file
View File

@@ -0,0 +1,146 @@
#!/usr/bin/env bash
# audit-touch-targets.sh — Static analysis for 44px minimum touch targets on mobile.
# Scans JS templates for clickable elements, cross-references CSS for mobile sizing.
# Usage: bash scripts/audit-touch-targets.sh
set -euo pipefail
CSS_DIR="${1:-src/css}"
JS_DIR="${2:-src/js}"
PKG_DIR="${3:-packages}"
VIOLATIONS=0
# ── Phase 1: Build a map of class -> mobile sizing from CSS ──
# For each class, record whether it has min-width/min-height >= 44px in mobile breakpoint
declare -A HAS_MOBILE_SIZE # class -> "1" if it has adequate mobile touch target
declare -A CLOSE_BUTTONS # class -> "file:line" for close-button-like elements
# Known close button classes to audit specifically
CLOSE_CLASSES=(
"sw-banner__close"
"sw-toast__close"
"sw-dialog__close"
"sw-drawer__close"
"sw-tabs__arrow"
"modal-close"
)
# Scan CSS for mobile breakpoint rules with min-height/min-width >= 44px
for f in "$CSS_DIR"/*.css "$PKG_DIR"/*/css/main.css; do
[ -f "$f" ] || continue
in_mobile=0
lineno=0
current_selector=""
while IFS= read -r line; do
lineno=$((lineno + 1))
trimmed="${line#"${line%%[![:space:]]*}"}"
# Enter mobile @media block
if [[ "$trimmed" =~ ^@media.*max-width.*768 ]]; then
in_mobile=1
continue
fi
if (( in_mobile )); then
# Track brace depth to know when we leave @media
if [[ "$trimmed" == "}" ]] && [[ -z "$current_selector" ]]; then
in_mobile=0
continue
fi
# Selector line
if [[ "$trimmed" == *"."* ]] && [[ "$trimmed" == *"{"* ]]; then
sel="${trimmed%%\{*}"
if [[ "$sel" =~ \.([a-zA-Z_][a-zA-Z0-9_-]*) ]]; then
current_selector="${BASH_REMATCH[1]}"
fi
# Check inline properties
if [[ "$trimmed" =~ min-height.*44px ]] || [[ "$trimmed" =~ min-height.*2\.75rem ]] || [[ "$trimmed" =~ min-height.*var\(--sp-12\) ]]; then
HAS_MOBILE_SIZE["$current_selector"]=1
fi
if [[ "$trimmed" =~ min-width.*44px ]] || [[ "$trimmed" =~ min-width.*2\.75rem ]]; then
HAS_MOBILE_SIZE["$current_selector"]=1
fi
[[ "$trimmed" == *"}"* ]] && current_selector=""
continue
fi
# Property line inside mobile block
if [[ -n "$current_selector" ]]; then
if [[ "$trimmed" =~ min-height.*44px ]] || [[ "$trimmed" =~ min-height.*2\.75rem ]] || [[ "$trimmed" =~ min-height.*var\(--sp-12\) ]]; then
HAS_MOBILE_SIZE["$current_selector"]=1
fi
if [[ "$trimmed" =~ min-width.*44px ]] || [[ "$trimmed" =~ min-width.*2\.75rem ]]; then
HAS_MOBILE_SIZE["$current_selector"]=1
fi
[[ "$trimmed" == "}" ]] && current_selector=""
fi
fi
done < "$f"
done
echo "╔══════════════════════════════════════════════════════════════════════╗"
echo "║ Touch Target Audit Report ║"
echo "╠══════════════════════════════════════════════════════════════════════╣"
echo ""
# ── Phase 2: Check known close-button classes ──
echo "## Close Button Classes"
echo ""
for cls in "${CLOSE_CLASSES[@]}"; do
if [[ -n "${HAS_MOBILE_SIZE[$cls]:-}" ]]; then
printf " ✓ .%-30s — has mobile touch target\n" "$cls"
else
printf " ✗ .%-30s — MISSING min-width/min-height: 44px in mobile breakpoint\n" "$cls"
VIOLATIONS=$((VIOLATIONS + 1))
fi
done
echo ""
# ── Phase 3: Scan JS for small interactive elements ──
echo "## Interactive Elements in JS Templates"
echo ""
# Find elements with onClick or <button that have small/no padding and no mobile override
for f in $(find "$JS_DIR" "$PKG_DIR" -name '*.js' -type f 2>/dev/null); do
[ -f "$f" ] || continue
lineno=0
while IFS= read -r line; do
lineno=$((lineno + 1))
# Look for onClick with a class
if [[ "$line" =~ onClick ]] && [[ "$line" =~ class.*\"([a-zA-Z_][a-zA-Z0-9_\ -]*)\" ]]; then
classes="${BASH_REMATCH[1]}"
for cls in $classes; do
# Skip known-good patterns (buttons with adequate padding)
[[ "$cls" == sw-btn* ]] && continue
[[ "$cls" == sw-menu__item* ]] && continue
[[ "$cls" == sw-dropdown__option* ]] && continue
[[ "$cls" == sw-tabs__tab* ]] && continue
# Check if this class has a mobile override
if [[ -z "${HAS_MOBILE_SIZE[$cls]:-}" ]]; then
# Only flag if it looks like a small interactive element
if [[ "$cls" == *close* ]] || [[ "$cls" == *btn* ]] || [[ "$cls" == *toggle* ]] || [[ "$cls" == *trigger* ]] || [[ "$cls" == *action* ]] || [[ "$cls" == *clear* ]] || [[ "$cls" == *delete* ]] || [[ "$cls" == *remove* ]]; then
printf " ? %-50s — .%s (no mobile min-size found)\n" "$(basename "$f"):$lineno" "$cls"
fi
fi
done
fi
done < "$f"
done
echo ""
echo "╚══════════════════════════════════════════════════════════════════════╝"
echo ""
echo "Summary: $VIOLATIONS known violations in close-button classes"
echo ""
if (( VIOLATIONS > 0 )); then
echo "RESULT: FAIL — $VIOLATIONS close button(s) need min-width/min-height: 44px in @media (max-width: 768px)"
exit 1
else
echo "RESULT: PASS — all known interactive elements have adequate mobile touch targets"
exit 0
fi

225
scripts/check-contrast.sh Executable file
View File

@@ -0,0 +1,225 @@
#!/usr/bin/env bash
# check-contrast.sh — WCAG AA contrast ratio checker for theme tokens.
# Parses variables.css for dark/light token pairs, checks text-on-background contrast.
# Usage: bash scripts/check-contrast.sh [variables.css]
set -euo pipefail
CSS_FILE="${1:-src/css/variables.css}"
# Verify python3 is available
if ! command -v python3 &>/dev/null; then
echo "ERROR: python3 required for luminance computation" >&2
exit 1
fi
# ── Extract tokens from a CSS block ─────────────────────
# Usage: extract_tokens <label> <start-pattern>
# Populates an associative array via eval
extract_block() {
local label="$1" start_pat="$2"
local in_block=0 depth=0
declare -gA "$label"
while IFS= read -r line; do
if [[ "$line" =~ $start_pat ]]; then
in_block=1; depth=0
fi
if (( in_block )); then
# Count braces
opens="${line//[^\{]/}"; depth=$((depth + ${#opens}))
closes="${line//[^\}]/}"; depth=$((depth - ${#closes}))
# Extract token definitions
if [[ "$line" =~ --([a-zA-Z0-9_-]+):[[:space:]]*([^;]+)\; ]]; then
local key="${BASH_REMATCH[1]}" val="${BASH_REMATCH[2]}"
val="${val%"${val##*[![:space:]]}"}" # trim trailing ws
eval "${label}[${key}]=\"${val}\""
fi
(( depth <= 0 && in_block )) && in_block=0
fi
done < "$CSS_FILE"
}
extract_block "DARK" "^:root"
extract_block "LIGHT" '^\[data-theme="light"\]'
# ── Semantic pairings to check ──────────────────────────
# Format: "foreground_token:background_token:context:threshold"
# threshold: 4.5 = AA normal text, 3.0 = AA large text (>=18px bold or >=24px)
PAIRINGS=(
"text:bg:body text:4.5"
"text:bg-surface:surface text:4.5"
"text:bg-raised:raised text:4.5"
"text:bg-hover:hover text:4.5"
"text:bg-active:active text:4.5"
"text-2:bg:secondary text on body:4.5"
"text-2:bg-surface:secondary text on surface:4.5"
"text-2:bg-raised:secondary text on raised:4.5"
"text-3:bg:muted text on body:3.0"
"text-3:bg-surface:muted text on surface:3.0"
"text-3:bg-raised:muted text on raised:3.0"
"accent:bg:accent on body:3.0"
"accent:bg-surface:accent on surface:3.0"
"accent:bg-raised:accent on raised:3.0"
"accent-light:accent-dim:badge accent:3.0"
"danger-light:danger-dim:badge danger:3.0"
"success-light:success-dim:badge success:3.0"
"warning-light:warning-dim:badge warning:3.0"
"text-on-color:accent:text on accent btn:3.0"
"text-on-color:danger:text on danger btn:3.0"
"text-on-color:success:text on success btn:3.0"
"text-on-warning:warning:text on warning:4.5"
"text:input-bg:input text:4.5"
"text-3:input-bg:placeholder on input:3.0"
)
# ── Python script for contrast computation ──────────────
CONTRAST_PY=$(cat <<'PYEOF'
import sys, re, json
def parse_color(val, base_bg=None):
"""Parse hex or rgba() color to (r,g,b) tuple, 0-255."""
val = val.strip()
m = re.match(r'^#([0-9a-fA-F]{6})$', val)
if m:
h = m.group(1)
return (int(h[0:2],16), int(h[2:4],16), int(h[4:6],16))
m = re.match(r'^#([0-9a-fA-F]{3})$', val)
if m:
h = m.group(1)
return (int(h[0]*2,16), int(h[1]*2,16), int(h[2]*2,16))
m = re.match(r'^rgba?\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)\s*(?:,\s*([\d.]+))?\s*\)$', val)
if m:
r,g,b = int(m.group(1)), int(m.group(2)), int(m.group(3))
a = float(m.group(4)) if m.group(4) else 1.0
if a < 1.0 and base_bg:
br, bg_, bb = base_bg
r = int(r * a + br * (1 - a))
g = int(g * a + bg_ * (1 - a))
b = int(b * a + bb * (1 - a))
return (r, g, b)
return None
def relative_luminance(rgb):
"""WCAG 2.1 relative luminance."""
def linearize(c):
s = c / 255.0
return s / 12.92 if s <= 0.04045 else ((s + 0.055) / 1.055) ** 2.4
r, g, b = rgb
return 0.2126 * linearize(r) + 0.7152 * linearize(g) + 0.0722 * linearize(b)
def contrast_ratio(c1, c2):
l1, l2 = relative_luminance(c1), relative_luminance(c2)
if l1 < l2: l1, l2 = l2, l1
return (l1 + 0.05) / (l2 + 0.05)
# Read input: JSON with dark/light tokens + pairings
data = json.loads(sys.stdin.read())
results = []
for theme_name, tokens in [("dark", data["dark"]), ("light", data["light"])]:
base_bg = parse_color(tokens.get("bg", "#000000"))
for p in data["pairings"]:
fg_key, bg_key, ctx = p[0], p[1], p[2]
fg_val = tokens.get(fg_key)
bg_val = tokens.get(bg_key)
if not fg_val or not bg_val:
results.append({"theme": theme_name, "fg": fg_key, "bg": bg_key, "context": ctx,
"ratio": "N/A", "result": "SKIP", "note": "token missing"})
continue
fg_rgb = parse_color(fg_val, base_bg)
bg_rgb = parse_color(bg_val, base_bg)
if not fg_rgb or not bg_rgb:
results.append({"theme": theme_name, "fg": fg_key, "bg": bg_key, "context": ctx,
"ratio": "N/A", "result": "SKIP", "note": "unparseable color"})
continue
ratio = contrast_ratio(fg_rgb, bg_rgb)
threshold = p[3] if len(p) > 3 else 4.5
passed = ratio >= threshold
level = "AA" if threshold >= 4.5 else "AA-lg"
results.append({"theme": theme_name, "fg": fg_key, "bg": bg_key, "context": ctx,
"fg_val": fg_val, "bg_val": bg_val,
"ratio": f"{ratio:.2f}:1", "result": "PASS" if passed else "FAIL",
"level": level})
json.dump(results, sys.stdout)
PYEOF
)
# ── Build JSON input for Python ─────────────────────────
build_tokens_json() {
local -n arr=$1
echo "{"
local first=true
for key in "${!arr[@]}"; do
$first && first=false || echo ","
# Escape any quotes in value
local val="${arr[$key]}"
printf ' "%s": "%s"' "$key" "$val"
done
echo ""
echo "}"
}
build_pairings_json() {
echo "["
local first=true
for p in "${PAIRINGS[@]}"; do
IFS=':' read -r fg bg ctx threshold <<< "$p"
threshold="${threshold:-4.5}"
$first && first=false || echo ","
printf ' ["%s", "%s", "%s", %s]' "$fg" "$bg" "$ctx" "$threshold"
done
echo ""
echo "]"
}
INPUT_JSON=$(cat <<JSONEOF
{
"dark": $(build_tokens_json DARK),
"light": $(build_tokens_json LIGHT),
"pairings": $(build_pairings_json)
}
JSONEOF
)
RESULTS=$(echo "$INPUT_JSON" | python3 -c "$CONTRAST_PY")
# ── Format output ───────────────────────────────────────
echo "╔══════════════════════════════════════════════════════════════════════════════════╗"
echo "║ WCAG AA Contrast Ratio Report ║"
echo "╠══════════════════════════════════════════════════════════════════════════════════╣"
printf "║ %-6s │ %-20s │ %-16s │ %-10s │ %-6s │ %-5s │ %-6s ║\n" "Theme" "Context" "FG Token" "BG Token" "Ratio" "Level" "Result"
echo "╠══════════════════════════════════════════════════════════════════════════════════╣"
pass=0 fail=0 skip=0
while IFS= read -r row; do
theme=$(echo "$row" | python3 -c "import sys,json; d=json.load(sys.stdin); print(d['theme'])")
ctx=$(echo "$row" | python3 -c "import sys,json; d=json.load(sys.stdin); print(d['context'])")
fg=$(echo "$row" | python3 -c "import sys,json; d=json.load(sys.stdin); print(d['fg'])")
bg=$(echo "$row" | python3 -c "import sys,json; d=json.load(sys.stdin); print(d['bg'])")
ratio=$(echo "$row" | python3 -c "import sys,json; d=json.load(sys.stdin); print(d['ratio'])")
result=$(echo "$row" | python3 -c "import sys,json; d=json.load(sys.stdin); print(d['result'])")
level=$(echo "$row" | python3 -c "import sys,json; d=json.load(sys.stdin); print(d.get('level','AA'))")
case "$result" in
PASS) pass=$((pass + 1));;
FAIL) fail=$((fail + 1));;
SKIP) skip=$((skip + 1));;
esac
printf "║ %-6s │ %-20s │ %-16s │ %-10s │ %-6s │ %-5s │ %-6s ║\n" "$theme" "$ctx" "$fg" "$bg" "$ratio" "$level" "$result"
done < <(echo "$RESULTS" | python3 -c "
import sys, json
for item in json.loads(sys.stdin.read()):
print(json.dumps(item))
")
echo "╚══════════════════════════════════════════════════════════════════════════════════╝"
echo ""
echo "Summary: $pass PASS, $fail FAIL, $skip SKIP"
echo ""
if (( fail > 0 )); then
echo "RESULT: FAIL — $fail contrast violation(s) found"
exit 1
else
echo "RESULT: PASS — all checked pairings meet WCAG AA (4.5:1)"
exit 0
fi

View File

@@ -0,0 +1,160 @@
#!/usr/bin/env bash
# generate-coverage-matrix.sh — Component coverage matrix.
# Shows which kernel primitives are used by which surfaces.
# Flags deprecated component usage.
# Usage: bash scripts/generate-coverage-matrix.sh
set -euo pipefail
# ── Kernel primitives (CSS class prefix : JS import name) ──
declare -A PRIMITIVES=(
[button]="sw-btn|Button"
[input]="sw-input|sw-field|FormField"
[dropdown]="sw-dropdown|Dropdown"
[dialog]="sw-dialog|Dialog|confirm("
[toast]="sw-toast|toast("
[menu]="sw-menu|Menu"
[tabs]="sw-tabs|Tabs"
[avatar]="sw-avatar|Avatar"
[spinner]="sw-spinner|Spinner"
[tooltip]="sw-tooltip|Tooltip"
[drawer]="sw-drawer|Drawer"
[banner]="sw-banner|Banner"
)
# Ordered list for consistent output
PRIM_ORDER=(button input dropdown dialog toast menu tabs avatar spinner tooltip drawer banner)
# ── Deprecated patterns ──
DEPRECATED_PATTERNS="btn-primary|btn-small|btn-danger|btn-full|\.toast-container[^-]|popup-menu"
# ── Surfaces to scan ──
declare -A SURFACE_DIRS
# Kernel surfaces
for d in src/js/sw/surfaces/*/; do
[ -d "$d" ] || continue
name="$(basename "$d")"
SURFACE_DIRS["kernel/$name"]="$d"
done
# Shell components
SURFACE_DIRS["kernel/shell"]="src/js/sw/shell/"
# Extension packages
for d in packages/*/; do
[ -d "$d" ] || continue
slug="$(basename "$d")"
SURFACE_DIRS["ext/$slug"]="$d"
done
# Sort surface names
SURFACES=()
while IFS= read -r s; do SURFACES+=("$s"); done < <(printf '%s\n' "${!SURFACE_DIRS[@]}" | sort)
# ── Scan each surface for each primitive ──
declare -A MATRIX # key="primitive:surface" value="1" or "D" (deprecated)
for surface in "${SURFACES[@]}"; do
dir="${SURFACE_DIRS[$surface]}"
[ -d "$dir" ] || continue
# Gather all JS and CSS in this surface
content=""
while IFS= read -r f; do
content+="$(cat "$f")"$'\n'
done < <(find "$dir" -type f \( -name '*.js' -o -name '*.css' \) 2>/dev/null)
for prim in "${PRIM_ORDER[@]}"; do
patterns="${PRIMITIVES[$prim]}"
IFS='|' read -ra pats <<< "$patterns"
for pat in "${pats[@]}"; do
if echo "$content" | grep -qF "$pat"; then
MATRIX["$prim:$surface"]="1"
break
fi
done
done
# Check for deprecated patterns
if echo "$content" | grep -qE "$DEPRECATED_PATTERNS"; then
MATRIX["deprecated:$surface"]="1"
fi
done
# ── Build markdown table ──
echo "# Component Coverage Matrix"
echo ""
echo "Generated: $(date -u '+%Y-%m-%d %H:%M UTC')"
echo ""
# Header row
header="| Primitive |"
separator="|-----------|"
for surface in "${SURFACES[@]}"; do
short="${surface#*/}" # Remove kernel/ or ext/ prefix
header+=" $short |"
separator+="------|"
done
echo "$header"
echo "$separator"
# Data rows
for prim in "${PRIM_ORDER[@]}"; do
row="| **$prim** |"
for surface in "${SURFACES[@]}"; do
if [[ -n "${MATRIX["$prim:$surface"]:-}" ]]; then
row+=" ✓ |"
else
row+=" · |"
fi
done
echo "$row"
done
# Deprecated row
row="| ⚠ deprecated |"
has_deprecated=false
for surface in "${SURFACES[@]}"; do
if [[ -n "${MATRIX["deprecated:$surface"]:-}" ]]; then
row+=" ⚠ |"
has_deprecated=true
else
row+=" · |"
fi
done
echo "$row"
echo ""
echo "Legend: ✓ = uses kernel primitive, · = not used, ⚠ = deprecated component detected"
echo ""
# ── Deprecated detail ──
if $has_deprecated; then
echo "## Deprecated Component Usage"
echo ""
for surface in "${SURFACES[@]}"; do
[[ -z "${MATRIX["deprecated:$surface"]:-}" ]] && continue
dir="${SURFACE_DIRS[$surface]}"
echo "### $surface"
grep -rnE "$DEPRECATED_PATTERNS" "$dir" --include='*.js' --include='*.css' 2>/dev/null | head -10 || true
echo ""
done
fi
# ── Summary ──
total_cells=$(( ${#PRIM_ORDER[@]} * ${#SURFACES[@]} ))
used=0
for prim in "${PRIM_ORDER[@]}"; do
for surface in "${SURFACES[@]}"; do
[[ -n "${MATRIX["$prim:$surface"]:-}" ]] && used=$((used + 1))
done
done
echo "---"
echo "Coverage: $used / $total_cells cells ($((used * 100 / total_cells))%)"
if $has_deprecated; then
echo "STATUS: WARN — deprecated component usage found (see above)"
else
echo "STATUS: PASS — no deprecated component usage"
fi

287
scripts/generate-ui-inventory.sh Executable file
View File

@@ -0,0 +1,287 @@
#!/usr/bin/env bash
# generate-ui-inventory.sh — Walk kernel + package CSS, extract class selectors with metadata.
# Outputs ui-inventory.json to stdout.
# Usage: bash scripts/generate-ui-inventory.sh [css-dir] [packages-dir]
set -euo pipefail
CSS_DIR="${1:-src/css}"
PKG_DIR="${2:-packages}"
# Collect all CSS files: kernel + packages
FILES=()
for f in "$CSS_DIR"/*.css; do [ -f "$f" ] && FILES+=("$f"); done
for f in "$PKG_DIR"/*/css/main.css; do [ -f "$f" ] && FILES+=("$f"); done
# Derive surface name from file path
surface_for() {
local f="$1"
if [[ "$f" == "$PKG_DIR"/* ]]; then
local slug
slug="$(basename "$(dirname "$(dirname "$f")")")"
echo "ext/$slug"
else
local base
base="$(basename "$f" .css)"
echo "kernel/$base"
fi
}
echo "["
first_entry=true
for f in "${FILES[@]}"; do
surface="$(surface_for "$f")"
relpath="$f"
in_comment=0
in_keyframes=0
in_fontface=0
brace_depth=0
media_breakpoint=""
current_selector=""
current_line=0
current_classes=""
current_spacing=""
current_fontsize=""
current_breakpoints=""
lineno=0
while IFS= read -r line; do
lineno=$((lineno + 1))
# Track block comments
if (( in_comment )); then
[[ "$line" == *"*/"* ]] && in_comment=0
continue
fi
if [[ "$line" == *"/*"* ]] && [[ "$line" != *"*/"* ]]; then
in_comment=1
continue
fi
# Trim leading whitespace
trimmed="${line#"${line%%[![:space:]]*}"}"
[[ -z "$trimmed" ]] && continue
[[ "$trimmed" == /\** ]] && continue
# Track @keyframes blocks
if [[ "$trimmed" =~ ^@keyframes ]]; then
in_keyframes=1
continue
fi
if (( in_keyframes )); then
[[ "$trimmed" == "}" ]] && in_keyframes=0
continue
fi
# Skip @font-face
if [[ "$trimmed" =~ ^@font-face ]]; then
in_fontface=1
continue
fi
if (( in_fontface )); then
[[ "$trimmed" == *"}"* ]] && in_fontface=0
continue
fi
# Track @media breakpoints
if [[ "$trimmed" =~ ^@media.*max-width ]]; then
if [[ "$trimmed" =~ 768 ]]; then
media_breakpoint="768px"
elif [[ "$trimmed" =~ 1024 ]]; then
media_breakpoint="1024px"
else
media_breakpoint="other"
fi
continue
fi
# Skip other @-rules
[[ "$trimmed" =~ ^@.+ ]] && continue
# Handle closing brace
if [[ "$trimmed" == "}" ]]; then
if [[ -n "$media_breakpoint" ]] && (( brace_depth == 0 )); then
media_breakpoint=""
fi
# If we had an open selector block, emit it
if [[ -n "$current_selector" ]]; then
# Build breakpoints array
bp_json="[]"
if [[ -n "$current_breakpoints" ]]; then
bp_json="["
bp_first=true
for bp in $current_breakpoints; do
$bp_first && bp_first=false || bp_json="$bp_json,"
bp_json="$bp_json\"$bp\""
done
bp_json="$bp_json]"
fi
# Build spacing array
sp_json="[]"
if [[ -n "$current_spacing" ]]; then
sp_json="["
sp_first=true
# Deduplicate
seen_sp=""
for sp in $current_spacing; do
[[ " $seen_sp " == *" $sp "* ]] && continue
seen_sp="$seen_sp $sp"
$sp_first && sp_first=false || sp_json="$sp_json,"
sp_json="$sp_json\"$sp\""
done
sp_json="$sp_json]"
fi
# Build font-size array
fs_json="[]"
if [[ -n "$current_fontsize" ]]; then
fs_json="["
fs_first=true
for fs in $current_fontsize; do
$fs_first && fs_first=false || fs_json="$fs_json,"
fs_json="$fs_json\"$fs\""
done
fs_json="$fs_json]"
fi
# Build classes array
cls_json="["
cls_first=true
for cls in $current_classes; do
$cls_first && cls_first=false || cls_json="$cls_json,"
cls_json="$cls_json\"$cls\""
done
cls_json="$cls_json]"
$first_entry && first_entry=false || printf ",\n"
printf ' {"surface":"%s","component":"%s","classes":%s,"file":"%s","line":%d,"responsive_breakpoints":%s,"spacing_tokens_used":%s,"font_size_tokens_used":%s}' \
"$surface" "$current_selector" "$cls_json" "$relpath" "$current_line" "$bp_json" "$sp_json" "$fs_json"
current_selector=""
current_classes=""
current_spacing=""
current_fontsize=""
current_breakpoints=""
fi
continue
fi
# Selector line — contains a class and an opening brace
if [[ "$trimmed" == *"."* ]] && [[ "$trimmed" == *"{"* ]]; then
# Extract the selector part (before {)
sel_part="${trimmed%%\{*}"
# Extract the property part (after {), may have properties on the same line
prop_part="${trimmed#*\{}"
prop_part="${prop_part%\}}"
# Get all class names from the selector
classes=""
tmp="$sel_part"
while [[ "$tmp" =~ \.([a-zA-Z_][a-zA-Z0-9_-]*) ]]; do
cls="${BASH_REMATCH[1]}"
classes="$classes $cls"
tmp="${tmp#*${BASH_REMATCH[0]}}"
done
# Use the first class as the component name
first_cls=""
for c in $classes; do first_cls="$c"; break; done
[[ -z "$first_cls" ]] && continue
current_selector="$first_cls"
current_line=$lineno
current_classes="$classes"
current_spacing=""
current_fontsize=""
current_breakpoints=""
[[ -n "$media_breakpoint" ]] && current_breakpoints="$media_breakpoint"
# Check inline properties (single-line rules like .foo { padding: ...; })
if [[ -n "$prop_part" ]]; then
# Extract spacing tokens
sp_tmp="$prop_part"
while [[ "$sp_tmp" =~ var\(--sp-([0-9h]+)\) ]]; do
current_spacing="$current_spacing --sp-${BASH_REMATCH[1]}"
sp_tmp="${sp_tmp#*${BASH_REMATCH[0]}}"
done
# Extract font-size
if [[ "$prop_part" =~ font-size:[[:space:]]*([^;]+) ]]; then
fs_val="${BASH_REMATCH[1]}"
fs_val="${fs_val%"${fs_val##*[![:space:]]}"}"
current_fontsize="$current_fontsize $fs_val"
fi
fi
# If the rule is self-closing (has } on the same line), emit now
if [[ "$trimmed" == *"}"* ]]; then
bp_json="[]"
if [[ -n "$current_breakpoints" ]]; then
bp_json="[\"$current_breakpoints\"]"
fi
sp_json="[]"
if [[ -n "$current_spacing" ]]; then
sp_json="["
sp_first=true
seen_sp=""
for sp in $current_spacing; do
[[ " $seen_sp " == *" $sp "* ]] && continue
seen_sp="$seen_sp $sp"
$sp_first && sp_first=false || sp_json="$sp_json,"
sp_json="$sp_json\"$sp\""
done
sp_json="$sp_json]"
fi
fs_json="[]"
if [[ -n "$current_fontsize" ]]; then
fs_json="["
fs_first=true
for fs in $current_fontsize; do
$fs_first && fs_first=false || fs_json="$fs_json,"
fs_json="$fs_json\"$fs\""
done
fs_json="$fs_json]"
fi
cls_json="["
cls_first=true
for cls in $current_classes; do
$cls_first && cls_first=false || cls_json="$cls_json,"
cls_json="$cls_json\"$cls\""
done
cls_json="$cls_json]"
$first_entry && first_entry=false || printf ",\n"
printf ' {"surface":"%s","component":"%s","classes":%s,"file":"%s","line":%d,"responsive_breakpoints":%s,"spacing_tokens_used":%s,"font_size_tokens_used":%s}' \
"$surface" "$current_selector" "$cls_json" "$relpath" "$current_line" "$bp_json" "$sp_json" "$fs_json"
current_selector=""
current_classes=""
current_spacing=""
current_fontsize=""
current_breakpoints=""
fi
continue
fi
# Property line inside an open block
if [[ -n "$current_selector" ]]; then
# Extract spacing tokens
sp_tmp="$trimmed"
while [[ "$sp_tmp" =~ var\(--sp-([0-9h]+)\) ]]; do
current_spacing="$current_spacing --sp-${BASH_REMATCH[1]}"
sp_tmp="${sp_tmp#*${BASH_REMATCH[0]}}"
done
# Extract font-size
if [[ "$trimmed" =~ font-size:[[:space:]]*([^;]+) ]]; then
fs_val="${BASH_REMATCH[1]}"
fs_val="${fs_val%"${fs_val##*[![:space:]]}"}"
current_fontsize="$current_fontsize $fs_val"
fi
fi
done < "$f"
done
echo ""
echo "]"