Skip to content

Commit 9f665ea

Browse files
angusbezzinaclaude
andcommitted
feat(web): syntax highlighting on the code plates
A forty-line tokenizer for the three grammars the page actually ships (Swift, the note block, one shell line) instead of a highlighter library. Keywords take the accent, comments the muted ink; two new palette tokens carry strings and types and clear 4.5:1 on paper-2 in both themes. Plain tokens stay bare text, so the copied string and the a11y tree are unchanged. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent ae62868 commit 9f665ea

6 files changed

Lines changed: 185 additions & 1 deletion

File tree

web/scripts/contrast.mjs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -117,6 +117,8 @@ const PAIRS = [
117117
["neutral", "paper", 3.0, "hairline labels (large/decorative only)"],
118118
["accent", "paper", 4.5, "section numbers, link underlines, error-free marks"],
119119
["accent", "paper-2", 4.5, "accent inside the code block"],
120+
["syntax-string", "paper-2", 4.5, "string literals in the code block"],
121+
["syntax-type", "paper-2", 4.5, "type names in the code block"],
120122
["accent-ink", "accent", 4.5, "the submit, and the sub-footer band"],
121123
["focus", "paper", 3.0, "focus ring against the page"],
122124
["focus", "paper-2", 3.0, "focus ring against a tinted field"],

web/src/components/CodeBlock.tsx

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { Check, Copy, X } from "lucide-react";
44
import { Icon } from "./Icon";
55
import { codeBlock } from "../copy";
66
import { withCode } from "../markup";
7+
import { tokenize } from "../highlight";
78

89
/**
910
* A code sample in a typographic frame — a caption row above a hairline
@@ -94,7 +95,19 @@ export function CodeBlock({
9495
<ScrollArea.Root className="code__plate" type="auto">
9596
<ScrollArea.Viewport className="code__viewport" tabIndex={0}>
9697
<pre className="code__pre">
97-
<code data-language={language}>{code}</code>
98+
<code data-language={language}>
99+
{/* Paint only: plain tokens stay bare text, so the string the
100+
* tree exposes is exactly the one the copy button writes. */}
101+
{tokenize(code, language).map((token, i) =>
102+
token.kind === "plain" ? (
103+
token.text
104+
) : (
105+
<span key={i} className={`tok tok--${token.kind}`}>
106+
{token.text}
107+
</span>
108+
),
109+
)}
110+
</code>
98111
</pre>
99112
</ScrollArea.Viewport>
100113
<ScrollArea.Scrollbar className="code__scrollbar" orientation="horizontal">

web/src/highlight.ts

Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
1+
/**
2+
* Syntax highlighting for the five snippets on the page, and nothing more.
3+
*
4+
* Hand-rolled rather than a library: the page ships three Swift snippets, one
5+
* note block and one shell line, and a general-purpose highlighter would be
6+
* the largest thing in the bundle by a wide margin for the sake of maybe forty
7+
* tokens. The grammars below are tuned to exactly what `copy.ts` contains; a
8+
* construct they do not know falls through as plain text, never as a wrong
9+
* colour.
10+
*
11+
* The one invariant worth a test: concatenating the tokens' text gives back
12+
* the input byte for byte. Highlighting is paint, not content — the copy
13+
* button and the accessibility tree both see the original string.
14+
*/
15+
16+
export type TokenKind = "plain" | "keyword" | "type" | "string" | "comment" | "heading" | "field";
17+
18+
export type Token = { kind: TokenKind; text: string };
19+
20+
type Rule = { kind: TokenKind; re: RegExp };
21+
22+
/**
23+
* Swift. Keywords and compiler directives take the accent; capitalised
24+
* identifiers read as types (which on this page they all are: modules,
25+
* namespaces, views, sinks); strings and comments get their own inks.
26+
*/
27+
const swift: Rule[] = [
28+
{ kind: "comment", re: /\/\/[^\n]*/y },
29+
{ kind: "string", re: /"(?:[^"\\]|\\.)*"/y },
30+
{ kind: "keyword", re: /#(?:if|endif|else|elseif)\b|\b(?:import|let|var|func|struct|return|some|true|false|nil)\b/y },
31+
// A type has a lowercase letter in it; an all-caps word (`DEBUG`) is a
32+
// compilation condition and stays plain, as it does in Xcode.
33+
{ kind: "type", re: /\b[A-Z](?=[A-Za-z0-9_]*[a-z])[A-Za-z0-9_]*\b/y },
34+
{ kind: "plain", re: /[A-Za-z_][A-Za-z0-9_]*|\s+|./y },
35+
];
36+
37+
/**
38+
* The note block. A heading line, then `**Field**: value` lines, then the
39+
* comment as prose. Quoted text inside a value (the element's label) reads as
40+
* a string, the selector after the dash in the heading reads as a type: it is
41+
* the identifier an agent greps for.
42+
*/
43+
const markdown: Rule[] = [
44+
{ kind: "heading", re: /^##[^\n]*/my },
45+
{ kind: "field", re: /^\*\*[^*\n]+\*\*:/my },
46+
{ kind: "string", re: /"(?:[^"\\]|\\.)*"/y },
47+
{ kind: "plain", re: /[^\n"*]+|./y },
48+
];
49+
50+
/** One shell line: the command word is the keyword, its argument a path. */
51+
const sh: Rule[] = [
52+
{ kind: "comment", re: /#[^\n]*/y },
53+
{ kind: "keyword", re: /^\s*[a-z][\w-]*/my },
54+
{ kind: "string", re: /\S*\//y },
55+
{ kind: "plain", re: /\S+|\s+/y },
56+
];
57+
58+
const grammars: Record<string, Rule[]> = { swift, markdown, sh };
59+
60+
export function tokenize(code: string, language: string): Token[] {
61+
const rules = grammars[language];
62+
if (!rules) return [{ kind: "plain", text: code }];
63+
64+
const out: Token[] = [];
65+
let i = 0;
66+
while (i < code.length) {
67+
let matched = false;
68+
for (const { kind, re } of rules) {
69+
re.lastIndex = i;
70+
const m = re.exec(code);
71+
if (m && m[0].length > 0) {
72+
push(out, kind, m[0]);
73+
i += m[0].length;
74+
matched = true;
75+
break;
76+
}
77+
}
78+
if (!matched) {
79+
// Unreachable while every grammar ends in a catch-all, but a silent
80+
// infinite loop is the worst possible failure, so advance regardless.
81+
push(out, "plain", code.charAt(i));
82+
i += 1;
83+
}
84+
}
85+
return out;
86+
}
87+
88+
/** Merge adjacent tokens of one kind so the DOM carries as few spans as the text allows. */
89+
function push(out: Token[], kind: TokenKind, text: string) {
90+
const last = out[out.length - 1];
91+
if (last && last.kind === kind) last.text += text;
92+
else out.push({ kind, text });
93+
}

web/src/styles/base.css

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -825,6 +825,32 @@ pre {
825825
font-size: inherit;
826826
}
827827

828+
/* Syntax. Keywords take the accent the plate's border already wears, comments
829+
* step back to the muted ink, and strings and types get the two inks added
830+
* for them. Fields in the note block are bold ink rather than a colour: they
831+
* are labels, and the eye should read across them to the value. */
832+
.tok--keyword,
833+
.tok--heading {
834+
color: var(--color-accent);
835+
}
836+
837+
.tok--heading,
838+
.tok--field {
839+
font-weight: var(--weight-body-strong);
840+
}
841+
842+
.tok--comment {
843+
color: var(--color-muted);
844+
}
845+
846+
.tok--string {
847+
color: var(--color-syntax-string);
848+
}
849+
850+
.tok--type {
851+
color: var(--color-syntax-type);
852+
}
853+
828854
/* Horizontal only — code overflows sideways, never down. `type="auto"` means
829855
* the bar exists only while there is something to scroll to. */
830856
.code__scrollbar {

web/src/styles/tokens.css

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -85,6 +85,13 @@
8585
--color-accent-ink: oklch(97.5% 0.008 78); /* text on an accent fill */
8686
--color-focus: oklch(45% 0.160 45);
8787
--color-error: oklch(47% 0.180 25);
88+
/* Syntax inks for the code plates. Two, not a rainbow: the accent already
89+
* carries keywords and the muted ink carries comments, so the only colours
90+
* the grammar needs that the palette lacks are a string and a type. Both
91+
* sit at the ink's weight, one cool and one green, and both clear 4.5:1 on
92+
* paper-2 in each theme (see scripts/contrast.mjs). */
93+
--color-syntax-string: oklch(42% 0.090 160);
94+
--color-syntax-type: oklch(40% 0.100 250);
8895

8996
/* GPU CLI's brand green, and the page's one borrowed colour. It exists so
9097
* the subfoot link hovers into the org's own ink rather than AnnotKit's,
@@ -209,6 +216,8 @@
209216
--color-accent-ink: oklch(19% 0.014 60); /* text on an accent fill */
210217
--color-focus: oklch(78% 0.130 55);
211218
--color-error: oklch(72% 0.150 25);
219+
--color-syntax-string: oklch(80% 0.100 160);
220+
--color-syntax-type: oklch(80% 0.085 240);
212221
--color-gpu: #19dc6a; /* the same brand value — see the light block */
213222

214223
--tint-hairline: color-mix(in oklch, var(--color-ink) 14%, transparent);

web/tests/highlight.test.ts

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
import { describe, expect, it } from "vitest";
2+
import { tokenize } from "../src/highlight";
3+
import { agentNotes, install, mcpBridge } from "../src/copy";
4+
5+
const snippets = [
6+
["swift", install.appKit.code],
7+
["swift", install.swiftUI.code],
8+
["swift", install.sink.code],
9+
["markdown", agentNotes.sample.code],
10+
["sh", mcpBridge.command.code],
11+
] as const;
12+
13+
describe("the highlighter", () => {
14+
it("gives back every snippet byte for byte", () => {
15+
for (const [language, code] of snippets) {
16+
expect(tokenize(code, language).map((t) => t.text).join("")).toBe(code);
17+
}
18+
});
19+
20+
it("leaves an unknown language alone", () => {
21+
expect(tokenize("anything", "cobol")).toEqual([{ kind: "plain", text: "anything" }]);
22+
});
23+
24+
it("reads the Swift it is given", () => {
25+
const kinds = Object.fromEntries(
26+
tokenize(install.appKit.code, "swift").map((t) => [t.text.trim(), t.kind]),
27+
);
28+
expect(kinds["import"]).toBe("keyword");
29+
expect(kinds["#if"]).toBe("keyword");
30+
expect(kinds["AnnotKit"]).toBe("type");
31+
expect(kinds["DEBUG"]).toBe("plain");
32+
expect(kinds["// floating toolbar; click a view, type a note"]).toBe("comment");
33+
});
34+
35+
it("reads the note block", () => {
36+
const tokens = tokenize(agentNotes.sample.code, "markdown");
37+
expect(tokens[0]?.kind).toBe("heading");
38+
expect(tokens.some((t) => t.kind === "field" && t.text === "**Timestamp**:")).toBe(true);
39+
expect(tokens.some((t) => t.kind === "string" && t.text === '"Save"')).toBe(true);
40+
});
41+
});

0 commit comments

Comments
 (0)