mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-20 05:56:53 +00:00
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:
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user