From 2f1b5f35c6bb63352286cde751647095a9143bb7 Mon Sep 17 00:00:00 2001 From: Michael Freno Date: Mon, 27 Jul 2026 15:47:59 -0400 Subject: [PATCH] fix: color and blog fixes, favicon pointers --- src/app.css | 10 +++ src/components/blog/PostBodyClient.tsx | 7 +- src/components/ui/Button.tsx | 116 +++++++++++++++++++++++-- src/lib/site-context.ts | 8 +- src/routes/nessa/index.tsx | 2 +- 5 files changed, 131 insertions(+), 12 deletions(-) diff --git a/src/app.css b/src/app.css index 4ad271e..5840673 100644 --- a/src/app.css +++ b/src/app.css @@ -74,6 +74,11 @@ --color-base: #fbf1c7; --color-mantle: #f3eac1; --color-crust: #e7deb7; + + /* Button text colors: white on dark bg variants (primary/danger), + theme text on light bg variants (secondary). */ + --color-button-text: #ffffff; + --color-button-text-alt: var(--color-text); } @media (prefers-color-scheme: dark) { @@ -104,6 +109,11 @@ --color-base: #1e1e2e; --color-mantle: #141620; --color-crust: #0e0f16; + + /* Dark mode: dark text on lighter bg variants (primary/danger), + white text on dark bg variants (secondary). */ + --color-button-text: var(--color-crust); + --color-button-text-alt: #ffffff; } @theme { --color-rosewater: #efc9c2; diff --git a/src/components/blog/PostBodyClient.tsx b/src/components/blog/PostBodyClient.tsx index fe9cbf4..d68efa5 100644 --- a/src/components/blog/PostBodyClient.tsx +++ b/src/components/blog/PostBodyClient.tsx @@ -18,8 +18,11 @@ function sanitizeHtml(html: string): string { ) .forEach((el) => el.remove()); - // Remove event handler attributes and dangerous URLs from all elements - doc.querySelectorAll("[on*], [href], [style], [action]").forEach((el) => { + // Remove event handler attributes and dangerous URLs from all elements. + // NOTE: attribute-name wildcards (e.g. [on*]) are not valid CSS selectors and + // throw a SyntaxError on querySelectorAll, so we match all elements here and + // the per-attribute checks below handle on*/href/style/action filtering. + doc.querySelectorAll("*").forEach((el) => { const attrs = Array.from(el.attributes); attrs.forEach((attr) => { const name = attr.name; diff --git a/src/components/ui/Button.tsx b/src/components/ui/Button.tsx index 7d40183..4ca7558 100644 --- a/src/components/ui/Button.tsx +++ b/src/components/ui/Button.tsx @@ -7,6 +7,97 @@ import { } 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 { variant?: "primary" | "secondary" | "danger" | "ghost" | "download"; size?: "sm" | "md" | "lg"; @@ -65,8 +156,8 @@ export default function Button(props: ButtonProps) { : "bg-surface0 hover:brightness-125 active:scale-90"; case "download": return isDisabledOrLoading - ? "text-base cursor-not-allowed brightness-75" - : "text-base hover:brightness-125 active:scale-90"; + ? "cursor-not-allowed brightness-75" + : "hover:brightness-125 active:scale-90"; case "danger": return isDisabledOrLoading ? "bg-red cursor-not-allowed brightness-75" @@ -80,6 +171,15 @@ export default function Button(props: ButtonProps) { } }; + /** 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" }; + } + return adjustForContrast(local.color || "var(--color-blue)"); + }; + const sizeClasses = () => { switch (size()) { case "sm": @@ -96,10 +196,16 @@ export default function Button(props: ButtonProps) { const widthClass = () => (local.fullWidth ? "w-full" : ""); const buttonStyle = (): JSX.CSSProperties => { - if (local.color) { - return { background: local.color }; + if (variant() === "download") { + const { bg, text } = downloadColors(); + return { background: bg, color: text }; } - return {}; + // 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 ( diff --git a/src/lib/site-context.ts b/src/lib/site-context.ts index 87b8cb2..83bc74d 100644 --- a/src/lib/site-context.ts +++ b/src/lib/site-context.ts @@ -88,7 +88,7 @@ export const SITE_CONFIG: Record = { brandColor: "#527640", brandColorDark: "#6CA86C", ogDefaultImage: "/nessa/og-default.png", - faviconPath: "/nessa/favicon.ico" + faviconPath: "/nessa/favicon/favicon.ico" }, lineage: { id: "lineage", @@ -99,7 +99,7 @@ export const SITE_CONFIG: Record = { titleSuffix: " | Life and Lineage", brandColor: "#a13536", ogDefaultImage: "/lineage/og-default.png", - faviconPath: "/lineage/favicon.ico" + faviconPath: "/lineage/favicon/favicon.ico" }, gaze: { id: "gaze", @@ -110,7 +110,7 @@ export const SITE_CONFIG: Record = { titleSuffix: " | Gaze", brandColor: "#f9e2af", ogDefaultImage: "/gaze/og-default.png", - faviconPath: "/gaze/favicon.ico" + faviconPath: "/gaze/favicon/favicon.ico" }, inputhalo: { id: "inputhalo", @@ -121,7 +121,7 @@ export const SITE_CONFIG: Record = { titleSuffix: " | InputHalo", brandColor: "#f38ba8", ogDefaultImage: "/inputhalo/og-default.png", - faviconPath: "/inputhalo/favicon.ico" + faviconPath: "/inputhalo/favicon/favicon.ico" } }; diff --git a/src/routes/nessa/index.tsx b/src/routes/nessa/index.tsx index a6645f6..6c01162 100644 --- a/src/routes/nessa/index.tsx +++ b/src/routes/nessa/index.tsx @@ -107,7 +107,7 @@ export default function NessaLanding() { Request beta access