Accent colors
View on GitHubAdd a new accent color
Worked example: adding an 11th option, teal. Because AccentColor
is 'default' | ProjectColors (see the
README's architecture section), this single new name
becomes both a new accent option and a new project badge color at
the same time — there's no way to add one without the other, since
they're the same union. Four separate, hand-maintained places need the
new name; miss one and you get a different specific kind of breakage,
not a build error.
Step 1 — Add it to the shared color vocabulary
File: resources/js/types/Projects.ts
export type ProjectColors =
| 'red'
| 'orange'
| 'yellow'
| 'green'
| 'lime'
| 'blue'
| 'sky'
| 'violet'
| 'purple'
| 'pink'
| 'teal';
export const AVAILABLE_COLORS: ProjectColors[] = [
'red',
'orange',
'yellow',
'green',
'lime',
'blue',
'sky',
'violet',
'purple',
'pink',
'teal',
];
This alone makes teal a valid project badge color end to end —
every project color picker (NewProjectModal, ProjectDetailsForm,
ProjectOnboardingForm, and others) reads from AVAILABLE_COLORS
generically, no per-component change needed. It does not yet make
teal render correctly (step 2) or make it selectable as an accent
(steps 3–4) — each of those needs its own update below.
The name must be a real Tailwind color name (teal, not something
invented) — step 2's classes are literal strings Tailwind's build
scans for; a made-up name has no bg-‹name›-500 utility to generate
at all.
Step 2 — Give it a project-badge Tailwind theme
File: resources/js/utils/colors.ts
teal: {
badgeBg: 'bg-teal-500/10 text-teal-400',
border: 'hover:border-teal-500/30 shadow-teal-500/5',
gradient: 'from-teal-500/5 to-transparent',
accent: 'bg-teal-500',
textGroupHover: 'group-hover:text-teal-500',
},
These must be written out literally, not built from a template —
Tailwind's scanner only generates a utility class if the exact string
appears somewhere in the scanned source (tailwind.config.js's
content globs include resources/js/**/*.{ts,tsx}); a
`bg-${color}-500` template expression is invisible to that scan
and would silently produce no CSS at all, no matter how correct the
runtime string ends up being. Copy the five-property shape from an
existing color exactly, swapping only the color name.
Step 3 — Make it a valid accent value
File: resources/js/context/AccentContext.tsx
const isAccentColor = (value: string | null): value is AccentColor => {
return (
value === 'default' ||
value === 'red' ||
value === 'orange' ||
value === 'yellow' ||
value === 'green' ||
value === 'lime' ||
value === 'blue' ||
value === 'sky' ||
value === 'violet' ||
value === 'purple' ||
value === 'pink' ||
value === 'teal'
);
};
Without this, a teal value read back from localStorage on a future
visit fails the guard and silently resets to 'default' — the
picker (step 4) would still let someone select it once, but the
choice wouldn't survive a page reload.
Step 4 — Give it accent hex values and a picker label
File: resources/js/utils/accentColors.ts
export const ACCENT_COLOR_OPTIONS: AccentColor[] = [
'default',
'red',
'orange',
'yellow',
'green',
'lime',
'blue',
'sky',
'violet',
'purple',
'pink',
'teal',
];
const PROJECT_ACCENT_HEX: Record<
ProjectColors,
{ base: string; light: string }
> = {
red: { base: '#ef4444', light: '#f87171' },
orange: { base: '#f97316', light: '#fb923c' },
yellow: { base: '#eab308', light: '#facc15' },
green: { base: '#22c55e', light: '#4ade80' },
lime: { base: '#84cc16', light: '#a3e635' },
blue: { base: '#3b82f6', light: '#60a5fa' },
sky: { base: '#0ea5e9', light: '#38bdf8' },
violet: { base: '#8b5cf6', light: '#a78bfa' },
purple: { base: '#a855f7', light: '#c084fc' },
pink: { base: '#ec4899', light: '#f472b6' },
teal: { base: '#14b8a6', light: '#2dd4bf' },
};
base/light here are hex codes, not Tailwind class names — this
is the map step 2 does not share, even though both are keyed by the
same teal name (see the README's architecture note). Use the
official Tailwind palette's -500/-400 hex values for the color
(here, teal-500/teal-400) so the accent swatch and the project
badge read as "the same teal," even though nothing enforces that
match in code.
File:
resources/js/Components/Organisms/AccountSettingsContent/AccountSettingsPreferencesTab.tsx
const accentLabels: Record<AccentColor, string> = {
default: 'Default',
red: 'Red',
orange: 'Orange',
yellow: 'Yellow',
green: 'Green',
lime: 'Lime',
blue: 'Blue',
sky: 'Sky',
violet: 'Violet',
purple: 'Purple',
pink: 'Pink',
teal: 'Teal',
};
This is a third, independent hand-maintained record keyed by the same
names — Record<AccentColor, string> means TypeScript itself will
refuse to compile once step 3/4 widen AccentColor until this map
also gets the new key, which is the one piece of this whole chain
that does have a compiler safety net.
Step 5 — Update the pre-paint script (don't skip this one)
File: resources/views/app.blade.php
There's a fourth, independent copy of the base/light hex pair, inside
the inline <script> in <head> that applies the stored theme/accent
before React ever mounts (to avoid a flash of the wrong color on first
paint — see ../theme-colors/01-how-theme-switching-works.md
for the same "before first paint" concern on the theme side). This
script can't import from accentColors.ts — it runs before any bundle
does — so it hardcodes its own copy in plain JS:
<script>
(function () {
try {
var stored = localStorage.getItem('theme');
var mode = (stored === 'light' || stored === 'dark' || stored === 'system') ? stored : 'dark';
var resolved = mode === 'system'
? (window.matchMedia('(prefers-color-scheme: light)').matches ? 'light' : 'dark')
: mode;
document.documentElement.setAttribute('data-theme', resolved);
var accentHex = {
red: ['#ef4444', '#f87171'],
orange: ['#f97316', '#fb923c'],
yellow: ['#eab308', '#facc15'],
green: ['#22c55e', '#4ade80'],
lime: ['#84cc16', '#a3e635'],
blue: ['#3b82f6', '#60a5fa'],
sky: ['#0ea5e9', '#38bdf8'],
violet: ['#8b5cf6', '#a78bfa'],
purple: ['#a855f7', '#c084fc'],
pink: ['#ec4899', '#f472b6'],
teal: ['#14b8a6', '#2dd4bf']
};
var accent = localStorage.getItem('accentColor');
if (accent && accent !== 'default' && accentHex[accent]) {
var base = accentHex[accent][0];
var light = accentHex[accent][1];
var r = parseInt(base.slice(1, 3), 16);
var g = parseInt(base.slice(3, 5), 16);
var b = parseInt(base.slice(5, 7), 16);
var alpha = resolved === 'light' ? 0.12 : 0.2;
var root = document.documentElement.style;
root.setProperty('--accent-color', base);
root.setProperty('--accent-light-color', light);
root.setProperty('--accent-color-opacity', 'rgba(' + r + ', ' + g + ', ' + b + ', ' + alpha + ')');
}
} catch (e) {}
})();
</script>
Skip this step and teal still works correctly — but only after
AccentProvider's effect runs on mount. Until then, the very first
paint renders with no accent override at all (the plain --accent-color
global.css defines), then visibly snaps to teal a moment later — the
exact flash-of-wrong-color this script exists to prevent for every
other accent. Keep this hex pair in sync with
PROJECT_ACCENT_HEX in step 4 by hand; nothing enforces the two match.
Tests
resources/js/utils/colors.test.ts— itstest.each(AVAILABLE_COLORS)('returns a complete, color-matched theme for "%s"', ...)already iterates the array generically; once step 1 adds'teal'and step 2 adds its entry, this test covers it with zero changes, as long as step 2's classes follow the exactbg-${color}-500naming pattern the test asserts against.resources/js/utils/accentColors.tshas no dedicated test file today — addresources/js/utils/accentColors.test.tscoveringgetAccentSwatch('teal')(returns thebasehex) andgetAccentCssVariables('teal', 'dark')/getAccentCssVariables('teal', 'light')(returns the rightaccentColor/accentLightColor, and anaccentColorOpacityalpha of0.2for dark vs.0.12for light), mirroringresources/js/context/AccentContext.test.tsx's existing assertions ongetAccentCssVariables's shape for the colors it already exercises.resources/js/context/AccentContext.test.tsx— no change required; its'setAccentColor updates state, persists to localStorage, and sets the CSS variables'test uses a fixed existing color as its fixture and isn't parameterized over everyAccentColor, so a new name doesn't need a new case here unless you want one for extra confidence.
