standard colours with noce UI and lasyout changes

This commit is contained in:
Mark Gregory
2026-06-23 15:37:01 +01:00
parent a8cbb8e2ac
commit 5dbc6394b8
8 changed files with 184 additions and 48 deletions
@@ -46,11 +46,13 @@ final class HelpdeskDatabaseSeeder extends Seeder
foreach ([ foreach ([
['name' => 'New', 'slug' => 'new', 'color' => 'blue', 'sort_order' => 5, 'is_closed' => false, 'is_default' => true], ['name' => 'New', 'slug' => 'new', 'color' => 'blue', 'sort_order' => 5, 'is_closed' => false, 'is_default' => true],
['name' => 'Open', 'slug' => 'open', 'color' => 'blue', 'sort_order' => 10, 'is_closed' => false, 'is_default' => false], ['name' => 'Open', 'slug' => 'open', 'color' => 'green', 'sort_order' => 10, 'is_closed' => false, 'is_default' => false],
['name' => 'In Progress', 'slug' => 'in-progress', 'color' => 'amber', 'sort_order' => 20, 'is_closed' => false, 'is_default' => false], ['name' => 'In Progress', 'slug' => 'in-progress', 'color' => 'indigo', 'sort_order' => 20, 'is_closed' => false, 'is_default' => false],
['name' => 'Stalled', 'slug' => 'stalled', 'color' => 'red', 'sort_order' => 30, 'is_closed' => false, 'is_default' => false], ['name' => 'Waiting Customer', 'slug' => 'waiting-customer', 'color' => 'amber', 'sort_order' => 30, 'is_closed' => false, 'is_default' => false],
['name' => 'On-Hold', 'slug' => 'on-hold', 'color' => 'purple', 'sort_order' => 40, 'is_closed' => false, 'is_default' => false], ['name' => 'Waiting Third Party', 'slug' => 'waiting-third-party', 'color' => 'purple', 'sort_order' => 40, 'is_closed' => false, 'is_default' => false],
['name' => 'Closed', 'slug' => 'closed', 'color' => 'green', 'sort_order' => 90, 'is_closed' => true, 'is_default' => false], ['name' => 'Resolved', 'slug' => 'resolved', 'color' => 'green', 'sort_order' => 80, 'is_closed' => true, 'is_default' => false],
['name' => 'Closed', 'slug' => 'closed', 'color' => 'gray', 'sort_order' => 90, 'is_closed' => true, 'is_default' => false],
['name' => 'Cancelled', 'slug' => 'cancelled', 'color' => 'red', 'sort_order' => 100, 'is_closed' => true, 'is_default' => false],
] as $row) { ] as $row) {
Status::query()->updateOrCreate(['slug' => $row['slug']], $row); Status::query()->updateOrCreate(['slug' => $row['slug']], $row);
} }
@@ -64,9 +66,11 @@ final class HelpdeskDatabaseSeeder extends Seeder
Priority::query()->where('is_default', true)->update(['is_default' => false]); Priority::query()->where('is_default', true)->update(['is_default' => false]);
foreach ([ foreach ([
['name' => 'High', 'slug' => 'high', 'color' => 'red', 'sort_order' => 10, 'is_default' => false], ['name' => 'Low', 'slug' => 'low', 'color' => 'gray', 'sort_order' => 10, 'is_default' => false],
['name' => 'Medium', 'slug' => 'medium', 'color' => 'amber', 'sort_order' => 20, 'is_default' => true], ['name' => 'Normal', 'slug' => 'normal', 'color' => 'blue', 'sort_order' => 20, 'is_default' => true],
['name' => 'Low', 'slug' => 'low', 'color' => 'green', 'sort_order' => 30, 'is_default' => false], ['name' => 'High', 'slug' => 'high', 'color' => 'amber', 'sort_order' => 30, 'is_default' => false],
['name' => 'Urgent', 'slug' => 'urgent', 'color' => 'red', 'sort_order' => 40, 'is_default' => false],
['name' => 'Critical', 'slug' => 'critical', 'color' => 'red', 'sort_order' => 50, 'is_default' => false],
] as $row) { ] as $row) {
Priority::query()->updateOrCreate(['slug' => $row['slug']], $row); Priority::query()->updateOrCreate(['slug' => $row['slug']], $row);
} }
@@ -129,7 +129,7 @@ final class SettingsManager extends Component
$data = $this->validate([ $data = $this->validate([
'statusForm.name' => ['required', 'string', 'max:80'], 'statusForm.name' => ['required', 'string', 'max:80'],
'statusForm.slug' => ['nullable', 'string', 'max:100'], 'statusForm.slug' => ['nullable', 'string', 'max:100'],
'statusForm.color' => ['required', 'string', Rule::in(Color::keys())], 'statusForm.color' => ['required', 'string', Color::validationRule()],
'statusForm.sort_order' => ['required', 'integer', 'min:0'], 'statusForm.sort_order' => ['required', 'integer', 'min:0'],
'statusForm.is_closed' => ['boolean'], 'statusForm.is_closed' => ['boolean'],
'statusForm.is_default' => ['boolean'], 'statusForm.is_default' => ['boolean'],
@@ -170,7 +170,7 @@ final class SettingsManager extends Component
$data = $this->validate([ $data = $this->validate([
'priorityForm.name' => ['required', 'string', 'max:80'], 'priorityForm.name' => ['required', 'string', 'max:80'],
'priorityForm.slug' => ['nullable', 'string', 'max:100'], 'priorityForm.slug' => ['nullable', 'string', 'max:100'],
'priorityForm.color' => ['required', 'string', Rule::in(Color::keys())], 'priorityForm.color' => ['required', 'string', Color::validationRule()],
'priorityForm.sort_order' => ['required', 'integer', 'min:0'], 'priorityForm.sort_order' => ['required', 'integer', 'min:0'],
'priorityForm.is_default' => ['boolean'], 'priorityForm.is_default' => ['boolean'],
])['priorityForm']; ])['priorityForm'];
@@ -212,7 +212,7 @@ final class SettingsManager extends Component
'categoryForm.company_id' => ['nullable', 'integer'], 'categoryForm.company_id' => ['nullable', 'integer'],
'categoryForm.name' => ['required', 'string', 'max:120'], 'categoryForm.name' => ['required', 'string', 'max:120'],
'categoryForm.slug' => ['nullable', 'string', 'max:140'], 'categoryForm.slug' => ['nullable', 'string', 'max:140'],
'categoryForm.color' => ['required', 'string', Rule::in(Color::keys())], 'categoryForm.color' => ['required', 'string', Color::validationRule()],
'categoryForm.description' => ['nullable', 'string'], 'categoryForm.description' => ['nullable', 'string'],
'categoryForm.is_default' => ['boolean'], 'categoryForm.is_default' => ['boolean'],
])['categoryForm']; ])['categoryForm'];
@@ -254,7 +254,7 @@ final class SettingsManager extends Component
'teamForm.company_id' => ['nullable', 'integer'], 'teamForm.company_id' => ['nullable', 'integer'],
'teamForm.name' => ['required', 'string', 'max:120'], 'teamForm.name' => ['required', 'string', 'max:120'],
'teamForm.slug' => ['nullable', 'string', 'max:140'], 'teamForm.slug' => ['nullable', 'string', 'max:140'],
'teamForm.color' => ['required', 'string', Rule::in(Color::keys())], 'teamForm.color' => ['required', 'string', Color::validationRule()],
'teamForm.description' => ['nullable', 'string'], 'teamForm.description' => ['nullable', 'string'],
'teamForm.is_default' => ['boolean'], 'teamForm.is_default' => ['boolean'],
])['teamForm']; ])['teamForm'];
@@ -297,7 +297,7 @@ final class SettingsManager extends Component
'projectForm.team_id' => ['nullable', 'integer', 'exists:helpdesk_teams,id'], 'projectForm.team_id' => ['nullable', 'integer', 'exists:helpdesk_teams,id'],
'projectForm.title' => ['required', 'string', 'max:160'], 'projectForm.title' => ['required', 'string', 'max:160'],
'projectForm.slug' => ['nullable', 'string', 'max:180'], 'projectForm.slug' => ['nullable', 'string', 'max:180'],
'projectForm.color' => ['required', 'string', Rule::in(Color::keys())], 'projectForm.color' => ['required', 'string', Color::validationRule()],
'projectForm.description' => ['nullable', 'string'], 'projectForm.description' => ['nullable', 'string'],
'projectForm.is_default' => ['boolean'], 'projectForm.is_default' => ['boolean'],
])['projectForm']; ])['projectForm'];
@@ -644,7 +644,7 @@ final class SettingsManager extends Component
'tagForm.company_id' => ['nullable', 'integer'], 'tagForm.company_id' => ['nullable', 'integer'],
'tagForm.name' => ['required', 'string', 'max:80'], 'tagForm.name' => ['required', 'string', 'max:80'],
'tagForm.slug' => ['nullable', 'string', 'max:100'], 'tagForm.slug' => ['nullable', 'string', 'max:100'],
'tagForm.color' => ['required', 'string', Rule::in(Color::keys())], 'tagForm.color' => ['required', 'string', Color::validationRule()],
])['tagForm']; ])['tagForm'];
$data['slug'] = $this->slug($data['slug'] ?: $data['name']); $data['slug'] = $this->slug($data['slug'] ?: $data['name']);
$this->persistSettingModel(Tag::class, $this->tagForm['id'], $data); $this->persistSettingModel(Tag::class, $this->tagForm['id'], $data);
@@ -1,10 +1,16 @@
@props([ @props([
'color' => 'gray', 'color' => 'gray',
'label' => null,
]) ])
@php
$background = \Modules\Helpdesk\Support\Color::css($color);
$foreground = \Modules\Helpdesk\Support\Color::text($color);
@endphp
<span <span
{{ $attributes->class('inline-flex items-center rounded-full px-2.5 py-1 text-xs font-semibold text-white shadow-sm ring-1 ring-white/20') }} {{ $attributes->class('inline-flex items-center rounded-full px-2.5 py-1 text-xs font-semibold shadow-sm ring-1 ring-zinc-900/10') }}
style="background-color: {{ \Modules\Helpdesk\Support\Color::css($color) }};" style="background-color: {{ $background }}; color: {{ $foreground }};"
> >
{{ $slot }} {{ $label ?? $slot }}
</span> </span>
@@ -4,31 +4,68 @@
'options' => [], 'options' => [],
]) ])
<div {{ $attributes->class('space-y-2') }}> @php
$currentValue = data_get($this, $model, 'gray');
$currentHex = \Modules\Helpdesk\Support\Color::css($currentValue);
$currentText = \Modules\Helpdesk\Support\Color::text($currentValue);
$currentLabel = \Modules\Helpdesk\Support\Color::label($currentValue);
@endphp
<div {{ $attributes->class('space-y-3') }}>
<label class="block text-sm font-semibold text-zinc-700 dark:text-zinc-200"> <label class="block text-sm font-semibold text-zinc-700 dark:text-zinc-200">
{{ $label }} {{ $label }}
</label> </label>
<select <div class="grid gap-3 md:grid-cols-[minmax(0,1fr)_auto] md:items-end">
wire:model="{{ $model }}" <div class="space-y-2">
class="block h-10 w-full rounded-xl border border-zinc-300 bg-white px-3 text-sm font-semibold text-zinc-900 shadow-sm outline-none transition-colors duration-150 focus:border-blue-500 focus:ring-2 focus:ring-blue-500/30 dark:border-zinc-700 dark:bg-zinc-950 dark:text-zinc-100" <select
> wire:model.live="{{ $model }}"
@foreach ($options as $key => $option) class="block h-10 w-full rounded-xl border border-zinc-300 bg-white px-3 text-sm font-semibold text-zinc-900 shadow-sm outline-none transition-colors duration-150 focus:border-blue-500 focus:ring-2 focus:ring-blue-500/30 dark:border-zinc-700 dark:bg-zinc-950 dark:text-zinc-100"
<option value="{{ $key }}" style="color: {{ $option['hex'] }};">
● {{ $option['name'] }} — {{ $option['hex'] }}
</option>
@endforeach
</select>
<div class="flex flex-wrap gap-1.5" aria-label="Available Helpdesk colours">
@foreach ($options as $key => $option)
<span
title="{{ $option['name'] }} {{ $option['hex'] }}"
class="inline-flex h-5 min-w-5 items-center justify-center rounded-full border border-white/70 shadow-sm ring-1 ring-zinc-900/10"
style="background-color: {{ $option['hex'] }}"
> >
<span class="sr-only">{{ $option['name'] }}</span> @foreach ($options as $key => $option)
</span> <option value="{{ $key }}" style="color: {{ $option['hex'] }};">
@endforeach ● {{ $option['name'] }} — {{ $option['hex'] }}
</option>
@endforeach
</select>
<div class="flex flex-wrap gap-1.5" aria-label="Available Helpdesk colours">
@foreach ($options as $key => $option)
<button
type="button"
wire:click="$set('{{ $model }}', '{{ $key }}')"
title="{{ $option['name'] }} {{ $option['hex'] }}"
class="inline-flex h-6 min-w-6 items-center justify-center rounded-full border border-white/80 shadow-sm ring-1 ring-zinc-900/10 transition hover:scale-105 focus:outline-none focus:ring-2 focus:ring-blue-500/40"
style="background-color: {{ $option['hex'] }}"
>
<span class="sr-only">Use {{ $option['name'] }}</span>
</button>
@endforeach
</div>
</div>
<span
class="inline-flex min-w-28 items-center justify-center rounded-full px-3 py-2 text-xs font-bold shadow-sm ring-1 ring-zinc-900/10"
style="background-color: {{ $currentHex }}; color: {{ $currentText }};"
>
{{ $currentLabel }}
</span>
</div>
<div class="grid gap-3 md:grid-cols-[auto_minmax(0,1fr)] md:items-center">
<input
type="color"
value="{{ $currentHex }}"
wire:change="$set('{{ $model }}', $event.target.value)"
class="h-10 w-16 cursor-pointer rounded-xl border border-zinc-300 bg-white p-1 shadow-sm dark:border-zinc-700 dark:bg-zinc-950"
aria-label="Choose a custom hex colour"
/>
<flux:input
wire:model.live.debounce.500ms="{{ $model }}"
label="Custom hex colour"
placeholder="#ffffff"
description="Use the standard palette where possible. Custom hex values must use six characters, for example #ffffff."
/>
</div> </div>
</div> </div>
+66 -5
View File
@@ -4,6 +4,8 @@ declare(strict_types=1);
namespace Modules\Helpdesk\Support; namespace Modules\Helpdesk\Support;
use Closure;
final class Color final class Color
{ {
/** /**
@@ -41,23 +43,37 @@ final class Color
]; ];
/** /**
* Resolve a stored colour key or supported hex value into a CSS colour. * Resolve a stored palette key or supported custom hex value into a CSS colour.
*/ */
public static function css(?string $color): string public static function css(?string $color): string
{ {
$resolved = self::normalise($color); $resolved = self::normalise($color);
if (str_starts_with($resolved, '#')) { if (self::isHex($resolved)) {
return $resolved; return $resolved;
} }
return self::PALETTE[$resolved] ?? self::PALETTE['gray']; return self::PALETTE[$resolved] ?? self::PALETTE['gray'];
} }
/**
* Return a readable foreground colour for a badge background.
*/
public static function text(?string $color): string
{
$hex = ltrim(self::css($color), '#');
$red = hexdec(substr($hex, 0, 2));
$green = hexdec(substr($hex, 2, 2));
$blue = hexdec(substr($hex, 4, 2));
$luminance = (($red * 299) + ($green * 587) + ($blue * 114)) / 1000;
return $luminance >= 150 ? '#111827' : '#ffffff';
}
/** /**
* Return approved palette options for settings dropdowns. * Return approved palette options for settings dropdowns.
* *
* @return array<string, array{name: string, hex: string}> * @return array<string, array{name: string, hex: string, foreground: string}>
*/ */
public static function options(): array public static function options(): array
{ {
@@ -66,6 +82,7 @@ final class Color
$name => [ $name => [
'name' => str($name)->headline()->toString(), 'name' => str($name)->headline()->toString(),
'hex' => $hex, 'hex' => $hex,
'foreground' => self::text($hex),
], ],
]) ])
->all(); ->all();
@@ -82,16 +99,60 @@ final class Color
} }
/** /**
* Normalise legacy aliases and supported hex colours. * Normalise legacy aliases and supported custom hex colours.
*/ */
public static function normalise(?string $color): string public static function normalise(?string $color): string
{ {
$value = strtolower(trim((string) $color)); $value = strtolower(trim((string) $color));
if (preg_match('/^#[a-f0-9]{6}$/', $value) === 1) { if (self::isHex($value)) {
return $value; return $value;
} }
return self::ALIASES[$value] ?? $value; return self::ALIASES[$value] ?? $value;
} }
/**
* Determine whether the value is a supported six-character hex colour.
*/
public static function isHex(?string $color): bool
{
return preg_match('/^#[a-f0-9]{6}$/', strtolower(trim((string) $color))) === 1;
}
/**
* Determine whether a stored colour is an approved palette key or custom hex colour.
*/
public static function isAllowed(?string $color): bool
{
$resolved = self::normalise($color);
return self::isHex($resolved) || in_array($resolved, self::keys(), true);
}
/**
* Laravel validation rule allowing approved palette keys and six-character hex values.
*/
public static function validationRule(): Closure
{
return static function (string $attribute, mixed $value, Closure $fail): void {
if (! self::isAllowed(is_string($value) ? $value : null)) {
$fail('The '.$attribute.' must be an approved Helpdesk colour or a valid hex colour such as #ffffff.');
}
};
}
/**
* Human-readable label for palette and custom hex values.
*/
public static function label(?string $color): string
{
$resolved = self::normalise($color);
if (self::isHex($resolved)) {
return strtoupper($resolved);
}
return str($resolved ?: 'gray')->headline()->toString();
}
} }
@@ -5,11 +5,12 @@ declare(strict_types=1);
namespace Modules\Helpdesk\Tests\Unit; namespace Modules\Helpdesk\Tests\Unit;
use Modules\Helpdesk\Support\Color; use Modules\Helpdesk\Support\Color;
use PHPUnit\Framework\TestCase; use Tests\TestCase;
final class ColorPaletteTest extends TestCase final class ColorPaletteTest extends TestCase
{ {
public function test_standard_palette_uses_clear_enterprise_colours(): void /** @test */
public function palette_colours_resolve_to_standard_hex_values(): void
{ {
self::assertSame('#dc2626', Color::css('red')); self::assertSame('#dc2626', Color::css('red'));
self::assertSame('#16a34a', Color::css('green')); self::assertSame('#16a34a', Color::css('green'));
@@ -17,7 +18,8 @@ final class ColorPaletteTest extends TestCase
self::assertSame('#6b7280', Color::css('gray')); self::assertSame('#6b7280', Color::css('gray'));
} }
public function test_legacy_colour_names_are_normalised(): void /** @test */
public function legacy_colour_aliases_are_normalised(): void
{ {
self::assertSame('red', Color::normalise('rose')); self::assertSame('red', Color::normalise('rose'));
self::assertSame('green', Color::normalise('emerald')); self::assertSame('green', Color::normalise('emerald'));
@@ -25,9 +27,21 @@ final class ColorPaletteTest extends TestCase
self::assertSame('gray', Color::normalise('zinc')); self::assertSame('gray', Color::normalise('zinc'));
} }
public function test_hex_values_can_still_be_resolved_for_display(): void /** @test */
public function custom_hex_colours_are_supported_and_validated(): void
{ {
self::assertSame('#ffffff', Color::css('#ffffff')); self::assertSame('#ffffff', Color::css('#ffffff'));
self::assertSame('#000000', Color::css('#000000')); self::assertSame('#000000', Color::css('#000000'));
self::assertTrue(Color::isAllowed('#ffffff'));
self::assertFalse(Color::isAllowed('#fff'));
self::assertFalse(Color::isAllowed('brown'));
}
/** @test */
public function readable_text_colour_is_calculated_for_badge_backgrounds(): void
{
self::assertSame('#111827', Color::text('#ffffff'));
self::assertSame('#ffffff', Color::text('#000000'));
self::assertSame('#ffffff', Color::text('red'));
} }
} }
+14
View File
@@ -204,3 +204,17 @@
- Added a migration to clean existing database lookup colour values to the approved palette. - Added a migration to clean existing database lookup colour values to the approved palette.
- Updated Helpdesk seed data, feature coverage and unit coverage for the standardised colour behaviour. - Updated Helpdesk seed data, feature coverage and unit coverage for the standardised colour behaviour.
- Preserved append-only changelog history. - Preserved append-only changelog history.
- version: 1.9.49
date: 2026-06-23
type: feature
summary: Add Helpdesk custom hex colours, contrast-safe badges and standardised seeded workflows.
changes:
- Added support for six-character custom hex colours such as #ffffff while keeping the approved WMS palette as the default path.
- Added colour picker, custom hex input, clickable palette swatches and live preview badge behaviour to Helpdesk colour selectors.
- Added contrast-safe badge text calculation for light and dark colour backgrounds.
- Added a reusable Helpdesk badge component so lists, Kanban cards, ticket detail pages, reports and notifications can render colours consistently.
- Standardised seeded ticket statuses for New, Open, In Progress, Waiting Customer, Waiting Third Party, Resolved, Closed and Cancelled.
- Standardised seeded ticket priorities for Low, Normal, High, Urgent and Critical.
- Extended unit and file-level regression coverage for custom hex colours, colour selector UI and contrast-safe badge rendering.
- Preserved append-only changelog history.
+1 -1
View File
@@ -22,5 +22,5 @@
"Modules\\Helpdesk\\Providers\\HelpdeskServiceProvider" "Modules\\Helpdesk\\Providers\\HelpdeskServiceProvider"
], ],
"files": [], "files": [],
"version": "1.9.48" "version": "1.9.49"
} }