feat(app): sidebar display preferences, a shared menu engine, and a colour pass

Sidebar polish round.

Menus. `dropdown-menu` and `context-menu` were two copies of one engine that
differed only in how the trigger fires — identical from `computePosition` down —
and neither could nest. Both are now thin wrappers over
`components/ui/menu/`, which owns anchoring, a pure page-stack navigation model,
and one presentation fork: flyout on pointer, pushed pages in a sheet on compact.
All 31 call sites are unchanged. Items got a rounded inset fill, a visible
separator, and `selected` (a check) split from `active` (a fill).

Sidebar display preferences. The flat wall of four sections becomes one row per
decision with its value, and per-feature toggles over what a workspace row shows:
host, pull request, checks, scripts. Each is independent — a ticked toggle that
draws nothing is a lie. `display-preferences/` hides that grouping lives in
zustand while title source and row items are app settings.

Timestamps. "Last activity" now ticks itself. The label rounds to the minute and
never shows seconds; past a week it becomes a date. The timer lives in the leaf
`<Text>`, at most one per resolution tier shared by every row, and a tick only
re-renders when the string actually changed — so a memoized row that legitimately
never re-renders still stays current.

Colour. Full status tier regenerated at 90% of gamut max (was 85), same rule as
the muted tier, lightness and hue untouched. File icons are pulled toward neutral
in OKLab behind `getFileIconSvg` — material-icon-theme picks its palette for a
tree where the icon is the only colour on the row, and ours is not. New/deleted
files use the forge square-plus/square-minus glyphs at muted weight instead of
tinted pills. Project icons round a little more; uploaded icons never do.

The project status badge is a knockout again: it fills with the colour of the row
behind it, so the ring reads as a gap in the icon rather than a white halo.

File rows. The always-visible kebab is gone from the file explorer and diff tree
in favour of right-click, which also reclaims the reserved action slot.
This commit is contained in:
Mohamed Boudra
2026-08-03 18:58:06 +02:00
parent 777fe968f5
commit ab8151d33f
81 changed files with 5457 additions and 2903 deletions
+62
View File
@@ -0,0 +1,62 @@
import { describe, expect, it } from "vitest";
import { desaturateHexColor, parseHexColor } from "./color";
describe("parseHexColor", () => {
it("parses six-digit hex", () => {
expect(parseHexColor("#ff8000")).toEqual([1, 128 / 255, 0]);
});
it("expands three-digit hex", () => {
expect(parseHexColor("#f80")).toEqual(parseHexColor("#ff8800"));
});
it("rejects anything else", () => {
expect(parseHexColor("none")).toBeNull();
expect(parseHexColor("url(#a)")).toBeNull();
expect(parseHexColor("#ff800")).toBeNull();
});
});
describe("desaturateHexColor", () => {
it("leaves a colour alone at full chroma", () => {
expect(desaturateHexColor("#0288d1", 1)).toBe("#0288d1");
});
it("returns grey at zero chroma", () => {
const grey = desaturateHexColor("#0288d1", 0);
const rgb = parseHexColor(grey);
expect(rgb).not.toBeNull();
const [r, g, b] = rgb as [number, number, number];
expect(Math.abs(r - g)).toBeLessThan(0.01);
expect(Math.abs(g - b)).toBeLessThan(0.01);
});
it("drops chroma monotonically", () => {
const spread = (hex: string) => {
const [r, g, b] = parseHexColor(hex) as [number, number, number];
return Math.max(r, g, b) - Math.min(r, g, b);
};
for (const source of ["#ffca28", "#0288d1", "#f44336", "#7e57c2"]) {
const steps = [1, 0.75, 0.5, 0.25, 0].map((amount) =>
spread(desaturateHexColor(source, amount)),
);
for (let i = 1; i < steps.length; i++) {
expect(steps[i]).toBeLessThan(steps[i - 1]);
}
}
});
// Pinned so a typo in the OKLab matrices shows up as a diff rather than as icons that look
// slightly off. The interesting one is the yellow: its green channel goes *up* and its blue
// rises to meet it, which is what holding lightness instead of scaling HSL saturation buys.
it("matches known values", () => {
expect(desaturateHexColor("#0288d1", 0.65)).toBe("#4a87b6");
expect(desaturateHexColor("#ffca28", 0.65)).toBe("#efce7b");
expect(desaturateHexColor("#f44336", 0.65)).toBe("#d4685a");
});
it("passes non-hex input through untouched", () => {
expect(desaturateHexColor("none", 0.5)).toBe("none");
expect(desaturateHexColor("currentColor", 0.5)).toBe("currentColor");
});
});
+95
View File
@@ -0,0 +1,95 @@
/**
* Colour maths in OKLab, for the cases where a colour has to be adjusted rather than chosen.
*
* OKLab because chroma and lightness are separable there in a way they are not in HSL: scaling
* HSL saturation drags perceived brightness with it, so a set of icons desaturated that way ends
* up with the yellows washed out and the blues barely touched. In OKLab, holding L fixed and
* scaling the a/b vector moves a colour straight toward grey at the same apparent lightness.
*
* Reducing chroma always stays inside sRGB — the gamut is convex around the neutral axis — so
* these conversions never need gamut mapping.
*/
interface Oklab {
L: number;
a: number;
b: number;
}
function srgbToLinear(channel: number): number {
return channel <= 0.04045 ? channel / 12.92 : ((channel + 0.055) / 1.055) ** 2.4;
}
function linearToSrgb(channel: number): number {
return channel <= 0.0031308 ? channel * 12.92 : 1.055 * channel ** (1 / 2.4) - 0.055;
}
function linearRgbToOklab(r: number, g: number, b: number): Oklab {
const l = Math.cbrt(0.4122214708 * r + 0.5363325363 * g + 0.0514459929 * b);
const m = Math.cbrt(0.2119034982 * r + 0.6806995451 * g + 0.1073969566 * b);
const s = Math.cbrt(0.0883024619 * r + 0.2817188376 * g + 0.6299787005 * b);
return {
L: 0.2104542553 * l + 0.793617785 * m - 0.0040720468 * s,
a: 1.9779984951 * l - 2.428592205 * m + 0.4505937099 * s,
b: 0.0259040371 * l + 0.7827717662 * m - 0.808675766 * s,
};
}
function oklabToLinearRgb({ L, a, b }: Oklab): [number, number, number] {
const l = (L + 0.3963377774 * a + 0.2158037573 * b) ** 3;
const m = (L - 0.1055613458 * a - 0.0638541728 * b) ** 3;
const s = (L - 0.0894841775 * a - 1.291485548 * b) ** 3;
return [
4.0767416621 * l - 3.3077115913 * m + 0.2309699292 * s,
-1.2684380046 * l + 2.6097574011 * m - 0.3413193965 * s,
-0.0041960863 * l - 0.7034186147 * m + 1.707614701 * s,
];
}
/** Parses `#rgb` and `#rrggbb` into 0–1 sRGB channels. Anything else is not a colour we own. */
export function parseHexColor(hex: string): [number, number, number] | null {
const body = hex.startsWith("#") ? hex.slice(1) : hex;
if (!/^[0-9a-fA-F]+$/.test(body)) return null;
let expanded: string;
if (body.length === 3) {
expanded = `${body[0]}${body[0]}${body[1]}${body[1]}${body[2]}${body[2]}`;
} else if (body.length === 6) {
expanded = body;
} else {
return null;
}
return [
Number.parseInt(expanded.slice(0, 2), 16) / 255,
Number.parseInt(expanded.slice(2, 4), 16) / 255,
Number.parseInt(expanded.slice(4, 6), 16) / 255,
];
}
function toHexChannel(channel: number): string {
const clamped = Math.min(255, Math.max(0, Math.round(channel * 255)));
return clamped.toString(16).padStart(2, "0");
}
/**
* Scales a colour's chroma toward neutral while holding its perceived lightness.
*
* `amount` is the fraction of the original chroma to keep: `1` is unchanged, `0` is grey. Input
* that is not a hex colour comes back untouched, so this is safe to run over strings that mix
* colours with things like `none` or `currentColor`.
*/
export function desaturateHexColor(hex: string, amount: number): string {
const rgb = parseHexColor(hex);
if (!rgb) return hex;
const [r, g, b] = rgb;
const lab = linearRgbToOklab(srgbToLinear(r), srgbToLinear(g), srgbToLinear(b));
const [linearR, linearG, linearB] = oklabToLinearRgb({
L: lab.L,
a: lab.a * amount,
b: lab.b * amount,
});
return `#${toHexChannel(linearToSrgb(linearR))}${toHexChannel(linearToSrgb(linearG))}${toHexChannel(linearToSrgb(linearB))}`;
}
@@ -0,0 +1,99 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { activeRelativeTimeTickerCount, subscribeToRelativeTimeTick } from "./relative-time-ticker";
const MINUTE = 60_000;
describe("subscribeToRelativeTimeTick", () => {
beforeEach(() => {
vi.useFakeTimers();
// Align the clock so the first tick lands a whole period later, not part-way through one.
vi.setSystemTime(new Date("2026-07-16T12:00:00.000Z"));
});
afterEach(() => {
vi.useRealTimers();
});
it("runs no timer until something subscribes", () => {
expect(activeRelativeTimeTickerCount()).toBe(0);
});
it("ticks a subscriber at its tier's period", () => {
const listener = vi.fn();
const unsubscribe = subscribeToRelativeTimeTick("minute", listener);
vi.advanceTimersByTime(MINUTE);
expect(listener).toHaveBeenCalledTimes(1);
vi.advanceTimersByTime(MINUTE);
expect(listener).toHaveBeenCalledTimes(2);
unsubscribe();
});
it("shares one timer across every subscriber at the same tier", () => {
const first = vi.fn();
const second = vi.fn();
const unsubFirst = subscribeToRelativeTimeTick("minute", first);
const unsubSecond = subscribeToRelativeTimeTick("minute", second);
// Fifty rows must not mean fifty timers; the tier count is what proves it.
expect(activeRelativeTimeTickerCount()).toBe(1);
vi.advanceTimersByTime(MINUTE);
expect(first).toHaveBeenCalledTimes(1);
expect(second).toHaveBeenCalledTimes(1);
unsubFirst();
unsubSecond();
});
it("stops the timer when the last subscriber leaves", () => {
const unsubFirst = subscribeToRelativeTimeTick("minute", vi.fn());
const unsubSecond = subscribeToRelativeTimeTick("minute", vi.fn());
unsubFirst();
expect(activeRelativeTimeTickerCount()).toBe(1);
unsubSecond();
expect(activeRelativeTimeTickerCount()).toBe(0);
});
it("runs tiers independently", () => {
const minute = vi.fn();
const hour = vi.fn();
const unsubMinute = subscribeToRelativeTimeTick("minute", minute);
const unsubHour = subscribeToRelativeTimeTick("hour", hour);
expect(activeRelativeTimeTickerCount()).toBe(2);
vi.advanceTimersByTime(MINUTE);
expect(minute).toHaveBeenCalledTimes(1);
expect(hour).not.toHaveBeenCalled();
vi.advanceTimersByTime(30 * MINUTE);
expect(hour).toHaveBeenCalledTimes(1);
unsubMinute();
unsubHour();
expect(activeRelativeTimeTickerCount()).toBe(0);
});
it("survives a listener unsubscribing during its own tick", () => {
const survivor = vi.fn();
let unsubSelf = () => {};
const quitter = vi.fn(() => unsubSelf());
unsubSelf = subscribeToRelativeTimeTick("minute", quitter);
const unsubSurvivor = subscribeToRelativeTimeTick("minute", survivor);
expect(() => vi.advanceTimersByTime(MINUTE)).not.toThrow();
expect(survivor).toHaveBeenCalledTimes(1);
vi.advanceTimersByTime(MINUTE);
expect(quitter).toHaveBeenCalledTimes(1);
expect(survivor).toHaveBeenCalledTimes(2);
unsubSurvivor();
});
});
@@ -0,0 +1,96 @@
import type { RelativeTimeResolution } from "./time";
/**
* The shared clock behind relative timestamps.
*
* A sidebar row that nothing has happened to should never re-render — that is what the
* memoization is for — so a "2m" label sitting inside one cannot wait to be re-rendered by
* something else. It needs its own clock. The naive version of that is one timer per row, which
* turns a list of fifty workspaces into fifty timers firing at fifty different moments.
*
* Instead there is at most one timer per resolution, shared by every subscriber at that
* resolution, and a tier with no subscribers has no timer at all. A sidebar full of week-old
* workspaces runs no timers whatsoever, because those labels are `static` and never subscribe.
*/
export type TickResolution = Exclude<RelativeTimeResolution, "static">;
/**
* How often each tier wakes. The label only changes once per unit, so the interval sets how
* stale it can be, not how often it moves: an hour-resolution label is at most half an hour
* behind, which is invisible on a value that reads "3h" for sixty minutes either way.
*/
const INTERVAL_MS: Record<TickResolution, number> = {
minute: 60_000,
hour: 30 * 60_000,
day: 60 * 60_000,
};
interface Tier {
listeners: Set<() => void>;
handle: ReturnType<typeof setTimeout> | null;
interval: ReturnType<typeof setInterval> | null;
}
function createTier(): Tier {
return { listeners: new Set(), handle: null, interval: null };
}
const tiers: Record<TickResolution, Tier> = {
minute: createTier(),
hour: createTier(),
day: createTier(),
};
function notify(tier: Tier): void {
// Iterated live rather than copied: a listener that unsubscribes on its own tick — which is
// exactly what happens when a label ages into a slower tier — deletes from this set mid-loop,
// and Set iteration is defined under deletion. An entry removed before it is reached is simply
// not called, which is what unsubscribing asked for.
for (const listener of tier.listeners) {
listener();
}
}
function start(resolution: TickResolution): void {
const tier = tiers[resolution];
if (tier.handle !== null || tier.interval !== null) return;
// Aligned to the wall clock so every row flips together rather than each on the anniversary of
// whenever it happened to mount.
const period = INTERVAL_MS[resolution];
const delay = period - (Date.now() % period);
tier.handle = setTimeout(() => {
tier.handle = null;
notify(tier);
tier.interval = setInterval(() => notify(tier), period);
}, delay);
}
function stop(resolution: TickResolution): void {
const tier = tiers[resolution];
if (tier.handle !== null) clearTimeout(tier.handle);
if (tier.interval !== null) clearInterval(tier.interval);
tier.handle = null;
tier.interval = null;
}
/** Subscribes to a tier's tick. The tier's timer runs only while it has at least one listener. */
export function subscribeToRelativeTimeTick(
resolution: TickResolution,
listener: () => void,
): () => void {
const tier = tiers[resolution];
tier.listeners.add(listener);
if (tier.listeners.size === 1) start(resolution);
return () => {
tier.listeners.delete(listener);
if (tier.listeners.size === 0) stop(resolution);
};
}
/** Test seam: how many tiers currently hold a live timer. */
export function activeRelativeTimeTickerCount(): number {
return Object.values(tiers).filter((tier) => tier.handle !== null || tier.interval !== null)
.length;
}
+48 -1
View File
@@ -1,5 +1,11 @@
import { describe, it, expect } from "vitest";
import { formatDuration, formatMessageTimestamp, formatTimeAgo } from "./time";
import {
describeCompactTimeAgo,
formatCompactTimeAgo,
formatDuration,
formatMessageTimestamp,
formatTimeAgo,
} from "./time";
describe("formatTimeAgo", () => {
const now = new Date("2026-07-16T12:00:00.000Z");
@@ -16,6 +22,47 @@ describe("formatTimeAgo", () => {
});
});
describe("describeCompactTimeAgo", () => {
const now = new Date("2026-07-16T12:00:00.000Z");
it.each([
["2026-07-16T11:59:59.000Z", "now", "minute"],
["2026-07-16T11:59:30.000Z", "now", "minute"],
["2026-07-16T11:59:00.000Z", "1m", "minute"],
["2026-07-16T11:55:00.000Z", "5m", "minute"],
["2026-07-16T11:00:00.000Z", "1h", "hour"],
["2026-07-16T10:00:00.000Z", "2h", "hour"],
["2026-07-15T12:00:00.000Z", "1d", "day"],
["2026-07-13T12:00:00.000Z", "3d", "day"],
["2026-07-10T12:00:00.000Z", "6d", "day"],
["2026-07-09T12:00:00.000Z", "Jul 9", "static"],
["2026-01-15T12:00:00.000Z", "Jan 15", "static"],
] as const)("formats %s as %s at %s resolution", (date, label, resolution) => {
expect(describeCompactTimeAgo(new Date(date), now)).toEqual({ label, resolution });
});
it("never shows a sub-minute count", () => {
// A seconds label is only true for the second it rendered. Everything under a minute is
// "now", which stays true and lets the row sit still.
for (let seconds = 0; seconds < 60; seconds += 1) {
const date = new Date(now.getTime() - seconds * 1000);
expect(describeCompactTimeAgo(date, now).label).toBe("now");
}
});
it("switches to an absolute date exactly at seven days", () => {
const almost = new Date(now.getTime() - (7 * 24 * 60 * 60 * 1000 - 1));
const exactly = new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000);
expect(describeCompactTimeAgo(almost, now).resolution).toBe("day");
expect(describeCompactTimeAgo(exactly, now).resolution).toBe("static");
});
it("keeps formatCompactTimeAgo as the label alone", () => {
const date = new Date("2026-07-16T10:00:00.000Z");
expect(formatCompactTimeAgo(date, now)).toBe(describeCompactTimeAgo(date, now).label);
});
});
describe("formatDuration", () => {
it("renders sub-minute durations as whole seconds", () => {
expect(formatDuration(0)).toBe("0s");
+80 -24
View File
@@ -1,38 +1,94 @@
/**
* Format a date as a human-friendly relative time string
* Examples: "just now", "5m ago", "2h ago", "3d ago", "Jan 15"
* How long ago something was, before it's worded. The two formatters below share this so
* their thresholds can't drift apart; they differ only in how much room they have to say it.
*
* `elapsed` takes an "ago" in prose, `now` and `date` read as absolutes and never do.
*/
export function formatTimeAgo(date: Date, now: Date = new Date()): string {
type Elapsed =
| { kind: "now" }
| { kind: "elapsed"; value: string }
| { kind: "date"; value: string };
function describeElapsed(date: Date, now: Date): Elapsed {
const diffMs = now.getTime() - date.getTime();
const diffSec = Math.floor(diffMs / 1000);
const diffMin = Math.floor(diffSec / 60);
const diffHour = Math.floor(diffMin / 60);
const diffDay = Math.floor(diffHour / 24);
if (diffSec < 10) {
return "just now";
}
if (diffMin < 1) {
return `${diffSec}s ago`;
}
if (diffHour < 1) {
return `${diffMin}m ago`;
}
if (diffDay < 1) {
return `${diffHour}h ago`;
}
if (diffDay < 7) {
return `${diffDay}d ago`;
}
if (diffSec < 10) return { kind: "now" };
if (diffMin < 1) return { kind: "elapsed", value: `${diffSec}s` };
if (diffHour < 1) return { kind: "elapsed", value: `${diffMin}m` };
if (diffDay < 1) return { kind: "elapsed", value: `${diffHour}h` };
if (diffDay < 7) return { kind: "elapsed", value: `${diffDay}d` };
// For older dates, show abbreviated month and day
const month = date.toLocaleDateString("en-US", { month: "short" });
const day = date.getDate();
return `${month} ${day}`;
return { kind: "date", value: `${month} ${date.getDate()}` };
}
/**
* Format a date as a human-friendly relative time string
* Examples: "just now", "5m ago", "2h ago", "3d ago", "Jan 15"
*/
export function formatTimeAgo(date: Date, now: Date = new Date()): string {
const elapsed = describeElapsed(date, now);
if (elapsed.kind === "now") return "just now";
if (elapsed.kind === "elapsed") return `${elapsed.value} ago`;
return elapsed.value;
}
/**
* How often a compact label can change, which is all a caller needs to know to keep it honest.
* `static` means it never will again.
*/
export type RelativeTimeResolution = "minute" | "hour" | "day" | "static";
export interface CompactTimeAgo {
label: string;
resolution: RelativeTimeResolution;
}
const MINUTE_MS = 60_000;
const HOUR_MS = 60 * MINUTE_MS;
const DAY_MS = 24 * HOUR_MS;
/** Past a week the elapsed count stops meaning anything; the date itself is more use. */
const ABSOLUTE_AFTER_MS = 7 * DAY_MS;
/**
* The same instant with the prose removed, for somewhere with no room for it — a dense list
* where the column is understood to be a timestamp and "ago" is the only word on the line.
* Examples: "now", "5m", "2h", "3d", "Jan 15".
*
* Deliberately never sub-minute. A seconds label is only correct for the second it was rendered,
* so it either lies or forces a once-a-second re-render of a list that has nothing new to say.
* Everything under a minute is "now", which is both true and stable.
*
* The resolution comes back with the label so a caller can wake at the rate the label actually
* changes instead of guessing — see `useCompactTimeAgo`.
*/
export function describeCompactTimeAgo(date: Date, now: Date = new Date()): CompactTimeAgo {
const elapsedMs = now.getTime() - date.getTime();
if (elapsedMs < MINUTE_MS) {
return { label: "now", resolution: "minute" };
}
if (elapsedMs < HOUR_MS) {
return { label: `${Math.floor(elapsedMs / MINUTE_MS)}m`, resolution: "minute" };
}
if (elapsedMs < DAY_MS) {
return { label: `${Math.floor(elapsedMs / HOUR_MS)}h`, resolution: "hour" };
}
if (elapsedMs < ABSOLUTE_AFTER_MS) {
return { label: `${Math.floor(elapsedMs / DAY_MS)}d`, resolution: "day" };
}
const month = date.toLocaleDateString("en-US", { month: "short" });
return { label: `${month} ${date.getDate()}`, resolution: "static" };
}
export function formatCompactTimeAgo(date: Date, now: Date = new Date()): string {
return describeCompactTimeAgo(date, now).label;
}
function isSameLocalDay(a: Date, b: Date): boolean {