mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-18 21:16:47 +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:
@@ -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 {
|
||||
|
||||
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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
@@ -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.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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` |
|
||||
|
||||
@@ -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`. |
|
||||
|
||||
Reference in New Issue
Block a user