Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion apps/web/src/app/api/og/route.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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'>
Expand Down
18 changes: 14 additions & 4 deletions apps/web/src/app/globals.css
Original file line number Diff line number Diff line change
Expand Up @@ -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 *));
Expand Down Expand Up @@ -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 {
Expand Down
2 changes: 1 addition & 1 deletion apps/web/src/common/utils/app-icon.functions.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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'>
Expand Down
2 changes: 1 addition & 1 deletion apps/web/src/components/common/Button.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ export default function Button({
sm: 'gap-x-1.5 px-2.5 py-1.5 text-xs',
};
const variants: Record<NonNullable<Props['variant']>, 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',
Expand Down
2 changes: 1 addition & 1 deletion apps/web/src/components/forms/LoginForm.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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'>
<div className='mb-6 flex flex-col items-center text-center'>
<div className='bg-primary-600 mb-3 inline-flex h-11 w-11 items-center justify-center rounded-xl text-white shadow-sm'>
<div className='bg-primary-600 text-primary-foreground mb-3 inline-flex h-11 w-11 items-center justify-center rounded-xl shadow-sm'>
<Icon icon='CloudArrowUp' className='h-6 w-6' />
</div>
<h1 className='text-lg font-semibold'>{appName}</h1>
Expand Down
4 changes: 2 additions & 2 deletions apps/web/src/components/layouts/AccountSidebarContent.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -171,7 +171,7 @@ export default function AccountSidebarContent({
return (
<>
<div className='flex h-16 shrink-0 items-center gap-x-2.5 border-b border-zinc-300 px-5 dark:border-zinc-700'>
<div className='bg-primary-600 inline-flex h-8 w-8 items-center justify-center rounded-lg text-white'>
<div className='bg-primary-600 text-primary-foreground inline-flex h-8 w-8 items-center justify-center rounded-lg'>
<Icon icon='CloudArrowUp' className='h-5 w-5' />
</div>
<div className='leading-tight'>
Expand Down Expand Up @@ -217,7 +217,7 @@ export default function AccountSidebarContent({
</p>
<a
href={getGoogleAuthUrl()}
className='bg-primary-600 hover:bg-primary-700 inline-flex items-center justify-center gap-x-2 rounded-lg px-3 py-2 text-sm font-medium text-white transition'>
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'>
<Icon icon='LinkIcon' className='h-4 w-4' />
{driveStatus?.error ? 'Reconnect Drive' : 'Connect Drive'}
</a>
Expand Down
2 changes: 1 addition & 1 deletion apps/web/src/components/layouts/NotFoundContent.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ export default function NotFoundContent() {
</p>
<Link
href='/'
className='bg-primary-600 hover:bg-primary-700 rounded-xl px-4 py-2.5 text-sm font-semibold text-white transition-colors duration-150 ease-out'>
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
</Link>
</main>
Expand Down
44 changes: 42 additions & 2 deletions apps/web/src/configs/app/primary-color.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand All @@ -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 <html>, or `undefined` on the built-in green (globals.css falls back to it). */
variables?: Record<string, string>;
}
Expand Down Expand Up @@ -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.
**/
Expand Down Expand Up @@ -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 };
}

/**
Expand Down
Loading