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
65 changes: 65 additions & 0 deletions src/components/settings/BrandColorPicker.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
import { useEffect, useState } from 'react';
import { normalizeHex } from '../../utils/color';

const PRESETS = ['#6366f1', '#0ea5e9', '#10b981', '#f59e0b', '#ef4444', '#8b5cf6', '#ec4899', '#334155'];

interface Props {
value: string;
onChange: (hex: string) => void;
}

/**
* Brand-color control: the OS color picker, an editable hex field (type or paste
* an exact color), and quick preset swatches. The hex field keeps a local draft
* so partial typing is smooth; it only commits a valid, normalized hex.
*/
export function BrandColorPicker({ value, onChange }: Props) {
const [draft, setDraft] = useState(value);
// Reflect external changes (picker, presets) into the hex field.
useEffect(() => setDraft(value), [value]);

function onHexInput(raw: string) {
setDraft(raw);
const normalized = normalizeHex(raw);
if (normalized) onChange(normalized);
}

return (
<div>
<div className="flex items-center gap-3">
<input
type="color"
value={value}
onChange={(e) => onChange(e.target.value)}
className="h-10 w-14 shrink-0 cursor-pointer rounded border border-slate-700 bg-slate-800"
aria-label="Pick brand color"
/>
<input
type="text"
value={draft}
onChange={(e) => onHexInput(e.target.value)}
onBlur={() => setDraft(value)}
spellCheck={false}
placeholder="#6366F1"
aria-label="Brand color hex"
className="w-28 rounded-md border border-slate-700 bg-slate-800 px-2.5 py-1.5 font-mono text-sm uppercase text-slate-200 outline-none transition-colors focus:border-indigo-500 focus:ring-1 focus:ring-indigo-500"
/>
</div>
<div className="mt-3 flex flex-wrap gap-2">
{PRESETS.map((sw) => (
<button
key={sw}
type="button"
onClick={() => onChange(sw)}
style={{ backgroundColor: sw }}
aria-label={`Use ${sw}`}
className={[
'h-6 w-6 rounded-full transition-transform hover:scale-110',
value.toLowerCase() === sw ? 'ring-2 ring-white ring-offset-2 ring-offset-slate-800' : 'border border-slate-600',
].join(' ')}
/>
))}
</div>
</div>
);
}
31 changes: 31 additions & 0 deletions src/components/settings/__tests__/BrandColorPicker.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import { describe, it, expect, vi } from 'vitest';
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { BrandColorPicker } from '../BrandColorPicker';

describe('BrandColorPicker', () => {
it('commits a valid typed hex, normalized', async () => {
const onChange = vi.fn();
render(<BrandColorPicker value="#6366f1" onChange={onChange} />);
const hex = screen.getByLabelText('Brand color hex');
await userEvent.clear(hex);
await userEvent.type(hex, '0af');
expect(onChange).toHaveBeenLastCalledWith('#00aaff');
});

it('does not commit an incomplete hex', async () => {
const onChange = vi.fn();
render(<BrandColorPicker value="#6366f1" onChange={onChange} />);
const hex = screen.getByLabelText('Brand color hex');
await userEvent.clear(hex);
await userEvent.type(hex, '#12');
expect(onChange).not.toHaveBeenCalled();
});

it('applies a preset swatch', async () => {
const onChange = vi.fn();
render(<BrandColorPicker value="#6366f1" onChange={onChange} />);
await userEvent.click(screen.getByLabelText('Use #10b981'));
expect(onChange).toHaveBeenCalledWith('#10b981');
});
});
26 changes: 5 additions & 21 deletions src/pages/Settings.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import { loadSampleData, countDemoData } from '../utils/sampleData';
import { isEncryptionEnabled, enableEncryption, disableEncryption } from '../db/encryption';
import { fileToLogoDataUrl } from '../utils/image';
import { DEFAULT_BRAND } from '../utils/pdf';
import { BrandColorPicker } from '../components/settings/BrandColorPicker';
import { validateGitHubToken } from '../utils/github';
import { Toast } from '../components/ui/Toast';
import { useToast } from '../hooks/useToast';
Expand Down Expand Up @@ -393,27 +394,10 @@ export default function Settings() {
<div className="grid grid-cols-1 gap-6 sm:grid-cols-2">
<div>
<label className="mb-1.5 block text-sm font-medium text-slate-300">Brand color</label>
<div className="flex items-center gap-3">
<input
type="color"
{...register('brandColor')}
className="h-10 w-14 cursor-pointer rounded border border-slate-700 bg-slate-800"
aria-label="Brand color"
/>
<span className="font-mono text-sm uppercase text-slate-400">{watch('brandColor')}</span>
</div>
<div className="mt-3 flex flex-wrap gap-2">
{['#6366f1', '#0ea5e9', '#10b981', '#f59e0b', '#ef4444', '#8b5cf6', '#ec4899', '#334155'].map((sw) => (
<button
key={sw}
type="button"
onClick={() => setValue('brandColor', sw, { shouldDirty: true })}
className="h-6 w-6 rounded-full border border-slate-600 transition-transform hover:scale-110"
style={{ backgroundColor: sw }}
aria-label={`Use ${sw}`}
/>
))}
</div>
<BrandColorPicker
value={watch('brandColor') || DEFAULT_BRAND}
onChange={(hex) => setValue('brandColor', hex, { shouldDirty: true })}
/>
</div>

<div>
Expand Down
24 changes: 24 additions & 0 deletions src/utils/__tests__/color.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import { describe, test, expect } from 'vitest';
import { normalizeHex } from '../color';

describe('normalizeHex', () => {
test('normalizes a 6-digit hex to lowercase', () => {
expect(normalizeHex('#6366F1')).toBe('#6366f1');
});
test('adds a missing leading #', () => {
expect(normalizeHex('6366f1')).toBe('#6366f1');
});
test('expands 3-digit shorthand', () => {
expect(normalizeHex('#0af')).toBe('#00aaff');
expect(normalizeHex('abc')).toBe('#aabbcc');
});
test('trims surrounding whitespace', () => {
expect(normalizeHex(' #ABCDEF ')).toBe('#abcdef');
});
test('returns null for invalid input', () => {
expect(normalizeHex('#12')).toBeNull();
expect(normalizeHex('nope')).toBeNull();
expect(normalizeHex('#12345g')).toBeNull();
expect(normalizeHex('')).toBeNull();
});
});
16 changes: 16 additions & 0 deletions src/utils/color.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
const HEX_RE = /^#([0-9a-f]{3}|[0-9a-f]{6})$/i;

/**
* Normalize a user-typed hex color to lowercase `#rrggbb`, adding a missing `#`
* and expanding shorthand (`#0af` → `#00aaff`). Returns null if it isn't a valid
* 3- or 6-digit hex — callers keep the previous value in that case.
*/
export function normalizeHex(raw: string): string | null {
let v = raw.trim().toLowerCase();
if (v && !v.startsWith('#')) v = `#${v}`;
if (!HEX_RE.test(v)) return null;
if (v.length === 4) {
v = `#${v.slice(1).split('').map((c) => c + c).join('')}`;
}
return v;
}
Loading