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:
@@ -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