feat: htmx derivation home page 1:1 from react

This commit is contained in:
2026-04-23 23:05:50 -06:00
parent 72448f36d9
commit 928fdd8f75
14 changed files with 13953 additions and 7 deletions

35
htmx-demo/README.md Normal file
View File

@@ -0,0 +1,35 @@
# HTMX Showcase — Validation Harness
Parallel to `app/page.tsx` (the React showcase), `public/htmx.html` is a plain HTML page that exercises the generated `dist/greyhaven.htmx.css` across every static component. Load it while running `pnpm dev` at `/htmx.html`.
## Purpose
Validate that `greyhaven.htmx.css` produces visually-equivalent output to the React components. The HTMX page only uses:
- `data-slot` / `data-variant` / `data-size` attributes
- Standard HTML tags (`<button>`, `<span>`, `<input>`, `<div>`)
- Inline SVGs for icons (no lucide-react)
No React, no JavaScript (apart from the theme toggle).
## Build
```bash
pnpm htmx-css:build # Regenerate dist/greyhaven.htmx.css from components/ui/*.tsx
pnpm htmx-demo:build # Compile htmx-demo/input.css + tokens + htmx.css → public/htmx.css
pnpm dev # Serves /htmx.html at http://localhost:3000/htmx.html
```
## What's covered
- Typography (H1/H2/H3 + body + UI label)
- Button — variants (6), sizes (3), states (5), icon sizes (3)
- Badge — core (4), tag/value (2), semantic (4), channel pills (5), on-muted-surface (6)
- Input + Textarea (default / with value / disabled) + Label
- Card (simple + with header/action/content/footer)
- Alert (default + destructive)
- Separator, Progress, Skeleton, Kbd
## What's intentionally out of scope
- Interactive components (Dialog, Dropdown, Popover, Select, Combobox, Accordion, Tabs, Tooltip) — their CSS rules exist in `greyhaven.htmx.css` but require Alpine.js or HTMX swap patterns for open/close state. Validate those in a separate runtime-integration test.
- Form Control primitives with JS state (Checkbox, Switch, RadioGroup, Slider) — Radix renders these with bespoke markup the CSS targets via `data-state=checked`. Native `<input type="checkbox">` won't match without additional bridging.

68
htmx-demo/compare-all.sh Executable file
View File

@@ -0,0 +1,68 @@
#!/bin/bash
# Batch section-by-section comparison: React vs HTMX.
# Each entry: <label> <react-selector> <htmx-selector>
#
# Assumes `pnpm dev` is running and Charlotte MCP is unavailable from shell —
# so this script expects screenshots already captured via Charlotte by name.
# Run compare.py on each pair and emit a summary.
set -u
OUT="${OUT:-/home/tito/code/monadical/greyproxy/docs/screenshots}"
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
CMP="$SCRIPT_DIR/compare.py"
SECTIONS=(
"colors"
"typo"
"btn-variants"
"btn-sizes"
"btn-states"
"icon-buttons"
"btn-with-icons"
"badges-core"
"badges-tag"
"badges-semantic"
"badges-channel"
"badges-muted"
"inputs"
"select"
"checkboxes-switches"
"tabs"
"tooltips"
"sample-form"
"settings-card"
"header"
"footer"
)
printf "%-25s %-12s %-12s %s\n" "section" "similarity" "differing" "notes"
printf "%-25s %-12s %-12s %s\n" "-------" "----------" "---------" "-----"
fail=0
for s in "${SECTIONS[@]}"; do
r="$OUT/$s-react.webp"
h="$OUT/$s-htmx.webp"
d="$OUT/$s-diff.webp"
if [ ! -f "$r" ] || [ ! -f "$h" ]; then
printf "%-25s %-12s %-12s %s\n" "$s" "-" "-" "missing ($([ ! -f "$r" ] && echo react) $([ ! -f "$h" ] && echo htmx))"
continue
fi
line=$(python3 "$CMP" "$r" "$h" --out "$d" 2>&1 | tail -1)
# " similarity = 99.97% (393 / 1436512 pixels differ > 12)"
sim=$(echo "$line" | sed -nE 's/.*similarity = ([0-9.]+)%.*/\1/p')
diff=$(echo "$line" | sed -nE 's/.*\(([0-9]+) \/ .*/\1/p')
# Threshold: 99.0%. Residual diffs under this threshold are driven by:
# - font sub-pixel anti-aliasing (~0.03%)
# - sticky-header overlay differences in Charlotte's selector screenshot
# when element rects happen to land at different viewport Y positions
# between React and HTMX (still has the same CSS, just different scroll).
if awk "BEGIN{exit !($sim>=99.0)}"; then
marker=PASS
else
marker=FAIL
fail=1
fi
printf "%-25s %-12s %-12s %s\n" "$s" "${sim}%" "$diff" "$marker"
done
exit $fail

100
htmx-demo/compare.py Normal file
View File

@@ -0,0 +1,100 @@
#!/usr/bin/env python3
"""
Image comparator for React vs HTMX showcase validation.
Compares two PNG/WEBP screenshots and produces:
1. Similarity percentage (pixels within tolerance / total pixels)
2. A diff image with mismatches highlighted magenta on a faded background
Tolerance is per-channel: anti-aliasing / sub-pixel hinting is accepted
(default 12 of 255 per channel, tweakable via --tol). Font / layout / color
changes produce large regions of divergence that will exceed the tolerance.
Usage:
python3 compare.py react.webp htmx.webp [--out diff.webp] [--tol 12]
"""
import argparse
import sys
from pathlib import Path
from PIL import Image, ImageChops, ImageDraw
def load(path):
img = Image.open(path).convert("RGB")
return img
def compare(react_path, htmx_path, out_path, tol):
a = load(react_path)
b = load(htmx_path)
if a.size != b.size:
# Pad the smaller one with transparent/white so we can still diff
w, h = max(a.width, b.width), max(a.height, b.height)
pad_a = Image.new("RGB", (w, h), (255, 255, 255))
pad_b = Image.new("RGB", (w, h), (255, 255, 255))
pad_a.paste(a, (0, 0))
pad_b.paste(b, (0, 0))
a, b = pad_a, pad_b
size_mismatch = True
else:
size_mismatch = False
diff = ImageChops.difference(a, b)
# Per-pixel max channel diff
total = a.width * a.height
differing = 0
mask = Image.new("L", a.size, 0)
mask_pixels = mask.load()
diff_pixels = diff.load()
for y in range(a.height):
for x in range(a.width):
r, g, bl = diff_pixels[x, y]
if max(r, g, bl) > tol:
differing += 1
mask_pixels[x, y] = 255
similarity = 100.0 * (total - differing) / total
# Build diff image: React screenshot faded 50%, with diffs in magenta
faded = Image.eval(a, lambda v: int(v * 0.4 + 0.6 * 255))
magenta = Image.new("RGB", a.size, (255, 0, 180))
out = Image.composite(magenta, faded, mask)
# Add a header text
draw = ImageDraw.Draw(out)
header = (
f"similarity={similarity:.2f}% "
f"differing={differing}/{total} "
f"tol={tol}"
+ (" (SIZE MISMATCH — padded)" if size_mismatch else "")
)
draw.rectangle([0, 0, a.width, 24], fill=(0, 0, 0))
draw.text((8, 4), header, fill=(255, 255, 255))
out.save(out_path)
return similarity, differing, total
def main():
p = argparse.ArgumentParser()
p.add_argument("react")
p.add_argument("htmx")
p.add_argument("--out", default="diff.webp")
p.add_argument("--tol", type=int, default=12)
args = p.parse_args()
sim, diff_px, total = compare(args.react, args.htmx, args.out, args.tol)
print(f"react = {args.react}")
print(f"htmx = {args.htmx}")
print(f"diff -> {args.out}")
print(f" similarity = {sim:.2f}% ({diff_px} / {total} pixels differ > {args.tol})")
if sim < 99.5:
sys.exit(1)
sys.exit(0)
if __name__ == "__main__":
main()

88
htmx-demo/input.css Normal file
View File

@@ -0,0 +1,88 @@
/* Greyhaven HTMX Showcase — Tailwind v4 source
*
* Pairs the generated `dist/greyhaven.htmx.css` with the design system tokens
* so a plain HTML page (no React) can render every component via data-slot
* attribute selectors.
*
* Compiled output: public/htmx.css (served at /htmx.css)
*/
@import "tailwindcss";
@import "tw-animate-css";
@import "../app/tokens/tokens-light.css";
@import "../app/tokens/tokens-dark.css";
@import "../dist/greyhaven.htmx.css";
@source "./*.html";
@source "../public/htmx.html";
@custom-variant dark (&:is(.dark *));
/* Self-hosted Aspekta (served from /fonts/) */
@font-face { font-family: 'Aspekta'; font-style: normal; font-weight: 400; font-display: swap; src: url('/fonts/Aspekta-400.woff2') format('woff2'); }
@font-face { font-family: 'Aspekta'; font-style: normal; font-weight: 500; font-display: swap; src: url('/fonts/Aspekta-500.woff2') format('woff2'); }
@font-face { font-family: 'Aspekta'; font-style: normal; font-weight: 600; font-display: swap; src: url('/fonts/Aspekta-600.woff2') format('woff2'); }
@font-face { font-family: 'Aspekta'; font-style: normal; font-weight: 700; font-display: swap; src: url('/fonts/Aspekta-700.woff2') format('woff2'); }
:root {
--radius: 0.375rem;
}
@theme inline {
--font-sans: 'Aspekta', ui-sans-serif, system-ui, sans-serif;
/* Matches React's `var(--font-source-serif, 'Source Serif 4'), 'Source Serif Pro', Georgia, serif`.
* Next.js injects --font-source-serif via next/font/google. We load Source Serif 4 from
* Google Fonts directly in htmx.html <link>, so naming it here is enough. */
--font-serif: 'Source Serif 4', 'Source Serif Pro', Georgia, serif;
--font-mono: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
--color-background: rgb(var(--background));
--color-foreground: rgb(var(--foreground));
--color-card: rgb(var(--card));
--color-card-foreground: rgb(var(--card-foreground));
--color-popover: rgb(var(--popover));
--color-popover-foreground: rgb(var(--popover-foreground));
--color-primary: rgb(var(--primary));
--color-primary-foreground: rgb(var(--primary-foreground));
--color-secondary: rgb(var(--secondary));
--color-secondary-foreground: rgb(var(--secondary-foreground));
--color-muted: rgb(var(--muted));
--color-muted-foreground: rgb(var(--muted-foreground));
--color-accent: rgb(var(--accent));
--color-accent-foreground: rgb(var(--accent-foreground));
--color-destructive: rgb(var(--destructive));
--color-destructive-foreground: rgb(var(--destructive-foreground));
--color-border: rgb(var(--border));
--color-input: rgb(var(--input));
--color-ring: rgb(var(--ring));
--color-chart-1: rgb(var(--chart-1));
--color-chart-2: rgb(var(--chart-2));
--color-chart-3: rgb(var(--chart-3));
--color-chart-4: rgb(var(--chart-4));
--color-chart-5: rgb(var(--chart-5));
--color-hero-bg: rgb(var(--hero-bg));
--color-sidebar: rgb(var(--sidebar));
--color-sidebar-foreground: rgb(var(--sidebar-foreground));
--color-sidebar-primary: rgb(var(--sidebar-primary));
--color-sidebar-primary-foreground: rgb(var(--sidebar-primary-foreground));
--color-sidebar-accent: rgb(var(--sidebar-accent));
--color-sidebar-accent-foreground: rgb(var(--sidebar-accent-foreground));
--color-sidebar-border: rgb(var(--sidebar-border));
--color-sidebar-ring: rgb(var(--sidebar-ring));
--radius-sm: calc(var(--radius) - 2px);
--radius-md: var(--radius);
--radius-lg: calc(var(--radius) + 2px);
--radius-xl: calc(var(--radius) + 4px);
}
@layer base {
* {
border-color: rgb(var(--border));
}
body {
background-color: rgb(var(--background));
color: rgb(var(--foreground));
font-family: var(--font-sans);
}
}