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
Colour
++ Links stay outlined rather than filled whichever colour you pick, so they + are still tellable apart from apps NOMAD manages. +
+