feat: a dev workspace's badge wears its fork's own colour

Every surface that names a dev workspace drew the same blue badge, which said
nothing about which workspace it belonged to. One component, DevWorkspaceBadge,
now draws it: a fork with a colour fills the badge with that hue and writes the
label in white, and a fork without one keeps whatever its row had before.

The hue cannot carry white at a fixed lightness — HSL lightness is not perceived
brightness, so yellow and purple at the same L differ by several stops — so
forkAccentStyle solves the badge's lightness by bisection instead, taking the
lightest shade of the hue that still holds white at WCAG AA. Measured across the
wheel the contrast lands between 4.52 and 4.64.

The session picker opens with every family collapsed, since the fork a session
runs in is the session's own and not something that picker should offer to
change.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Guilhem Lemouel
2026-10-06 09:30:18 -07:00
co-authored by Claude Opus 5
parent 730ccf2a81
commit 9a838ff262
9 changed files with 205 additions and 47 deletions
@@ -0,0 +1,55 @@
<!--
@component
The badge naming a dev workspace's environment, wherever one is shown.
A fork that has a colour wears it: the badge is filled with that hue at the lightest shade white
still reads on (`forkAccentStyle`), so a yellow workspace says "dev" in dark yellow rather than in
a blue that belongs to no workspace in particular. A fork with no colour keeps whatever its host
gave it — `fallbackColor` and `fallbackClass`, which is what every dev workspace looked like
before a colour could be picked.
-->
<script lang="ts">
import { Badge } from '$lib/components/common'
import type { BadgeColor } from '$lib/components/common/badge/model'
import { devBadgeText } from '$lib/utils/devWorkspaceLabel'
import { forkAccentStyle } from '$lib/utils/forkColor'
import { twMerge } from 'tailwind-merge'
interface Props {
/** The workspace's `dev_workspace_label`; spelled the way `devBadgeText` spells it. */
label?: string | undefined
/** The fork's own colour. Without one — or with an unparsable one — the badge falls back. */
color?: string | undefined
/** The badge's colour when the fork has none of its own. */
fallbackColor?: BadgeColor
/** Extra classes for that same case, for a host that tuned the uncoloured badge. */
fallbackClass?: string
/** Size and spacing, which the row around the badge decides either way. */
class?: string
}
let {
label,
color,
fallbackColor = 'dark-blue',
fallbackClass,
class: className
}: Props = $props()
// Set on the badge itself rather than read from an ancestor: a host that paints a coloured chip
// around it already defines these, and defining them again costs nothing and makes the badge
// work the same in a row that has no chip.
const accent = $derived(forkAccentStyle(color))
</script>
<Badge
color={fallbackColor}
small
style={accent}
class={twMerge(
accent
? 'bg-[color:var(--fork-accent-badge)] dark:bg-[color:var(--fork-accent-badge)] text-white dark:text-white'
: fallbackClass,
className
)}>{devBadgeText(label)}</Badge
>
@@ -1,6 +1,7 @@
<script lang="ts">
import { workspaceStore, userWorkspaces, usersWorkspaceStore, superadmin } from '$lib/stores'
import { WorkspaceService, type ProtectionRuleset } from '$lib/gen'
import DevWorkspaceBadge from '$lib/components/DevWorkspaceBadge.svelte'
import { Badge, Button } from '$lib/components/common'
import Select from '$lib/components/select/Select.svelte'
import Toggle from '$lib/components/Toggle.svelte'
@@ -17,7 +18,6 @@
import { getUserExt } from '$lib/user'
import {
DEV_WORKSPACE_LABELS,
devBadgeText,
devLabelKey,
devLabelNoun,
type DevWorkspaceLabelKey
@@ -554,7 +554,7 @@
<b>{parentId}</b>. Promote changes from the home page banner or the Compare &amp; Deploy page.
</p>
<div class="text-2xs text-secondary">
Environment: <Badge color="indigo" small>{devBadgeText(currentLabel)}</Badge>
Environment: <DevWorkspaceBadge label={currentLabel} color={currentWs?.color} fallbackColor="indigo" />
<span class="ml-1">
Set when the workspace is created or attached. Git sync deploys to the
<span class="font-mono">{currentLabel}</span> branch.
@@ -2,8 +2,8 @@
import { userWorkspaces, type UserWorkspace } from '$lib/stores'
import { findWorkspaceRoot } from '$lib/utils/workspaceHierarchy'
import { forkAccentStyle } from '$lib/utils/forkColor'
import { Badge, Button } from '$lib/components/common'
import { devBadgeText } from '$lib/utils/devWorkspaceLabel'
import { Button } from '$lib/components/common'
import DevWorkspaceBadge from '$lib/components/DevWorkspaceBadge.svelte'
import { Building, ChevronDown, EllipsisVertical, GitFork } from 'lucide-svelte'
import DropdownV2 from '$lib/components/DropdownV2.svelte'
import { type Item } from '$lib/utils'
@@ -171,12 +171,12 @@
{#if pendingFork}
<span class="shrink-0 opacity-70 font-normal">(new)</span>
{:else if currentWs?.is_dev_workspace}
<Badge
color="dark-blue"
small
class="text-3xs px-1 py-0 dark:bg-surface-accent-primary text-white dark:text-white"
>{devBadgeText(currentWs.dev_workspace_label)}</Badge
>
<DevWorkspaceBadge
label={currentWs.dev_workspace_label}
color={color ?? (pendingFork ? undefined : currentWs.color)}
fallbackClass="dark:bg-surface-accent-primary text-white dark:text-white"
class="text-3xs px-1 py-0"
/>
{/if}
{/if}
</Button>
@@ -2,8 +2,7 @@
import WorkspaceDiffDrawer from './WorkspaceDiffDrawer.svelte'
import { ArrowRight, GitFork, Pencil } from 'lucide-svelte'
import { userWorkspaces } from '$lib/stores'
import Badge from '$lib/components/common/badge/Badge.svelte'
import { devBadgeText } from '$lib/utils/devWorkspaceLabel'
import DevWorkspaceBadge from '$lib/components/DevWorkspaceBadge.svelte'
import { useSessionDeployModel } from './sessionDeployModel.svelte'
import type { DeployItem } from './sessionDeployModel'
@@ -95,7 +94,7 @@
{ws?.name ?? workspaceId}
</span>
{#if ws?.is_dev_workspace}
<Badge color="indigo" small>{devBadgeText(ws.dev_workspace_label)}</Badge>
<DevWorkspaceBadge label={ws.dev_workspace_label} color={ws.color} fallbackColor="indigo" />
{/if}
<ArrowRight class="w-3 h-3 shrink-0 text-tertiary" />
<span class="font-medium truncate" title={parentWs?.name ?? parentWorkspaceId}>
@@ -2,7 +2,7 @@
import ConfirmationModal from '$lib/components/common/confirmationModal/ConfirmationModal.svelte'
import Modal from '$lib/components/common/modal/Modal.svelte'
import Checkbox from '$lib/components/common/checkbox/Checkbox.svelte'
import { Badge, Button } from '$lib/components/common'
import { Button } from '$lib/components/common'
import {
Archive,
ArchiveRestore,
@@ -67,7 +67,7 @@
import { currentWorkspaceRootId, workspaceRootId } from './sessionScope.svelte'
import { page } from '$app/state'
import { base } from '$app/paths'
import { devBadgeText } from '$lib/utils/devWorkspaceLabel'
import DevWorkspaceBadge from '$lib/components/DevWorkspaceBadge.svelte'
import {
dateBucket,
GROUP_BY_OPTIONS,
@@ -1220,9 +1220,12 @@
{group.label}
</span>
{#if groupWs?.is_dev_workspace}
<Badge color="gray" small class="text-3xs px-1 py-0 shrink-0">
{devBadgeText(groupWs.dev_workspace_label)}
</Badge>
<DevWorkspaceBadge
label={groupWs.dev_workspace_label}
color={groupWs.color}
fallbackColor="gray"
class="text-3xs px-1 py-0 shrink-0"
/>
{/if}
{#if groupWsId}
<Button
@@ -32,8 +32,9 @@
import DropdownV2 from '$lib/components/DropdownV2.svelte'
import InputError from '$lib/components/InputError.svelte'
import PrefixedInput from '$lib/components/PrefixedInput.svelte'
import { Badge, Button, CopyButton } from '$lib/components/common'
import { devBadgeText, devLabelNoun } from '$lib/utils/devWorkspaceLabel'
import { Button, CopyButton } from '$lib/components/common'
import { devLabelNoun } from '$lib/utils/devWorkspaceLabel'
import DevWorkspaceBadge from '$lib/components/DevWorkspaceBadge.svelte'
import Select from '../select/Select.svelte'
import { Building, Check, GitFork, Plus, Settings } from 'lucide-svelte'
@@ -448,12 +449,12 @@
>{f.name}</span
>
{#if f.is_dev_workspace}
<Badge
color="dark-blue"
small
class="text-3xs px-1 py-0 dark:bg-surface-accent-primary text-white dark:text-white"
>{devBadgeText(f.dev_workspace_label)}</Badge
>
<DevWorkspaceBadge
label={f.dev_workspace_label}
color={f.color}
fallbackClass="dark:bg-surface-accent-primary text-white dark:text-white"
class="text-3xs px-1 py-0"
/>
{/if}
{#if isSelected(f.id)}
<Check size={14} class="shrink-0 ml-auto text-accent" />
@@ -18,7 +18,7 @@
import { Check, ChevronDown, ChevronRight, Plus, Settings } from 'lucide-svelte'
import { forkAccentStyle } from '$lib/utils/forkColor'
import { SvelteSet } from 'svelte/reactivity'
import { Badge, CopyButton } from '$lib/components/common'
import { CopyButton } from '$lib/components/common'
import { MenuItem, Tooltip } from '$lib/components/meltComponents'
import { EXECUTIONS_HINT } from './executionsHint'
import WorkspaceIcon from '$lib/components/workspace/WorkspaceIcon.svelte'
@@ -39,7 +39,7 @@
} from '$lib/utils/workspaceHierarchy'
import { canCreateFork } from '$lib/utils/editInFork'
import { workspaceRootId } from '$lib/components/sessions/sessionScope.svelte'
import { devBadgeText } from '$lib/utils/devWorkspaceLabel'
import DevWorkspaceBadge from '$lib/components/DevWorkspaceBadge.svelte'
interface Props {
// The melt item builder of the menu (or submenu) hosting this list. Melt
@@ -55,9 +55,19 @@
// workspaces must not navigate away from it. Separate from strictWorkspaceSelect,
// which only strips the sidebar-specific rows.
keepPageOnSwitch?: boolean
/** Opens with every family closed, forks included. For a picker whose job is to move between
* families rather than within one — a session's, where the fork is the session's own and
* not something the picker should offer to change. */
collapseFamilies?: boolean
}
let { item, strictWorkspaceSelect = false, keepPageOnSwitch = false, closeMenu }: Props = $props()
let {
item,
strictWorkspaceSelect = false,
keepPageOnSwitch = false,
collapseFamilies = false,
closeMenu
}: Props = $props()
async function toggleSwitchWorkspace(id: string) {
if ($workspaceStore === id) {
@@ -211,7 +221,7 @@
// the ones they are most likely to switch between — are there without a click, and the tick
// sits on the actual workspace rather than on its collapsed root. Other families stay closed.
untrack(() => {
if (lineageRoot && familiesWithForks.has(lineageRoot.id)) {
if (!collapseFamilies && lineageRoot && familiesWithForks.has(lineageRoot.id)) {
expandedFamilies.add(lineageRoot.id)
}
})
@@ -299,12 +309,12 @@
{workspace.name}{workspace.disabled ? ' (user disabled)' : ''}
</div>
{#if workspace.is_dev_workspace}
<Badge
color="dark-blue"
small
class="text-3xs px-1 py-0 dark:bg-surface-accent-primary text-white dark:text-white"
>{devBadgeText(workspace.dev_workspace_label)}</Badge
>
<DevWorkspaceBadge
label={workspace.dev_workspace_label}
color={workspace.color}
fallbackClass="dark:bg-surface-accent-primary text-white dark:text-white"
class="text-3xs px-1 py-0"
/>
{/if}
</div>
{#if ambiguousNames.has(workspace.name)}
@@ -12,7 +12,8 @@
import WorkspaceIcon from './WorkspaceIcon.svelte'
import WorkspaceCard from './WorkspaceCard.svelte'
import { twMerge } from 'tailwind-merge'
import { devBadgeText, devLabelWord } from '$lib/utils/devWorkspaceLabel'
import { devLabelWord } from '$lib/utils/devWorkspaceLabel'
import DevWorkspaceBadge from '$lib/components/DevWorkspaceBadge.svelte'
interface ExtendedWorkspace extends UserWorkspace {
_children?: ExtendedWorkspace[]
@@ -143,12 +144,12 @@
{/if}
</span>
{#if workspace.is_dev_workspace}
<Badge
color="dark-blue"
small
class="text-3xs px-1 py-0 dark:bg-surface-accent-primary text-white dark:text-white"
>{devBadgeText(workspace.dev_workspace_label)}</Badge
>
<DevWorkspaceBadge
label={workspace.dev_workspace_label}
color={workspace.color}
fallbackClass="dark:bg-surface-accent-primary text-white dark:text-white"
class="text-3xs px-1 py-0"
/>
{/if}
<span class="text-secondary text-xs">-</span>
{#if workspace.id === 'admins'}
+93 -4
View File
@@ -2,6 +2,17 @@
// blue's light/dark bg+text profile, re-hued. Lightness is fixed per role so
// any picked hue stays readable; saturation follows the user color within a
// safe band so grayish picks yield grayish chips.
//
// The badge filled with the hue is the exception, because white is written on
// it and a fixed lightness cannot carry white across the wheel: HSL lightness
// is not perceived brightness, so a yellow and a purple at the same L differ by
// several stops of luminance. Its lightness is solved instead — the lightest
// shade of the hue that still holds white at WCAG AA.
/** WCAG AA for small text: the badge's own label is what is being read. */
const TEXT_MIN_CONTRAST = 4.5
/** White, which the badge's label is written in. */
const WHITE_LUMINANCE = 1
function hexToHsl(hex: string): { h: number; s: number; l: number } | undefined {
const m = /^#?([0-9a-f]{3}|[0-9a-f]{6})$/i.exec(hex.trim())
@@ -32,10 +43,79 @@ function hsl(h: number, s: number, l: number): string {
return `hsl(${Math.round(h)}, ${Math.round(s * 100)}%, ${Math.round(l * 100)}%)`
}
/** The same string with lightness floored: a solved lightness is the lightest value that clears
* its contrast target, so rounding the whole percent up would spend the margin just bought. */
function hslFloorL(h: number, s: number, l: number): string {
return `hsl(${Math.round(h)}, ${Math.round(s * 100)}%, ${Math.floor(l * 100)}%)`
}
function hslToRgb(h: number, s: number, l: number): [number, number, number] {
const c = (1 - Math.abs(2 * l - 1)) * s
const hp = (((h % 360) + 360) % 360) / 60
const x = c * (1 - Math.abs((hp % 2) - 1))
const [r, g, b] =
hp < 1
? [c, x, 0]
: hp < 2
? [x, c, 0]
: hp < 3
? [0, c, x]
: hp < 4
? [0, x, c]
: hp < 5
? [x, 0, c]
: [c, 0, x]
const m = l - c / 2
return [r + m, g + m, b + m]
}
function relativeLuminance(r: number, g: number, b: number): number {
const f = (x: number) => (x <= 0.03928 ? x / 12.92 : ((x + 0.055) / 1.055) ** 2.4)
return 0.2126 * f(r) + 0.7152 * f(g) + 0.0722 * f(b)
}
function contrast(a: number, b: number): number {
return (Math.max(a, b) + 0.05) / (Math.min(a, b) + 0.05)
}
/** Luminance of what `hslFloorL` paints, not of the exact numbers: a percent of lightness is
* worth more than the margin being solved for. */
function paintedLuminance(h: number, s: number, l: number): number {
const [r, g, b] = hslToRgb(Math.round(h), Math.round(s * 100) / 100, Math.floor(l * 100) / 100)
return relativeLuminance(r, g, b)
}
/**
* Inline-style string setting the four `--fork-accent-*` custom properties a
* colored fork chip consumes (see WorkspaceScopeTrigger). Returns undefined
* for an unparsable color, letting the chip fall back to the default accent.
* The lightest L in [0, hi] whose color contrasts at least `target` against `against`. Luminance
* rises with L at a fixed hue and saturation, so the predicate flips once and a bisection finds
* the edge; 12 steps put it within 0.03 of a percent, finer than the whole percent the string
* carries.
*/
function lightestAtContrast(
h: number,
s: number,
hi: number,
against: number,
target: number
): number {
let lo = 0
let best = 0
for (let i = 0; i < 12; i++) {
const mid = (lo + hi) / 2
if (contrast(paintedLuminance(h, s, mid), against) >= target) {
best = mid
lo = mid
} else {
hi = mid
}
}
return best
}
/**
* Inline-style string setting the `--fork-accent-*` custom properties a colored
* fork chip consumes (see WorkspaceScopeTrigger). Returns undefined for an
* unparsable color, letting the chip fall back to the default accent.
*/
export function forkAccentStyle(color: string | undefined): string | undefined {
if (!color) return undefined
@@ -46,5 +126,14 @@ export function forkAccentStyle(color: string | undefined): string | undefined {
const lightText = hsl(h, clamp(s, 0.3, 0.8), 0.42)
const darkBg = hsl(h, clamp(s * 0.35, 0.08, 0.25), 0.26)
const darkText = hsl(h, clamp(s, 0.3, 0.9), 0.86)
return `--fork-accent-bg: ${lightBg}; --fork-accent-text: ${lightText}; --fork-accent-bg-dark: ${darkBg}; --fork-accent-text-dark: ${darkText};`
// The filled badge inside the chip: the hue at near-full strength, as light as it can be while
// white still reads on it. Same in both themes — it is the one thing on the line that is a
// block of color rather than a tint, so it needs no second version.
const badgeSat = clamp(s, 0.35, 0.9)
const badge = hslFloorL(
h,
badgeSat,
lightestAtContrast(h, badgeSat, 0.7, WHITE_LUMINANCE, TEXT_MIN_CONTRAST)
)
return `--fork-accent-bg: ${lightBg}; --fork-accent-text: ${lightText}; --fork-accent-badge: ${badge}; --fork-accent-bg-dark: ${darkBg}; --fork-accent-text-dark: ${darkText};`
}