feat: htmx derivation home page 1:1 from react
This commit is contained in:
35
htmx-demo/README.md
Normal file
35
htmx-demo/README.md
Normal 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
68
htmx-demo/compare-all.sh
Executable 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
100
htmx-demo/compare.py
Normal 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
88
htmx-demo/input.css
Normal 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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user