Files
freno-dev/src/components/ui/Button.tsx
Michael Freno 898c891bd5 chore: remediate pygienium audit findings
Dead code: 77 verified-unused exports, files (BackArrow, MenuBars,
cookies.ts, db/create.ts, schemas/comment.ts, security-headers.ts) and
12 unused dependencies removed
Comments: ~370 RESTATE comments stripped across 53 files; 2 verbose
blocks tightened; dead commented-out config removed
Complexity: bulkUpsert extracted into 11 per-entity helpers (CCN 156->~10);
login formHandler split into 3 submitters (CCN 63->~5); account page render
split into 8 section components (CCN 40); updatePost SQL builder rebuilt;
assert*Owned consolidated behind generic assertOwnedBy
Defensive guards: 4 redundant rethrow/nullish guards removed
2026-08-11 13:36:18 -04:00

248 lines
7.4 KiB
TypeScript

import {
type JSX,
splitProps,
Show,
createSignal,
createEffect
} from "solid-js";
import { Spinner } from "~/components/Spinner";
/** Parse a hex color string like `#f9e2af` into `[r, g, b]` in 0..255. */
function hexToRgb(hex: string): [number, number, number] {
const h = hex.replace(/^#/, "");
return [
parseInt(h.substring(0, 2), 16),
parseInt(h.substring(2, 4), 16),
parseInt(h.substring(4, 6), 16)
];
}
/** Convert `[r, g, b]` 0..255 to `[h, s, l]` — h in 0..360, s and l in 0..1. */
function rgbToHsl(r: number, g: number, b: number): [number, number, number] {
const rn = r / 255;
const gn = g / 255;
const bn = b / 255;
const max = Math.max(rn, gn, bn);
const min = Math.min(rn, gn, bn);
const l = (max + min) / 2;
let h = 0;
let s = 0;
if (max !== min) {
const d = max - min;
s = l > 0.5 ? d / (2 - max - min) : d / (max + min);
switch (max) {
case rn:
h = ((gn - bn) / d + (gn < bn ? 6 : 0)) / 6;
break;
case gn:
h = ((bn - rn) / d + 2) / 6;
break;
case bn:
h = ((rn - gn) / d + 4) / 6;
break;
}
}
return [h * 360, s, l];
}
/** Convert `[h, s, l]` (h in 0..360, s and l in 0..1) to `[r, g, b]` 0..255. */
function hslToRgb(h: number, s: number, l: number): [number, number, number] {
const a = s * Math.min(l, 1 - l);
const f = (n: number) => {
const k = (n + h / 30) % 12;
return Math.round(255 * (l - a * Math.max(Math.min(k - 3, 9 - k, 1), -1)));
};
return [f(0), f(8), f(4)];
}
/**
* Compute the relative luminance (WCAG) of an sRGB color, given linear
* channel values in 0..1.
*/
function luminance(r: number, g: number, b: number): number {
return (
0.2126 * (r > 0.04045 ? ((r + 0.055) / 1.055) ** 2.4 : r / 12.92) +
0.7152 * (g > 0.04045 ? ((g + 0.055) / 1.055) ** 2.4 : g / 12.92) +
0.0722 * (b > 0.04045 ? ((b + 0.055) / 1.055) ** 2.4 : b / 12.92)
);
}
/**
* Given a background color, return a pair of `{ bg, text }` CSS color
* strings that guarantee WCAG AA contrast.
*
* Light backgrounds (luminance > 0.4) are darkened in HSL space so the
* result is dark enough for white text. Dark backgrounds are kept as-is
* with white text. This is used by the download variant whose background
* is a product brand color that can be arbitrarily light.
*/
function adjustForContrast(color: string): { bg: string; text: string } {
const [r, g, b] = hexToRgb(color);
const [h, s, l] = rgbToHsl(r, g, b);
const lum = luminance(r / 255, g / 255, b / 255);
if (lum > 0.4) {
// Darken: keep hue and saturation, clamp lightness so the result is
// dark enough for white text (l ~ 0.35 → luminance ~ 0.17, contrast
// with white ≈ 6:1).
const dl = Math.min(l, 0.35);
const [dr, dg, db] = hslToRgb(h, s, dl);
return {
bg: `rgb(${dr}, ${dg}, ${db})`,
text: "#ffffff"
};
}
return {
bg: `rgb(${r}, ${g}, ${b})`,
text: "#ffffff"
};
}
export interface ButtonProps extends JSX.ButtonHTMLAttributes<HTMLButtonElement> {
variant?: "primary" | "secondary" | "danger" | "ghost" | "download";
size?: "sm" | "md" | "lg";
loading?: boolean;
fullWidth?: boolean;
/**
* Override the variant's background color with an explicit CSS color.
* Used by download pages to theme the button with the product brand color.
*/
color?: string;
}
export default function Button(props: ButtonProps) {
const [local, others] = splitProps(props, [
"variant",
"size",
"loading",
"fullWidth",
"class",
"children",
"disabled",
"color"
]);
let contentRef: HTMLSpanElement | undefined;
const [dimensions, setDimensions] = createSignal<{
width: number;
height: number;
} | null>(null);
createEffect(() => {
if (!local.loading && contentRef) {
const rect = contentRef.getBoundingClientRect();
setDimensions({ width: rect.width, height: rect.height });
}
});
const variant = () => local.variant || "primary";
const size = () => local.size || "md";
const baseClasses =
"flex justify-center cursor-pointer items-center rounded transition-all duration-300 ease-out";
const variantClasses = () => {
const isDisabledOrLoading = local.disabled || local.loading;
switch (variant()) {
case "primary":
return isDisabledOrLoading
? "bg-blue cursor-not-allowed brightness-75"
: "bg-blue hover:brightness-125 active:scale-90";
case "secondary":
return isDisabledOrLoading
? "bg-surface0 cursor-not-allowed brightness-75"
: "bg-surface0 hover:brightness-125 active:scale-90";
case "download":
return isDisabledOrLoading
? "cursor-not-allowed brightness-75"
: "hover:brightness-125 active:scale-90";
case "danger":
return isDisabledOrLoading
? "bg-red cursor-not-allowed brightness-75"
: "bg-red hover:brightness-125 active:scale-90";
case "ghost":
return isDisabledOrLoading
? "cursor-not-allowed opacity-50"
: "hover:brightness-125 active:scale-90";
default:
return "";
}
};
/** Compute background + text color for the download variant. */
const downloadColors = () => {
const isDisabledOrLoading = local.disabled || local.loading;
if (isDisabledOrLoading) {
return { bg: "var(--color-base)", text: "#ffffff" };
}
// When no explicit color is given, fall back to the theme blue
// (a dark color whose contrast with white is already fine). Only
// pass a hex string to adjustForContrast — CSS variable values can't
// be parsed numerically.
if (!local.color) return { bg: "var(--color-blue)", text: "#ffffff" };
return adjustForContrast(local.color);
};
const sizeClasses = () => {
switch (size()) {
case "sm":
return "px-3 py-1.5 text-sm";
case "md":
return "px-4 py-2 text-base";
case "lg":
return "px-6 py-3 text-lg";
default:
return "";
}
};
const widthClass = () => (local.fullWidth ? "w-full" : "");
const buttonStyle = (): JSX.CSSProperties => {
if (variant() === "download") {
const { bg, text } = downloadColors();
return { background: bg, color: text };
}
// Theme-aware text: white on dark bg variants (primary/danger),
// theme text on light bg variants (secondary). The CSS variables
// flip between light/dark modes so the contrast stays good in both.
if (variant() === "secondary")
return { color: "var(--color-button-text-alt)" };
return { color: "var(--color-button-text)" };
};
return (
<button
{...others}
disabled={local.disabled || local.loading}
class={`${baseClasses} ${variantClasses()} ${sizeClasses()} ${widthClass()} ${local.class || ""}`}
style={buttonStyle()}
>
<Show
when={local.loading}
fallback={
<span ref={contentRef} style={{ display: "inline-flex" }}>
{local.children}
</span>
}
>
<span
style={{
display: "inline-flex",
"align-items": "center",
"justify-content": "center",
"min-width": dimensions() ? `${dimensions()!.width}px` : undefined,
"min-height": dimensions() ? `${dimensions()!.height}px` : undefined
}}
>
<Spinner size={24} />
</span>
</Show>
</button>
);
}
// Named export for consistency
export { Button };