From 4a867583c01229c39ec38f078e8d93fae5586ca0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Petr=20Ka=C5=A1par?= Date: Sun, 20 Sep 2026 17:40:29 +0200 Subject: [PATCH] fix(web): keep accent text readable on a light primary color --- apps/web/src/app/api/og/route.tsx | 2 +- apps/web/src/app/globals.css | 18 ++++++-- .../src/common/utils/app-icon.functions.tsx | 2 +- apps/web/src/components/common/Button.tsx | 2 +- apps/web/src/components/forms/LoginForm.tsx | 2 +- .../layouts/AccountSidebarContent.tsx | 4 +- .../components/layouts/NotFoundContent.tsx | 2 +- .../src/configs/app/primary-color.config.ts | 44 ++++++++++++++++++- 8 files changed, 63 insertions(+), 13 deletions(-) diff --git a/apps/web/src/app/api/og/route.tsx b/apps/web/src/app/api/og/route.tsx index 91d164e..ac24612 100644 --- a/apps/web/src/app/api/og/route.tsx +++ b/apps/web/src/app/api/og/route.tsx @@ -57,7 +57,7 @@ export async function GET() { height='96' viewBox='0 0 24 24' fill='none' - stroke='white' + stroke={appServerConfig.primaryColor.foreground} strokeWidth={2} strokeLinecap='round' strokeLinejoin='round'> diff --git a/apps/web/src/app/globals.css b/apps/web/src/app/globals.css index e4ff4e5..caed5e3 100644 --- a/apps/web/src/app/globals.css +++ b/apps/web/src/app/globals.css @@ -14,6 +14,8 @@ --color-primary-800: var(--app-primary-800, var(--color-green-800)); --color-primary-900: var(--app-primary-900, var(--color-green-900)); --color-primary-950: var(--app-primary-950, var(--color-green-950)); + /* Ink on a solid `primary-600` fill; the config flips it to near-black when the accent is light. */ + --color-primary-foreground: var(--app-primary-foreground, white); } @custom-variant dark (&:where(.dark, .dark *)); @@ -70,10 +72,18 @@ input[type='checkbox']:checked { @apply border-primary-600 bg-primary-600; - background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16' fill='none' stroke='white' stroke-width='2.5' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M3.5 8.5l3 3 6-6'/%3E%3C/svg%3E"); - background-size: 100% 100%; - background-position: center; - background-repeat: no-repeat; + } + + /* The tick itself is the contrast ink (masked), so it stays visible even on a light accent. */ + input[type='checkbox']:checked::after { + content: ''; + position: absolute; + inset: 0; + background-color: var(--color-primary-foreground); + -webkit-mask: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16' fill='none' stroke='black' stroke-width='2.5' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M3.5 8.5l3 3 6-6'/%3E%3C/svg%3E") + center / 100% 100% no-repeat; + mask: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16' fill='none' stroke='black' stroke-width='2.5' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M3.5 8.5l3 3 6-6'/%3E%3C/svg%3E") + center / 100% 100% no-repeat; } input[type='checkbox']:disabled { diff --git a/apps/web/src/common/utils/app-icon.functions.tsx b/apps/web/src/common/utils/app-icon.functions.tsx index ca51532..c413160 100644 --- a/apps/web/src/common/utils/app-icon.functions.tsx +++ b/apps/web/src/common/utils/app-icon.functions.tsx @@ -32,7 +32,7 @@ export function renderAppIcon(size: number, { radius }: AppIconOptions = {}): Im height={glyphSize} viewBox='0 0 24 24' fill='none' - stroke='white' + stroke={appServerConfig.primaryColor.foreground} strokeWidth={2} strokeLinecap='round' strokeLinejoin='round'> diff --git a/apps/web/src/components/common/Button.tsx b/apps/web/src/components/common/Button.tsx index 6a77021..ed99a3b 100644 --- a/apps/web/src/components/common/Button.tsx +++ b/apps/web/src/components/common/Button.tsx @@ -34,7 +34,7 @@ export default function Button({ sm: 'gap-x-1.5 px-2.5 py-1.5 text-xs', }; const variants: Record, string> = { - primary: 'bg-primary-600 hover:bg-primary-700 disabled:bg-primary-600 text-white', + primary: 'bg-primary-600 hover:bg-primary-700 disabled:bg-primary-600 text-primary-foreground', secondary: 'bg-primary-600/10 hover:bg-primary-600/20 text-primary-700 dark:bg-primary-600/20 dark:hover:bg-primary-600/30 dark:text-primary-400', danger: 'bg-red-500/90 hover:bg-red-500 text-white', diff --git a/apps/web/src/components/forms/LoginForm.tsx b/apps/web/src/components/forms/LoginForm.tsx index 07e07c8..1802a6d 100644 --- a/apps/web/src/components/forms/LoginForm.tsx +++ b/apps/web/src/components/forms/LoginForm.tsx @@ -70,7 +70,7 @@ export default function LoginForm({ appName }: Props) { transition={{ type: 'spring', stiffness: 260, damping: 24 }} className='w-full max-w-sm rounded-2xl border border-zinc-300 bg-zinc-50 p-8 shadow-xl dark:border-zinc-700 dark:bg-zinc-800'>
-
+

{appName}

diff --git a/apps/web/src/components/layouts/AccountSidebarContent.tsx b/apps/web/src/components/layouts/AccountSidebarContent.tsx index c4828bc..ae5e615 100644 --- a/apps/web/src/components/layouts/AccountSidebarContent.tsx +++ b/apps/web/src/components/layouts/AccountSidebarContent.tsx @@ -171,7 +171,7 @@ export default function AccountSidebarContent({ return ( <>
-
+
@@ -217,7 +217,7 @@ export default function AccountSidebarContent({

+ className='bg-primary-600 hover:bg-primary-700 text-primary-foreground inline-flex items-center justify-center gap-x-2 rounded-lg px-3 py-2 text-sm font-medium transition'> {driveStatus?.error ? 'Reconnect Drive' : 'Connect Drive'} diff --git a/apps/web/src/components/layouts/NotFoundContent.tsx b/apps/web/src/components/layouts/NotFoundContent.tsx index 1f06590..8b3d40a 100644 --- a/apps/web/src/components/layouts/NotFoundContent.tsx +++ b/apps/web/src/components/layouts/NotFoundContent.tsx @@ -14,7 +14,7 @@ export default function NotFoundContent() {

+ className='bg-primary-600 hover:bg-primary-700 text-primary-foreground rounded-xl px-4 py-2.5 text-sm font-semibold transition-colors duration-150 ease-out'> Back to the workspace diff --git a/apps/web/src/configs/app/primary-color.config.ts b/apps/web/src/configs/app/primary-color.config.ts index eb8415e..993f21b 100644 --- a/apps/web/src/configs/app/primary-color.config.ts +++ b/apps/web/src/configs/app/primary-color.config.ts @@ -3,6 +3,11 @@ /** Tailwind's `green-600`, the palette DropTo shipped with — used whenever the env is unset. */ const DEFAULT_PRIMARY_HEX = '#00a63e'; +/** Ink laid over a solid `primary-600` fill: white by default, near-black (zinc-950) when the accent is light. */ +const WHITE_FOREGROUND = '#ffffff'; +const DARK_FOREGROUND = '#18181b'; +const DARK_FOREGROUND_RGB: [number, number, number] = [24, 24, 27]; + /** The shade `APP_PRIMARY_COLOR` stands for; every other shade is interpolated around it. */ const ANCHOR_SHADE = 600; @@ -28,6 +33,8 @@ const SHADES = Object.keys(REFERENCE_RAMP).map(Number) as Shade[]; export interface PrimaryColor { /** The accent's `600` shade in hex — for everything that cannot read a CSS variable. */ hex: string; + /** Readable ink for text/glyphs on a solid `600` fill (white, or near-black when the accent is light). */ + foreground: string; /** `--app-primary-*` overrides for , or `undefined` on the built-in green (globals.css falls back to it). */ variables?: Record; } @@ -80,6 +87,30 @@ function rgbToOklch([r, g, b]: [number, number, number]) { const round = (value: number, decimals: number): number => Number(value.toFixed(decimals)); +/** + * WCAG relative luminance (0–1) of an sRGB triple. + **/ +const relativeLuminance = ([r, g, b]: [number, number, number]): number => + 0.2126 * toLinear(r) + 0.7152 * toLinear(g) + 0.0722 * toLinear(b); + +/** + * WCAG contrast ratio between two relative luminances (1–21, order-independent). + **/ +const contrastRatio = (a: number, b: number): number => + (Math.max(a, b) + 0.05) / (Math.min(a, b) + 0.05); + +/** + * Readable ink for a solid fill of `rgb`: white unless near-black reads better against it, so a light + * accent never leaves white text on a near-white button. + **/ +function foregroundFor(rgb: [number, number, number]): string { + const background = relativeLuminance(rgb); + const onWhite = contrastRatio(background, 1); + const onDark = contrastRatio(background, relativeLuminance(DARK_FOREGROUND_RGB)); + + return onDark > onWhite ? DARK_FOREGROUND : WHITE_FOREGROUND; +} + /** * Builds the `--app-primary-*` ramp around the hex: the reference curve gives each step's lightness/chroma, the hex the hue. **/ @@ -114,12 +145,21 @@ export function resolvePrimaryColor(value: string | undefined): PrimaryColor { const rgb = value ? parseHex(value) : null; if (!rgb) { - return { hex: DEFAULT_PRIMARY_HEX }; + return { hex: DEFAULT_PRIMARY_HEX, foreground: WHITE_FOREGROUND }; } const hex = `#${rgb.map((channel) => channel.toString(16).padStart(2, '0')).join('')}`; - return hex === DEFAULT_PRIMARY_HEX ? { hex } : { hex, variables: buildRamp(rgb) }; + // The shipped green keeps its white text untouched (no ramp, globals.css green fallback). + if (hex === DEFAULT_PRIMARY_HEX) { + return { hex, foreground: WHITE_FOREGROUND }; + } + + const foreground = foregroundFor(rgb); + const variables = buildRamp(rgb); + variables['--app-primary-foreground'] = foreground; + + return { hex, foreground, variables }; } /**