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:
@@ -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();
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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' })
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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 = [
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -29,11 +29,16 @@ import {
|
||||
CustomFieldType,
|
||||
IssueTypeOption,
|
||||
ProjectSettings,
|
||||
SETTING_COLORS,
|
||||
StatusCategory,
|
||||
TYPE_ICONS,
|
||||
} from '@task-stream/web/tracker/domain';
|
||||
import { CATEGORY, COLOR_LABEL, ICON_LABEL, tone } from '../ui/copy';
|
||||
import { CATEGORY, ICON_LABEL, tone } from '../ui/copy';
|
||||
import {
|
||||
isHexColor,
|
||||
PALETTE_HEX,
|
||||
pickedColor,
|
||||
pickerValue,
|
||||
} from '../ui/colors';
|
||||
|
||||
type Section = 'statuses' | 'issueTypes' | 'areas' | 'customFields' | 'teams';
|
||||
|
||||
@@ -155,6 +160,12 @@ function idFrom(name: string): string {
|
||||
PiNoteComponent,
|
||||
],
|
||||
template: `
|
||||
<!-- The palette as suggestions in every colour picker. -->
|
||||
<datalist id="ts-palette">
|
||||
@for (hex of paletteHex; track hex) {
|
||||
<option [value]="hex"></option>
|
||||
}
|
||||
</datalist>
|
||||
<div class="ts-settings-layout">
|
||||
<nav class="ts-settings-nav" aria-label="Secciones de la configuración">
|
||||
<p class="ts-settings-nav__title">Configuración</p>
|
||||
@@ -334,19 +345,20 @@ function idFrom(name: string): string {
|
||||
</option>
|
||||
}
|
||||
</select>
|
||||
<select
|
||||
class="ts-input"
|
||||
aria-label="Color"
|
||||
(change)="
|
||||
edit('statuses', i, { color: $any($event.target).value })
|
||||
"
|
||||
>
|
||||
@for (c of colors; track c) {
|
||||
<option [value]="c" [selected]="c === status.color">
|
||||
{{ colorLabel(c) }}
|
||||
</option>
|
||||
}
|
||||
</select>
|
||||
<label class="ts-color" [title]="colorTitle(status.color)">
|
||||
<input
|
||||
type="color"
|
||||
list="ts-palette"
|
||||
[attr.aria-label]="'Color del estado ' + status.name"
|
||||
[value]="pickerValue(status.color)"
|
||||
(input)="
|
||||
edit('statuses', i, {
|
||||
color: picked($any($event.target).value),
|
||||
})
|
||||
"
|
||||
/>
|
||||
<code>{{ pickerValue(status.color) }}</code>
|
||||
</label>
|
||||
<input
|
||||
class="ts-input"
|
||||
type="number"
|
||||
@@ -517,21 +529,20 @@ function idFrom(name: string): string {
|
||||
</option>
|
||||
}
|
||||
</select>
|
||||
<select
|
||||
class="ts-input"
|
||||
aria-label="Color"
|
||||
(change)="
|
||||
edit('issueTypes', i, {
|
||||
color: $any($event.target).value,
|
||||
})
|
||||
"
|
||||
>
|
||||
@for (c of colors; track c) {
|
||||
<option [value]="c" [selected]="c === type.color">
|
||||
{{ colorLabel(c) }}
|
||||
</option>
|
||||
}
|
||||
</select>
|
||||
<label class="ts-color" [title]="colorTitle(type.color)">
|
||||
<input
|
||||
type="color"
|
||||
list="ts-palette"
|
||||
[attr.aria-label]="'Color del tipo ' + type.name"
|
||||
[value]="pickerValue(type.color)"
|
||||
(input)="
|
||||
edit('issueTypes', i, {
|
||||
color: picked($any($event.target).value),
|
||||
})
|
||||
"
|
||||
/>
|
||||
<code>{{ pickerValue(type.color) }}</code>
|
||||
</label>
|
||||
<button
|
||||
type="button"
|
||||
class="ts-add ts-template-toggle"
|
||||
@@ -1236,7 +1247,7 @@ function idFrom(name: string): string {
|
||||
> select:nth-of-type(1) {
|
||||
grid-area: 2 / 2;
|
||||
}
|
||||
> select:nth-of-type(2) {
|
||||
> .ts-color {
|
||||
grid-area: 2 / 3;
|
||||
}
|
||||
> .ts-template-toggle {
|
||||
@@ -1255,7 +1266,7 @@ function idFrom(name: string): string {
|
||||
> select:nth-of-type(1) {
|
||||
grid-area: 1 / 3;
|
||||
}
|
||||
> select:nth-of-type(2) {
|
||||
> .ts-color {
|
||||
grid-area: 1 / 4;
|
||||
}
|
||||
> .ts-template-toggle {
|
||||
@@ -1348,6 +1359,45 @@ function idFrom(name: string): string {
|
||||
min-width: 0;
|
||||
}
|
||||
}
|
||||
/* A native colour picker as a round swatch, with its value beside it. */
|
||||
.ts-color {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
min-width: 0;
|
||||
cursor: pointer;
|
||||
input {
|
||||
flex: none;
|
||||
width: 2rem;
|
||||
height: 2rem;
|
||||
padding: 0;
|
||||
border: 1px solid var(--ts-line-strong);
|
||||
border-radius: 50%;
|
||||
background: none;
|
||||
cursor: pointer;
|
||||
&::-webkit-color-swatch-wrapper {
|
||||
padding: 3px;
|
||||
}
|
||||
&::-webkit-color-swatch {
|
||||
border: 0;
|
||||
border-radius: 50%;
|
||||
}
|
||||
&::-moz-color-swatch {
|
||||
border: 0;
|
||||
border-radius: 50%;
|
||||
}
|
||||
&:focus-visible {
|
||||
outline: 2px solid var(--ts-brand);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
}
|
||||
code {
|
||||
overflow: hidden;
|
||||
color: var(--ts-muted);
|
||||
font-size: 0.75rem;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
}
|
||||
.ts-swatch {
|
||||
flex: none;
|
||||
width: 0.625rem;
|
||||
@@ -1541,7 +1591,6 @@ function idFrom(name: string): string {
|
||||
export class SettingsPage {
|
||||
protected readonly facade = inject(ProjectFacade);
|
||||
protected readonly categories = CATEGORIES;
|
||||
protected readonly colors = SETTING_COLORS;
|
||||
protected readonly icons = TYPE_ICONS;
|
||||
protected readonly fieldTypes = FIELD_TYPES;
|
||||
|
||||
@@ -1641,8 +1690,14 @@ export class SettingsPage {
|
||||
protected readonly categoryLabel = (c: StatusCategory) => CATEGORY[c].label;
|
||||
protected readonly categoryHint = (c: StatusCategory) => CATEGORY[c].hint;
|
||||
protected readonly iconLabel = (icon: string) => ICON_LABEL[icon] ?? icon;
|
||||
protected readonly colorLabel = (c: (typeof SETTING_COLORS)[number]) =>
|
||||
COLOR_LABEL[c];
|
||||
protected readonly paletteHex = Object.values(PALETTE_HEX);
|
||||
protected readonly pickerValue = pickerValue;
|
||||
protected readonly picked = pickedColor;
|
||||
/** The palette's colours follow the theme by hand; free ones, by contrast. */
|
||||
protected readonly colorTitle = (color: string) =>
|
||||
isHexColor(color)
|
||||
? 'Color propio: en modo oscuro se aclara si hace falta para que se vea'
|
||||
: 'Color de la paleta (con su versión para modo oscuro)';
|
||||
|
||||
/** Values the API does not have yet: they can still be removed or have their id follow the name. */
|
||||
protected isNew(section: Section, id: string): boolean {
|
||||
|
||||
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 {
|
||||
Release,
|
||||
IssueLinkType,
|
||||
IssuePriority,
|
||||
ProjectRole,
|
||||
ReleaseStatus,
|
||||
SettingColor,
|
||||
StatusCategory,
|
||||
Workflow,
|
||||
} from '@task-stream/web/tracker/domain';
|
||||
@@ -20,7 +20,9 @@ type ChipColor =
|
||||
/* UI copy (es) and the visual language of the tracker. */
|
||||
|
||||
/** CSS colour of a settings colour token (`--ts-c-*` in styles.scss). */
|
||||
export const tone = (color: string) => `var(--ts-c-${color}, var(--ts-muted))`;
|
||||
/** A palette name (`--ts-c-*`) or a free `#rrggbb`, following the theme. */
|
||||
export const tone = (color: string) =>
|
||||
isHexColor(color) ? hexTone(color) : `var(--ts-c-${color}, var(--ts-muted))`;
|
||||
|
||||
/** Badge of public projects (readable without signing in). */
|
||||
export const PUBLIC_TONE = tone('teal');
|
||||
@@ -89,20 +91,6 @@ export const ICON_LABEL: Record<string, string> = {
|
||||
support: 'Soporte',
|
||||
};
|
||||
|
||||
export const COLOR_LABEL: Record<SettingColor, string> = {
|
||||
slate: 'Pizarra',
|
||||
stone: 'Piedra',
|
||||
violet: 'Violeta',
|
||||
blue: 'Azul',
|
||||
sky: 'Cielo',
|
||||
teal: 'Verde azulado',
|
||||
green: 'Verde',
|
||||
amber: 'Ámbar',
|
||||
orange: 'Naranja',
|
||||
red: 'Rojo',
|
||||
pink: 'Rosa',
|
||||
};
|
||||
|
||||
/** Signal strength of a priority, drawn as bars (1–4) like a phone signal. */
|
||||
export const PRIORITY_LEVEL: Record<IssuePriority, number> = {
|
||||
[IssuePriority.CRITICAL]: 4,
|
||||
|
||||
Reference in New Issue
Block a user