diff --git a/apps/docs/blume.config.ts b/apps/docs/blume.config.ts index 7d22356..96ba578 100644 --- a/apps/docs/blume.config.ts +++ b/apps/docs/blume.config.ts @@ -1,4 +1,5 @@ import { defineConfig } from "blume"; +import { openrouter } from "blume/ai"; import type { StandardSchema } from "blume/core/standard-schema.ts"; import { custom, filesystem } from "blume/sources"; import { githubReleasesSource } from "blume/sources/github-releases.ts"; @@ -134,6 +135,18 @@ export default defineConfig({ structuredData: true, x: { handle: "@prdlk" }, }, + // The assistant's `/api/ask` route runs on demand and reads + // OPENROUTER_API_KEY at request time (`getSecret`), so it needs a server + // runtime holding that secret — a static host cannot answer it. + ai: { + assistant: { + enabled: true, + provider: openrouter({ + model: "qwen/qwen3.8-27b:free", + reasoning: "none", + }), + }, + }, agents: { llmsTxt: true, mcp: { enabled: false }, diff --git a/apps/docs/lib/tag-colors.ts b/apps/docs/lib/tag-colors.ts index fa4b383..11d8fc0 100644 --- a/apps/docs/lib/tag-colors.ts +++ b/apps/docs/lib/tag-colors.ts @@ -3,78 +3,127 @@ * row (`components/ProblemBadges.astro`) paints each tag with it, so a tag * reads the same on every page and no two tags share a colour. * - * Every colour sits at one OKLCH lightness, so each badge has the same - * contrast whatever its hue; only the hue varies. A tag's hue is seeded by a - * hash of its slug (FNV-1a) and walked forward past any hue already taken - * within `gap` degrees, in slug order. Seeding by slug rather than by position - * keeps colours stable as tags come and go: a new tag moves only itself, - * unless it lands on a hue a later slug's seed wanted. + * Fifty-odd hues cannot all be told apart, so uniqueness alone is not the + * goal: tags that share a problem page must be told apart, since that is + * where they are seen together. The palette is a fixed grid in OKLCH, a few + * lightness rows by `HUE_STEP` hues, and tags are coloured greedily, most-used + * first. Each tag takes the free palette colour farthest (OKLab distance) + * from every colour its co-tags already hold, ties broken by distance from + * every colour taken at all. The answer is a pure function of the pages' + * tags, so it is identical on every page of one build; a newly solved problem + * can shift colours in the next. * - * Blume's Badge takes a custom colour only as hex, and uses that one value - * for both themes, so the lightness is the midpoint that stays legible as text - * on the light and the dark background alike. + * Blume's Badge takes a custom colour only as hex and uses that one value in + * both themes, as text over a 12% tint of itself, so the rows sit in the + * narrow lightness band where that text holds ≥ 3:1 contrast on the light and + * the dark background alike; wider rows spread tags further but fail one theme. */ -const LIGHTNESS = 0.6; +const LIGHTNESS = [0.55, 0.6]; const CHROMA = 0.15; +const HUE_STEP = 5; -/** slug → `#rrggbb`, pairwise distinct, for every slug in `slugs`. */ -export function tagColors(slugs: Iterable): Map { - const sorted = [...new Set(slugs)].sort(); - // Each taken hue blocks 2·gap−1 whole degrees, so n·(2·gap−1) < 360 always - // leaves a free one; past 180 tags the hues can only be distinct. - const gap = Math.max(1, Math.floor(360 / (2 * sorted.length))); - const taken: number[] = []; - const colors = new Map(); - const used = new Set(); - for (const slug of sorted) { - let hue = fnv1a(slug) % 360; - for (let step = 0; step < 360; step++, hue = (hue + 1) % 360) { - if (taken.some((t) => Math.min(Math.abs(t - hue), 360 - Math.abs(t - hue)) < gap)) continue; - if (!used.has(oklchHex(hue))) break; - } - taken.push(hue); - const hex = oklchHex(hue); - used.add(hex); - colors.set(slug, hex); - } - return colors; -} - -function fnv1a(text: string): number { - let hash = 0x811c9dc5; - for (let i = 0; i < text.length; i++) { - hash ^= text.charCodeAt(i); - hash = Math.imul(hash, 0x01000193); - } - return hash >>> 0; +interface Swatch { + hex: string; + lab: [number, number, number]; } /** - * `oklch(LIGHTNESS CHROMA hue)` as sRGB hex. Hues sRGB cannot reach at full - * chroma (cyans) give up chroma until they fit, rather than being clipped - * per channel, which would shift the hue. + * slug → `#rrggbb`, pairwise distinct, for every slug in `slugs`; `pages` is + * each problem page's tag slugs, the co-occurrence the spread is judged by. */ -function oklchHex(hue: number): string { +export function tagColors(slugs: Iterable, pages: readonly (readonly string[])[]): Map { + const universe = new Set(slugs); + const uses = new Map(); + const cotags = new Map>(); + for (const page of pages) { + const on = page.filter((slug) => universe.has(slug)); + for (const slug of on) { + uses.set(slug, (uses.get(slug) ?? 0) + 1); + const set = cotags.get(slug) ?? new Set(); + for (const other of on) if (other !== slug) set.add(other); + cotags.set(slug, set); + } + } + const order = [...universe].sort((a, b) => (uses.get(b) ?? 0) - (uses.get(a) ?? 0) || a.localeCompare(b)); + + const palette = swatches(order.length); + const free = new Set(palette.keys()); + const chosen = new Map(); + for (const slug of order) { + const near = [...(cotags.get(slug) ?? [])].flatMap((other) => chosen.get(other) ?? []); + const all = [...chosen.values()]; + let best = -1; + let bestScore: [number, number] = [-1, -1]; + for (const i of free) { + const score: [number, number] = [nearest(palette[i]!, near), nearest(palette[i]!, all)]; + if (score[0] > bestScore[0] || (score[0] === bestScore[0] && score[1] > bestScore[1])) { + best = i; + bestScore = score; + } + } + free.delete(best); + chosen.set(slug, palette[best]!); + } + return new Map([...chosen].map(([slug, swatch]) => [slug, swatch.hex])); +} + +/** Distance from `swatch` to the closest of `others`; Infinity when there are none. */ +function nearest(swatch: Swatch, others: readonly Swatch[]): number { + let min = Number.POSITIVE_INFINITY; + for (const other of others) { + const d = Math.hypot(swatch.lab[0] - other.lab[0], swatch.lab[1] - other.lab[1], swatch.lab[2] - other.lab[2]); + if (d < min) min = d; + } + return min; +} + +/** + * The palette: every `HUE_STEP` degrees in each `LIGHTNESS` row, the step + * shrunk when there are more tags than swatches. Duplicate hexes (possible + * only at a tiny step) are dropped, which keeps every assignment unique. + */ +function swatches(count: number): Swatch[] { + const step = Math.min(HUE_STEP, Math.max(1, Math.floor((360 * LIGHTNESS.length) / Math.max(count, 1)))); + const seen = new Set(); + const out: Swatch[] = []; + for (const lightness of LIGHTNESS) { + for (let hue = 0; hue < 360; hue += step) { + const swatch = oklch(lightness, hue); + if (seen.has(swatch.hex)) continue; + seen.add(swatch.hex); + out.push(swatch); + } + } + return out; +} + +/** + * `oklch(lightness CHROMA hue)` as sRGB hex plus its OKLab coordinates. Hues + * sRGB cannot reach at full chroma (cyans) give up chroma until they fit, + * rather than being clipped per channel, which would shift the hue. + */ +function oklch(lightness: number, hue: number): Swatch { const rad = (hue * Math.PI) / 180; for (let chroma = CHROMA; ; chroma -= 0.005) { const a = chroma * Math.cos(rad); const b = chroma * Math.sin(rad); - const l = (LIGHTNESS + 0.3963377774 * a + 0.2158037573 * b) ** 3; - const m = (LIGHTNESS - 0.1055613458 * a - 0.0638541728 * b) ** 3; - const s = (LIGHTNESS - 0.0894841775 * a - 1.291485548 * b) ** 3; + const l = (lightness + 0.3963377774 * a + 0.2158037573 * b) ** 3; + const m = (lightness - 0.1055613458 * a - 0.0638541728 * b) ** 3; + const s = (lightness - 0.0894841775 * a - 1.291485548 * b) ** 3; const rgb = [ 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.707614681 * s, ]; if (chroma > 0 && rgb.some((c) => c < 0 || c > 1)) continue; - return `#${rgb + const hex = rgb .map((c) => { const v = Math.min(1, Math.max(0, c)); const srgb = v <= 0.0031308 ? 12.92 * v : 1.055 * v ** (1 / 2.4) - 0.055; return Math.round(srgb * 255).toString(16).padStart(2, "0"); }) - .join("")}`; + .join(""); + return { hex: `#${hex}`, lab: [lightness, a, b] }; } } diff --git a/apps/docs/package.json b/apps/docs/package.json index 2ff8d17..a25b072 100644 --- a/apps/docs/package.json +++ b/apps/docs/package.json @@ -8,6 +8,7 @@ "doctor": "blume doctor" }, "dependencies": { + "@openrouter/ai-sdk-provider": "^3.1.0", "blume": "^2.1.0", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1",