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:
@@ -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",
|
||||||
|
|||||||
@@ -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!
|
||||||
|
|||||||
@@ -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 */
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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' })
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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({
|
||||||
|
|||||||
@@ -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 = [
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
25
libs/web/tracker/presenters/src/lib/ui/colors.spec.ts
Normal file
25
libs/web/tracker/presenters/src/lib/ui/colors.spec.ts
Normal 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');
|
||||||
|
});
|
||||||
|
});
|
||||||
83
libs/web/tracker/presenters/src/lib/ui/colors.ts
Normal file
83
libs/web/tracker/presenters/src/lib/ui/colors.ts
Normal 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;
|
||||||
|
}
|
||||||
@@ -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
56
package-lock.json
generated
@@ -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",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
Reference in New Issue
Block a user