polish(dedicated): drive bay title, HDD/SSD optgroups, OS expansion + grouping

Four customer-copy / UX cleanups bundled together:

1. Drive bay title strip — shortGroupLabel() collapses "LFF/SFF/NVMe
   Drive Bays" to just "Drive Bays" everywhere it surfaces (rail
   anchor, configurator section title, BuildSummary line item).
   Each chassis only ever shows one drive bay group, so the
   form-factor prefix was redundant noise.

2. HDD/SSD optgroups in Drive Selection — VSelect now interleaves
   VListSubheader rows ("HDDs", "SSDs", "NVMe") between options.
   Sentinel header values (`__hdr_<cat>`) are filtered in
   onDriveChange so a stray header click can't propagate.

3. OS list expansion — went from 6 entries to 14: added AlmaLinux 8,
   Rocky 8, Ubuntu 22.04 LTS, Debian 11, Fedora Server 41, FreeBSD 14,
   Proxmox VE 8, Windows Server 2019 (BYOL). Default flipped from
   "No OS" → "AlmaLinux 9" (matching what most dedicated buyers
   actually want — flag and revert via seeder if you'd rather keep
   bare-metal as the default).

4. OS picker grouped by distro — OsGroupSelector renders family
   sections (AlmaLinux, Rocky Linux, Ubuntu, Debian, Fedora,
   FreeBSD, Proxmox VE, Windows Server, Other) with a small
   uppercase heading above each row of tiles. metaFor() helper
   maps slug → family + logo path. New SVG logos for fedora,
   freebsd, proxmox; refined geometry on almalinux + rocky + debian.

Reseeded the OS group (deleted old 6 values, recreated 14 with new
ordering). 20/20 dedicated tests still pass. `npm run build` clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-26 21:21:50 -04:00
parent f0df110b47
commit dd8f83a990
11 changed files with 235 additions and 69 deletions

View File

@@ -447,13 +447,24 @@ class ConfigOptionSeeder extends Seeder
); );
$gen14OsOption = $this->seedRadioOption($gen14Os, 'Operating System', false, 1); $gen14OsOption = $this->seedRadioOption($gen14Os, 'Operating System', false, 1);
// Order: enterprise Linux first (Alma / Rocky), then mainstream LTS,
// then specialty (Fedora / FreeBSD / Proxmox), then Windows BYOL,
// with "No OS" last so the picker reads as a positive distro choice.
$this->seedValues($gen14OsOption, [ $this->seedValues($gen14OsOption, [
['label' => 'No OS (BYO image / custom PXE)', 'value' => 'none', 'monthly' => 0, 'is_default' => true], ['label' => 'AlmaLinux 9', 'value' => 'alma9', 'monthly' => 0, 'is_default' => true],
['label' => 'AlmaLinux 9', 'value' => 'alma9', 'monthly' => 0], ['label' => 'AlmaLinux 8', 'value' => 'alma8', 'monthly' => 0],
['label' => 'Ubuntu 24.04 LTS', 'value' => 'ubuntu24', 'monthly' => 0],
['label' => 'Debian 12', 'value' => 'debian12', 'monthly' => 0],
['label' => 'Rocky Linux 9', 'value' => 'rocky9', 'monthly' => 0], ['label' => 'Rocky Linux 9', 'value' => 'rocky9', 'monthly' => 0],
['label' => 'Rocky Linux 8', 'value' => 'rocky8', 'monthly' => 0],
['label' => 'Ubuntu 24.04 LTS', 'value' => 'ubuntu24', 'monthly' => 0],
['label' => 'Ubuntu 22.04 LTS', 'value' => 'ubuntu22', 'monthly' => 0],
['label' => 'Debian 12', 'value' => 'debian12', 'monthly' => 0],
['label' => 'Debian 11', 'value' => 'debian11', 'monthly' => 0],
['label' => 'Fedora Server 41', 'value' => 'fedora41', 'monthly' => 0],
['label' => 'FreeBSD 14', 'value' => 'freebsd14', 'monthly' => 0],
['label' => 'Proxmox VE 8', 'value' => 'proxmox8', 'monthly' => 0],
['label' => 'Windows Server 2022 (BYOL)', 'value' => 'windows-2022-byol', 'monthly' => 0], ['label' => 'Windows Server 2022 (BYOL)', 'value' => 'windows-2022-byol', 'monthly' => 0],
['label' => 'Windows Server 2019 (BYOL)', 'value' => 'windows-2019-byol', 'monthly' => 0],
['label' => 'No OS (BYO image / custom PXE)', 'value' => 'none', 'monthly' => 0],
]); ]);
$gen14Os->plans()->syncWithoutDetaching($gen14AllPlans); $gen14Os->plans()->syncWithoutDetaching($gen14AllPlans);

View File

@@ -1,5 +1,8 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" role="img" aria-label="AlmaLinux"> <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" role="img" aria-label="AlmaLinux">
<circle cx="32" cy="32" r="32" fill="#0E1F3D"/> <circle cx="32" cy="32" r="32" fill="#0E1F3D"/>
<path d="M32 12 C 36 22, 44 26, 50 30 C 44 32, 36 38, 32 50 C 28 38, 20 32, 14 30 C 20 26, 28 22, 32 12 Z" fill="#FA9001"/> <g transform="translate(32 32)">
<circle cx="32" cy="30" r="3.5" fill="#FFD200"/> <circle r="14" fill="none" stroke="#FA9001" stroke-width="3"/>
<path d="M-9 -2 Q 0 -14, 9 -2 Q 0 4, -9 -2 Z" fill="#FA9001"/>
<path d="M-7 5 Q 0 -2, 7 5 Q 0 12, -7 5 Z" fill="#FFD200"/>
</g>
</svg> </svg>

Before

Width:  |  Height:  |  Size: 329 B

After

Width:  |  Height:  |  Size: 392 B

View File

@@ -1,5 +1,7 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" role="img" aria-label="Debian"> <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" role="img" aria-label="Debian">
<circle cx="32" cy="32" r="32" fill="#fff"/> <circle cx="32" cy="32" r="32" fill="#fff"/>
<path d="M40 9 C 22 9, 9 22, 9 39 C 9 50, 16 58, 25 58 C 17 55, 13 47, 13 38 C 13 23, 25 11, 41 11 C 47 11, 52 13, 56 17 C 52 12, 47 9, 40 9 Z" fill="#D70751"/> <g transform="translate(32 32)">
<path d="M30 23 C 38 23, 42 28, 42 33 C 42 39, 38 43, 31 43 C 28 42, 26 40, 26 38 C 28 39, 30 40, 32 40 C 36 40, 39 38, 39 33 C 39 28, 35 25, 30 25 Z" fill="#D70751"/> <path d="M -2 -16 C -10 -16, -16 -10, -16 -2 C -16 6, -10 12, -2 12 C 4 12, 8 9, 9 5 C 6 8, 2 9, -2 8 C -8 7, -12 2, -12 -3 C -12 -8, -8 -13, -2 -13 C 3 -13, 7 -10, 8 -6 C 8 -12, 4 -16, -2 -16 Z" fill="#D70751"/>
<circle cx="9" cy="-6" r="1.4" fill="#D70751"/>
</g>
</svg> </svg>

Before

Width:  |  Height:  |  Size: 479 B

After

Width:  |  Height:  |  Size: 457 B

View File

@@ -0,0 +1,7 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" role="img" aria-label="Fedora">
<circle cx="32" cy="32" r="32" fill="#294172"/>
<g transform="translate(32 32)">
<path d="M -2 -14 L 10 -14 L 10 -8 L 4 -8 L 4 -3 L 9 -3 L 9 3 L 4 3 L 4 14 L -2 14 L -2 -8 C -2 -12, -1 -14, 2 -14 Z" fill="#fff"/>
<circle cx="-6" cy="9" r="5" fill="none" stroke="#fff" stroke-width="2.5"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 407 B

View File

@@ -0,0 +1,10 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" role="img" aria-label="FreeBSD">
<circle cx="32" cy="32" r="32" fill="#AB2B28"/>
<g transform="translate(32 32)">
<circle r="15" fill="#fff"/>
<circle cx="-5" cy="-3" r="2.5" fill="#AB2B28"/>
<circle cx="5" cy="-3" r="2.5" fill="#AB2B28"/>
<path d="M -7 4 Q 0 11, 7 4" fill="none" stroke="#AB2B28" stroke-width="2.5" stroke-linecap="round"/>
<path d="M -12 -10 L -8 -6 M 12 -10 L 8 -6" stroke="#AB2B28" stroke-width="3" stroke-linecap="round"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 543 B

View File

@@ -0,0 +1,11 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" role="img" aria-label="Proxmox VE">
<circle cx="32" cy="32" r="32" fill="#E57000"/>
<g transform="translate(32 32)">
<rect x="-13" y="-13" width="26" height="26" rx="2" fill="#fff"/>
<rect x="-9" y="-9" width="6" height="6" fill="#E57000"/>
<rect x="3" y="-9" width="6" height="6" fill="#E57000"/>
<rect x="-9" y="3" width="6" height="6" fill="#E57000"/>
<rect x="3" y="3" width="6" height="6" fill="#E57000"/>
<rect x="-3" y="-3" width="6" height="6" fill="#E57000"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 571 B

View File

@@ -1,7 +1,9 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" role="img" aria-label="Rocky Linux"> <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" role="img" aria-label="Rocky Linux">
<circle cx="32" cy="32" r="32" fill="#10B981"/> <circle cx="32" cy="32" r="32" fill="#10B981"/>
<path d="M8 50 L 22 26 L 32 38 L 42 22 L 56 50 Z" fill="#fff"/> <g>
<path d="M22 26 L 32 38 L 27 38 Z" fill="#0F766E" opacity="0.45"/> <path d="M14 46 L 26 30 L 32 38 L 42 22 L 50 33 L 50 46 Z" fill="#fff"/>
<path d="M42 22 L 32 38 L 37 38 Z" fill="#0F766E" opacity="0.45"/> <path d="M26 30 L 32 38 L 28.5 38 Z" fill="#0F766E" opacity="0.35"/>
<circle cx="46" cy="20" r="3" fill="#FDE68A"/> <path d="M42 22 L 50 33 L 45 33 Z" fill="#0F766E" opacity="0.35"/>
<circle cx="44" cy="20" r="3" fill="#FDE68A"/>
</g>
</svg> </svg>

Before

Width:  |  Height:  |  Size: 407 B

After

Width:  |  Height:  |  Size: 439 B

View File

@@ -1,6 +1,6 @@
<script lang="ts" setup> <script lang="ts" setup>
import { computed } from 'vue' import { computed } from 'vue'
import type { DedicatedConfigGroup, DedicatedConfigOption, DedicatedConfigValue, DedicatedCycle } from '@/stores/dedicatedConfigurator' import { shortGroupLabel, type DedicatedConfigGroup, type DedicatedConfigOption, type DedicatedConfigValue, type DedicatedCycle } from '@/stores/dedicatedConfigurator'
interface Props { interface Props {
group: DedicatedConfigGroup group: DedicatedConfigGroup
@@ -69,24 +69,68 @@ interface DriveItem {
isDefault: boolean isDefault: boolean
} }
const driveItems = computed<DriveItem[]>(() => interface DriveHeader {
driveValues.value.map(v => ({ value: string // unique sentinel like "__hdr_hdds" so VSelect doesn't try to select it
value: v.value, title: string
label: v.label, isHeader: true
price: priceFor(v), props: { disabled: true }
isDefault: v.is_default, }
})),
)
function driveItemLabel(item: DriveItem | { price?: number; isDefault?: boolean } | undefined): string { type DriveListItem = DriveItem | DriveHeader
const price = item?.price
const isDefault = item?.isDefault // Categorize a drive value slug → media class header label.
// "none" stays at the top with no header. Everything else gets one of:
// HDDs (sata-hdd-*, sas-hdd-*) / SSDs (sata-ssd-*, sas-ssd-*) / NVMe (u2-nvme-*).
function categoryFor(slug: string): string {
if (slug === 'none') return ''
if (slug.includes('hdd')) return 'HDDs'
if (slug.includes('nvme')) return 'NVMe'
if (slug.includes('ssd')) return 'SSDs'
return ''
}
// Build the items list with subheader rows interleaved between media classes.
const driveItems = computed<DriveListItem[]>(() => {
const out: DriveListItem[] = []
let currentCategory = ''
for (const v of driveValues.value) {
const cat = categoryFor(v.value)
if (cat && cat !== currentCategory) {
out.push({
value: `__hdr_${cat.toLowerCase()}`,
title: cat,
isHeader: true,
props: { disabled: true },
})
currentCategory = cat
}
out.push({
value: v.value,
label: v.label,
price: priceFor(v),
isDefault: v.is_default,
})
}
return out
})
function isHeaderItem(item: DriveListItem | undefined): item is DriveHeader {
return !!item && (item as DriveHeader).isHeader === true
}
function driveItemLabel(item: DriveListItem | { price?: number; isDefault?: boolean } | undefined): string {
if (item && (item as DriveHeader).isHeader) return ''
const price = (item as DriveItem | undefined)?.price
const isDefault = (item as DriveItem | undefined)?.isDefault
if (typeof price !== 'number' || price === 0) return isDefault ? 'no drives' : 'free' if (typeof price !== 'number' || price === 0) return isDefault ? 'no drives' : 'free'
return `+$${price.toFixed(2)}${perDriveSuffix.value}` return `+$${price.toFixed(2)}${perDriveSuffix.value}`
} }
function onDriveChange(v: unknown): void { function onDriveChange(v: unknown): void {
if (typeof v === 'string') pickDrive(v) // Guard against any future stray subheader-row click — those values start
// with "__hdr_" and should never propagate to selection state.
if (typeof v !== 'string' || v.startsWith('__hdr_')) return
pickDrive(v)
} }
const cycleSuffix = computed<string>(() => const cycleSuffix = computed<string>(() =>
@@ -130,7 +174,7 @@ function pickDrive(value: string): void {
<template> <template>
<div class="drive-bay-group"> <div class="drive-bay-group">
<div class="drive-bay-group__head"> <div class="drive-bay-group__head">
<h4 class="drive-bay-group__title">{{ group.name.replace('Dedicated 14th Gen — ', '') }}</h4> <h4 class="drive-bay-group__title">{{ shortGroupLabel(group.name) }}</h4>
<p v-if="group.description" class="drive-bay-group__desc">{{ group.description }}</p> <p v-if="group.description" class="drive-bay-group__desc">{{ group.description }}</p>
</div> </div>
@@ -160,7 +204,13 @@ function pickDrive(value: string): void {
</div> </div>
</template> </template>
<template #item="{ item, props: itemProps }"> <template #item="{ item, props: itemProps }">
<VListItem v-bind="itemProps" :title="undefined"> <VListSubheader
v-if="isHeaderItem(item.raw)"
class="drive-bay-group__subheader"
>
{{ item.raw.title }}
</VListSubheader>
<VListItem v-else v-bind="itemProps" :title="undefined">
<div class="drive-bay-group__row drive-bay-group__row--menu"> <div class="drive-bay-group__row drive-bay-group__row--menu">
<span class="drive-bay-group__row-label">{{ item.raw.label }}</span> <span class="drive-bay-group__row-label">{{ item.raw.label }}</span>
<span <span
@@ -345,4 +395,16 @@ function pickDrive(value: string): void {
.drive-bay-group__menu .v-list-item { .drive-bay-group__menu .v-list-item {
padding-block: 6px; padding-block: 6px;
} }
.drive-bay-group__menu .drive-bay-group__subheader {
font-size: 10px;
font-weight: 700;
letter-spacing: 0.1em;
text-transform: uppercase;
color: rgba(var(--v-theme-on-surface), 0.55);
padding-block: 8px;
padding-inline: 16px;
min-height: auto;
line-height: 1.3;
}
</style> </style>

View File

@@ -1,6 +1,6 @@
<script lang="ts" setup> <script lang="ts" setup>
import { computed } from 'vue' import { computed } from 'vue'
import type { DedicatedConfigGroup, DedicatedConfigValue, DedicatedCycle } from '@/stores/dedicatedConfigurator' import { shortGroupLabel, type DedicatedConfigGroup, type DedicatedConfigValue, type DedicatedCycle } from '@/stores/dedicatedConfigurator'
interface Props { interface Props {
group: DedicatedConfigGroup group: DedicatedConfigGroup
@@ -66,7 +66,7 @@ function onUpdate(v: unknown): void {
<template> <template>
<div class="option-group"> <div class="option-group">
<div class="option-group__head"> <div class="option-group__head">
<h4 class="option-group__title">{{ group.name.replace('Dedicated 14th Gen — ', '') }}</h4> <h4 class="option-group__title">{{ shortGroupLabel(group.name) }}</h4>
<p v-if="group.description" class="option-group__desc">{{ group.description }}</p> <p v-if="group.description" class="option-group__desc">{{ group.description }}</p>
</div> </div>

View File

@@ -1,6 +1,6 @@
<script lang="ts" setup> <script lang="ts" setup>
import { computed } from 'vue' import { computed } from 'vue'
import type { DedicatedConfigGroup, DedicatedConfigValue, DedicatedCycle } from '@/stores/dedicatedConfigurator' import { shortGroupLabel, type DedicatedConfigGroup, type DedicatedConfigValue, type DedicatedCycle } from '@/stores/dedicatedConfigurator'
interface Props { interface Props {
group: DedicatedConfigGroup group: DedicatedConfigGroup
@@ -16,19 +16,49 @@ const emit = defineEmits<{
const values = computed<DedicatedConfigValue[]>(() => props.group.options[0]?.values ?? []) const values = computed<DedicatedConfigValue[]>(() => props.group.options[0]?.values ?? [])
// Map seeded value slugs → static SVG logos in /public/img/os. interface FamilyMeta {
// Anything not in this map falls back to the generic terminal icon. family: string
const logoMap: Record<string, string> = { logo: string
none: '/img/os/no-os.svg', // Lower number = earlier in the picker. Lets us order families
alma9: '/img/os/almalinux.svg', // independently of the seeder's value order.
ubuntu24: '/img/os/ubuntu.svg', rank: number
debian12: '/img/os/debian.svg',
rocky9: '/img/os/rocky.svg',
'windows-2022-byol': '/img/os/windows.svg',
} }
// Map a value slug to its distro family + logo path. Adding a new OS
// in the seeder means adding one row here so the picker knows where
// to slot it.
function metaFor(slug: string): FamilyMeta {
if (slug.startsWith('alma')) return { family: 'AlmaLinux', logo: '/img/os/almalinux.svg', rank: 10 }
if (slug.startsWith('rocky')) return { family: 'Rocky Linux', logo: '/img/os/rocky.svg', rank: 20 }
if (slug.startsWith('ubuntu')) return { family: 'Ubuntu', logo: '/img/os/ubuntu.svg', rank: 30 }
if (slug.startsWith('debian')) return { family: 'Debian', logo: '/img/os/debian.svg', rank: 40 }
if (slug.startsWith('fedora')) return { family: 'Fedora', logo: '/img/os/fedora.svg', rank: 50 }
if (slug.startsWith('freebsd')) return { family: 'FreeBSD', logo: '/img/os/freebsd.svg', rank: 60 }
if (slug.startsWith('proxmox')) return { family: 'Proxmox VE', logo: '/img/os/proxmox.svg', rank: 70 }
if (slug.startsWith('windows')) return { family: 'Windows Server', logo: '/img/os/windows.svg', rank: 80 }
return { family: 'Other', logo: '/img/os/no-os.svg', rank: 99 }
}
interface FamilyGroup {
family: string
rank: number
values: DedicatedConfigValue[]
}
const familyGroups = computed<FamilyGroup[]>(() => {
const map = new Map<string, FamilyGroup>()
for (const v of values.value) {
const meta = metaFor(v.value)
if (!map.has(meta.family)) {
map.set(meta.family, { family: meta.family, rank: meta.rank, values: [] })
}
map.get(meta.family)!.values.push(v)
}
return Array.from(map.values()).sort((a, b) => a.rank - b.rank)
})
function logoFor(slug: string): string { function logoFor(slug: string): string {
return logoMap[slug] ?? '/img/os/no-os.svg' return metaFor(slug).logo
} }
function priceFor(v: DedicatedConfigValue): number { function priceFor(v: DedicatedConfigValue): number {
@@ -62,44 +92,53 @@ function priceLabel(v: DedicatedConfigValue): string {
<template> <template>
<div class="os-group"> <div class="os-group">
<div class="os-group__head"> <div class="os-group__head">
<h4 class="os-group__title">{{ group.name.replace('Dedicated 14th Gen — ', '') }}</h4> <h4 class="os-group__title">{{ shortGroupLabel(group.name) }}</h4>
<p v-if="group.description" class="os-group__desc">{{ group.description }}</p> <p v-if="group.description" class="os-group__desc">{{ group.description }}</p>
</div> </div>
<div class="os-group__grid"> <div class="os-group__families">
<button <div
v-for="v in values" v-for="fam in familyGroups"
:key="v.id" :key="fam.family"
type="button" class="os-family"
class="os-tile"
:class="{ 'os-tile--active': selected === v.value }"
:aria-pressed="selected === v.value"
@click="emit('update:selected', v.value)"
> >
<div v-if="selected === v.value" class="os-tile__check"> <div class="os-family__heading">{{ fam.family }}</div>
<VIcon icon="tabler-circle-check-filled" color="primary" size="18" /> <div class="os-family__grid">
<button
v-for="v in fam.values"
:key="v.id"
type="button"
class="os-tile"
:class="{ 'os-tile--active': selected === v.value }"
:aria-pressed="selected === v.value"
@click="emit('update:selected', v.value)"
>
<div v-if="selected === v.value" class="os-tile__check">
<VIcon icon="tabler-circle-check-filled" color="primary" size="18" />
</div>
<img
:src="logoFor(v.value)"
:alt="v.label"
class="os-tile__logo"
loading="lazy"
/>
<div class="os-tile__name">{{ v.label }}</div>
<div
class="os-tile__price"
:class="priceFor(v) === 0 ? 'os-tile__price--zero' : 'os-tile__price--paid'"
>
{{ priceLabel(v) }}
</div>
</button>
</div> </div>
<img </div>
:src="logoFor(v.value)"
:alt="v.label"
class="os-tile__logo"
loading="lazy"
/>
<div class="os-tile__name">{{ v.label }}</div>
<div
class="os-tile__price"
:class="priceFor(v) === 0 ? 'os-tile__price--zero' : 'os-tile__price--paid'"
>
{{ priceLabel(v) }}
</div>
</button>
</div> </div>
</div> </div>
</template> </template>
<style lang="scss" scoped> <style lang="scss" scoped>
.os-group__head { .os-group__head {
margin-bottom: 12px; margin-bottom: 14px;
} }
.os-group__title { .os-group__title {
@@ -115,7 +154,22 @@ function priceLabel(v: DedicatedConfigValue): string {
margin-bottom: 0; margin-bottom: 0;
} }
.os-group__grid { .os-group__families {
display: flex;
flex-direction: column;
gap: 18px;
}
.os-family__heading {
font-size: 11px;
font-weight: 700;
letter-spacing: 0.1em;
text-transform: uppercase;
color: rgba(var(--v-theme-on-surface), 0.55);
margin-bottom: 8px;
}
.os-family__grid {
display: grid; display: grid;
grid-template-columns: repeat(auto-fill, minmax(140px, 1fr)); grid-template-columns: repeat(auto-fill, minmax(140px, 1fr));
gap: 10px; gap: 10px;

View File

@@ -76,7 +76,11 @@ export function groupAnchorId(name: string): string {
export function shortGroupLabel(name: string): string { export function shortGroupLabel(name: string): string {
// Strip the leading "Dedicated 14th Gen — " for compact rail/anchor display. // Strip the leading "Dedicated 14th Gen — " for compact rail/anchor display.
return name.replace(/^Dedicated 14th Gen — /, '') const trimmed = name.replace(/^Dedicated 14th Gen — /, '')
// Drive bay groups: every chassis gets exactly one (LFF / SFF / NVMe based
// on bay type), so the form-factor prefix is redundant in the customer UI.
if (trimmed.endsWith('Drive Bays')) return 'Drive Bays'
return trimmed
} }
function isDriveBaySelection(sel: DedicatedSelection | undefined): sel is DriveBaySelection { function isDriveBaySelection(sel: DedicatedSelection | undefined): sel is DriveBaySelection {