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

@@ -29,11 +29,16 @@ import {
CustomFieldType,
IssueTypeOption,
ProjectSettings,
SETTING_COLORS,
StatusCategory,
TYPE_ICONS,
} from '@task-stream/web/tracker/domain';
import { CATEGORY, COLOR_LABEL, ICON_LABEL, tone } from '../ui/copy';
import { CATEGORY, ICON_LABEL, tone } from '../ui/copy';
import {
isHexColor,
PALETTE_HEX,
pickedColor,
pickerValue,
} from '../ui/colors';
type Section = 'statuses' | 'issueTypes' | 'areas' | 'customFields' | 'teams';
@@ -155,6 +160,12 @@ function idFrom(name: string): string {
PiNoteComponent,
],
template: `
<!-- The palette as suggestions in every colour picker. -->
<datalist id="ts-palette">
@for (hex of paletteHex; track hex) {
<option [value]="hex"></option>
}
</datalist>
<div class="ts-settings-layout">
<nav class="ts-settings-nav" aria-label="Secciones de la configuración">
<p class="ts-settings-nav__title">Configuración</p>
@@ -334,19 +345,20 @@ function idFrom(name: string): string {
</option>
}
</select>
<select
class="ts-input"
aria-label="Color"
(change)="
edit('statuses', i, { color: $any($event.target).value })
"
>
@for (c of colors; track c) {
<option [value]="c" [selected]="c === status.color">
{{ colorLabel(c) }}
</option>
}
</select>
<label class="ts-color" [title]="colorTitle(status.color)">
<input
type="color"
list="ts-palette"
[attr.aria-label]="'Color del estado ' + status.name"
[value]="pickerValue(status.color)"
(input)="
edit('statuses', i, {
color: picked($any($event.target).value),
})
"
/>
<code>{{ pickerValue(status.color) }}</code>
</label>
<input
class="ts-input"
type="number"
@@ -517,21 +529,20 @@ function idFrom(name: string): string {
</option>
}
</select>
<select
class="ts-input"
aria-label="Color"
(change)="
edit('issueTypes', i, {
color: $any($event.target).value,
})
"
>
@for (c of colors; track c) {
<option [value]="c" [selected]="c === type.color">
{{ colorLabel(c) }}
</option>
}
</select>
<label class="ts-color" [title]="colorTitle(type.color)">
<input
type="color"
list="ts-palette"
[attr.aria-label]="'Color del tipo ' + type.name"
[value]="pickerValue(type.color)"
(input)="
edit('issueTypes', i, {
color: picked($any($event.target).value),
})
"
/>
<code>{{ pickerValue(type.color) }}</code>
</label>
<button
type="button"
class="ts-add ts-template-toggle"
@@ -1236,7 +1247,7 @@ function idFrom(name: string): string {
> select:nth-of-type(1) {
grid-area: 2 / 2;
}
> select:nth-of-type(2) {
> .ts-color {
grid-area: 2 / 3;
}
> .ts-template-toggle {
@@ -1255,7 +1266,7 @@ function idFrom(name: string): string {
> select:nth-of-type(1) {
grid-area: 1 / 3;
}
> select:nth-of-type(2) {
> .ts-color {
grid-area: 1 / 4;
}
> .ts-template-toggle {
@@ -1348,6 +1359,45 @@ function idFrom(name: string): string {
min-width: 0;
}
}
/* A native colour picker as a round swatch, with its value beside it. */
.ts-color {
display: inline-flex;
align-items: center;
gap: 0.5rem;
min-width: 0;
cursor: pointer;
input {
flex: none;
width: 2rem;
height: 2rem;
padding: 0;
border: 1px solid var(--ts-line-strong);
border-radius: 50%;
background: none;
cursor: pointer;
&::-webkit-color-swatch-wrapper {
padding: 3px;
}
&::-webkit-color-swatch {
border: 0;
border-radius: 50%;
}
&::-moz-color-swatch {
border: 0;
border-radius: 50%;
}
&:focus-visible {
outline: 2px solid var(--ts-brand);
outline-offset: 2px;
}
}
code {
overflow: hidden;
color: var(--ts-muted);
font-size: 0.75rem;
text-overflow: ellipsis;
}
}
.ts-swatch {
flex: none;
width: 0.625rem;
@@ -1541,7 +1591,6 @@ function idFrom(name: string): string {
export class SettingsPage {
protected readonly facade = inject(ProjectFacade);
protected readonly categories = CATEGORIES;
protected readonly colors = SETTING_COLORS;
protected readonly icons = TYPE_ICONS;
protected readonly fieldTypes = FIELD_TYPES;
@@ -1641,8 +1690,14 @@ export class SettingsPage {
protected readonly categoryLabel = (c: StatusCategory) => CATEGORY[c].label;
protected readonly categoryHint = (c: StatusCategory) => CATEGORY[c].hint;
protected readonly iconLabel = (icon: string) => ICON_LABEL[icon] ?? icon;
protected readonly colorLabel = (c: (typeof SETTING_COLORS)[number]) =>
COLOR_LABEL[c];
protected readonly paletteHex = Object.values(PALETTE_HEX);
protected readonly pickerValue = pickerValue;
protected readonly picked = pickedColor;
/** The palette's colours follow the theme by hand; free ones, by contrast. */
protected readonly colorTitle = (color: string) =>
isHexColor(color)
? 'Color propio: en modo oscuro se aclara si hace falta para que se vea'
: 'Color de la paleta (con su versión para modo oscuro)';
/** Values the API does not have yet: they can still be removed or have their id follow the name. */
protected isNew(section: Section, id: string): boolean {

View File

@@ -0,0 +1,25 @@
import Color from 'color';
import { hexTone, pickedColor, pickerValue } from './colors';
const variants = (tone: string) =>
/light-dark\((#[0-9a-f]{6}), (#[0-9a-f]{6})\)/.exec(tone)?.slice(1) ?? [];
describe('free colours', () => {
it('stand out from both themes', () => {
for (const color of ['#fef08a', '#0b1a3a', '#7c3aed', '#ffffff']) {
const [light, dark] = variants(hexTone(color));
expect(Color(light).contrast(Color('#ffffff'))).toBeGreaterThanOrEqual(3);
expect(Color(dark).contrast(Color('#161922'))).toBeGreaterThanOrEqual(3);
}
});
it('keep a colour that already stands out', () => {
expect(variants(hexTone('#7C3AED'))[0]).toBe('#7c3aed');
});
it('go back to the palette name when picked from it', () => {
expect(pickerValue('violet')).toBe('#a347e1');
expect(pickedColor('#A347E1')).toBe('violet');
expect(pickedColor('#123456')).toBe('#123456');
});
});

View File

@@ -0,0 +1,83 @@
import Color, { type ColorInstance } from 'color';
import { SETTING_COLORS } from '@task-stream/web/tracker/domain';
type PaletteColor = (typeof SETTING_COLORS)[number];
/**
* The palette in the light theme (`--ts-c-*` in styles.scss): what the
* picker shows for a status or type saved with a palette name.
*/
export const PALETTE_HEX: Record<PaletteColor, string> = {
slate: '#64748b',
stone: '#8b8b9a',
violet: '#a347e1',
blue: '#1879bf',
sky: '#0284c7',
teal: '#0f766e',
green: '#15803d',
amber: '#b7791f',
orange: '#ea580c',
red: '#dc2626',
pink: '#d6409f',
};
/** `--ts-surface` of each theme: what a colour has to stand out from. */
const SURFACE = { light: Color('#ffffff'), dark: Color('#161922') };
/** WCAG's minimum for what is not text (dots, bars, borders). */
const MIN_CONTRAST = 3;
const HEX = /^#[0-9a-f]{6}$/i;
export const isHexColor = (color: string): boolean => HEX.test(color);
/**
* The colour, moved towards white or black just enough to stand out from
* the surface (a pale yellow darkens on white, a navy lightens on dark).
*/
function readableOn(color: string, surface: ColorInstance): string {
const towards = Color(surface.isDark() ? '#ffffff' : '#000000');
let readable = Color(color);
for (
let step = 0;
step < 12 && readable.contrast(surface) < MIN_CONTRAST;
step++
)
readable = readable.mix(towards, 0.12);
return readable.hex().toLowerCase();
}
const tones = new Map<string, string>();
/**
* A free colour as a CSS value that follows the theme, like the palette:
* `light-dark()` picks the variant for the page's colour scheme.
*/
export function hexTone(color: string): string {
const key = color.toLowerCase();
let tone = tones.get(key);
if (!tone) {
tone = `light-dark(${readableOn(key, SURFACE.light)}, ${readableOn(key, SURFACE.dark)})`;
tones.set(key, tone);
}
return tone;
}
/** What the picker shows for a saved colour. */
export function pickerValue(color: string): string {
return isHexColor(color)
? color.toLowerCase()
: (PALETTE_HEX[color as PaletteColor] ?? PALETTE_HEX.slate);
}
/**
* What to save for a picked colour: the palette's name when it is one of
* its colours (so it keeps its hand-tuned dark version), else the hex.
*/
export function pickedColor(hex: string): string {
const picked = hex.toLowerCase();
const name = (Object.keys(PALETTE_HEX) as PaletteColor[]).find(
(c) => PALETTE_HEX[c] === picked,
);
return name ?? picked;
}

View File

@@ -1,10 +1,10 @@
import { hexTone, isHexColor } from './colors';
import {
Release,
IssueLinkType,
IssuePriority,
ProjectRole,
ReleaseStatus,
SettingColor,
StatusCategory,
Workflow,
} from '@task-stream/web/tracker/domain';
@@ -20,7 +20,9 @@ type ChipColor =
/* UI copy (es) and the visual language of the tracker. */
/** CSS colour of a settings colour token (`--ts-c-*` in styles.scss). */
export const tone = (color: string) => `var(--ts-c-${color}, var(--ts-muted))`;
/** A palette name (`--ts-c-*`) or a free `#rrggbb`, following the theme. */
export const tone = (color: string) =>
isHexColor(color) ? hexTone(color) : `var(--ts-c-${color}, var(--ts-muted))`;
/** Badge of public projects (readable without signing in). */
export const PUBLIC_TONE = tone('teal');
@@ -89,20 +91,6 @@ export const ICON_LABEL: Record<string, string> = {
support: 'Soporte',
};
export const COLOR_LABEL: Record<SettingColor, string> = {
slate: 'Pizarra',
stone: 'Piedra',
violet: 'Violeta',
blue: 'Azul',
sky: 'Cielo',
teal: 'Verde azulado',
green: 'Verde',
amber: 'Ámbar',
orange: 'Naranja',
red: 'Rojo',
pink: 'Rosa',
};
/** Signal strength of a priority, drawn as bars (1–4) like a phone signal. */
export const PRIORITY_LEVEL: Record<IssuePriority, number> = {
[IssuePriority.CRITICAL]: 4,