From 33c593521e50bef4dc11549e34d419cbfbe27785 Mon Sep 17 00:00:00 2001 From: Josep Piguillem Date: Sat, 17 Oct 2026 16:08:47 +0200 Subject: [PATCH] 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 --- apps/api/openapi.json | 50 ++----- apps/api/schema.gql | 6 +- apps/mcp/src/app/api/taskstream.schema.ts | 26 +++- docs/03-api.md | 3 +- docs/04-cliente-web.md | 7 +- .../src/lib/project/project-settings.spec.ts | 19 +++ .../src/lib/project/project-settings.ts | 19 ++- .../presenters/src/lib/graphql/inputs.ts | 8 +- .../presenters/src/lib/graphql/types.ts | 6 +- .../src/lib/http/dto/requests.dto.ts | 24 +++- .../src/lib/http/dto/responses.dto.ts | 11 +- .../tracker/domain/src/lib/models/workflow.ts | 3 +- .../src/lib/graphql/generated.ts | 2 + .../presenters/src/lib/pages/settings.page.ts | 125 +++++++++++++----- .../presenters/src/lib/ui/colors.spec.ts | 25 ++++ .../tracker/presenters/src/lib/ui/colors.ts | 83 ++++++++++++ .../web/tracker/presenters/src/lib/ui/copy.ts | 20 +-- package-lock.json | 56 ++++++++ package.json | 1 + 19 files changed, 379 insertions(+), 115 deletions(-) create mode 100644 libs/web/tracker/presenters/src/lib/ui/colors.spec.ts create mode 100644 libs/web/tracker/presenters/src/lib/ui/colors.ts diff --git a/apps/api/openapi.json b/apps/api/openapi.json index c497f8f..c713180 100644 --- a/apps/api/openapi.json +++ b/apps/api/openapi.json @@ -12763,22 +12763,6 @@ "CANCELLED" ] }, - "SettingColor": { - "type": "string", - "enum": [ - "slate", - "stone", - "violet", - "blue", - "sky", - "teal", - "green", - "amber", - "orange", - "red", - "pink" - ] - }, "WorkflowStatusDto": { "type": "object", "properties": { @@ -12799,11 +12783,9 @@ ] }, "color": { - "allOf": [ - { - "$ref": "#/components/schemas/SettingColor" - } - ] + "type": "string", + "description": "A palette colour (follows the theme) or #rrggbb", + "example": "violet" }, "requiresReason": { "type": "boolean", @@ -12907,11 +12889,9 @@ ] }, "color": { - "allOf": [ - { - "$ref": "#/components/schemas/SettingColor" - } - ] + "type": "string", + "description": "A palette colour (follows the theme) or #rrggbb", + "example": "violet" }, "archived": { "type": "boolean" @@ -13262,11 +13242,10 @@ ] }, "color": { - "allOf": [ - { - "$ref": "#/components/schemas/SettingColor" - } - ] + "type": "string", + "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": "^(slate|stone|violet|blue|sky|teal|green|amber|orange|red|pink|#[0-9a-fA-F]{6})$" }, "requiresReason": { "type": "boolean", @@ -13351,11 +13330,10 @@ ] }, "color": { - "allOf": [ - { - "$ref": "#/components/schemas/SettingColor" - } - ] + "type": "string", + "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": "^(slate|stone|violet|blue|sky|teal|green|amber|orange|red|pink|#[0-9a-fA-F]{6})$" }, "archived": { "type": "boolean", diff --git a/apps/api/schema.gql b/apps/api/schema.gql index 64daef0..0c74d4c 100644 --- a/apps/api/schema.gql +++ b/apps/api/schema.gql @@ -866,6 +866,8 @@ type IssueTypeOption { input IssueTypeOptionInput { archived: Boolean! = false + + """A palette colour (follows the theme) or #rrggbb""" color: String! icon: String! id: String! @@ -2161,7 +2163,7 @@ type WorkflowStatus { archived: Boolean! category: StatusCategory! - """Colour token: slate, violet, green…""" + """A palette colour (slate, violet, green…, follows the theme) or #rrggbb""" color: String! """Stable id stored on tickets, e.g. IN_REVIEW""" @@ -2180,6 +2182,8 @@ type WorkflowStatus { input WorkflowStatusInput { archived: Boolean! = false category: StatusCategory! + + """A palette colour (follows the theme) or #rrggbb""" color: String! id: String! name: String! diff --git a/apps/mcp/src/app/api/taskstream.schema.ts b/apps/mcp/src/app/api/taskstream.schema.ts index e2b64c1..4a4cf5c 100644 --- a/apps/mcp/src/app/api/taskstream.schema.ts +++ b/apps/mcp/src/app/api/taskstream.schema.ts @@ -1982,8 +1982,6 @@ export interface components { }; /** @enum {string} */ StatusCategory: "TODO" | "IN_PROGRESS" | "QA" | "DONE" | "CANCELLED"; - /** @enum {string} */ - SettingColor: "slate" | "stone" | "violet" | "blue" | "sky" | "teal" | "green" | "amber" | "orange" | "red" | "pink"; WorkflowStatusDto: { /** * @description Stable id on tickets @@ -1993,7 +1991,11 @@ export interface components { /** @example En revisión */ name: string; 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 */ requiresReason: boolean; /** @description No new tickets or transitions into it */ @@ -2024,7 +2026,11 @@ export interface components { /** @example Bug */ name: string; icon: components["schemas"]["TypeIcon"]; - color: components["schemas"]["SettingColor"]; + /** + * @description A palette colour (follows the theme) or #rrggbb + * @example violet + */ + color: string; archived: boolean; /** @description What a new ticket of the type starts with */ template?: components["schemas"]["IssueTemplateDto"]; @@ -2153,7 +2159,11 @@ export interface components { /** @example En revisión */ name: string; 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 */ requiresReason: boolean; /** @default false */ @@ -2182,7 +2192,11 @@ export interface components { /** @example Spike */ name: string; 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 */ archived: boolean; /** @description What a new ticket of the type starts with */ diff --git a/docs/03-api.md b/docs/03-api.md index a7bc67c..15e8753 100644 --- a/docs/03-api.md +++ b/docs/03-api.md @@ -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 tipo activo. - 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`. Un proyecto nuevo puede **empezar con la configuración de otro** que administres: `templateProjectId` en diff --git a/docs/04-cliente-web.md b/docs/04-cliente-web.md index 5d29702..fc69e02 100644 --- a/docs/04-cliente-web.md +++ b/docs/04-cliente-web.md @@ -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 `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-`, con su -versión oscura). Un id que ya no está configurado sigue mostrándose (`IN_REVIEW` → «In review»). «Mi trabajo» mezcla +estado pide motivo. `statusLook`/`typeLook` de `ui/copy.ts` lo convierten en etiqueta y tono: `--ts-c-` para +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 (``), 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. En el tablero, un ticket solo se puede soltar en las columnas a las que su workflow le deja pasar, y los tipos diff --git a/libs/api/tracker/domain/src/lib/project/project-settings.spec.ts b/libs/api/tracker/domain/src/lib/project/project-settings.spec.ts index a2d421d..baccaae 100644 --- a/libs/api/tracker/domain/src/lib/project/project-settings.spec.ts +++ b/libs/api/tracker/domain/src/lib/project/project-settings.spec.ts @@ -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(); diff --git a/libs/api/tracker/domain/src/lib/project/project-settings.ts b/libs/api/tracker/domain/src/lib/project/project-settings.ts index ef0c447..ef1571b 100644 --- a/libs/api/tracker/domain/src/lib/project/project-settings.ts +++ b/libs/api/tracker/domain/src/lib/project/project-settings.ts @@ -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( diff --git a/libs/api/tracker/presenters/src/lib/graphql/inputs.ts b/libs/api/tracker/presenters/src/lib/graphql/inputs.ts index a2d0ff8..bc71234 100644 --- a/libs/api/tracker/presenters/src/lib/graphql/inputs.ts +++ b/libs/api/tracker/presenters/src/lib/graphql/inputs.ts @@ -661,7 +661,9 @@ export class WorkflowStatusInput { @Field(() => StatusCategory) @IsEnum(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() archived!: boolean; @Field(() => Int, { @@ -705,7 +707,9 @@ export class IssueTypeOptionInput { @Field() @Matches(SETTING_ID) id!: string; @Field() @IsString() @MinLength(1) @MaxLength(40) name!: 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(() => IssueTemplateInput, { nullable: true }) @IsOptional() diff --git a/libs/api/tracker/presenters/src/lib/graphql/types.ts b/libs/api/tracker/presenters/src/lib/graphql/types.ts index 0ab8683..a062891 100644 --- a/libs/api/tracker/presenters/src/lib/graphql/types.ts +++ b/libs/api/tracker/presenters/src/lib/graphql/types.ts @@ -35,7 +35,11 @@ export class WorkflowStatusGql { id!: string; @Field() name!: string; @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' }) requiresReason!: boolean; @Field({ description: 'No new tickets or transitions into it' }) diff --git a/libs/api/tracker/presenters/src/lib/http/dto/requests.dto.ts b/libs/api/tracker/presenters/src/lib/http/dto/requests.dto.ts index f217554..b65ade7 100644 --- a/libs/api/tracker/presenters/src/lib/http/dto/requests.dto.ts +++ b/libs/api/tracker/presenters/src/lib/http/dto/requests.dto.ts @@ -10,7 +10,7 @@ import { IssuePriority, ProjectRole, REACTIONS, - SETTING_COLORS, + SETTING_COLOR_PATTERN, StatusCategory, TYPE_ICONS, } from '@task-stream/api/tracker/domain'; @@ -153,8 +153,15 @@ export class WorkflowStatusBodyDto { @ApiProperty({ enum: StatusCategory, enumName: 'StatusCategory' }) @IsEnum(StatusCategory) category!: StatusCategory; - @ApiProperty({ enum: SETTING_COLORS, enumName: 'SettingColor' }) - @IsIn(SETTING_COLORS) + @ApiProperty({ + 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; @ApiPropertyOptional({ type: Boolean, default: false }) @IsOptional() @@ -212,8 +219,15 @@ export class IssueTypeOptionBodyDto { @ApiProperty({ enum: TYPE_ICONS, enumName: 'TypeIcon' }) @IsIn(TYPE_ICONS) icon!: string; - @ApiProperty({ enum: SETTING_COLORS, enumName: 'SettingColor' }) - @IsIn(SETTING_COLORS) + @ApiProperty({ + 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; @ApiPropertyOptional({ type: Boolean, default: false }) @IsOptional() diff --git a/libs/api/tracker/presenters/src/lib/http/dto/responses.dto.ts b/libs/api/tracker/presenters/src/lib/http/dto/responses.dto.ts index d9aff59..e559497 100644 --- a/libs/api/tracker/presenters/src/lib/http/dto/responses.dto.ts +++ b/libs/api/tracker/presenters/src/lib/http/dto/responses.dto.ts @@ -7,7 +7,6 @@ import { ProjectRole, ReleaseStatus, SprintStatus, - SETTING_COLORS, StatusCategory, TYPE_ICONS, } from '@task-stream/api/tracker/domain'; @@ -34,7 +33,10 @@ export class WorkflowStatusDto { @ApiProperty({ example: 'En revisión' }) name!: string; @ApiProperty({ enum: StatusCategory, enumName: 'StatusCategory' }) category!: StatusCategory; - @ApiProperty({ enum: SETTING_COLORS, enumName: 'SettingColor' }) + @ApiProperty({ + description: 'A palette colour (follows the theme) or #rrggbb', + example: 'violet', + }) color!: string; @ApiProperty({ description: 'Entering it needs a reason' }) requiresReason!: boolean; @@ -63,7 +65,10 @@ export class IssueTypeOptionDto { @ApiProperty({ example: 'BUG' }) id!: string; @ApiProperty({ example: 'Bug' }) name!: 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; @ApiProperty() archived!: boolean; @ApiPropertyOptional({ diff --git a/libs/web/tracker/domain/src/lib/models/workflow.ts b/libs/web/tracker/domain/src/lib/models/workflow.ts index c5a39a8..b243880 100644 --- a/libs/web/tracker/domain/src/lib/models/workflow.ts +++ b/libs/web/tracker/domain/src/lib/models/workflow.ts @@ -14,7 +14,8 @@ export const SETTING_COLORS = [ 'red', 'pink', ] 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. */ export const TYPE_ICONS = [ diff --git a/libs/web/tracker/infrastructure/src/lib/graphql/generated.ts b/libs/web/tracker/infrastructure/src/lib/graphql/generated.ts index a0b4dec..047c2ac 100644 --- a/libs/web/tracker/infrastructure/src/lib/graphql/generated.ts +++ b/libs/web/tracker/infrastructure/src/lib/graphql/generated.ts @@ -231,6 +231,7 @@ export type IssueTemplateInput = { export type IssueTypeOptionInput = { archived?: boolean; + /** A palette colour (follows the theme) or #rrggbb */ color: string; icon: string; id: string; @@ -422,6 +423,7 @@ export type UpdateWebhookInput = { export type WorkflowStatusInput = { archived?: boolean; category: StatusCategory; + /** A palette colour (follows the theme) or #rrggbb */ color: string; id: string; name: string; diff --git a/libs/web/tracker/presenters/src/lib/pages/settings.page.ts b/libs/web/tracker/presenters/src/lib/pages/settings.page.ts index 4260470..69baddc 100644 --- a/libs/web/tracker/presenters/src/lib/pages/settings.page.ts +++ b/libs/web/tracker/presenters/src/lib/pages/settings.page.ts @@ -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: ` + + + @for (hex of paletteHex; track hex) { + + } +