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
+11 -2
View File
@@ -691,14 +691,23 @@ export interface PluginDayScheduleItem {
* visible while scrolling the itinerary. The card has three separately tintable
* regions; an absent one is not tinted and renders exactly as it does with no plugin.
* Server-normalized: dayIds checked against the trip, one contribution per day (first
* granted provider wins), the `tone` shorthand already resolved into the regions,
* labels sanitized + capped. Tone only — the client picks the alpha per theme. */
* granted provider wins), the `tone` / `color` shorthands already resolved into the
* regions, labels sanitized + capped.
*
* A region carries EITHER a tone from the fixed palette or the plugin's own colour,
* never both — the server picked the winner. `*Color` is guaranteed `#rrggbb` (nothing
* else survives normalization, because it lands inside a CSS value); the client still
* owns how strongly it renders — alpha per theme and per region, lightness clamped
* into a readable band. */
export type PluginDayTintTone = 'default' | 'success' | 'warn' | 'danger'
export interface PluginDayTint {
pluginId: string; dayId: number;
badgeTone?: PluginDayTintTone;
badgeColor?: string;
headerTone?: PluginDayTintTone;
headerColor?: string;
activityTone?: PluginDayTintTone;
activityColor?: string;
label?: string;
}
@@ -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 {
+19
View File
@@ -541,6 +541,19 @@ input[type="number"], input[type="time"], input[type="date"], input[type="dateti
--day-tint-header-hover: 14%;
--day-tint-activity: 6%;
/* The OKLCH lightness band a plugin's OWN colour is clamped into before it is mixed,
so "any hex" cannot mean "a day nobody can read". The band brackets the four tones
above (L 0.51–0.77) rather than tightening around them: an ordinary choice passes
through untouched, and a hex equal to a tone renders exactly like that tone. Only
the extremes move — here, a near-white that would otherwise wash out against the
sidebar's own near-white surfaces.
This bounds a colour, it does not boost one: hue and chroma are never touched, so a
near-white GREY still lands as a faint tint. That is the intended trade — the
failure this must prevent is an unreadable card, not an understated one. */
--day-tint-l-min: 0.40;
--day-tint-l-max: 0.78;
--fs-scale-title: 1;
--fs-scale-subtitle: 1;
--fs-scale-body: 1;
@@ -637,6 +650,12 @@ input[type="number"], input[type="time"], input[type="date"], input[type="dateti
--day-tint-header-hover: 20%;
--day-tint-activity: 10%;
/* Shifted up, and the reason the band is per theme at all: here the failure is a
near-BLACK tint disappearing into the dark sidebar, while a pale one reads fine.
Inherited by the mobile chips too — mobile.css sets only their alpha. */
--day-tint-l-min: 0.48;
--day-tint-l-max: 0.88;
--success: #22c55e; --success-soft: rgba(34, 197, 94, 0.15);
--danger: #ef4444; --danger-soft: rgba(239, 68, 68, 0.15);
--warning: #f59e0b; --warning-soft: rgba(245, 158, 11, 0.15);
@@ -316,7 +316,7 @@ export default function MTripShell({
// The chip is mobile's day-number badge, so it follows badgeTone.
// Inactive chips only — an inline background would otherwise beat
// the active chip's bg-m-act class.
style={active ? undefined : { background: dayTintBackground(tint?.badgeTone, '--day-tint-chip') }}
style={active ? undefined : { background: dayTintBackground(tint, 'badge', '--day-tint-chip') }}
className={`flex-1 whitespace-nowrap rounded-full px-3 py-[5px] text-center text-[0.75rem] font-semibold ${
active ? 'bg-m-act text-m-actfg shadow-[0_6px_16px_-6px_rgba(0,0,0,.4)]' : 'text-m-ink'
}`}