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'
}`}
+22 -7
View File
@@ -587,24 +587,39 @@ export interface DayScheduleProvider {
/** A colour the host paints into one day card in the Plan sidebar (and into that day's
* mobile chip) — so a trip split into legs shows its leg membership while you scroll
* the itinerary. Tone only, never a hex: the host picks the alpha per theme AND per
* region, so the tint stays subtle and readable in both light and dark.
* the itinerary.
*
* The card has three separately tintable regions. Set `tone` to colour all of them,
* or name regions individually for finer control — e.g. a bold badge marking the leg
* with the activity list left plain, so a dense day stays easy to read. */
* Pick a `tone` from the shared palette, or send your own `#rrggbb` when four tones
* cannot keep your legs apart — a twenty-stop trip needs twenty colours. Either way
* you choose the hue and the host chooses the weight: it sets the alpha per theme and
* per region and clamps a colour's lightness into a band that reads on both the light
* and the dark sidebar. So the tint always lands as a wash behind the day, never as a
* fill, and no colour you send can make a day unreadable. Anything that is not exactly
* six hex digits is ignored (`#RGB` shorthand and CSS names included).
*
* The card has three separately tintable regions. Set `tone` / `color` to paint all of
* them, or name regions individually for finer control — e.g. a bold badge marking the
* leg with the activity list left plain, so a dense day stays easy to read.
*
* Colour is decoration, not information: pair it with `label` (and with a
* dayScheduleProvider row where it matters) so the meaning survives for anyone who
* cannot tell your legs apart by hue. */
export interface DayTintContribution {
dayId: number; // must be a day of the requested trip
/** Shorthand: tints every region you do NOT name below. Omit it and only the
* regions you name are tinted. */
/** Shorthands: paint every region you do NOT name below. Omit both and only the
* regions you name are tinted. `color` wins over `tone` at the same level. */
tone?: ContributionTone;
color?: string; // your own colour, `#rrggbb` only
/** The day-number badge — the smallest, boldest mark. Also tints the mobile day chip. */
badgeTone?: ContributionTone;
badgeColor?: string;
/** The day header row (number, title, date, cost). Its hover state deepens the tint. */
headerTone?: ContributionTone;
headerColor?: string;
/** The expanded activity list — places, bookings and transport for the day. The
* largest surface and the one behind the densest text, so it is tinted faintest. */
activityTone?: ContributionTone;
activityColor?: string;
label?: string; // optional tooltip on the day (≤60 chars)
}
export interface DayTintProvider {
+80 -20
View File
@@ -14,8 +14,14 @@ import { stripEmoji } from './text-sanitize';
*
* The card exposes three separately tintable regions — the day-number badge, the
* header row, and the expanded activity list — so a plugin can mark the leg boldly on
* the badge while leaving the dense activity list plain. `tone` is the shorthand that
* fills every region a contribution does not name.
* the badge while leaving the dense activity list plain. `tone` / `color` are the
* shorthands that fill every region a contribution does not name.
*
* A region is painted by a tone from the fixed palette or by the plugin's own
* `#rrggbb` — four tones cannot keep a twenty-leg trip's days apart. The split of
* control is: the plugin picks the hue, the host picks how strongly it lands (alpha
* per theme and per region) and clamps it into a readable lightness band, so no
* contribution can produce an unreadable card in either theme.
*
* Why this is its own hook rather than one dayScheduleProvider item per day: that
* hook is capped at MAX_ITEMS = 60 per provider, so a six-month trip would tint its
@@ -32,11 +38,16 @@ type Tone = 'default' | 'success' | 'warn' | 'danger';
export interface DayTint {
pluginId: string;
dayId: number;
/** Per-region tones, already resolved against the contribution's `tone` shorthand.
* An absent region is not tinted and renders exactly as it does without plugins. */
/** Per-region paint, already resolved against the contribution's shorthands. Each
* region carries EITHER a tone or a `#rrggbb` colour, never both — one region, one
* paint instruction, so the client never has to break a tie. An absent region is not
* tinted and renders exactly as it does without plugins. */
badgeTone?: Tone;
badgeColor?: string;
headerTone?: Tone;
headerColor?: string;
activityTone?: Tone;
activityColor?: string;
/** Optional tooltip on the tinted day ("Kanazawa leg"). */
label?: string;
}
@@ -44,6 +55,19 @@ export interface DayTint {
const TONES: ReadonlySet<string> = new Set(['default', 'success', 'warn', 'danger']);
const cap = (v: unknown, n: number): string => stripEmoji(String(v ?? '')).slice(0, n);
/**
* A plugin's own colour, or undefined for anything that is not exactly six hex digits.
*
* This is a security boundary, not input tidying. The client interpolates the value
* into a `color-mix()` inside an inline `background`, and `background` is a shorthand
* that takes a comma-separated layer list — a string closing the `color-mix(` paren
* early could append a `url(...)` layer and turn everyone viewing the trip into a
* beacon for the plugin's own server. Nothing but `#rrggbb` gets through.
*/
const HEX = /^#[0-9a-fA-F]{6}$/;
const hex = (v: unknown): string | undefined =>
typeof v === 'string' && HEX.test(v) ? v.toLowerCase() : undefined;
// Bound the work, not just the output: an all-invalid raw array (no entry ever
// reaching `out`) would otherwise be iterated in full. Slice up front, well above
// any legitimate payload — a trip has one tint per day, and 2000 days is 5½ years.
@@ -59,6 +83,39 @@ const MAX_RAW_TINTS = 2000;
const region = (v: unknown): Tone | undefined =>
v === undefined || v === null ? undefined : (TONES.has(v as string) ? (v as Tone) : 'default');
const named = (v: unknown): boolean => v !== undefined && v !== null;
/** One region of the card, resolved whole — a colour, a tone, or nothing. */
interface RegionTint { tone?: Tone; color?: string }
/**
* Resolve one region against the contribution's `tone` / `color` shorthands, in one
* order: what the region names beats the shorthand, and a colour beats a tone at the
* same level. A colour wins because it is the more specific request — and emitting
* only the winner keeps a tie out of the payload for the client to break.
*
* A region that names a colour the host cannot use (`badgeColor: 'chartreuse'`) and
* nothing else degrades to `default` rather than letting the shorthand answer for it —
* exactly what a bogus `badgeTone` already does. The plugin overrode the shorthand on
* purpose here; it just sent junk, and a silently untinted region is the harder
* failure for a plugin author to notice.
*/
function resolveRegion(
rawTone: unknown,
rawColor: unknown,
toneShorthand: Tone | undefined,
colorShorthand: string | undefined,
): RegionTint {
const color = named(rawColor) ? hex(rawColor) : undefined;
if (color) return { color };
const tone = region(rawTone);
if (tone) return { tone };
if (named(rawColor)) return { tone: 'default' };
if (colorShorthand) return { color: colorShorthand };
if (toneShorthand) return { tone: toneShorthand };
return {};
}
function normalize(pluginId: string, tripDayIds: ReadonlySet<number>, raw: unknown): DayTint[] {
const list = Array.isArray(raw) ? (raw as Array<Record<string, unknown>>).slice(0, MAX_RAW_TINTS) : [];
const out: DayTint[] = [];
@@ -72,22 +129,25 @@ function normalize(pluginId: string, tripDayIds: ReadonlySet<number>, raw: unkno
if (!Number.isInteger(dayId) || !tripDayIds.has(dayId) || seen.has(dayId)) continue;
seen.add(dayId);
const label = cap(it.label, 60);
const shorthand = region(it.tone);
let badgeTone = region(it.badgeTone) ?? shorthand;
let headerTone = region(it.headerTone) ?? shorthand;
let activityTone = region(it.activityTone) ?? shorthand;
// An entry that names no region and carries no shorthand still means "tint this
// day" — the dayId is the payload, the colour is decoration. Give it the default
// tone everywhere rather than returning a contribution that paints nothing.
if (!badgeTone && !headerTone && !activityTone) badgeTone = headerTone = activityTone = 'default';
out.push({
pluginId,
dayId,
...(badgeTone ? { badgeTone } : {}),
...(headerTone ? { headerTone } : {}),
...(activityTone ? { activityTone } : {}),
...(label ? { label } : {}),
});
const shTone = region(it.tone);
const shColor = hex(it.color);
const badge = resolveRegion(it.badgeTone, it.badgeColor, shTone, shColor);
const header = resolveRegion(it.headerTone, it.headerColor, shTone, shColor);
const activity = resolveRegion(it.activityTone, it.activityColor, shTone, shColor);
// An entry that names no region and carries no usable shorthand still means "tint
// this day" — the dayId is the payload, the colour is decoration. Give it the
// default tone everywhere rather than returning a contribution that paints nothing.
const painted = [badge, header, activity].filter((r) => r.tone || r.color);
if (painted.length === 0) badge.tone = header.tone = activity.tone = 'default';
const tint: DayTint = { pluginId, dayId };
if (badge.tone) tint.badgeTone = badge.tone;
if (badge.color) tint.badgeColor = badge.color;
if (header.tone) tint.headerTone = header.tone;
if (header.color) tint.headerColor = header.color;
if (activity.tone) tint.activityTone = activity.tone;
if (activity.color) tint.activityColor = activity.color;
if (label) tint.label = label;
out.push(tint);
}
return out;
}
@@ -90,6 +90,63 @@ describe('DayTintsController', () => {
expect(out[1]).toEqual({ pluginId: 'p1', dayId: 11, headerTone: 'default' });
});
it('spreads a `color` shorthand across all three regions, lower-cased', async () => {
const { c } = controller(() => [{ dayId: 10, color: '#A1B2C3' }]);
const out = (await c.get('1', req(5))).tints;
expect(out[0]).toMatchObject({ badgeColor: '#a1b2c3', headerColor: '#a1b2c3', activityColor: '#a1b2c3' });
});
it('gives a region exactly one paint — a colour beats a tone at the same level', async () => {
// Both set on the badge: the colour is the more specific request, and the tone
// must not ride along, or the client has a tie to break.
const { c } = controller(() => [{ dayId: 10, badgeTone: 'danger', badgeColor: '#00ff00' }]);
const out = (await c.get('1', req(5))).tints;
expect(out[0]).toEqual({ pluginId: 'p1', dayId: 10, badgeColor: '#00ff00' });
expect(out[0].badgeTone).toBeUndefined();
});
it('lets a named region override the shorthand across channels, both ways', async () => {
const { c } = controller(() => [
{ dayId: 10, color: '#112233', badgeTone: 'danger' }, // tone overrides a colour shorthand
{ dayId: 11, tone: 'warn', badgeColor: '#112233' }, // colour overrides a tone shorthand
]);
const out = (await c.get('1', req(5))).tints;
expect(out[0]).toMatchObject({ badgeTone: 'danger', headerColor: '#112233', activityColor: '#112233' });
expect(out[0].badgeColor).toBeUndefined();
expect(out[1]).toMatchObject({ badgeColor: '#112233', headerTone: 'warn', activityTone: 'warn' });
});
it('accepts ONLY #rrggbb — a CSS colour cannot be smuggled in as a string', async () => {
// The value ends up inside a `color-mix()` in an inline `background`, which takes a
// layer list: a string that closes the paren early could append a url() layer and
// beacon every viewer of the trip. None of these may reach the client as a colour.
const evil = 'red 50%, transparent), url(https://evil.example/x.png';
const { c } = controller(() => [
{ dayId: 10, badgeColor: evil, headerColor: 'rebeccapurple', activityColor: '#abc' },
{ dayId: 11, color: 'rgb(1,2,3)', headerColor: '#12345', activityColor: '#1234567' },
]);
const out = (await c.get('1', req(5))).tints;
expect(JSON.stringify(out)).not.toContain('evil.example');
for (const t of out) {
expect(t.badgeColor).toBeUndefined();
expect(t.headerColor).toBeUndefined();
expect(t.activityColor).toBeUndefined();
}
// Rejected, but the regions still asked to be tinted — they degrade like a bogus
// tone does rather than silently rendering plain.
expect(out[0]).toMatchObject({ badgeTone: 'default', headerTone: 'default', activityTone: 'default' });
// A junk shorthand paints nothing, so the badge — which named neither channel —
// stays untinted, while the two regions that did name one degrade to `default`.
expect(out[1]).toMatchObject({ headerTone: 'default', activityTone: 'default' });
expect(out[1].badgeTone).toBeUndefined();
});
it('keeps a valid sibling when only one channel of a region is junk', async () => {
const { c } = controller(() => [{ dayId: 10, badgeColor: 'chartreuse', badgeTone: 'success' }]);
const out = (await c.get('1', req(5))).tints;
expect(out[0]).toEqual({ pluginId: 'p1', dayId: 10, badgeTone: 'success' });
});
it('treats an entry with no tone at all as "tint this day", default everywhere', async () => {
const { c } = controller(() => [{ dayId: 10 }, { dayId: 11, tone: undefined }]);
const out = (await c.get('1', req(5))).tints;
+38 -9
View File
@@ -485,7 +485,8 @@ dayScheduleProvider: {
`hook:day-schedule-provider` can announce "Kanazawa begins" as a row, but it cannot make
day 12 *look* like it belongs to Kanazawa. `hook:day-tint-provider` does — it colours a
day card in the Plan sidebar (and that day's mobile chip) with one of the four tones:
day card in the Plan sidebar (and that day's mobile chip), with one of the four tones or
with a colour of your own:
```js
dayTintProvider: {
@@ -504,6 +505,35 @@ Return **one entry per day** — the bound here is the trip's day count, not a f
cap, so this stays correct on a six-month itinerary where the ≤60-item day-schedule hook
would tint only the first 60 days.
#### Bring your own colour
Four tones cannot keep a twenty-stop trip's legs apart. Send `color` instead — `#rrggbb`
only, and nothing else survives (`#abc`, `rgb(...)` and CSS names are all ignored,
because the value ends up inside a CSS colour the host builds):
```js
// One hue per leg, from your own palette.
return days.map((d) => ({ dayId: d.id, color: legColor(d), label: legName(d) }))
// Mix freely: your colour on the badge, a shared tone for the rest of the card.
return days.map((d) => ({ dayId: d.id, tone: 'default', badgeColor: legColor(d) }))
```
Each region takes `<region>Color` alongside `<region>Tone`. Within one region a colour
wins over a tone, and anything you name on a region beats the shorthand — so the second
example above tints the badge with your colour and the header and activity list with
`default`.
You choose the hue; the host still chooses the weight. It sets the alpha per theme and
per region and clamps the colour's lightness into a band that reads on both the light
and the dark sidebar, so the tint always lands as a wash behind the day rather than a
fill, and no colour you send can make a day unreadable. An ordinary mid-range colour
comes through as you sent it; only the extremes (near-white, near-black) are pulled in.
Colour is decoration, not information — pair it with `label` (and a day-schedule row
where the meaning matters) so it still works for anyone who can't tell your legs apart
by hue.
#### Tint one region instead of the whole card
A day card has three separately tintable regions, and `tone` above is just the shorthand
@@ -528,17 +558,16 @@ return days.map((d) => ({ dayId: d.id, headerTone: legTone(d), activityTone: ove
| Field | Region |
|---|---|
| `badgeTone` | the day-number badge — smallest and boldest; also the mobile day chip |
| `headerTone` | the day header row (number, title, date, cost); hovering deepens the tint |
| `activityTone` | the expanded activity list — largest, behind the densest text, tinted faintest |
| `badgeTone` / `badgeColor` | the day-number badge — smallest and boldest; also the mobile day chip |
| `headerTone` / `headerColor` | the day header row (number, title, date, cost); hovering deepens the tint |
| `activityTone` / `activityColor` | the expanded activity list — largest, behind the densest text, tinted faintest |
A region you never name is not tinted and renders exactly as it does without your plugin.
Give the host a tone, never a hex: it picks the alpha per theme *and* per region, so the
tint stays subtle in both light and dark. `label` becomes the day's tooltip. A day takes
at most one contribution and it is resolved **whole** — within your list the first entry
for a day wins, and if another plugin also tints that day the first granted provider
wins outright, so days never flicker and two plugins can't each own part of one card.
`label` becomes the day's tooltip. A day takes at most one contribution and it is
resolved **whole** — within your list the first entry for a day wins, and if another
plugin also tints that day the first granted provider wins outright, so days never
flicker and two plugins can't each own part of one card.
---
+1 -1
View File
@@ -668,7 +668,7 @@ module.exports = definePlugin({
| `mapLayerProvider.getLayers(tripId, ctx)` → `MapLayerContribution[]` | `hook:map-layer-provider` | **live** — bounded vector overlays on the trip map: a computed route, a reachable-range corridor, a zone. Each layer is `{id, name?, features}` with features `{type: 'polyline'\|'polygon'\|'circle', points?/center?+radiusM?, tone?, width?, dash?, opacity?, fill?, label?}`. Styling stays in the tone palette; width (1–8), opacity (0.05–1) and radius (≤2000 km) are clamped, `dash` is an enum. Budgets per plugin: ≤4 layers, ≤150 features, ≤8000 vertices, ≤2000 vertices/shape — an oversized or partly-invalid shape is dropped whole, never truncated. Declarative only; drawn beneath TREK's own day route on both the Leaflet and GL renderers. Also `GET /api/map-layers/:tripId` |
| `routeProvider.getRoute(request, ctx)` → `RouteProviderResult` | `hook:route-provider` | **live** — routes a day's stops under one of the plugin's declared `capabilities.routeProfiles` (an EV profile with charging stops, a scenic profile…). `request` is `{tripId, dayId, profile, waypoints}` (2–30 located stops in visit order); the result is `{coordinates, distance, duration, legs, viaPoints?}` and is validated **whole**: ≤10000 vertices, `legs` must be exactly `waypoints−1` entries (each `{distance, duration, note?}` — `note` ≤120 chars shows on the sidebar connector), ≤40 via points (`{lat, lng, label?, tone?, dwellSeconds?}` drawn as stops on the route line). A malformed result is discarded and the planner falls back to straight lines. **Targeted, not a fan-out**: TREK calls exactly the provider whose profile the user picked in the route toggle, with a 20 s timeout (room to call an external solver through your declared egress). `POST /api/plugin-routes/:pluginId/:profileId` |
| `dayScheduleProvider.getSchedule(tripId, ctx)` → `DayScheduleContribution[]` | `hook:day-schedule-provider` | **live** — time contributions rendered into the day plan on desktop and mobile: "35 min charging at this stop", "45 min security before this flight". Each is `{id, dayId, assignmentId?/reservationId?/position?, minutes?, label, tone?}` — anchored under a place/booking row or at a day's start/end (default end). `dayId` must belong to the trip (checked server-side), `minutes` (1–1440) is shown on the row **and folded into the day's route-footer total**, `label` ≤120 chars, ≤60 items/plugin. Also `GET /api/day-schedule/:tripId` |
| `dayTintProvider.getDayTints(tripId, ctx)` → `DayTintContribution[]` | `hook:day-tint-provider` | **live** — colours painted into a day card in the Plan sidebar (and into that day's mobile chip), so a trip split into legs shows its leg membership while you scroll. Each is `{dayId, tone?, badgeTone?, headerTone?, activityTone?, label?}`. The card has **three separately tintable regions** — the day-number badge (also the mobile chip), the header row, and the expanded activity list — so a plugin can mark the leg boldly on the badge while leaving the dense activity list plain; `tone` is the shorthand that fills every region you do not name, and an unnamed region renders exactly as it does with no plugin. Tone only, never a hex: the host picks the alpha per theme **and** per region, so the tint stays subtle in light and dark and a tinted header hovers to a deeper mix of its own tone. `dayId` must belong to the trip (checked server-side), `label` ≤ 60 chars becomes the day's tooltip, raw array sliced at 2000. **A day takes at most one contribution, resolved whole**: within your own list the first entry for a day wins, and across plugins the first granted provider wins — so a day never flickers between colours, and a losing plugin cannot fill in the winner's untinted regions. Bounded by the trip's day count rather than a fixed item cap, which is why this is not `dayScheduleProvider` (≤60 items would tint only the first 60 days of a long trip). Also `GET /api/day-tints/:tripId` |
| `dayTintProvider.getDayTints(tripId, ctx)` → `DayTintContribution[]` | `hook:day-tint-provider` | **live** — colours painted into a day card in the Plan sidebar (and into that day's mobile chip), so a trip split into legs shows its leg membership while you scroll. Each is `{dayId, tone?, color?, badgeTone?, badgeColor?, headerTone?, headerColor?, activityTone?, activityColor?, label?}`. The card has **three separately tintable regions** — the day-number badge (also the mobile chip), the header row, and the expanded activity list — so a plugin can mark the leg boldly on the badge while leaving the dense activity list plain; `tone` / `color` are the shorthands that fill every region you do not name, and an unnamed region renders exactly as it does with no plugin. Paint a region with a palette tone or with your own `#rrggbb` (nothing else is accepted — the value lands inside a CSS colour); a colour beats a tone within a region, and anything a region names beats the shorthand. You choose the hue, the host chooses the weight: it sets the alpha per theme **and** per region and clamps a colour's lightness into a band that reads in both themes, so the tint stays subtle in light and dark, no contribution can make a day unreadable, and a tinted header hovers to a deeper mix of its own colour. `dayId` must belong to the trip (checked server-side), `label` ≤ 60 chars becomes the day's tooltip, raw array sliced at 2000. **A day takes at most one contribution, resolved whole**: within your own list the first entry for a day wins, and across plugins the first granted provider wins — so a day never flickers between colours, and a losing plugin cannot fill in the winner's untinted regions. Bounded by the trip's day count rather than a fixed item cap, which is why this is not `dayScheduleProvider` (≤60 items would tint only the first 60 days of a long trip). Also `GET /api/day-tints/:tripId` |
| `pdfSectionProvider.getSections(tripId, ctx)` → `PdfSection[]` | `hook:pdf-section-provider` | **live** — text-only sections appended to the trip PDF export. Each is `{title, paragraphs?, table?}`; the host escapes and lays everything out itself (no markup ever reaches the document), caps counts (≤5 sections/plugin, ≤20 paragraphs, ≤8 headers, ≤50 rows) + lengths (title 120, paragraph 2000, header 60, cell 200) and clips rows to the header width. Also `GET /api/pdf-sections/:tripId` |
| `atlasLayerProvider.getLayers(ctx)` → `AtlasLayer[]` | `hook:atlas-layer-provider` | **live** — country tint layers drawn over the Atlas world map (wishlists, advisories, …). **User-scoped**: the host binds the acting user, the hook takes no target parameter. Each layer is `{id, name?, countries: [{code, tone?, label?}]}`; codes must be ISO-3166 alpha-2 (uppercase-coerced), tone is enum-whitelisted, counts capped (≤3 layers/plugin, ≤300 countries/layer). Declarative only — plugin JS never runs on the map canvas. Also `GET /api/atlas-layers` |
| `journalEntryProvider.getRows(entryId, ctx)` → `{ label, value?, url? }[]` | `hook:journal-entry-provider` | **live** — extra rows rendered under a journal entry card (needs the Journey addon; the entry's journey is access-checked like the journal detail routes). Same hardening as place details plus server-side normalization: ≤12 rows/plugin, label ≤60, value ≤200, url http/https/mailto-only. Also `GET /api/journal-entry-rows/:entryId` |
+1 -1
View File
@@ -63,7 +63,7 @@ ungranted capability is physically unreachable**, not just disallowed. See
| `hook:map-layer-provider` | Overlay bounded **vector layers** (polylines, polygons, metric circles) on the trip map via the `hooks.mapLayerProvider` provider hook | Implement `MapLayerProvider` in `hooks` — returns `{id, name?, features}[]` where a feature is `{type, points?/center?+radiusM?, tone?, width?, dash?, opacity?, fill?, label?}`. **Declarative only** — the host draws everything; styling is the tone palette plus clamped numerics (width 1–8, opacity 0.05–1, radius ≤2000 km) and a dash enum, so a layer can't impersonate core UI. Budgets per plugin: ≤4 layers / ≤150 features / ≤8000 vertices / ≤2000 vertices per shape; invalid or oversized shapes are dropped whole (never truncated); a failing provider is skipped. Drawn beneath TREK's own routes. Exposed at `GET /api/map-layers/:tripId`. |
| `hook:route-provider` | Offer **routing profiles** the planner's route toggle can route days with, via the `hooks.routeProvider` provider hook | Implement `RouteProvider` in `hooks` and declare the profiles in `capabilities.routeProfiles` (max 3; each `{id, label, icon?}`). TREK calls `getRoute({tripId, dayId, profile, waypoints})` for exactly the profile the user picked — this is **targeted**, not a fan-out — with a 20 s timeout so the plugin can call an external solver through its declared egress. The result (`coordinates`, `distance`, `duration`, `legs`, `viaPoints?`) is validated whole (leg count must equal `waypoints−1`, ≤10000 vertices, ≤40 vias); anything malformed is discarded and the planner falls back to straight lines. Exposed at `POST /api/plugin-routes/:pluginId/:profileId` (member-gated per trip). |
| `hook:day-schedule-provider` | Attach **time contributions** to the day plan via the `hooks.dayScheduleProvider` provider hook | Implement `DayScheduleProvider` in `hooks` — returns `{id, dayId, assignmentId?/reservationId?/position?, minutes?, label, tone?}[]`. Rows render under their anchor (a place/booking row, or a day's start/end) on desktop and mobile, and `minutes` is **folded into the day's route-footer total** — the one hook whose output feeds displayed timing, which is why minutes are clamped (1–1440) and `dayId` is checked against the trip's own days. ≤60 items per plugin, labels sanitized + capped (≤120); a failing provider is skipped. Exposed at `GET /api/day-schedule/:tripId`. |
| `hook:day-tint-provider` | Colour-code **whole days** in the day plan via the `hooks.dayTintProvider` provider hook | Implement `DayTintProvider` in `hooks` — returns `{dayId, tone?, badgeTone?, headerTone?, activityTone?, label?}[]`, one entry per day you want coloured. A day card has **three separately tintable regions** — the day-number badge (which is also the mobile day chip), the header row, and the expanded activity list — so you can mark a leg boldly on the badge and leave the dense activity list plain. `tone` is a shorthand for every region you do not name; a region you never name is not tinted at all and renders exactly as it does without your plugin. **Tone only, never a hex** — a fixed pastel would break the dark theme; the host chooses the alpha per theme and per region (a large surface behind dense text gets a fainter tint than a small badge), and a tinted header hovers to a deeper mix of its own tone instead of losing its colour. `dayId` is checked against the trip's own days, `label` (≤60) becomes the day's tooltip, the raw array is sliced at 2000. **A day takes at most one contribution, and it is resolved whole**: within one provider's list the first entry for a day wins, and across plugins the first granted provider wins — so a day can't flicker between colours, and a losing plugin can't fill in the winner's untinted regions. Unlike `hook:day-schedule-provider` there is no fixed item cap; the bound is the trip's day count, which is what makes this usable on a six-month itinerary. A failing provider is skipped. Exposed at `GET /api/day-tints/:tripId`. |
| `hook:day-tint-provider` | Colour-code **whole days** in the day plan via the `hooks.dayTintProvider` provider hook | Implement `DayTintProvider` in `hooks` — returns `{dayId, tone?, color?, badgeTone?, badgeColor?, headerTone?, headerColor?, activityTone?, activityColor?, label?}[]`, one entry per day you want coloured. A day card has **three separately tintable regions** — the day-number badge (which is also the mobile day chip), the header row, and the expanded activity list — so you can mark a leg boldly on the badge and leave the dense activity list plain. `tone` / `color` are shorthands for every region you do not name; a region you never name is not tinted at all and renders exactly as it does without your plugin. Paint a region with a palette tone or with your own colour — **`#rrggbb` only**, since the value ends up inside a CSS colour the host builds; a colour beats a tone within a region, and a region's own value beats the shorthand. **You choose the hue, the host chooses the weight**: it sets the alpha per theme and per region (a large surface behind dense text gets a fainter tint than a small badge) and clamps your colour's lightness into a band that reads on both the light and the dark sidebar, so no contribution can hand a user a day card they cannot read. A tinted header hovers to a deeper mix of its own colour instead of losing it. `dayId` is checked against the trip's own days, `label` (≤60) becomes the day's tooltip, the raw array is sliced at 2000. **A day takes at most one contribution, and it is resolved whole**: within one provider's list the first entry for a day wins, and across plugins the first granted provider wins — so a day can't flicker between colours, and a losing plugin can't fill in the winner's untinted regions. Unlike `hook:day-schedule-provider` there is no fixed item cap; the bound is the trip's day count, which is what makes this usable on a six-month itinerary. A failing provider is skipped. Exposed at `GET /api/day-tints/:tripId`. |
| `geolocation:read` | Ask the HOST for the browser's live position from the plugin's sandboxed frames (`window.trek.geolocation`) | A **bridge-level** permission — it unlocks no ctx RPC method. The sandbox itself never gains the geolocation API: the host page reads `navigator.geolocation` (the browser's own site permission prompt still applies) and posts plain `{lat, lng, accuracy, heading, speed, timestamp}` data into the frame over postMessage. `get()` resolves one fix; `watch(cb)` streams updates until unsubscribed, and the host force-stops the GPS watch when the frame closes. Nothing is sent to the server — a position only reaches the plugin's server code if its own client ships it through one of its routes (visible in that route's traffic like any other call). |
| `hook:table-contributor` | Contribute host-rendered **columns/actions** into a native planner view via the `hooks.tableContributor` provider hook | Implement `TableContributor` in `hooks` — returns declarative column/action leaves keyed by `entityId` (never markup). The host normalizes + bounds every field (length caps, `http`/`https`/`mailto`-only urls, enum tone/target) and renders them in the reservations, places, day, costs, packing and files views; an action opens your sandboxed frame or calls one of your routes. Also exposed at `GET /api/view-contributions/:view/:tripId`. |
| `hook:pdf-section-provider` | Append text-only **sections** to the trip PDF export via the `hooks.pdfSectionProvider` provider hook | Implement `PdfSectionProvider` in `hooks` — returns `{title, paragraphs?, table?}[]` of plain strings the export escapes and lays out itself; no markup ever reaches the document. The host caps the section/paragraph/header/row counts (≤5/≤20/≤8/≤50) and every string length, and clips table rows to the header width; a failing provider is skipped. Exposed at `GET /api/pdf-sections/:tripId`. |