feat: statuses and ticket types take any colour from a colour picker

Settings shows a native colour picker (the palette as suggestions)
instead of the list of eleven names. The API takes a palette name, as
before, or any #rrggbb; picking a palette colour still saves its name,
so it keeps its hand-made dark version. A free colour gets one variant
per theme, computed with the `color` library (moved towards white or
black until it contrasts 3:1 with the surface) and applied with
light-dark(): a pale yellow darkens on white, a navy lightens on dark.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-17 16:08:47 +02:00
parent 034704e46f
commit 33c593521e
19 changed files with 379 additions and 115 deletions

View File

@@ -84,6 +84,25 @@ describe('Project settings', () => {
);
});
it('takes a palette colour or any #rrggbb, and nothing else', () => {
const project = newProject();
const settings = defaultProjectSettings();
settings.statuses[0].color = '#7c3aed';
settings.issueTypes[0].color = '#0EA5E9';
project.updateSettings('owner', settings);
expect(project.workflow.status(settings.statuses[0].id)?.color).toBe(
'#7c3aed',
);
for (const color of ['purple', '#abc', 'url(x)', '#7c3aedff']) {
const bad = defaultProjectSettings();
bad.statuses[0].color = color as never;
expect(() => project.updateSettings('owner', bad)).toThrow(
/Color no válido/,
);
}
});
it('archives values instead of deleting them', () => {
const project = newProject();
const settings = defaultProjectSettings();

View File

@@ -32,7 +32,17 @@ export const SETTING_COLORS = [
'red',
'pink',
] as const;
export type SettingColor = (typeof SETTING_COLORS)[number];
/** A colour: one of the palette's (they follow the theme) or any `#rrggbb`. */
export type SettingColor = (typeof SETTING_COLORS)[number] | `#${string}`;
/** A palette name or a hex colour, as the API takes it. */
export const SETTING_COLOR_PATTERN = new RegExp(
`^(${SETTING_COLORS.join('|')}|#[0-9a-fA-F]{6})$`,
);
export function isSettingColor(color: string): color is SettingColor {
return SETTING_COLOR_PATTERN.test(color);
}
/** Icons a ticket type can use (registered in the web app). */
export const TYPE_ICONS = [
@@ -513,7 +523,7 @@ export function validateProjectSettings(
`Categoría no válida en el estado ${s.name}`,
);
assertThat(
SETTING_COLORS.includes(s.color),
isSettingColor(s.color),
`Color no válido en el estado ${s.name}`,
);
assertThat(
@@ -527,10 +537,7 @@ export function validateProjectSettings(
TYPE_ICONS.includes(t.icon),
`Icono no válido en el tipo ${t.name}`,
);
assertThat(
SETTING_COLORS.includes(t.color),
`Color no válido en el tipo ${t.name}`,
);
assertThat(isSettingColor(t.color), `Color no válido en el tipo ${t.name}`);
}
const active = statuses.filter((s) => !s.archived);
assertThat(