From 400d64ab55142708b4aaa78d201ba772f0f546f4 Mon Sep 17 00:00:00 2001 From: Chris Sherwood Date: Mon, 31 Aug 2026 08:15:42 -0700 Subject: [PATCH] feat(dashboard): let a link tile carry a brand colour A link tile rendered on a flat surface colour, which read as a stark white card against the filled green app tiles. Tiles now take one of six colours from the brand palette, chosen when creating or editing one, and the card is tinted from it. Tiles stay visually distinct from managed apps regardless of colour: the dashed border and the external-link marker carry that, not the hue, and the tint is deliberately light so the distinction survives every option. Class names are written out in full rather than interpolated, because Tailwind scans source text for literal class strings and would silently drop a computed one, leaving the tile untinted. A test asserts that. Added as a separate migration from the one introducing is_link_tile, since that has already run on the test box and editing it in place would leave fresh and upgraded installs with different schemas. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01F8Mgg1vd2eKSs8StZiuyMV --- admin/app/controllers/system_controller.ts | 3 + admin/app/models/service.ts | 3 + admin/app/services/system_service.ts | 2 + admin/app/validators/system.ts | 3 + admin/constants/link_tile_colors.ts | 70 +++++++++++++++++++ ...786000000002_add_link_color_to_services.ts | 21 ++++++ admin/database/seeders/service_seeder.ts | 1 + admin/inertia/components/LinkTileModal.tsx | 27 +++++++ admin/inertia/lib/api.ts | 2 + admin/inertia/pages/home.tsx | 8 ++- admin/tests/unit/link_tile.spec.ts | 28 ++++++++ admin/types/services.ts | 1 + 12 files changed, 167 insertions(+), 2 deletions(-) create mode 100644 admin/constants/link_tile_colors.ts create mode 100644 admin/database/migrations/1786000000002_add_link_color_to_services.ts diff --git a/admin/app/controllers/system_controller.ts b/admin/app/controllers/system_controller.ts index 91ebfbbc..c8eedeba 100644 --- a/admin/app/controllers/system_controller.ts +++ b/admin/app/controllers/system_controller.ts @@ -38,6 +38,7 @@ import { inject } from '@adonisjs/core' import type { HttpContext } from '@adonisjs/core/http' import logger from '@adonisjs/core/services/logger' import Service from '#models/service' +import { DEFAULT_LINK_TILE_COLOR } from '../../constants/link_tile_colors.js' @inject() export default class SystemController { @@ -593,6 +594,7 @@ export default class SystemController { is_custom: false, is_user_modified: true, is_link_tile: true, + link_color: payload.link_color ?? DEFAULT_LINK_TILE_COLOR, category: 'Links', }) @@ -628,6 +630,7 @@ export default class SystemController { service.icon = payload.icon || DEFAULT_LINK_TILE_ICON service.display_order = payload.display_order ?? service.display_order ?? 90 service.custom_url = normalized + service.link_color = payload.link_color ?? service.link_color ?? DEFAULT_LINK_TILE_COLOR await service.save() return response.send({ success: true }) diff --git a/admin/app/models/service.ts b/admin/app/models/service.ts index 8204e052..4966d326 100644 --- a/admin/app/models/service.ts +++ b/admin/app/models/service.ts @@ -93,6 +93,9 @@ export default class Service extends BaseModel { }) declare is_link_tile: boolean + @column() + declare link_color: string | null + @column() declare category: string | null diff --git a/admin/app/services/system_service.ts b/admin/app/services/system_service.ts index edf5baf2..56371ece 100644 --- a/admin/app/services/system_service.ts +++ b/admin/app/services/system_service.ts @@ -344,6 +344,7 @@ export class SystemService { 'is_user_modified', 'is_deprecated', 'is_link_tile', + 'link_color', 'category' ) .where('is_dependency_service', false) @@ -385,6 +386,7 @@ export class SystemService { is_user_modified: service.is_user_modified, is_deprecated: service.is_deprecated, is_link_tile: service.is_link_tile, + link_color: service.link_color, category: service.category, }) } diff --git a/admin/app/validators/system.ts b/admin/app/validators/system.ts index 8dbef640..1153931d 100644 --- a/admin/app/validators/system.ts +++ b/admin/app/validators/system.ts @@ -1,4 +1,5 @@ import vine from '@vinejs/vine' +import { LINK_TILE_COLOR_IDS } from '../../constants/link_tile_colors.js' export const installServiceValidator = vine.compile( vine.object({ @@ -137,6 +138,7 @@ export const createLinkTileValidator = vine.compile( description: vine.string().trim().maxLength(200).nullable().optional(), icon: vine.string().trim().maxLength(60).nullable().optional(), display_order: vine.number().min(0).max(999).optional(), + link_color: vine.enum(LINK_TILE_COLOR_IDS).optional(), }) ) @@ -149,6 +151,7 @@ export const updateLinkTileValidator = vine.compile( description: vine.string().trim().maxLength(200).nullable().optional(), icon: vine.string().trim().maxLength(60).nullable().optional(), display_order: vine.number().min(0).max(999).optional(), + link_color: vine.enum(LINK_TILE_COLOR_IDS).optional(), }) ) diff --git a/admin/constants/link_tile_colors.ts b/admin/constants/link_tile_colors.ts new file mode 100644 index 00000000..ef03e106 --- /dev/null +++ b/admin/constants/link_tile_colors.ts @@ -0,0 +1,70 @@ +/** + * The colours a user can give a dashboard link tile. + * + * Drawn from the brand palette rather than arbitrary hex, so a tile always sits + * on-theme next to the managed app cards. The default white-on-green contrast + * read as jarring on the dashboard, which is why this exists. + * + * Tiles stay visually distinct from managed apps regardless of colour: the + * dashed border and the external-link marker are what carry that, not the hue. + * The tint is deliberately light so the distinction survives every option. + * + * Class names are written out in full and never interpolated. Tailwind scans + * source text for literal class strings, so a computed name like + * `bg-desert-${id}/10` would be silently dropped from the build. + */ +export const LINK_TILE_COLORS = [ + { + id: 'green', + label: 'Green', + border: 'border-desert-green/70', + bg: 'bg-desert-green/10', + marker: 'text-desert-green', + }, + { + id: 'olive', + label: 'Olive', + border: 'border-desert-olive/70', + bg: 'bg-desert-olive/10', + marker: 'text-desert-olive', + }, + { + id: 'orange', + label: 'Orange', + border: 'border-desert-orange/70', + bg: 'bg-desert-orange/10', + marker: 'text-desert-orange', + }, + { + id: 'red', + label: 'Red', + border: 'border-desert-red/70', + bg: 'bg-desert-red/10', + marker: 'text-desert-red', + }, + { + id: 'sand', + label: 'Sand', + border: 'border-desert-sand/70', + bg: 'bg-desert-sand/20', + marker: 'text-desert-stone-dark', + }, + { + id: 'stone', + label: 'Stone', + border: 'border-desert-stone/70', + bg: 'bg-desert-stone/10', + marker: 'text-desert-stone-dark', + }, +] as const + +export type LinkTileColorId = (typeof LINK_TILE_COLORS)[number]['id'] + +export const DEFAULT_LINK_TILE_COLOR: LinkTileColorId = 'green' + +export const LINK_TILE_COLOR_IDS: readonly string[] = LINK_TILE_COLORS.map((c) => c.id) + +/** Resolve a stored id, falling back to the default for anything unknown. */ +export function linkTileColor(id?: string | null) { + return LINK_TILE_COLORS.find((c) => c.id === id) ?? LINK_TILE_COLORS[0] +} diff --git a/admin/database/migrations/1786000000002_add_link_color_to_services.ts b/admin/database/migrations/1786000000002_add_link_color_to_services.ts new file mode 100644 index 00000000..493002e6 --- /dev/null +++ b/admin/database/migrations/1786000000002_add_link_color_to_services.ts @@ -0,0 +1,21 @@ +import { BaseSchema } from '@adonisjs/lucid/schema' + +export default class extends BaseSchema { + protected tableName = 'services' + + async up() { + this.schema.alterTable(this.tableName, (table) => { + // Brand-palette colour for a dashboard link tile. Null means the default. + // A separate migration from the one that added is_link_tile, because that + // one has already run on the test box; editing it in place would leave + // fresh installs and upgraded ones with different schemas. + table.string('link_color', 20).nullable() + }) + } + + async down() { + this.schema.alterTable(this.tableName, (table) => { + table.dropColumn('link_color') + }) + } +} diff --git a/admin/database/seeders/service_seeder.ts b/admin/database/seeders/service_seeder.ts index bc0c7edc..8b5e85d3 100644 --- a/admin/database/seeders/service_seeder.ts +++ b/admin/database/seeders/service_seeder.ts @@ -16,6 +16,7 @@ type ServiceSeedRecord = Omit< | 'is_user_modified' | 'is_deprecated' | 'is_link_tile' + | 'link_color' | 'custom_url' | 'auto_update_enabled' | 'available_update_first_seen_at' diff --git a/admin/inertia/components/LinkTileModal.tsx b/admin/inertia/components/LinkTileModal.tsx index 2f175cce..fcf7b548 100644 --- a/admin/inertia/components/LinkTileModal.tsx +++ b/admin/inertia/components/LinkTileModal.tsx @@ -5,6 +5,7 @@ import LinkTileIconPicker from './LinkTileIconPicker' import DynamicIcon, { DynamicIconName } from './DynamicIcon' import { normalizeCustomUrl } from '~/lib/navigation' import { DEFAULT_LINK_TILE_ICON } from '../../constants/link_tile_icons' +import { DEFAULT_LINK_TILE_COLOR, LINK_TILE_COLORS } from '../../constants/link_tile_colors' import { ServiceSlim } from '../../types/services' import api from '~/lib/api' @@ -35,6 +36,7 @@ export default function LinkTileModal({ const [url, setUrl] = useState('') const [description, setDescription] = useState('') const [icon, setIcon] = useState(DEFAULT_LINK_TILE_ICON) + const [color, setColor] = useState(DEFAULT_LINK_TILE_COLOR) const [submitting, setSubmitting] = useState(false) const isEdit = Boolean(tile) @@ -45,6 +47,7 @@ export default function LinkTileModal({ setUrl(tile?.custom_url ?? '') setDescription(tile?.description ?? '') setIcon(tile?.icon ?? DEFAULT_LINK_TILE_ICON) + setColor(tile?.link_color ?? DEFAULT_LINK_TILE_COLOR) }, [open, tile]) const trimmedName = name.trim() @@ -62,6 +65,7 @@ export default function LinkTileModal({ url: trimmedUrl, description: description.trim() ? description.trim() : null, icon, + link_color: color, } const result = isEdit @@ -151,6 +155,29 @@ export default function LinkTileModal({

+ +
+

Colour

+
+ {LINK_TILE_COLORS.map((option) => ( +
+

+ Links stay outlined rather than filled whichever colour you pick, so they + are still tellable apart from apps NOMAD manages. +

+
) diff --git a/admin/inertia/lib/api.ts b/admin/inertia/lib/api.ts index 6ddf9a58..bc93cffe 100644 --- a/admin/inertia/lib/api.ts +++ b/admin/inertia/lib/api.ts @@ -1137,6 +1137,7 @@ class API { description?: string | null icon?: string | null display_order?: number + link_color?: string }) { return catchInternal(async () => { const response = await this.client.post<{ success: boolean; service_name: string }>( @@ -1154,6 +1155,7 @@ class API { description?: string | null icon?: string | null display_order?: number + link_color?: string }) { return catchInternal(async () => { const response = await this.client.put<{ success: boolean }>('/system/services/links', data) diff --git a/admin/inertia/pages/home.tsx b/admin/inertia/pages/home.tsx index 3b70aff3..9a33f910 100644 --- a/admin/inertia/pages/home.tsx +++ b/admin/inertia/pages/home.tsx @@ -26,6 +26,7 @@ import { SERVICE_NAMES } from '../../constants/service_names' import LinkTileModal from '~/components/LinkTileModal' import { IconPlus, IconPencil, IconTrash, IconExternalLink } from '@tabler/icons-react' import { useState } from 'react' +import { linkTileColor } from '../../constants/link_tile_colors' // Maps is a Core Capability (display_order: 4) const MAPS_ITEM = { @@ -271,6 +272,9 @@ export default function Home(props: { const shouldHighlight = isEasySetup && shouldHighlightEasySetup const isLinkTile = Boolean(item.linkTile) + // A flat surface colour read as a stark white card next to the filled + // app tiles. Tint from the brand palette instead, user-chosen. + const tileColor = linkTileColor(item.linkTile?.link_color) // Link tiles are deliberately not styled like managed apps: outlined // rather than filled, with an external-link marker. If they looked the @@ -280,13 +284,13 @@ export default function Home(props: {
{isLinkTile && ( diff --git a/admin/tests/unit/link_tile.spec.ts b/admin/tests/unit/link_tile.spec.ts index e99b3bb1..964ec07b 100644 --- a/admin/tests/unit/link_tile.spec.ts +++ b/admin/tests/unit/link_tile.spec.ts @@ -18,6 +18,12 @@ import { LINK_TILE_ICONS, isLinkTileIcon, } from '../../constants/link_tile_icons.js' +import { + DEFAULT_LINK_TILE_COLOR, + LINK_TILE_COLORS, + LINK_TILE_COLOR_IDS, + linkTileColor, +} from '../../constants/link_tile_colors.js' test('a bare host:port gains http, which is what most LAN devices need', () => { assert.equal(normalizeCustomUrl('192.168.1.50:8080'), 'http://192.168.1.50:8080/') @@ -101,3 +107,25 @@ test('isLinkTileIcon accepts only names in the set', () => { test('the default icon is part of the offered set', () => { assert.ok((LINK_TILE_ICONS as readonly string[]).includes(DEFAULT_LINK_TILE_ICON)) }) + +test('link tile colours resolve from the brand palette', () => { + // Unknown, null and empty all fall back rather than rendering an untinted card. + assert.equal(linkTileColor('orange').id, 'orange') + assert.equal(linkTileColor(null).id, DEFAULT_LINK_TILE_COLOR) + assert.equal(linkTileColor('chartreuse').id, DEFAULT_LINK_TILE_COLOR) +}) + +test('every link tile colour ships complete, literal Tailwind classes', () => { + // Tailwind scans for literal class strings, so an interpolated name would be + // dropped from the build and the tile would render untinted. + for (const option of LINK_TILE_COLORS) { + assert.ok(option.border.startsWith('border-desert-'), option.id) + assert.ok(option.bg.startsWith('bg-desert-'), option.id) + assert.ok(option.marker.startsWith('text-desert-'), option.id) + assert.ok(!`${option.border}${option.bg}${option.marker}`.includes('${'), option.id) + } +}) + +test('the default colour is one of the offered options', () => { + assert.ok(LINK_TILE_COLOR_IDS.includes(DEFAULT_LINK_TILE_COLOR)) +}) diff --git a/admin/types/services.ts b/admin/types/services.ts index 062f881a..5452f5f7 100644 --- a/admin/types/services.ts +++ b/admin/types/services.ts @@ -20,5 +20,6 @@ export type ServiceSlim = Pick< | 'is_user_modified' | 'is_deprecated' | 'is_link_tile' + | 'link_color' | 'category' > & { status?: string }