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.
This commit is contained in:
@@ -0,0 +1,115 @@
|
||||
/**
|
||||
* 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';
|
||||
}
|
||||
Reference in New Issue
Block a user