fix: color and blog fixes, favicon pointers

This commit is contained in:
2026-07-27 15:47:59 -04:00
parent 44b6e7595c
commit 2f1b5f35c6
5 changed files with 131 additions and 12 deletions

View File

@@ -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;

View File

@@ -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;

View File

@@ -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<HTMLButtonElement> {
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 (

View File

@@ -88,7 +88,7 @@ export const SITE_CONFIG: Record<SiteId, Site> = {
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<SiteId, Site> = {
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<SiteId, Site> = {
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<SiteId, Site> = {
titleSuffix: " | InputHalo",
brandColor: "#f38ba8",
ogDefaultImage: "/inputhalo/og-default.png",
faviconPath: "/inputhalo/favicon.ico"
faviconPath: "/inputhalo/favicon/favicon.ico"
}
};

View File

@@ -107,7 +107,7 @@ export default function NessaLanding() {
</A>
<A
href="/contact"
class="border-surface2 hover:bg-surface0/40 rounded-full border-2 px-8 py-3 text-base font-semibold transition-colors"
class="border-surface2 hover:bg-surface0/40 text-accent rounded-full border-2 px-8 py-3 font-semibold transition-colors"
>
Request beta access
</A>