Files
codegraph/src/ui-server/highlight/theme.ts
T
Colby McHenry 2ad836d935 feat(ui): highlight source server-side with a near-monochrome Shiki theme (CG-43)
The viewer's code block stops lexing with a hand-rolled dialect table and
reads real TextMate grammars instead, run once in `/api/source`.

Three things make that safe to depend on:

* Highlighting never fails a request. A missing grammar, an oversized
  slice, an ESM import that did not resolve — every one of them answers
  `engine: 'plain'` with a reason and the source still goes out.
* Identifiers survive whatever token boundaries a grammar chose. Every
  code token is split into identifier runs before it goes on the wire, so
  the graph's call-site overlay claims a token the highlighter produced
  rather than re-cutting the line. `assignRefs` now matches on a token's
  text rather than on the class a grammar gave it, so a language that
  scopes type names as `storage.type` still links.
* The theme classifies rather than colours: its foregrounds are sentinels
  the server maps back to class names, and the viewer paints them from
  CSS custom properties — one token stream serves light and dark with no
  refetch, and the ramp lives only in app.css.

Comments move from --ink-3 to a new --code-comment. --ink-3 measures
3.46:1 on paper and 3.00:1 on the hot-line tint, both under AA for 12.5px
text; --code-comment is the smallest step along the same ramp that clears
4.5:1 on every background a code line can have, and stays quieter than
the strings and numbers above it.

Shipping: @shikijs/core and @shikijs/engine-javascript are runtime
dependencies (no wasm, no native module); @shikijs/langs stays a
devDependency and `npm run build:textmate` writes only the closure the
engine's 40-odd languages reach — 56 grammars, 2.6 MB, against 11 MB for
all 722. check-ui-build.mjs asserts the tree after every build and inside
every release archive.
2026-08-27 01:23:10 -05:00

116 lines
4.4 KiB
TypeScript

/**
* The near-monochrome code theme (design spec §2.2).
*
* The colouring is deliberately almost absent: comments and strings recede,
* keywords carry weight rather than hue, and the ONLY colour in the body is a
* resolved call site. A six-colour syntax theme buries exactly the thing the
* screen exists to show.
*
* ## Why the theme's colours are sentinels, not colours
*
* A TextMate theme classifies by mapping scopes to colours, so that is how the
* classification is *expressed* — but the values here are placeholders that
* mean "comment", "string", "keyword", "number", nothing. The server turns each
* one back into a class name; the viewer paints it from a CSS custom property.
*
* That indirection is load-bearing, not decoration:
*
* * **One token stream serves both modes.** The viewer flips light/dark from
* `prefers-color-scheme` with no reload and no refetch. Baking `#6a675d` into
* the payload would make dark mode a second request for the same source, and
* would put the design tokens in two places at once.
* * **Contrast is fixed where the tokens live.** `ui/src/app.css` owns the
* ramp; a colour change there cannot leave the server's copy behind.
*
* The sentinels are arbitrary but must be distinct and must never be a colour a
* grammar could plausibly emit through some other path, hence the `#00000n`
* block: TextMate themes only ever return values *this* theme defines.
*/
/** The classes a token can carry — the viewer's `TokenClass`, server side. */
export const TOKEN_CLASSES = ['other', 'ident', 'comment', 'string', 'keyword', 'number'] as const;
export type TokenClassName = (typeof TOKEN_CLASSES)[number];
/** Class name → its index in {@link TOKEN_CLASSES}, which is what the wire carries. */
export const CLASS_ID: Record<TokenClassName, number> = {
other: 0,
ident: 1,
comment: 2,
string: 3,
keyword: 4,
number: 5,
};
const FG_DEFAULT = '#000001';
const FG_COMMENT = '#000002';
const FG_STRING = '#000003';
const FG_KEYWORD = '#000004';
const FG_NUMBER = '#000005';
/** Sentinel foreground → the class it stands for. */
export const SENTINEL_CLASS: Record<string, TokenClassName> = {
[FG_DEFAULT]: 'other',
[FG_COMMENT]: 'comment',
[FG_STRING]: 'string',
[FG_KEYWORD]: 'keyword',
[FG_NUMBER]: 'number',
};
/**
* The theme itself.
*
* Scope selection follows the spec exactly: `comment` recedes furthest,
* `string`/`constant.numeric` sit one step in, `keyword`/`storage` stay ink and
* gain weight, everything else is ink. Nothing sets a background — a token that
* painted its own would fight the hovered-line and hot-line tints the rails use
* to point at it.
*/
export const MONO_THEME = {
name: 'codegraph-mono',
type: 'light' as const,
fg: FG_DEFAULT,
// TextMate wants a background; the viewer never reads it (the code block
// paints `--paper`), and it must not equal a foreground sentinel.
bg: '#ffffff',
settings: [
{ settings: { foreground: FG_DEFAULT } },
{ scope: ['comment', 'punctuation.definition.comment'], settings: { foreground: FG_COMMENT } },
{
scope: [
'string',
'string.template',
'punctuation.definition.string',
'constant.character.escape',
],
settings: { foreground: FG_STRING },
},
{
scope: ['constant.numeric', 'constant.language', 'keyword.other.unit'],
settings: { foreground: FG_NUMBER },
},
{
scope: ['keyword', 'keyword.control', 'storage', 'storage.type', 'storage.modifier'],
settings: { foreground: FG_KEYWORD },
},
// `keyword.operator` is a keyword scope by name only: it covers `=`, `+`,
// `=>` and `?.`. Weighting punctuation buys nothing and costs the calm the
// rest of the block is built on, so it drops back to plain ink — while the
// operators that are actually WORDS (`new`, `typeof`, `instanceof`, `in`)
// keep their weight through the more specific rule below. Shiki resolves
// the longest matching scope, so the order here is the order of rescue,
// not of priority.
{ scope: ['keyword.operator'], settings: { foreground: FG_DEFAULT } },
{
scope: ['keyword.operator.expression', 'keyword.operator.word', 'keyword.operator.new'],
settings: { foreground: FG_KEYWORD },
},
],
};
/** The class a Shiki token's resolved colour stands for. */
export function classOf(color: string | undefined): TokenClassName {
if (!color) return 'other';
return SENTINEL_CLASS[color.toLowerCase()] ?? 'other';
}