Let a dayTintProvider send its own colour

Four tones cannot keep a twenty-stop trip's legs apart, which is most of
the point of colour-coding one. A contribution can now carry `#rrggbb`
alongside `tone`, per region or as a shorthand.

The split of control is: the plugin picks the hue, the host picks the
weight. A colour rides the same per-theme, per-region alphas as a tone,
and its OKLCH lightness is clamped into a per-theme band first, so no
contribution can hand a user a day card they can't read. The band
brackets the built-in tones rather than tightening around them, so an
ordinary colour passes through untouched and a hex equal to a tone
renders exactly like that tone. Hue and chroma are never touched - it
bounds a colour, it doesn't boost one, so a near-white grey still lands
as a faint tint.

Only `#rrggbb` survives normalization, and that is a security boundary
rather than tidying: the value ends up inside a `color-mix()` in an
inline `background`, a shorthand that takes a layer list, so a string
closing the paren early could append a `url(...)` and beacon everyone
viewing the trip.

A region gets exactly one paint instruction - what it names beats the
shorthand, a colour beats a tone at the same level - so the client never
has a tie to break. A region naming a colour the host can't use degrades
to `default` like a bogus tone already did.
This commit is contained in:
Renzo Beux
2026-07-30 15:37:20 -03:00
committed by Maurice
parent c89d2bf3a1
commit f718240ffc
12 changed files with 314 additions and 57 deletions
@@ -34,7 +34,7 @@ import { useDayNotes } from '../../hooks/useDayNotes'
import { useExchangeRates } from '../../hooks/useExchangeRates'
import { RES_ICONS, getNoteIcon } from './DayPlanSidebar.constants'
import { RouteConnector, HotelRouteConnector } from './DayPlanSidebarRouteConnector'
import { usePluginDaySchedule, usePluginDayTints, dayTintBackground, PluginDayScheduleRow, formatScheduleMinutes } from '../Plugins/PluginDaySchedule'
import { usePluginDaySchedule, usePluginDayTints, dayTintBackground, dayTinted, PluginDayScheduleRow, formatScheduleMinutes } from '../Plugins/PluginDaySchedule'
import { MobileAddPlaceButton } from './DayPlanSidebarMobileAddPlaceButton'
import { DayPlanSidebarToolbar } from './DayPlanSidebarToolbar'
import { DayPlanSidebarNoteModal } from './DayPlanSidebarNoteModal'
@@ -1324,8 +1324,8 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP
// Resolved once per day: the header owns a background that its hover
// handlers reassign imperatively, so both the base and the hover value have
// to be tint-aware or the first hover-out would wipe the colour.
const headerTintBg = dayTintBackground(dayTint?.headerTone, '--day-tint-header') ?? 'transparent'
const headerTintHoverBg = dayTintBackground(dayTint?.headerTone, '--day-tint-header-hover') ?? 'var(--bg-tertiary)'
const headerTintBg = dayTintBackground(dayTint, 'header', '--day-tint-header') ?? 'transparent'
const headerTintHoverBg = dayTintBackground(dayTint, 'header', '--day-tint-header-hover') ?? 'var(--bg-tertiary)'
return (
// The card wrapper stays untinted — its three regions (badge, header,
@@ -1375,8 +1375,8 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP
// --bg-hover so it stays the same pill component, and takes
// --text-secondary: the number is 11px bold, so 4.5:1 applies
// and --text-muted is already borderline on the untinted pill.
background: isSelected ? 'var(--accent)' : (dayTintBackground(dayTint?.badgeTone, '--day-tint-badge', 'var(--bg-hover)') ?? 'var(--bg-hover)'),
color: isSelected ? 'var(--accent-text)' : (dayTint?.badgeTone ? 'var(--text-secondary)' : 'var(--text-muted)'),
background: isSelected ? 'var(--accent)' : (dayTintBackground(dayTint, 'badge', '--day-tint-badge', 'var(--bg-hover)') ?? 'var(--bg-hover)'),
color: isSelected ? 'var(--accent-text)' : (dayTinted(dayTint, 'badge') ? 'var(--text-secondary)' : 'var(--text-muted)'),
display: 'flex', flexDirection: 'column', alignItems: 'center', overflow: 'hidden',
}}>
<div style={{ width: '100%', height: 26, display: 'flex', alignItems: 'center', justifyContent: 'center', fontSize: 'calc(11px * var(--fs-scale-caption, 1))', fontWeight: 700 }}>
@@ -1500,7 +1500,7 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP
<div
// The activity list — the largest region and the one behind the
// densest text, so its tint is the faintest of the three.
style={{ background: dayTintBackground(dayTint?.activityTone, '--day-tint-activity', 'var(--bg-hover)') ?? 'var(--bg-hover)', paddingTop: 6 }}
style={{ background: dayTintBackground(dayTint, 'activity', '--day-tint-activity', 'var(--bg-hover)') ?? 'var(--bg-hover)', paddingTop: 6 }}
onDragOver={e => { e.preventDefault(); const cur = dropTargetRef.current; if (draggingId && (!cur || cur.startsWith('end-'))) setDropTargetKey(`end-${day.id}`) }}
onDrop={e => {
e.preventDefault()
@@ -10,7 +10,7 @@ vi.mock('../../api/client', () => ({
pluginsApi: { daySchedule: (tripId: number | string) => daySchedule(tripId) },
}))
import { usePluginDaySchedule, formatScheduleMinutes, PluginDayScheduleRow } from './PluginDaySchedule'
import { usePluginDaySchedule, formatScheduleMinutes, PluginDayScheduleRow, dayTintBackground, dayTinted } from './PluginDaySchedule'
function item(overrides: Partial<PluginDayScheduleItem> = {}): PluginDayScheduleItem {
return { pluginId: 'ev', id: 'i1', dayId: 1, label: 'Charging', tone: 'default', ...overrides }
@@ -138,6 +138,47 @@ describe('usePluginDaySchedule', () => {
})
})
describe('dayTintBackground', () => {
it('FE-W4PDS-015: mixes a tone at the region\'s own alpha, over the caller\'s base', () => {
expect(dayTintBackground({ headerTone: 'warn' }, 'header', '--day-tint-header'))
.toBe('color-mix(in srgb, #f59e0b var(--day-tint-header), transparent)')
expect(dayTintBackground({ badgeTone: 'danger' }, 'badge', '--day-tint-badge', 'var(--bg-hover)'))
.toBe('color-mix(in srgb, #ef4444 var(--day-tint-badge), var(--bg-hover))')
})
it('FE-W4PDS-016: clamps a plugin\'s own colour into the theme\'s lightness band', () => {
// The plugin owns the hue and the chroma (`c h` pass through); only `l` is bounded,
// so no colour it sends can wash out on the light sidebar or vanish on the dark one.
expect(dayTintBackground({ badgeColor: '#00ff00' }, 'badge', '--day-tint-badge'))
.toBe('color-mix(in srgb, oklch(from #00ff00 clamp(var(--day-tint-l-min), l, var(--day-tint-l-max)) c h) var(--day-tint-badge), transparent)')
})
it('FE-W4PDS-017: paints a region\'s colour over its tone', () => {
// The server never sends both, but the precedence has to be unambiguous here too.
const bg = dayTintBackground({ badgeTone: 'danger', badgeColor: '#123456' }, 'badge', '--a')
expect(bg).toContain('#123456')
expect(bg).not.toContain('#ef4444')
})
it('FE-W4PDS-018: leaves an unpainted region alone so the caller keeps its own look', () => {
expect(dayTintBackground(undefined, 'header', '--day-tint-header')).toBeUndefined()
// Named elsewhere on the card, but not here — this region must stay untouched.
expect(dayTintBackground({ badgeTone: 'success' }, 'activity', '--day-tint-activity')).toBeUndefined()
})
it('FE-W4PDS-019: falls back to the default hex for an unknown tone', () => {
expect(dayTintBackground({ headerTone: 'nope' as 'default' }, 'header', '--a'))
.toBe('color-mix(in srgb, #4F46E5 var(--a), transparent)')
})
it('FE-W4PDS-020: dayTinted reports either channel, per region', () => {
expect(dayTinted({ badgeTone: 'success' }, 'badge')).toBe(true)
expect(dayTinted({ badgeColor: '#123456' }, 'badge')).toBe(true)
expect(dayTinted({ badgeColor: '#123456' }, 'header')).toBe(false)
expect(dayTinted(undefined, 'badge')).toBe(false)
})
})
describe('PluginDayScheduleRow', () => {
it('FE-W4PDS-012: renders the duration, separator and label', () => {
render(<PluginDayScheduleRow item={item({ minutes: 95, label: 'Charging stop' })} />)
@@ -1,6 +1,6 @@
import { useEffect, useMemo, useState } from 'react'
import { Zap } from 'lucide-react'
import { pluginsApi, type PluginDayScheduleItem, type PluginDayTint, type PluginDayTintTone } from '../../api/client'
import { pluginsApi, type PluginDayScheduleItem, type PluginDayTint } from '../../api/client'
/**
* Host-rendered rows for the `dayScheduleProvider` plugin hook — time
@@ -62,8 +62,12 @@ export function usePluginDaySchedule(tripId?: number | string | null): PluginDay
}, [items])
}
/** dayId → the per-region tones (and optional tooltip) for that day's card. */
export type PluginDayTints = Record<number, Omit<PluginDayTint, 'pluginId' | 'dayId'>>
/** dayId → the per-region paint (and optional tooltip) for that day's card. */
export type PluginDayTintRegions = Omit<PluginDayTint, 'pluginId' | 'dayId'>
export type PluginDayTints = Record<number, PluginDayTintRegions>
/** The three separately tintable regions of a day card. */
export type PluginDayTintRegion = 'badge' | 'header' | 'activity'
const EMPTY_TINTS: PluginDayTints = {}
@@ -100,20 +104,43 @@ export function usePluginDayTints(tripId?: number | string | null): PluginDayTin
}, [tints])
}
/** The `color-mix` background for one tinted region, or undefined when that region has
* no tone — in which case the caller keeps whatever it renders without plugins.
/** True when a plugin paints this region at all — the callers that change more than a
* background (the badge switches its text colour) need this without rebuilding the
* background string. */
export function dayTinted(tint: PluginDayTintRegions | undefined, region: PluginDayTintRegion): boolean {
return Boolean(tint?.[`${region}Tone`] || tint?.[`${region}Color`])
}
/** A plugin's own colour, pulled into the lightness band the current theme can render.
* The plugin owns the hue and the chroma — only `l` is touched, and only when it falls
* outside the band, so an ordinary colour comes through exactly as sent.
*
* Without this, "any hex" means any plugin can paint a day nobody can read: near-white
* washes out against the light sidebar, near-black turns the dark one to mud. The band
* is a CSS variable rather than a constant here because the answer is per theme. */
const clampLightness = (color: string) =>
`oklch(from ${color} clamp(var(--day-tint-l-min), l, var(--day-tint-l-max)) c h)`
/** The `color-mix` background for one tinted region, or undefined when no plugin paints
* it — in which case the caller keeps whatever it renders without plugins.
*
* `alphaVar` names a CSS custom property holding the strength, which varies per theme
* AND per region: the tones are fixed hexes, so one alpha cannot serve both themes
* (#4F46E5 is itself a dark colour and vanishes on a dark surface at a light theme's
* alpha), and a large region behind dense text needs a fainter tint than a small badge. */
* alpha), and a large region behind dense text needs a fainter tint than a small badge.
* A plugin's own colour rides the same alphas — it chooses the hue, not the weight. */
export function dayTintBackground(
tone: PluginDayTintTone | undefined,
tint: PluginDayTintRegions | undefined,
region: PluginDayTintRegion,
alphaVar: string,
base = 'transparent',
): string | undefined {
if (!tone) return undefined
return `color-mix(in srgb, ${TONE_COLORS[tone] ?? TONE_COLORS.default} var(${alphaVar}), ${base})`
const color = tint?.[`${region}Color`]
const tone = tint?.[`${region}Tone`]
// Server-resolved: a region carries a colour or a tone, never both.
const paint = color ? clampLightness(color) : tone ? (TONE_COLORS[tone] ?? TONE_COLORS.default) : undefined
if (!paint) return undefined
return `color-mix(in srgb, ${paint} var(${alphaVar}), ${base})`
}
export function formatScheduleMinutes(minutes: number): string {