From 2e9a509c05b1a953b01fac9b543463572a717f7b Mon Sep 17 00:00:00 2001 From: Elizabeth Hazel Ainslie Date: Mon, 14 Sep 2026 05:17:12 -0500 Subject: [PATCH] feat: canonical theme picker and repo remotes Adopt the Keel theme picker as canonical (family-grouped site themes with menu-title headings, short Catppuccin labels, curated code themes via CODE_THEME_OPTIONS/codeThemeOptions) and derive ScmMenu links from RepoConfig.remotes or a single Source link for repo.url. ScmMenu renders one link as an icon link and several as the popover menu. --- CHANGELOG.md | 21 ++++++ README.md | 43 ++++++++++-- src/astro/ScmMenu.astro | 118 +++++++++++++++++++------------- src/astro/ThemePicker.astro | 131 ++++++++++++++++-------------------- src/core/config.ts | 36 +++++++--- src/core/themes.ts | 43 ++++++++++++ test/config.test.ts | 53 +++++++++++++++ test/themes.test.ts | 47 +++++++++++++ 8 files changed, 359 insertions(+), 133 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3ab68ab..28a3ff3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,27 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- Curated code themes. Core exports `CodeThemeOption`, `CODE_THEME_OPTIONS` + (`follow` plus the Catppuccin flavours, Nord and the Kolektiv palettes) and + `codeThemeOptions(config)`, which restricts the list to the themes a site + offers (so `themeFamilies` applies) and appends any `config.themes`. + `ThemePicker` renders site themes grouped by family — one `menu-title` per + family, Catppuccin shown by flavour (for example `Mocha`) — with the curated + code themes below `follow`. +- `RepoConfig.remotes` (`FooterLink[]`). When supplied, `defineDocsChrome` + derives `scm` from it, deduplicated by `href` and order-preserving; + otherwise it derives a single `{ label: 'Source', href: repo.url }`. An + explicit `scm` still wins. `deriveScm(repo)` is exported. + +### Changed + +- `ScmMenu` renders a single icon link when the deduplicated `scm` list has one + entry (previously it always opened a popover); several links keep the popover + menu and zero links render nothing. `defineDocsChrome` no longer emits the + duplicate `Source` / `yuri.capital` pair for `repo.url`. + ## [0.0.1-SNAPSHOT.5] - 2026-09-14 ### Added diff --git a/README.md b/README.md index 0da20d4..32e66c3 100644 --- a/README.md +++ b/README.md @@ -196,6 +196,7 @@ Config: - `resolveNavbar(config, override?)` — navbar flags + links, layered over `DEFAULT_NAVBAR` - `resolveSwitchers(config)` — implicit `lang` plus every generic switcher +- `deriveScm(repo)` — `remotes` (deduped) or a single `Source` link for `url` - `visibilityCss(config)` — `[data-lang-panel]` / `[data--panel]` / `[data-framework-panel]` rules - `RESERVED_SWITCHER_IDS`, `DEFAULT_CODE_THEME`, `DEFAULT_THEME_FAMILY`, @@ -203,11 +204,14 @@ Config: Themes: -- Types `ChromeTheme`, `ThemeFamily`, `ThemeScheme`, `ChromeThemeGroup` +- Types `ChromeTheme`, `ThemeFamily`, `ThemeScheme`, `ChromeThemeGroup`, + `CodeThemeOption` - `allChromeThemes()` — every built-in palette + daisyUI light/dark - `themesByFamily(themes?)` — `Record` - `groupChromeThemes(themes?, order?)` — ordered `{ family, label, themes }[]` - `siteThemes(config)` — built-ins filtered by `themeFamilies` + `config.themes` +- `CODE_THEME_OPTIONS` — curated code themes (`follow` + Kolektiv palettes) +- `codeThemeOptions(config?)` — curated code themes available for a site - `resolveTheme(id, themes?)` — resolve an id or alias - `shikiThemeForChrome(id, themes?)` — Shiki registration for a theme - `themeCssFor(themes)` — daisyUI CSS for the supplied themes @@ -246,11 +250,11 @@ Every component lives at `@kolektiv/common-docs-chrome/astro/.astro`: | `Navbar.astro` | Sticky navbar with the current page label and controls | | `Sidebar.astro` | Categorised nav, optional per-item icons, active highlight | | `Footer.astro` | Footer with links and the built-by mark | -| `ThemePicker.astro` | Site + code theme popover grouped by family | +| `ThemePicker.astro` | Site themes grouped by family + curated code themes | | `LangToggle.astro` | Language switch (renders only when `langs` is set) | | `Switcher.astro` | Generic segmented switcher (renders a `SwitcherConfig`) | | `FrameworkPicker.astro` | Framework switch (renders only when `frameworks` is set) | -| `ScmMenu.astro` | Source-control menu (renders when `scm`/`repo` is set) | +| `ScmMenu.astro` | Source-control link (one) or popover menu (many) | | `Mark.astro` | Logo/mark image, with the `@kolektiv/brand-core` icon mark as fallback | | `BuiltByMark.astro` | Always-on "Built by Kolektiv Computing" mark | | `SearchDialog.astro` | Client-side search over the configured nav | @@ -269,7 +273,7 @@ component names to import paths). | `base` | `string` | `/` | Astro base path. | | `logo` | `string` | — | Logo/wordmark URL for the navbar. | | `mark` | `string` | — | Icon URL; falls back to the `@kolektiv/brand-core` icon mark. | -| `repo` | `RepoConfig` | — | `{ url, branch?, editBaseUrl? }`. | +| `repo` | `RepoConfig` | — | `{ url, branch?, editBaseUrl?, remotes? }`. | | `nav` | `NavSection[]` | `[]` | Sidebar/nav model. | | `defaultTheme` | `string` | — | Required. Applied on first visit + SSR. | | `defaultCodeTheme` | `string` | `follow` | A theme id or `follow`. | @@ -282,7 +286,7 @@ component names to import paths). | `defaultFramework` | `string` | first `frameworks` | SSR default. | | `switchers` | `SwitcherConfig[]` | `[]` | Extra generic switchers (see below). | | `navbar` | `NavbarConfig` | all shown | Toggle built-in controls and add navbar links (see below). | -| `scm` | `FooterLink[]` | derived from `repo` | Source-control menu links. | +| `scm` | `FooterLink[]` | `repo.remotes`, else one `Source` link for `repo.url` | Source-control links; deduplicated by `href`. | | `footer.links` | `FooterLink[]` | `[]` | Footer link column. | | `footer.copyright` | `string` | `© {year} Kolektiv Computing` | `{year}` is replaced. | | `footer.tagline` | `string` | — | Blurb next to the built-by mark. | @@ -346,7 +350,34 @@ selected by `data-code-theme`: - `follow` (default) tracks the site theme. - Any theme id pins code blocks to that theme. -`ThemePicker` offers `Follow site theme` plus every registered theme. +`ThemePicker` groups the site themes by family (one `menu-title` heading per +family, ordered by `themeFamilies`) and offers a **curated** code-theme list — +`Follow site theme`, the Catppuccin flavours (shown by flavour, e.g. `Mocha`), +Nord, and the Kolektiv palettes. The curated list is restricted to the themes +that exist for the site, then any `config.themes` are appended, so custom site +themes appear in both lists. `codeThemeOptions(config)` returns that list and +`CODE_THEME_OPTIONS` is the curated constant. + +## Source-control links + +`ScmMenu` renders the deduplicated `scm` list. With **one** link it is a single +icon link (`title` / `aria-label` = the link label; external links get +`target`/`rel`); with **several** it opens the popover menu; with **none** it +renders nothing. + +By default `scm` is derived from `repo`: `repo.remotes` when supplied +(deduplicated by `href`, order preserved), otherwise a single +`{ label: 'Source', href: repo.url }`. An explicit `config.scm` always wins. + +```ts +repo: { + url: 'https://github.com/KolektivComputer/keel', + remotes: [ + { label: 'GitHub', href: 'https://github.com/KolektivComputer/keel' }, + { label: 'yuri.capital', href: 'https://yuri.capital/keel' }, + ], +}, +``` ## Generic switchers diff --git a/src/astro/ScmMenu.astro b/src/astro/ScmMenu.astro index 4b849c1..7f2b1b7 100644 --- a/src/astro/ScmMenu.astro +++ b/src/astro/ScmMenu.astro @@ -1,15 +1,16 @@ --- -import { isExternal, type DocsChromeConfig } from '@kolektiv/common-docs-chrome'; +import { isExternal, type DocsChromeConfig, type FooterLink } from '@kolektiv/common-docs-chrome'; interface Props { config: DocsChromeConfig; } const { config } = Astro.props; -const links: { label: string; href: string }[] = []; +const links: FooterLink[] = []; for (const link of config.scm ?? []) { if (!links.some((existing) => existing.href === link.href)) links.push(link); } +const single = links.length === 1 ? links[0] : undefined; const id = 'kdc-scm-pop'; const anchor = '--kdc-scm'; --- @@ -17,51 +18,78 @@ const anchor = '--kdc-scm'; { links.length > 0 && ( <> - - + + + + + + + + + )} ) } diff --git a/src/astro/ThemePicker.astro b/src/astro/ThemePicker.astro index 450ff76..7fd074b 100644 --- a/src/astro/ThemePicker.astro +++ b/src/astro/ThemePicker.astro @@ -1,5 +1,6 @@ --- import { + codeThemeOptions, groupChromeThemes, resolveTheme, siteThemes, @@ -14,14 +15,22 @@ interface Props { const { config } = Astro.props; const themes = siteThemes(config); const groups = groupChromeThemes(themes, config.themeFamilies ?? []); +const codeThemes = codeThemeOptions(config); const defaultCodeTheme = config.defaultCodeTheme ?? 'follow'; -const defaultTheme = resolveTheme(config.defaultTheme, themes) ?? themes[0]; +const defaultTheme = resolveTheme(config.defaultTheme, themes); const id = 'kdc-theme-pop'; const anchor = '--kdc-theme'; +/** Catppuccin flavours drop the redundant family prefix. */ +function displayLabel(theme: ChromeTheme): string { + return theme.label.replace(/^Catppuccin\s+/, ''); +} + function swatch(theme: ChromeTheme): string { return theme.colors?.['base-100'] ?? (theme.scheme === 'dark' ? '#1d232a' : '#ffffff'); } + +const currentLabel = defaultTheme ? displayLabel(defaultTheme) : config.defaultTheme; --- diff --git a/src/core/config.ts b/src/core/config.ts index c62a20e..b67902f 100644 --- a/src/core/config.ts +++ b/src/core/config.ts @@ -86,6 +86,11 @@ export interface RepoConfig { branch?: string; /** Base URL for "edit this page" links; used to derive the edit link. */ editBaseUrl?: string; + /** + * Source-control links. When supplied these become `scm` (deduplicated by + * `href`); otherwise `scm` falls back to a single `Source` link for `url`. + */ + remotes?: FooterLink[]; } export interface BuiltByConfig { @@ -191,20 +196,35 @@ export function resolveNavbar( return { ...flags, links: links ?? [] }; } +/** Drop duplicate links, keeping the first occurrence of each `href`. */ +function dedupeLinks(links: FooterLink[]): FooterLink[] { + const seen = new Set(); + const out: FooterLink[] = []; + for (const link of links) { + if (seen.has(link.href)) continue; + seen.add(link.href); + out.push(link); + } + return out; +} + +/** + * Source-control links derived from `repo`: its `remotes` when supplied, + * otherwise a single `Source` link for `repo.url`. + */ +export function deriveScm(repo: RepoConfig | undefined): FooterLink[] { + if (!repo) return []; + const links = repo.remotes ?? [{ label: 'Source', href: repo.url }]; + return dedupeLinks(links); +} + /** * Fill in defaults for a site config. Returns a new object; the input is not * mutated. */ export function defineDocsChrome(config: DocsChromeConfig): DocsChromeConfig { const repo = config.repo; - const scm = - config.scm ?? - (repo - ? [ - { label: 'Source', href: repo.url }, - { label: 'yuri.capital', href: repo.url }, - ] - : []); + const scm = config.scm ?? deriveScm(repo); return { ...config, title: config.title ?? config.name, diff --git a/src/core/themes.ts b/src/core/themes.ts index d4a8fe5..963230e 100644 --- a/src/core/themes.ts +++ b/src/core/themes.ts @@ -210,6 +210,49 @@ export function siteThemes( return [...byId.values()]; } +export interface CodeThemeOption { + id: string; + label: string; +} + +/** Curated code themes: follow + the Kolektiv palettes, never daisyUI. */ +export const CODE_THEME_OPTIONS: CodeThemeOption[] = [ + { id: 'follow', label: 'Follow site theme' }, + { id: 'catppuccin-latte', label: 'Latte' }, + { id: 'catppuccin-frappe', label: 'Frappé' }, + { id: 'catppuccin-macchiato', label: 'Macchiato' }, + { id: 'catppuccin-mocha', label: 'Mocha' }, + { id: 'nord', label: 'Nord' }, + { id: 'kolektiv-light', label: 'Kolektiv Light' }, + { id: 'kolektiv-dark', label: 'Kolektiv Dark' }, +]; + +/** + * Curated code themes limited to themes that exist for this site, plus any + * `config.themes`, preserving order and keeping `follow` first. + */ +export function codeThemeOptions( + config: Pick< + DocsChromeConfig, + 'themes' | 'themeFamilies' | 'themeFamily' + > = {}, +): CodeThemeOption[] { + const available = new Set(siteThemes(config).map((theme) => theme.id)); + const options: CodeThemeOption[] = []; + const seen = new Set(); + for (const option of CODE_THEME_OPTIONS) { + if (option.id !== 'follow' && !available.has(option.id)) continue; + options.push(option); + seen.add(option.id); + } + for (const theme of config.themes ?? []) { + if (seen.has(theme.id)) continue; + options.push({ id: theme.id, label: theme.label }); + seen.add(theme.id); + } + return options; +} + function themeCssForOne(theme: ChromeTheme): string { const ids = [theme.id, ...(theme.aliases ?? [])]; const selectors = ids.flatMap((id) => [ diff --git a/test/config.test.ts b/test/config.test.ts index b694a22..f24dd90 100644 --- a/test/config.test.ts +++ b/test/config.test.ts @@ -73,6 +73,59 @@ describe('defineDocsChrome', () => { expect(config.scm?.[0]?.href).toBe('https://github.com/KolektivComputer/keel'); }); + it('derives a single Source link from repo.url', () => { + const cfg = defineDocsChrome({ + name: 'X', + description: 'd', + siteUrl: 'https://x.example', + defaultTheme: 'dark', + nav: [], + repo: { url: 'https://github.com/KolektivComputer/keel' }, + }); + expect(cfg.scm).toEqual([ + { label: 'Source', href: 'https://github.com/KolektivComputer/keel' }, + ]); + }); + + it('derives scm from repo.remotes, deduped and ordered', () => { + const cfg = defineDocsChrome({ + name: 'X', + description: 'd', + siteUrl: 'https://x.example', + defaultTheme: 'dark', + nav: [], + repo: { + url: 'https://github.com/KolektivComputer/keel', + remotes: [ + { label: 'GitHub', href: 'https://github.com/KolektivComputer/keel' }, + { label: 'yuri.capital', href: 'https://yuri.capital/keel' }, + { label: 'Duplicate', href: 'https://github.com/KolektivComputer/keel' }, + ], + }, + }); + expect(cfg.scm).toEqual([ + { label: 'GitHub', href: 'https://github.com/KolektivComputer/keel' }, + { label: 'yuri.capital', href: 'https://yuri.capital/keel' }, + ]); + }); + + it('lets an explicit scm win over the repo derivation', () => { + const scm = [{ label: 'Mirror', href: 'https://mirror.example/keel' }]; + const cfg = defineDocsChrome({ + name: 'X', + description: 'd', + siteUrl: 'https://x.example', + defaultTheme: 'dark', + nav: [], + scm, + repo: { + url: 'https://github.com/KolektivComputer/keel', + remotes: [{ label: 'GitHub', href: 'https://github.com/KolektivComputer/keel' }], + }, + }); + expect(cfg.scm).toBe(scm); + }); + it('does not mutate the input', () => { const input = { name: 'X', diff --git a/test/themes.test.ts b/test/themes.test.ts index 00706e0..54675ed 100644 --- a/test/themes.test.ts +++ b/test/themes.test.ts @@ -2,7 +2,9 @@ import { describe, expect, it } from 'vitest'; import { defineDocsChrome } from '../src/core/config.js'; import { + CODE_THEME_OPTIONS, allChromeThemes, + codeThemeOptions, groupChromeThemes, isBuiltinPalette, resolveTheme, @@ -145,6 +147,51 @@ describe('shikiThemeForChrome', () => { }); }); +describe('CODE_THEME_OPTIONS', () => { + it('starts with follow and lists only the curated Kolektiv palettes', () => { + expect(CODE_THEME_OPTIONS[0]?.id).toBe('follow'); + expect(CODE_THEME_OPTIONS.map((option) => option.id)).toEqual([ + 'follow', + 'catppuccin-latte', + 'catppuccin-frappe', + 'catppuccin-macchiato', + 'catppuccin-mocha', + 'nord', + 'kolektiv-light', + 'kolektiv-dark', + ]); + }); + + it('never offers the daisyUI built-ins', () => { + const ids = CODE_THEME_OPTIONS.map((option) => option.id); + expect(ids).not.toContain('light'); + expect(ids).not.toContain('dark'); + }); +}); + +describe('codeThemeOptions', () => { + it('keeps follow first and applies the family restriction', () => { + const ids = codeThemeOptions(SITE_CONFIG).map((option) => option.id); + expect(ids[0]).toBe('follow'); + expect(ids).toContain('catppuccin-mocha'); + expect(ids).toContain('kolektiv-dark'); + expect(ids).not.toContain('nord'); + expect(ids).not.toContain('light'); + }); + + it('appends custom site themes without duplicates, after the curated list', () => { + const ids = codeThemeOptions(SITE_CONFIG).map((option) => option.id); + expect(ids).toContain('kalendee-dark'); + expect(new Set(ids).size).toBe(ids.length); + expect(ids.indexOf('kalendee-dark')).toBeGreaterThan(ids.indexOf('kolektiv-dark')); + }); + + it('offers nothing but follow when only daisyUI is allowed', () => { + const ids = codeThemeOptions({ themeFamilies: ['daisyui'] }).map((option) => option.id); + expect(ids).toEqual(['follow']); + }); +}); + describe('themeCssFor', () => { it('emits daisyUI variables for site themes', () => { const css = themeCssFor([resolveTheme('kalendee-dark', siteThemes(SITE_CONFIG))!]);