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

@@ -12763,22 +12763,6 @@
"CANCELLED" "CANCELLED"
] ]
}, },
"SettingColor": {
"type": "string",
"enum": [
"slate",
"stone",
"violet",
"blue",
"sky",
"teal",
"green",
"amber",
"orange",
"red",
"pink"
]
},
"WorkflowStatusDto": { "WorkflowStatusDto": {
"type": "object", "type": "object",
"properties": { "properties": {
@@ -12799,11 +12783,9 @@
] ]
}, },
"color": { "color": {
"allOf": [ "type": "string",
{ "description": "A palette colour (follows the theme) or #rrggbb",
"$ref": "#/components/schemas/SettingColor" "example": "violet"
}
]
}, },
"requiresReason": { "requiresReason": {
"type": "boolean", "type": "boolean",
@@ -12907,11 +12889,9 @@
] ]
}, },
"color": { "color": {
"allOf": [ "type": "string",
{ "description": "A palette colour (follows the theme) or #rrggbb",
"$ref": "#/components/schemas/SettingColor" "example": "violet"
}
]
}, },
"archived": { "archived": {
"type": "boolean" "type": "boolean"
@@ -13262,11 +13242,10 @@
] ]
}, },
"color": { "color": {
"allOf": [ "type": "string",
{ "description": "A palette colour (slate, stone, violet, blue, sky, teal, green, amber, orange, red, pink), which follows the theme, or any #rrggbb",
"$ref": "#/components/schemas/SettingColor" "example": "#7c3aed",
} "pattern": "^(slate|stone|violet|blue|sky|teal|green|amber|orange|red|pink|#[0-9a-fA-F]{6})$"
]
}, },
"requiresReason": { "requiresReason": {
"type": "boolean", "type": "boolean",
@@ -13351,11 +13330,10 @@
] ]
}, },
"color": { "color": {
"allOf": [ "type": "string",
{ "description": "A palette colour (slate, stone, violet, blue, sky, teal, green, amber, orange, red, pink), which follows the theme, or any #rrggbb",
"$ref": "#/components/schemas/SettingColor" "example": "#7c3aed",
} "pattern": "^(slate|stone|violet|blue|sky|teal|green|amber|orange|red|pink|#[0-9a-fA-F]{6})$"
]
}, },
"archived": { "archived": {
"type": "boolean", "type": "boolean",

View File

@@ -866,6 +866,8 @@ type IssueTypeOption {
input IssueTypeOptionInput { input IssueTypeOptionInput {
archived: Boolean! = false archived: Boolean! = false
"""A palette colour (follows the theme) or #rrggbb"""
color: String! color: String!
icon: String! icon: String!
id: String! id: String!
@@ -2161,7 +2163,7 @@ type WorkflowStatus {
archived: Boolean! archived: Boolean!
category: StatusCategory! category: StatusCategory!
"""Colour token: slate, violet, green…""" """A palette colour (slate, violet, green…, follows the theme) or #rrggbb"""
color: String! color: String!
"""Stable id stored on tickets, e.g. IN_REVIEW""" """Stable id stored on tickets, e.g. IN_REVIEW"""
@@ -2180,6 +2182,8 @@ type WorkflowStatus {
input WorkflowStatusInput { input WorkflowStatusInput {
archived: Boolean! = false archived: Boolean! = false
category: StatusCategory! category: StatusCategory!
"""A palette colour (follows the theme) or #rrggbb"""
color: String! color: String!
id: String! id: String!
name: String! name: String!

View File

@@ -1982,8 +1982,6 @@ export interface components {
}; };
/** @enum {string} */ /** @enum {string} */
StatusCategory: "TODO" | "IN_PROGRESS" | "QA" | "DONE" | "CANCELLED"; StatusCategory: "TODO" | "IN_PROGRESS" | "QA" | "DONE" | "CANCELLED";
/** @enum {string} */
SettingColor: "slate" | "stone" | "violet" | "blue" | "sky" | "teal" | "green" | "amber" | "orange" | "red" | "pink";
WorkflowStatusDto: { WorkflowStatusDto: {
/** /**
* @description Stable id on tickets * @description Stable id on tickets
@@ -1993,7 +1991,11 @@ export interface components {
/** @example En revisión */ /** @example En revisión */
name: string; name: string;
category: components["schemas"]["StatusCategory"]; category: components["schemas"]["StatusCategory"];
color: components["schemas"]["SettingColor"]; /**
* @description A palette colour (follows the theme) or #rrggbb
* @example violet
*/
color: string;
/** @description Entering it needs a reason */ /** @description Entering it needs a reason */
requiresReason: boolean; requiresReason: boolean;
/** @description No new tickets or transitions into it */ /** @description No new tickets or transitions into it */
@@ -2024,7 +2026,11 @@ export interface components {
/** @example Bug */ /** @example Bug */
name: string; name: string;
icon: components["schemas"]["TypeIcon"]; icon: components["schemas"]["TypeIcon"];
color: components["schemas"]["SettingColor"]; /**
* @description A palette colour (follows the theme) or #rrggbb
* @example violet
*/
color: string;
archived: boolean; archived: boolean;
/** @description What a new ticket of the type starts with */ /** @description What a new ticket of the type starts with */
template?: components["schemas"]["IssueTemplateDto"]; template?: components["schemas"]["IssueTemplateDto"];
@@ -2153,7 +2159,11 @@ export interface components {
/** @example En revisión */ /** @example En revisión */
name: string; name: string;
category: components["schemas"]["StatusCategory"]; category: components["schemas"]["StatusCategory"];
color: components["schemas"]["SettingColor"]; /**
* @description A palette colour (slate, stone, violet, blue, sky, teal, green, amber, orange, red, pink), which follows the theme, or any #rrggbb
* @example #7c3aed
*/
color: string;
/** @default false */ /** @default false */
requiresReason: boolean; requiresReason: boolean;
/** @default false */ /** @default false */
@@ -2182,7 +2192,11 @@ export interface components {
/** @example Spike */ /** @example Spike */
name: string; name: string;
icon: components["schemas"]["TypeIcon"]; icon: components["schemas"]["TypeIcon"];
color: components["schemas"]["SettingColor"]; /**
* @description A palette colour (slate, stone, violet, blue, sky, teal, green, amber, orange, red, pink), which follows the theme, or any #rrggbb
* @example #7c3aed
*/
color: string;
/** @default false */ /** @default false */
archived: boolean; archived: boolean;
/** @description What a new ticket of the type starts with */ /** @description What a new ticket of the type starts with */

View File

@@ -124,7 +124,8 @@ Reglas:
- Hace falta al menos un estado activo de categoría `TODO` (los tickets nuevos entran en el primero), uno `DONE` y un - Hace falta al menos un estado activo de categoría `TODO` (los tickets nuevos entran en el primero), uno `DONE` y un
tipo activo. tipo activo.
- Las transiciones solo pueden apuntar a estados que existen y nunca de un estado a sí mismo. - Las transiciones solo pueden apuntar a estados que existen y nunca de un estado a sí mismo.
- Colores: `slate`, `stone`, `violet`, `blue`, `sky`, `teal`, `green`, `amber`, `orange`, `red`, `pink`. Iconos: - Colores: uno de la paleta (`slate`, `stone`, `violet`, `blue`, `sky`, `teal`, `green`, `amber`, `orange`, `red`,
`pink`), que tiene su versión para modo oscuro, o cualquier `#rrggbb`. Iconos:
`bug`, `feature`, `palette`, `task`, `qa`, `flag`, `star-fill`, `rocket`, `document`, `light-bulb`, `key`, `support`. `bug`, `feature`, `palette`, `task`, `qa`, `flag`, `star-fill`, `rocket`, `document`, `light-bulb`, `key`, `support`.
Un proyecto nuevo puede **empezar con la configuración de otro** que administres: `templateProjectId` en Un proyecto nuevo puede **empezar con la configuración de otro** que administres: `templateProjectId` en

View File

@@ -255,8 +255,11 @@ globales como `.ts-pill` o `.ts-key` leen esa variable. Las animaciones se desac
Estados, tipos y áreas son de cada proyecto, así que la UI nunca los tiene fijos: `ProjectFacade.workflow()` envuelve Estados, tipos y áreas son de cada proyecto, así que la UI nunca los tiene fijos: `ProjectFacade.workflow()` envuelve
`project.settings` (`Workflow` en el dominio web) y da nombre, color, icono, categoría, transiciones permitidas y si un `project.settings` (`Workflow` en el dominio web) y da nombre, color, icono, categoría, transiciones permitidas y si un
estado pide motivo. `statusLook`/`typeLook` de `ui/copy.ts` lo convierten en etiqueta y tono (`--ts-c-<color>`, con su estado pide motivo. `statusLook`/`typeLook` de `ui/copy.ts` lo convierten en etiqueta y tono: `--ts-c-<color>` para
versión oscura). Un id que ya no está configurado sigue mostrándose (`IN_REVIEW` → «In review»). «Mi trabajo» mezcla la paleta, con su versión oscura hecha a mano, y para un `#rrggbb` propio, `light-dark()` con una variante por tema
que `ui/colors.ts` calcula con la librería `color` (se acerca al blanco o al negro hasta contrastar 3:1 con la
superficie). En Configuración, cada estado y tipo elige su color con el selector nativo (`<input type="color">`), con
la paleta como sugerencias; si se elige un color de la paleta, se guarda su nombre. Un id que ya no está configurado sigue mostrándose (`IN_REVIEW` → «In review»). «Mi trabajo» mezcla
proyectos: cada fila usa el workflow de su proyecto y los grupos son por categoría. proyectos: cada fila usa el workflow de su proyecto y los grupos son por categoría.
En el tablero, un ticket solo se puede soltar en las columnas a las que su workflow le deja pasar, y los tipos En el tablero, un ticket solo se puede soltar en las columnas a las que su workflow le deja pasar, y los tipos

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', () => { it('archives values instead of deleting them', () => {
const project = newProject(); const project = newProject();
const settings = defaultProjectSettings(); const settings = defaultProjectSettings();

View File

@@ -32,7 +32,17 @@ export const SETTING_COLORS = [
'red', 'red',
'pink', 'pink',
] as const; ] 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). */ /** Icons a ticket type can use (registered in the web app). */
export const TYPE_ICONS = [ export const TYPE_ICONS = [
@@ -513,7 +523,7 @@ export function validateProjectSettings(
`Categoría no válida en el estado ${s.name}`, `Categoría no válida en el estado ${s.name}`,
); );
assertThat( assertThat(
SETTING_COLORS.includes(s.color), isSettingColor(s.color),
`Color no válido en el estado ${s.name}`, `Color no válido en el estado ${s.name}`,
); );
assertThat( assertThat(
@@ -527,10 +537,7 @@ export function validateProjectSettings(
TYPE_ICONS.includes(t.icon), TYPE_ICONS.includes(t.icon),
`Icono no válido en el tipo ${t.name}`, `Icono no válido en el tipo ${t.name}`,
); );
assertThat( assertThat(isSettingColor(t.color), `Color no válido en el tipo ${t.name}`);
SETTING_COLORS.includes(t.color),
`Color no válido en el tipo ${t.name}`,
);
} }
const active = statuses.filter((s) => !s.archived); const active = statuses.filter((s) => !s.archived);
assertThat( assertThat(

View File

@@ -661,7 +661,9 @@ export class WorkflowStatusInput {
@Field(() => StatusCategory) @Field(() => StatusCategory)
@IsEnum(StatusCategory) @IsEnum(StatusCategory)
category!: StatusCategory; category!: StatusCategory;
@Field() @IsString() color!: string; @Field({ description: 'A palette colour (follows the theme) or #rrggbb' })
@IsString()
color!: string;
@Field({ defaultValue: false }) @IsBoolean() requiresReason!: boolean; @Field({ defaultValue: false }) @IsBoolean() requiresReason!: boolean;
@Field({ defaultValue: false }) @IsBoolean() archived!: boolean; @Field({ defaultValue: false }) @IsBoolean() archived!: boolean;
@Field(() => Int, { @Field(() => Int, {
@@ -705,7 +707,9 @@ export class IssueTypeOptionInput {
@Field() @Matches(SETTING_ID) id!: string; @Field() @Matches(SETTING_ID) id!: string;
@Field() @IsString() @MinLength(1) @MaxLength(40) name!: string; @Field() @IsString() @MinLength(1) @MaxLength(40) name!: string;
@Field() @IsString() icon!: string; @Field() @IsString() icon!: string;
@Field() @IsString() color!: string; @Field({ description: 'A palette colour (follows the theme) or #rrggbb' })
@IsString()
color!: string;
@Field({ defaultValue: false }) @IsBoolean() archived!: boolean; @Field({ defaultValue: false }) @IsBoolean() archived!: boolean;
@Field(() => IssueTemplateInput, { nullable: true }) @Field(() => IssueTemplateInput, { nullable: true })
@IsOptional() @IsOptional()

View File

@@ -35,7 +35,11 @@ export class WorkflowStatusGql {
id!: string; id!: string;
@Field() name!: string; @Field() name!: string;
@Field(() => StatusCategory) category!: StatusCategory; @Field(() => StatusCategory) category!: StatusCategory;
@Field({ description: 'Colour token: slate, violet, green…' }) color!: string; @Field({
description:
'A palette colour (slate, violet, green…, follows the theme) or #rrggbb',
})
color!: string;
@Field({ description: 'Entering it needs a reason' }) @Field({ description: 'Entering it needs a reason' })
requiresReason!: boolean; requiresReason!: boolean;
@Field({ description: 'No new tickets or transitions into it' }) @Field({ description: 'No new tickets or transitions into it' })

View File

@@ -10,7 +10,7 @@ import {
IssuePriority, IssuePriority,
ProjectRole, ProjectRole,
REACTIONS, REACTIONS,
SETTING_COLORS, SETTING_COLOR_PATTERN,
StatusCategory, StatusCategory,
TYPE_ICONS, TYPE_ICONS,
} from '@task-stream/api/tracker/domain'; } from '@task-stream/api/tracker/domain';
@@ -153,8 +153,15 @@ export class WorkflowStatusBodyDto {
@ApiProperty({ enum: StatusCategory, enumName: 'StatusCategory' }) @ApiProperty({ enum: StatusCategory, enumName: 'StatusCategory' })
@IsEnum(StatusCategory) @IsEnum(StatusCategory)
category!: StatusCategory; category!: StatusCategory;
@ApiProperty({ enum: SETTING_COLORS, enumName: 'SettingColor' }) @ApiProperty({
@IsIn(SETTING_COLORS) description:
'A palette colour (slate, stone, violet, blue, sky, teal, green, amber, orange, red, pink), which follows the theme, or any #rrggbb',
example: '#7c3aed',
pattern: SETTING_COLOR_PATTERN.source,
})
@Matches(SETTING_COLOR_PATTERN, {
message: 'color must be a palette colour or #rrggbb',
})
color!: string; color!: string;
@ApiPropertyOptional({ type: Boolean, default: false }) @ApiPropertyOptional({ type: Boolean, default: false })
@IsOptional() @IsOptional()
@@ -212,8 +219,15 @@ export class IssueTypeOptionBodyDto {
@ApiProperty({ enum: TYPE_ICONS, enumName: 'TypeIcon' }) @ApiProperty({ enum: TYPE_ICONS, enumName: 'TypeIcon' })
@IsIn(TYPE_ICONS) @IsIn(TYPE_ICONS)
icon!: string; icon!: string;
@ApiProperty({ enum: SETTING_COLORS, enumName: 'SettingColor' }) @ApiProperty({
@IsIn(SETTING_COLORS) description:
'A palette colour (slate, stone, violet, blue, sky, teal, green, amber, orange, red, pink), which follows the theme, or any #rrggbb',
example: '#7c3aed',
pattern: SETTING_COLOR_PATTERN.source,
})
@Matches(SETTING_COLOR_PATTERN, {
message: 'color must be a palette colour or #rrggbb',
})
color!: string; color!: string;
@ApiPropertyOptional({ type: Boolean, default: false }) @ApiPropertyOptional({ type: Boolean, default: false })
@IsOptional() @IsOptional()

View File

@@ -7,7 +7,6 @@ import {
ProjectRole, ProjectRole,
ReleaseStatus, ReleaseStatus,
SprintStatus, SprintStatus,
SETTING_COLORS,
StatusCategory, StatusCategory,
TYPE_ICONS, TYPE_ICONS,
} from '@task-stream/api/tracker/domain'; } from '@task-stream/api/tracker/domain';
@@ -34,7 +33,10 @@ export class WorkflowStatusDto {
@ApiProperty({ example: 'En revisión' }) name!: string; @ApiProperty({ example: 'En revisión' }) name!: string;
@ApiProperty({ enum: StatusCategory, enumName: 'StatusCategory' }) @ApiProperty({ enum: StatusCategory, enumName: 'StatusCategory' })
category!: StatusCategory; category!: StatusCategory;
@ApiProperty({ enum: SETTING_COLORS, enumName: 'SettingColor' }) @ApiProperty({
description: 'A palette colour (follows the theme) or #rrggbb',
example: 'violet',
})
color!: string; color!: string;
@ApiProperty({ description: 'Entering it needs a reason' }) @ApiProperty({ description: 'Entering it needs a reason' })
requiresReason!: boolean; requiresReason!: boolean;
@@ -63,7 +65,10 @@ export class IssueTypeOptionDto {
@ApiProperty({ example: 'BUG' }) id!: string; @ApiProperty({ example: 'BUG' }) id!: string;
@ApiProperty({ example: 'Bug' }) name!: string; @ApiProperty({ example: 'Bug' }) name!: string;
@ApiProperty({ enum: TYPE_ICONS, enumName: 'TypeIcon' }) icon!: string; @ApiProperty({ enum: TYPE_ICONS, enumName: 'TypeIcon' }) icon!: string;
@ApiProperty({ enum: SETTING_COLORS, enumName: 'SettingColor' }) @ApiProperty({
description: 'A palette colour (follows the theme) or #rrggbb',
example: 'violet',
})
color!: string; color!: string;
@ApiProperty() archived!: boolean; @ApiProperty() archived!: boolean;
@ApiPropertyOptional({ @ApiPropertyOptional({

View File

@@ -14,7 +14,8 @@ export const SETTING_COLORS = [
'red', 'red',
'pink', 'pink',
] as const; ] as const;
export type SettingColor = (typeof SETTING_COLORS)[number]; /** A palette name (follows the theme) or any `#rrggbb`. */
export type SettingColor = (typeof SETTING_COLORS)[number] | `#${string}`;
/** Icons a ticket type can use. */ /** Icons a ticket type can use. */
export const TYPE_ICONS = [ export const TYPE_ICONS = [

View File

@@ -231,6 +231,7 @@ export type IssueTemplateInput = {
export type IssueTypeOptionInput = { export type IssueTypeOptionInput = {
archived?: boolean; archived?: boolean;
/** A palette colour (follows the theme) or #rrggbb */
color: string; color: string;
icon: string; icon: string;
id: string; id: string;
@@ -422,6 +423,7 @@ export type UpdateWebhookInput = {
export type WorkflowStatusInput = { export type WorkflowStatusInput = {
archived?: boolean; archived?: boolean;
category: StatusCategory; category: StatusCategory;
/** A palette colour (follows the theme) or #rrggbb */
color: string; color: string;
id: string; id: string;
name: string; name: string;

View File

@@ -29,11 +29,16 @@ import {
CustomFieldType, CustomFieldType,
IssueTypeOption, IssueTypeOption,
ProjectSettings, ProjectSettings,
SETTING_COLORS,
StatusCategory, StatusCategory,
TYPE_ICONS, TYPE_ICONS,
} from '@task-stream/web/tracker/domain'; } 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'; type Section = 'statuses' | 'issueTypes' | 'areas' | 'customFields' | 'teams';
@@ -155,6 +160,12 @@ function idFrom(name: string): string {
PiNoteComponent, PiNoteComponent,
], ],
template: ` 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"> <div class="ts-settings-layout">
<nav class="ts-settings-nav" aria-label="Secciones de la configuración"> <nav class="ts-settings-nav" aria-label="Secciones de la configuración">
<p class="ts-settings-nav__title">Configuración</p> <p class="ts-settings-nav__title">Configuración</p>
@@ -334,19 +345,20 @@ function idFrom(name: string): string {
</option> </option>
} }
</select> </select>
<select <label class="ts-color" [title]="colorTitle(status.color)">
class="ts-input" <input
aria-label="Color" type="color"
(change)=" list="ts-palette"
edit('statuses', i, { color: $any($event.target).value }) [attr.aria-label]="'Color del estado ' + status.name"
" [value]="pickerValue(status.color)"
> (input)="
@for (c of colors; track c) { edit('statuses', i, {
<option [value]="c" [selected]="c === status.color"> color: picked($any($event.target).value),
{{ colorLabel(c) }} })
</option> "
} />
</select> <code>{{ pickerValue(status.color) }}</code>
</label>
<input <input
class="ts-input" class="ts-input"
type="number" type="number"
@@ -517,21 +529,20 @@ function idFrom(name: string): string {
</option> </option>
} }
</select> </select>
<select <label class="ts-color" [title]="colorTitle(type.color)">
class="ts-input" <input
aria-label="Color" type="color"
(change)=" list="ts-palette"
edit('issueTypes', i, { [attr.aria-label]="'Color del tipo ' + type.name"
color: $any($event.target).value, [value]="pickerValue(type.color)"
}) (input)="
" edit('issueTypes', i, {
> color: picked($any($event.target).value),
@for (c of colors; track c) { })
<option [value]="c" [selected]="c === type.color"> "
{{ colorLabel(c) }} />
</option> <code>{{ pickerValue(type.color) }}</code>
} </label>
</select>
<button <button
type="button" type="button"
class="ts-add ts-template-toggle" class="ts-add ts-template-toggle"
@@ -1236,7 +1247,7 @@ function idFrom(name: string): string {
> select:nth-of-type(1) { > select:nth-of-type(1) {
grid-area: 2 / 2; grid-area: 2 / 2;
} }
> select:nth-of-type(2) { > .ts-color {
grid-area: 2 / 3; grid-area: 2 / 3;
} }
> .ts-template-toggle { > .ts-template-toggle {
@@ -1255,7 +1266,7 @@ function idFrom(name: string): string {
> select:nth-of-type(1) { > select:nth-of-type(1) {
grid-area: 1 / 3; grid-area: 1 / 3;
} }
> select:nth-of-type(2) { > .ts-color {
grid-area: 1 / 4; grid-area: 1 / 4;
} }
> .ts-template-toggle { > .ts-template-toggle {
@@ -1348,6 +1359,45 @@ function idFrom(name: string): string {
min-width: 0; 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 { .ts-swatch {
flex: none; flex: none;
width: 0.625rem; width: 0.625rem;
@@ -1541,7 +1591,6 @@ function idFrom(name: string): string {
export class SettingsPage { export class SettingsPage {
protected readonly facade = inject(ProjectFacade); protected readonly facade = inject(ProjectFacade);
protected readonly categories = CATEGORIES; protected readonly categories = CATEGORIES;
protected readonly colors = SETTING_COLORS;
protected readonly icons = TYPE_ICONS; protected readonly icons = TYPE_ICONS;
protected readonly fieldTypes = FIELD_TYPES; protected readonly fieldTypes = FIELD_TYPES;
@@ -1641,8 +1690,14 @@ export class SettingsPage {
protected readonly categoryLabel = (c: StatusCategory) => CATEGORY[c].label; protected readonly categoryLabel = (c: StatusCategory) => CATEGORY[c].label;
protected readonly categoryHint = (c: StatusCategory) => CATEGORY[c].hint; protected readonly categoryHint = (c: StatusCategory) => CATEGORY[c].hint;
protected readonly iconLabel = (icon: string) => ICON_LABEL[icon] ?? icon; protected readonly iconLabel = (icon: string) => ICON_LABEL[icon] ?? icon;
protected readonly colorLabel = (c: (typeof SETTING_COLORS)[number]) => protected readonly paletteHex = Object.values(PALETTE_HEX);
COLOR_LABEL[c]; 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. */ /** 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 { 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 { import {
Release, Release,
IssueLinkType, IssueLinkType,
IssuePriority, IssuePriority,
ProjectRole, ProjectRole,
ReleaseStatus, ReleaseStatus,
SettingColor,
StatusCategory, StatusCategory,
Workflow, Workflow,
} from '@task-stream/web/tracker/domain'; } from '@task-stream/web/tracker/domain';
@@ -20,7 +20,9 @@ type ChipColor =
/* UI copy (es) and the visual language of the tracker. */ /* UI copy (es) and the visual language of the tracker. */
/** CSS colour of a settings colour token (`--ts-c-*` in styles.scss). */ /** 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). */ /** Badge of public projects (readable without signing in). */
export const PUBLIC_TONE = tone('teal'); export const PUBLIC_TONE = tone('teal');
@@ -89,20 +91,6 @@ export const ICON_LABEL: Record<string, string> = {
support: 'Soporte', 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. */ /** Signal strength of a priority, drawn as bars (1–4) like a phone signal. */
export const PRIORITY_LEVEL: Record<IssuePriority, number> = { export const PRIORITY_LEVEL: Record<IssuePriority, number> = {
[IssuePriority.CRITICAL]: 4, [IssuePriority.CRITICAL]: 4,

56
package-lock.json generated
View File

@@ -53,6 +53,7 @@
"chart.js": "^4.5.1", "chart.js": "^4.5.1",
"class-transformer": "^0.5.1", "class-transformer": "^0.5.1",
"class-validator": "^0.15.1", "class-validator": "^0.15.1",
"color": "^5.0.3",
"dompurify": "^3.4.16", "dompurify": "^3.4.16",
"graphql": "^16.14.2", "graphql": "^16.14.2",
"graphql-redis-subscriptions": "^2.7.0", "graphql-redis-subscriptions": "^2.7.0",
@@ -17126,6 +17127,19 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/color": {
"version": "5.0.3",
"resolved": "https://registry.npmjs.org/color/-/color-5.0.3.tgz",
"integrity": "sha512-ezmVcLR3xAVp8kYOm4GS45ZLLgIE6SPAFoduLr6hTDajwb3KZ2F46gulK3XpcwRFb5KKGCSezCBAY4Dw4HsyXA==",
"license": "MIT",
"dependencies": {
"color-convert": "^3.1.3",
"color-string": "^2.1.3"
},
"engines": {
"node": ">=18"
}
},
"node_modules/color-convert": { "node_modules/color-convert": {
"version": "2.0.1", "version": "2.0.1",
"license": "MIT", "license": "MIT",
@@ -17140,6 +17154,48 @@
"version": "1.1.4", "version": "1.1.4",
"license": "MIT" "license": "MIT"
}, },
"node_modules/color-string": {
"version": "2.1.4",
"resolved": "https://registry.npmjs.org/color-string/-/color-string-2.1.4.tgz",
"integrity": "sha512-Bb6Cq8oq0IjDOe8wJmi4JeNn763Xs9cfrBcaylK1tPypWzyoy2G3l90v9k64kjphl/ZJjPIShFztenRomi8WTg==",
"license": "MIT",
"dependencies": {
"color-name": "^2.0.0"
},
"engines": {
"node": ">=18"
}
},
"node_modules/color-string/node_modules/color-name": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/color-name/-/color-name-2.1.1.tgz",
"integrity": "sha512-p2FdgwVx1a9yWBHP2wI0VgShkDpgN4kZISkxdNipGBJWpa5G6b04OINlVWCyJj0JmfvcPrgqt95E9k8yvaOJFg==",
"license": "MIT",
"engines": {
"node": ">=12.20"
}
},
"node_modules/color/node_modules/color-convert": {
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-3.1.3.tgz",
"integrity": "sha512-fasDH2ont2GqF5HpyO4w0+BcewlhHEZOFn9c1ckZdHpJ56Qb7MHhH/IcJZbBGgvdtwdwNbLvxiBEdg336iA9Sg==",
"license": "MIT",
"dependencies": {
"color-name": "^2.0.0"
},
"engines": {
"node": ">=14.6"
}
},
"node_modules/color/node_modules/color-name": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/color-name/-/color-name-2.1.1.tgz",
"integrity": "sha512-p2FdgwVx1a9yWBHP2wI0VgShkDpgN4kZISkxdNipGBJWpa5G6b04OINlVWCyJj0JmfvcPrgqt95E9k8yvaOJFg==",
"license": "MIT",
"engines": {
"node": ">=12.20"
}
},
"node_modules/colorette": { "node_modules/colorette": {
"version": "2.0.20", "version": "2.0.20",
"resolved": "https://registry.npmjs.org/colorette/-/colorette-2.0.20.tgz", "resolved": "https://registry.npmjs.org/colorette/-/colorette-2.0.20.tgz",

View File

@@ -49,6 +49,7 @@
"chart.js": "^4.5.1", "chart.js": "^4.5.1",
"class-transformer": "^0.5.1", "class-transformer": "^0.5.1",
"class-validator": "^0.15.1", "class-validator": "^0.15.1",
"color": "^5.0.3",
"dompurify": "^3.4.16", "dompurify": "^3.4.16",
"graphql": "^16.14.2", "graphql": "^16.14.2",
"graphql-redis-subscriptions": "^2.7.0", "graphql-redis-subscriptions": "^2.7.0",