mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-22 06:56:35 +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:
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user