382 lines
18 KiB
JavaScript
382 lines
18 KiB
JavaScript
/**
|
|
* ============================================================================
|
|
* CENTRAL COLOR PALETTE — the single source of truth for `dashfront`.
|
|
* ============================================================================
|
|
*
|
|
* The palette is intentionally tiny: 1 white + 6 light greys + 6 dark greys
|
|
* + 7 chromatic families (each a `dark`/`light` pair) = 20 base values.
|
|
* There is no black: the darkest neutral is `DARK_6`, and every shadow or
|
|
* scrim is an alpha of it (see `shade()`).
|
|
*
|
|
* EVERYTHING ELSE IN THIS FILE IS DERIVED from those 20 values. Change one
|
|
* base and the whole app follows, live, via Vite HMR.
|
|
*
|
|
* HOW TO PLAY WITH THE COLORS
|
|
* ---------------------------
|
|
* 1. Edit any of the 20 base values in section 1.
|
|
* 2. `CHROMA.<name>.dark` → text, icons, borders, solid buttons.
|
|
* `CHROMA.<name>.light` → panel fills, chips, tints.
|
|
*
|
|
* RULES FOR NEW CODE
|
|
* ------------------
|
|
* * Never write a hex/rgba literal in a component — import a token.
|
|
* * Prefer a base token. Reach for a derived one only when it reads better.
|
|
*/
|
|
|
|
/* ────────────────────────────────────────────────────────────────────────────
|
|
* 1. THE 20 BASE VALUES
|
|
* -------------------------------------------------------------------------*/
|
|
|
|
/** Pure white — cards, dialogs, page panels. */
|
|
export const WHITE = '#ffffff';
|
|
|
|
/** Light greys, ascending darkness. `LIGHT_1` is the faintest surface. */
|
|
export const LIGHT_1 = '#fcfcfc';
|
|
export const LIGHT_2 = '#fafafa';
|
|
export const LIGHT_3 = '#f8fafc';
|
|
export const LIGHT_4 = '#f1f5f9';
|
|
export const LIGHT_5 = '#e2e8f0';
|
|
export const LIGHT_6 = '#cbd5e1';
|
|
|
|
/** Dark greys, descending lightness. `DARK_1` is muted text, `DARK_6` darkest. */
|
|
export const DARK_1 = '#94a3b8';
|
|
export const DARK_2 = '#64748b';
|
|
export const DARK_3 = '#475569';
|
|
export const DARK_4 = '#334155';
|
|
export const DARK_5 = '#1e293b';
|
|
export const DARK_6 = '#0f172a';
|
|
|
|
/**
|
|
* The seven chromatic families.
|
|
* dark → text, icons, borders, solid buttons (readable on white)
|
|
* light → panel fills, chips, tints
|
|
*/
|
|
export const CHROMA = {
|
|
RED: { dark: '#b91c1c', light: '#fecaca' },
|
|
ORANGE: { dark: '#d97706', light: '#fed7aa' },
|
|
YELLOW: { dark: '#ca8a04', light: '#fde68a' },
|
|
GREEN: { dark: '#15803d', light: '#bbf7d0' },
|
|
PETROL: { dark: '#0f766e', light: '#99f6e4' },
|
|
BLUE: { dark: '#1d4ed8', light: '#bfdbfe' },
|
|
PURPLE: { dark: '#6d28d9', light: '#ddd6fe' },
|
|
};
|
|
|
|
/* ────────────────────────────────────────────────────────────────────────────
|
|
* 2. COLOR MATH (the only functions in this file)
|
|
* -------------------------------------------------------------------------*/
|
|
|
|
function toHex(parts) {
|
|
return `#${parts
|
|
.map((v) => Math.max(0, Math.min(255, Math.round(v))).toString(16).padStart(2, '0'))
|
|
.join('')}`;
|
|
}
|
|
|
|
function channels(hex) {
|
|
return [1, 3, 5].map((i) => parseInt(hex.slice(i, i + 2), 16));
|
|
}
|
|
|
|
/** Linear blend: `amount` 0 → `a`, 1 → `b`. */
|
|
export function mix(a, b, amount) {
|
|
const ca = channels(a);
|
|
const cb = channels(b);
|
|
return toHex(ca.map((v, i) => v + (cb[i] - v) * amount));
|
|
}
|
|
|
|
/** `color` at `opacity`, as an `rgba()` string. */
|
|
export function alpha(color, opacity) {
|
|
const [r, g, b] = channels(color);
|
|
return `rgba(${r}, ${g}, ${b}, ${opacity})`;
|
|
}
|
|
|
|
/** An alpha of `DARK_6` — our stand-in for "black". */
|
|
export const shade = (opacity) => alpha(DARK_6, opacity);
|
|
|
|
/** An alpha of `WHITE` — used for text and highlights over dark surfaces. */
|
|
export const veil = (opacity) => alpha(WHITE, opacity);
|
|
|
|
/** A lighter tint of a chroma family: 0 → `light`, 1 → white. */
|
|
const tint = (family, amount) => mix(CHROMA[family].light, WHITE, amount);
|
|
|
|
/** Resolve a `{ dark, light }` pair against the active MUI theme. */
|
|
export function byMode(theme, pair, fallback) {
|
|
if (theme.palette.mode === 'dark' && pair.dark != null) return pair.dark;
|
|
return pair.light ?? fallback;
|
|
}
|
|
|
|
/* ────────────────────────────────────────────────────────────────────────────
|
|
* 3. BRAND & SHELL
|
|
* -------------------------------------------------------------------------*/
|
|
|
|
/** App background — a soft petrol wash. */
|
|
export const MAIN_BACKGROUND = mix(WHITE, CHROMA.PETROL.dark, 0.52);
|
|
|
|
/** MUI `palette.primary.main`. */
|
|
export const PRIMARY = CHROMA.PETROL.dark;
|
|
|
|
/** MUI `palette.background.paper`. */
|
|
export const PAPER = WHITE;
|
|
|
|
/** `meta[name="theme-color"]` in `index.html`. */
|
|
export const THEME_COLOR = CHROMA.BLUE.dark;
|
|
|
|
/* ────────────────────────────────────────────────────────────────────────────
|
|
* 4. NEUTRAL SURFACES
|
|
* -------------------------------------------------------------------------*/
|
|
|
|
export const SURFACE_FAINT = LIGHT_1; // attachment cards
|
|
export const SURFACE_SUBTLE = LIGHT_2; // placeholders, letterboxes
|
|
export const SURFACE_BASE = LIGHT_3; // panels, tiles, default tint
|
|
export const SURFACE_RAISED = LIGHT_4; // inset boxes, table heads, chips
|
|
export const BORDER = LIGHT_5; // dividers, outlines, placeholders
|
|
export const BORDER_STRONG = LIGHT_6; // muted rules, checkerboard
|
|
|
|
export const TEXT_MUTED = DARK_1;
|
|
export const TEXT_SECONDARY = DARK_2;
|
|
export const TEXT_BODY = DARK_3;
|
|
export const TEXT_STRONG = DARK_4;
|
|
export const INK = DARK_5; // barcode strokes
|
|
export const INK_STRONG = DARK_6; // label text on light chips
|
|
|
|
/** Semi-transparent panel wash for empty states. */
|
|
export const SURFACE_WASH = alpha(SURFACE_BASE, 0.7);
|
|
|
|
/** Neutral bubble / dialog fill that follows the active mode. */
|
|
export const NEUTRAL_BUBBLE = { dark: WHITE, light: LIGHT_4 };
|
|
|
|
/* ────────────────────────────────────────────────────────────────────────────
|
|
* 5. CHROMA DERIVATIONS
|
|
* Every pastel used for panels, chips and tiles comes from here.
|
|
* -------------------------------------------------------------------------*/
|
|
|
|
export const RED_BG = tint('RED', 0.85);
|
|
export const RED_PANEL = CHROMA.RED.light;
|
|
export const RED_SOFT = tint('RED', 0.35);
|
|
export const RED = CHROMA.RED.dark;
|
|
export const RED_HOVER = mix(CHROMA.RED.dark, CHROMA.ORANGE.dark, 0.5);
|
|
export const RED_TINT = alpha(CHROMA.RED.dark, 0.12);
|
|
export const RED_GLOW = alpha(CHROMA.RED.dark, 0.6);
|
|
|
|
export const ORANGE_BG = tint('ORANGE', 0.7);
|
|
export const ORANGE_PANEL = CHROMA.ORANGE.light;
|
|
export const ORANGE = CHROMA.ORANGE.dark;
|
|
|
|
export const YELLOW_BG = tint('YELLOW', 0.75);
|
|
export const YELLOW_PANEL = tint('YELLOW', 0.4);
|
|
export const YELLOW = CHROMA.YELLOW.dark;
|
|
|
|
export const GREEN_BG = tint('GREEN', 0.75);
|
|
export const GREEN_PANEL = CHROMA.GREEN.light;
|
|
export const GREEN_SOFT = tint('GREEN', 0.15);
|
|
export const GREEN = CHROMA.GREEN.dark;
|
|
/** Bright green for text/highlights over dark surfaces/scrims. */
|
|
export const GREEN_BRIGHT = CHROMA.GREEN.light;
|
|
|
|
|
|
export const PETROL_BG = tint('PETROL', 0.85);
|
|
export const PETROL_PANEL = CHROMA.PETROL.light;
|
|
export const PETROL = CHROMA.PETROL.dark;
|
|
|
|
export const BLUE_BG = tint('BLUE', 0.85);
|
|
export const BLUE_PANEL = CHROMA.BLUE.light;
|
|
export const BLUE_SOFT = tint('BLUE', 0.25);
|
|
export const BLUE = CHROMA.BLUE.dark;
|
|
export const BLUE_GLOW = alpha(CHROMA.BLUE.dark, 0.6);
|
|
export const BLUE_SHADOW = alpha(CHROMA.BLUE.dark, 0.25);
|
|
export const BLUE_TINT = alpha(CHROMA.BLUE.dark, 0.12);
|
|
export const BLUE_BORDER = alpha(CHROMA.BLUE.dark, 0.3);
|
|
|
|
export const PURPLE_BG = tint('PURPLE', 0.6);
|
|
export const PURPLE_PANEL = CHROMA.PURPLE.light;
|
|
export const PURPLE_SOFT = tint('PURPLE', 0.3);
|
|
export const PURPLE = CHROMA.PURPLE.dark;
|
|
|
|
/** Between violet and red — used for the article list editor. */
|
|
export const MAGENTA_PANEL = mix(CHROMA.PURPLE.light, CHROMA.RED.light, 0.45);
|
|
|
|
/** Between blue and petrol — used for brand text over photos. */
|
|
export const ICE = mix(CHROMA.BLUE.light, CHROMA.PETROL.light, 0.4);
|
|
|
|
/** Transparent checkerboard behind image-search results. */
|
|
export const CHECKERBOARD = `linear-gradient(45deg, ${BORDER_STRONG} 25%, transparent 25%), linear-gradient(-45deg, ${BORDER_STRONG} 25%, transparent 25%), linear-gradient(45deg, transparent 75%, ${BORDER_STRONG} 75%), linear-gradient(-45deg, transparent 75%, ${BORDER_STRONG} 75%)`;
|
|
|
|
/* ────────────────────────────────────────────────────────────────────────────
|
|
* 6. STATUS & FEEDBACK
|
|
* -------------------------------------------------------------------------*/
|
|
|
|
export const ERROR = RED;
|
|
export const ERROR_HOVER = mix(RED, INK, 0.35);
|
|
export const LINK = BLUE;
|
|
|
|
/** Selection state for list rows. */
|
|
export const SELECT_BG = BLUE_TINT;
|
|
export const SELECT_BG_HOVER = alpha(CHROMA.BLUE.dark, 0.16);
|
|
export const SELECT_BORDER = BLUE_BORDER;
|
|
|
|
/** SortableJS drag ghost — also published as a CSS variable. */
|
|
export const SORT_GHOST = GREEN;
|
|
export const SORT_GHOST_BG = alpha(CHROMA.GREEN.dark, 0.12);
|
|
|
|
/** "Processing…" ribbon on image search results. */
|
|
export const INFO_RIBBON = alpha(CHROMA.BLUE.dark, 0.92);
|
|
|
|
/** Delicate fills used on top of panel colors. */
|
|
export const TINT_FAINT = shade(0.01);
|
|
export const TINT_HAIRLINE = shade(0.03);
|
|
export const TINT_SUBTLE = shade(0.04);
|
|
export const TINT_ROW_HOVER = shade(0.05);
|
|
export const TINT_CHIP = shade(0.06);
|
|
export const TINT_CARD_HOVER = shade(0.06);
|
|
export const TINT_PILL = shade(0.08);
|
|
export const TINT_STRONG = shade(0.12);
|
|
export const TINT_PILL_ACTIVE = shade(0.14);
|
|
|
|
/** Darkening for white text/icons sitting on imagery. */
|
|
export const SCRIM_SOFT = shade(0.35);
|
|
export const SCRIM_MEDIUM = shade(0.45);
|
|
export const SCRIM_BADGE = shade(0.5);
|
|
export const SCRIM_BUTTON = shade(0.55);
|
|
export const SCRIM_ACTION = shade(0.6);
|
|
export const SCRIM_LABEL = shade(0.65);
|
|
export const SCRIM_STRONG = shade(0.75);
|
|
export const SCRIM_HEAVY = shade(0.85);
|
|
export const SCRIM_INK = shade(0.95);
|
|
|
|
/** White overlays: hover states on dark chrome, text on imagery. */
|
|
export const VEIL_FAINT = veil(0.04);
|
|
export const VEIL_SUBTLE = veil(0.05);
|
|
export const VEIL_CHIP = veil(0.08);
|
|
export const VEIL_HOVER = veil(0.15);
|
|
export const VEIL_DISABLED = veil(0.3);
|
|
export const VEIL_MUTED = veil(0.35);
|
|
export const VEIL_PANEL = veil(0.55);
|
|
export const VEIL_TEXT = veil(0.8);
|
|
export const VEIL_CARD = veil(0.85);
|
|
export const VEIL_CARD_STRONG = veil(0.9);
|
|
export const VEIL_OVERLAY = veil(0.92);
|
|
export const VEIL_SOLID = veil(1);
|
|
|
|
/* ────────────────────────────────────────────────────────────────────────────
|
|
* 7. DARK-MODE PAIRS (resolve with `byMode(theme, PAIR)`)
|
|
* -------------------------------------------------------------------------*/
|
|
|
|
export const SURFACE_SUBTLE_PAIR = { light: TINT_HAIRLINE, dark: VEIL_FAINT };
|
|
export const TABLE_HEADER_PAIR = { light: TINT_SUBTLE, dark: VEIL_SUBTLE };
|
|
export const CODE_INLINE_PAIR = { light: TINT_CHIP, dark: VEIL_CHIP };
|
|
export const CODE_BLOCK_PAIR = { light: TINT_CHIP, dark: SCRIM_SOFT };
|
|
export const ASK_PROMPT_BG_PAIR = { light: BLUE_BG, dark: BLUE_TINT };
|
|
export const ASK_PROMPT_BORDER_PAIR = { light: BLUE_PANEL, dark: BLUE_BORDER };
|
|
|
|
/* ────────────────────────────────────────────────────────────────────────────
|
|
* 8. SIDEBAR NAVIGATION
|
|
* -------------------------------------------------------------------------*/
|
|
|
|
/** Deep petrol — the app's structural dark. */
|
|
export const NAV_SURFACE = mix(DARK_6, CHROMA.PETROL.dark, 0.3);
|
|
|
|
/** Horizontal sheen across the sidebar — kept very soft, no hard banding. */
|
|
export const NAV_BACKGROUND = `linear-gradient(90deg, ${mix(NAV_SURFACE, DARK_6, 0.16)} 0%, ${mix(NAV_SURFACE, WHITE, 0.04)} 25%, ${NAV_SURFACE} 60%, ${mix(NAV_SURFACE, DARK_6, 0.12)} 100%)`;
|
|
|
|
export const NAV_ITEM = mix(WHITE, CHROMA.PETROL.dark, 0.45);
|
|
export const NAV_ITEM_HOVER = mix(DARK_5, CHROMA.PETROL.dark, 0.3);
|
|
export const NAV_ITEM_HOVER_TEXT = mix(WHITE, CHROMA.PETROL.dark, 0.1);
|
|
export const NAV_ITEM_ACTIVE = mix(DARK_5, CHROMA.PETROL.dark, 0.55);
|
|
export const NAV_ITEM_ACTIVE_TEXT = mix(CHROMA.PETROL.light, WHITE, 0.15);
|
|
|
|
/** Accent for active items and live draft badges. */
|
|
export const NAV_ITEM_ACTIVE_ACCENT = CHROMA.PETROL.light;
|
|
|
|
/**
|
|
* Sub-navigation (2nd level) sits on the light app background, not on the
|
|
* dark sidebar — so its active surface only needs a mid dip to let the light
|
|
* accent read. Well short of the fully deep `NAV_ITEM_ACTIVE`.
|
|
*/
|
|
export const NAV_SUB_ACTIVE = mix(MAIN_BACKGROUND, NAV_ITEM_ACTIVE, 0.55);
|
|
export const NAV_SUB_ACTIVE_HOVER = mix(MAIN_BACKGROUND, NAV_ITEM_ACTIVE, 0.55);
|
|
|
|
/* ────────────────────────────────────────────────────────────────────────────
|
|
* 9. CHAT / AI ASSISTANT
|
|
* -------------------------------------------------------------------------*/
|
|
|
|
export const CHAT = CHROMA.BLUE.dark;
|
|
export const CHAT_BUSY = CHROMA.ORANGE.dark;
|
|
export const CHAT_PENDING = mix(CHROMA.ORANGE.dark, CHROMA.RED.dark, 0.5);
|
|
|
|
/** Pulsing "assistant needs input" halo on the FAB. */
|
|
export const CHAT_FAB_PENDING = `linear-gradient(135deg, ${CHAT_BUSY} 0%, ${CHAT_PENDING} 100%)`;
|
|
/** Busy ("thinking") FAB. */
|
|
export const CHAT_FAB_BUSY = `linear-gradient(135deg, ${CHAT_BUSY} 0%, ${mix(CHAT_BUSY, INK, 0.25)} 100%)`;
|
|
|
|
/** Matching drop shadows, so the FAB appears to float in the active color. */
|
|
export const CHAT_FAB_SHADOW_IDLE = `0 8px 24px ${alpha(CHAT, 0.35)}`;
|
|
export const CHAT_FAB_SHADOW_BUSY = `0 8px 24px ${alpha(CHAT_BUSY, 0.35)}`;
|
|
export const CHAT_FAB_SHADOW_PENDING = `0 8px 24px ${alpha(CHAT_PENDING, 0.45)}`;
|
|
|
|
/* ────────────────────────────────────────────────────────────────────────────
|
|
* 10. GRADIENTS & SHADOWS
|
|
* -------------------------------------------------------------------------*/
|
|
|
|
/** Bottom scrims that keep captions legible over photos. */
|
|
export const SCRIM_TILE = `linear-gradient(transparent, ${SCRIM_LABEL})`;
|
|
export const SCRIM_ATTACHMENT = `linear-gradient(transparent, ${SCRIM_STRONG} 40%, ${SCRIM_HEAVY})`;
|
|
|
|
/** Text glow for tile captions drawn over imagery. */
|
|
export const TEXT_GLOW_DARK = `0 1px 3px ${SCRIM_INK}, 0 0 2px ${SCRIM_HEAVY}`;
|
|
export const TEXT_GLOW_LIGHT = `0 0 3px ${VEIL_SOLID}, 0 0 6px ${VEIL_CARD_STRONG}, 0 1px 2px ${VEIL_CARD_STRONG}`;
|
|
|
|
export const SHADOW_CARD = `0 1px 4px ${TINT_CARD_HOVER}`;
|
|
export const SHADOW_DIALOG = `0 20px 40px ${shade(0.2)}`;
|
|
export const SHADOW_POPOVER = `0 8px 28px ${shade(0.2)}`;
|
|
export const SHADOW_BADGE = `0 2px 5px ${SCRIM_SOFT}`;
|
|
export const SHADOW_DRAG = `0 8px 20px ${shade(0.28)}`;
|
|
export const SHADOW_SORTABLE = `0 4px 16px ${TINT_STRONG}`;
|
|
export const SHADOW_DRAWER = `-8px 0 32px ${TINT_STRONG}`;
|
|
export const SHADOW_THUMB_RING = `0 0 0 1px ${TINT_STRONG}`;
|
|
export const SHADOW_TAB = `drop-shadow(0px 2px 4px ${shade(0.2)}) drop-shadow(0px 4px 5px ${TINT_PILL_ACTIVE}) drop-shadow(0px 1px 10px ${TINT_STRONG})`;
|
|
export const SHADOW_BUBBLE_USER = `0 2px 8px ${BLUE_SHADOW}`;
|
|
export const SHADOW_BUBBLE_ASSISTANT = `0 1px 4px ${TINT_ROW_HOVER}`;
|
|
|
|
/* ────────────────────────────────────────────────────────────────────────────
|
|
* 10b. DIALOG FOG
|
|
* Shared backdrop for every dialog (`src/components/AppDialog.jsx`).
|
|
* Darker and more bluish than `MAIN_BACKGROUND`: deep-navy solid base
|
|
* plus a translucent variant for the MUI backdrop layer. The solid
|
|
* variant is also published as `--df-dialog-fog` so the
|
|
* scrollbar-gutter placeholder can switch to the fog color while a
|
|
* dialog is open (`html.df-dialog-open` in `src/index.css`).
|
|
* -------------------------------------------------------------------------*/
|
|
|
|
/** Raw deep-navy base tint used for the dialog fog backdrop. */
|
|
export const DIALOG_FOG_NAVY = mix(DARK_6, CHROMA.BLUE.dark, 0.42);
|
|
|
|
/** Translucent backdrop wash painted over the page. */
|
|
export const DIALOG_FOG = alpha(DIALOG_FOG_NAVY, 0.72);
|
|
|
|
/**
|
|
* Solid fog color seen when DIALOG_FOG (72% opacity) is drawn over MAIN_BACKGROUND.
|
|
* Published as `--df-dialog-fog` so the reserved scrollbar-gutter strip on
|
|
* `html.df-dialog-open` seamlessly matches the translucent fog backdrop over the app.
|
|
*/
|
|
export const DIALOG_FOG_SOLID = mix(MAIN_BACKGROUND, DIALOG_FOG_NAVY, 0.72);
|
|
|
|
/* ────────────────────────────────────────────────────────────────────────────
|
|
* 11. CSS VARIABLE BRIDGE
|
|
* `src/index.css` cannot import this module, so these few tokens are
|
|
* published as `var(--df-*)` custom properties by `installCssVars()`.
|
|
* -------------------------------------------------------------------------*/
|
|
|
|
export const CSS_VARS = {
|
|
'--df-main-background': MAIN_BACKGROUND,
|
|
'--df-dialog-fog': DIALOG_FOG_SOLID,
|
|
'--df-sort-ghost': SORT_GHOST,
|
|
'--df-sort-ghost-tint': SORT_GHOST_BG,
|
|
'--df-shadow-sortable': SHADOW_SORTABLE,
|
|
'--df-shadow-drag': SHADOW_DRAG,
|
|
};
|
|
|
|
/** Publish {@link CSS_VARS} onto `:root`. Call once at startup. */
|
|
export function installCssVars(target = document.documentElement) {
|
|
for (const [name, value] of Object.entries(CSS_VARS)) {
|
|
target.style.setProperty(name, value);
|
|
}
|
|
}
|