/** * @file tailwind.config.js * @description Tailwind CSS configuration — content globs and the dashboard's * dark/light color tokens. Token values live in `src/index.css` as CSS * variables (`:root` = light, `.dark` = dark); this file only wires Tailwind * class names to them. RGB-triplet vars (not hex) so `` keeps * opacity modifiers like `bg-surface-2/70` working under either theme. * @author Nguyễn Ngọc Trí Vĩ */ /** * A status/accent FILL is pre-mixed against one fixed base, never translucent. * * `bg-status-success/10` used to emit `rgb(var(--status-success) / 0.1)`, which * composites with whatever is behind it — so the one "success" chip rendered as * five different colours over the five surface steps, and washed out badly on * the light theme's near-white cards. Mixing against `--tint-base` (the card * surface of the active theme) gives an OPAQUE colour: the same chip reads the * same on the page, in a card, in a modal and inside an input row. * * Applied to `backgroundColor`/`borderColor` only. `text-status-warning/80` and * `ring-accent/40` stay genuinely translucent — text and rings sit on top of a * fill rather than replacing it — and the surface family keeps its alpha too, * or every modal scrim would turn solid. */ const tint = (variable) => ({ opacityValue }) => { const alpha = Number(opacityValue); // No modifier at all: Tailwind passes `var(--tw-bg-opacity, 1)` here. if (!Number.isFinite(alpha) || alpha >= 1) return `rgb(var(${variable}))`; return `color-mix(in srgb, rgb(var(${variable})) ${alpha * 100}%, rgb(var(--tint-base)))`; }; const tinted = { accent: { DEFAULT: tint("--accent"), hover: tint("--accent-hover"), muted: "color-mix(in srgb, rgb(var(--accent)) 15%, rgb(var(--tint-base)))", }, // The fill behind white text (`.btn-primary`, Switch on, Checkbox tick): // `--accent` is a TEXT colour, too light in dark themes to carry white. "accent-solid": { DEFAULT: tint("--accent-solid"), hover: tint("--accent-solid-hover"), }, status: { success: tint("--status-success"), danger: tint("--status-danger"), warning: tint("--status-warning"), }, diff: { mod: tint("--diff-mod") }, kept: tint("--kept"), }; /** @type {import('tailwindcss').Config} */ export default { darkMode: "class", content: ["./index.html", "./src/**/*.{js,ts,jsx,tsx}"], theme: { extend: { colors: { surface: { 0: "rgb(var(--surface-0) / )", 1: "rgb(var(--surface-1) / )", 2: "rgb(var(--surface-2) / )", 3: "rgb(var(--surface-3) / )", 4: "rgb(var(--surface-4) / )", 5: "rgb(var(--surface-5) / )", }, border: { DEFAULT: "rgb(var(--border) / )", light: "rgb(var(--border-light) / )", }, accent: { DEFAULT: "rgb(var(--accent) / )", hover: "rgb(var(--accent-hover) / )", muted: "rgb(var(--accent) / 0.15)", }, "accent-solid": { DEFAULT: "rgb(var(--accent-solid) / )", hover: "rgb(var(--accent-solid-hover) / )", }, fg: { primary: "rgb(var(--fg-primary) / )", secondary: "rgb(var(--fg-secondary) / )", muted: "rgb(var(--fg-muted) / )", }, syntax: { comment: "rgb(var(--syn-comment) / )", string: "rgb(var(--syn-string) / )", number: "rgb(var(--syn-number) / )", keyword: "rgb(var(--syn-keyword) / )", builtin: "rgb(var(--syn-builtin) / )", function: "rgb(var(--syn-function) / )", operator: "rgb(var(--syn-operator) / )", punctuation: "rgb(var(--syn-punctuation) / )", property: "rgb(var(--syn-property) / )", tag: "rgb(var(--syn-tag) / )", plain: "rgb(var(--syn-plain) / )", add: "rgb(var(--syn-add) / )", del: "rgb(var(--syn-del) / )", }, status: { success: "rgb(var(--status-success) / )", danger: "rgb(var(--status-danger) / )", warning: "rgb(var(--status-warning) / )", }, diff: { mod: "rgb(var(--diff-mod) / )" }, kept: "rgb(var(--kept) / )", }, backgroundColor: tinted, borderColor: tinted, // Text a step heavier than stock: regular (and `font-normal`) is 500. fontWeight: { normal: "500" }, fontFamily: { sans: ["Inter", "-apple-system", "BlinkMacSystemFont", "Segoe UI", "sans-serif"], mono: ["JetBrains Mono", "Fira Code", "Consolas", "monospace"], // Tailwind's stock stack minus Georgia: Windows picks Georgia, which has // no precomposed Vietnamese glyphs, so Chrome splits "ế" into "ê" + a // tone mark it cannot place and the chat reads "Chuyê ́n". serif: ["ui-serif", "Cambria", "Times New Roman", "Times", "serif"], }, animation: { // Status indicators pulse a few times and rest (never `infinite`: an // always-on animation keeps the browser drawing 60 frames a second). "pulse-slow": "pulse 3s cubic-bezier(0.4, 0, 0.6, 1) 3", ping: "ping 1s cubic-bezier(0, 0, 0.2, 1) 4", "fade-in": "fadeIn 0.3s ease-out", "slide-up": "slideUp 0.3s ease-out", }, keyframes: { fadeIn: { "0%": { opacity: "0" }, "100%": { opacity: "1" }, }, slideUp: { "0%": { opacity: "0", transform: "translateY(8px)" }, "100%": { opacity: "1", transform: "translateY(0)" }, }, }, }, }, plugins: [], };