One shared scanner-based highlighter (web/src/highlight.ts) replaces the two regex one-offs: real lexer states for strings/templates/comments, distinct token colors (strings green, numbers/literals violet, keywords amber, function names yellow, state/memory/api builtins blue). Proof rows become participant chips in place order — initials, crown glyph on the winner, full result in the hover — instead of a sentence per sim. Date filter inputs stay text-dim until they hold a value; picker glyph dimmed.
- commit
- fbb5627df1448f5f3d8d5d02d195b6e00c13e696
- parent
- 46dbdaf130b19acc3d476dc43869b1650c18af2f
- author
- Dami <dami.etoile@gmail.com>
| A | web/ | 140 | ++++++++++++++++++++++++++++++ |
| M | web/ | 30 | ++++++- |
| M | web/ | 67 | +++++++-------- |
| M | web/ | 27 | +----- |
| M | web/ | 7 | +- |
@@ -0,0 +1,140 @@
1+/**2+ * The one JS syntax highlighter (Create's source viewer + Browse's shared3+ * source). Scanner-based — strings, templates, and comments are real lexer4+ * states, so a `//` inside a string never eats the line. Token classes are5+ * styled centrally in style.css (.tok-*).6+ *7+ * Domain-aware: the tribe-script trinity (state, memory, api) gets its own8+ * color — a reader's eye finds the game surface instantly.9+ */10+11+const KEYWORDS = new Set([12+ 'break', 'case', 'catch', 'class', 'const', 'continue', 'default', 'delete',13+ 'do', 'else', 'finally', 'for', 'function', 'if', 'in', 'instanceof', 'let',14+ 'new', 'of', 'return', 'switch', 'throw', 'try', 'typeof', 'var', 'while',15+ 'yield', 'await', 'async',16+]);17+const LITERALS = new Set(['true', 'false', 'null', 'undefined', 'NaN', 'Infinity']);18+const BUILTINS = new Set(['state', 'memory', 'api', 'Math', 'JSON']);19+20+const isWordChar = (c: string): boolean => /[A-Za-z0-9_$]/.test(c);21+const isDigit = (c: string): boolean => c >= '0' && c <= '9';22+23+/** Render source into a target element as colored spans (no innerHTML). */24+export function renderHighlighted(target: HTMLElement, source: string): void {25+ target.textContent = '';26+ const frag = document.createDocumentFragment();27+ const emit = (text: string, cls?: string): void => {28+ if (text === '') return;29+ if (cls === undefined) {30+ frag.append(document.createTextNode(text));31+ } else {32+ const span = document.createElement('span');33+ span.className = cls;34+ span.textContent = text;35+ frag.append(span);36+ }37+ };38+39+ const n = source.length;40+ let i = 0;41+ let plain = '';42+ const flush = (): void => {43+ emit(plain);44+ plain = '';45+ };46+47+ while (i < n) {48+ const c = source[i]!;49+50+ // Comments.51+ if (c === '/' && source[i + 1] === '/') {52+ flush();53+ let j = i;54+ while (j < n && source[j] !== '\n') j++;55+ emit(source.slice(i, j), 'tok-comment');56+ i = j;57+ continue;58+ }59+ if (c === '/' && source[i + 1] === '*') {60+ flush();61+ let j = i + 2;62+ while (j + 1 < n && !(source[j] === '*' && source[j + 1] === '/')) j++;63+ j = Math.min(n, j + 2);64+ emit(source.slice(i, j), 'tok-comment');65+ i = j;66+ continue;67+ }68+69+ // Strings (quotes and templates; templates kept whole — good enough70+ // for display, and interpolations stay legible inside the color).71+ if (c === '"' || c === "'" || c === '`') {72+ flush();73+ let j = i + 1;74+ while (j < n) {75+ if (source[j] === '\\') {76+ j += 2;77+ continue;78+ }79+ if (source[j] === c) {80+ j++;81+ break;82+ }83+ if (c !== '`' && source[j] === '\n') break; // unterminated line string84+ j++;85+ }86+ emit(source.slice(i, j), 'tok-string');87+ i = j;88+ continue;89+ }90+91+ // Numbers (int, float, hex).92+ if (isDigit(c) || (c === '.' && isDigit(source[i + 1] ?? ''))) {93+ flush();94+ let j = i;95+ if (c === '0' && (source[i + 1] === 'x' || source[i + 1] === 'X')) {96+ j = i + 2;97+ while (j < n && /[0-9a-fA-F]/.test(source[j]!)) j++;98+ } else {99+ while (j < n && /[0-9._eE+-]/.test(source[j]!)) {100+ // Stop signs that end a numeric literal in practice.101+ if ((source[j] === '+' || source[j] === '-') && !/[eE]/.test(source[j - 1]!)) break;102+ j++;103+ }104+ }105+ emit(source.slice(i, j), 'tok-number');106+ i = j;107+ continue;108+ }109+110+ // Words: keywords, literals, builtins, function names, properties.111+ if (isWordChar(c) && !isDigit(c)) {112+ let j = i;113+ while (j < n && isWordChar(source[j]!)) j++;114+ const word = source.slice(i, j);115+ const prevChar = ((): string => {116+ let k = i - 1;117+ while (k >= 0 && (source[k] === ' ' || source[k] === '\t')) k--;118+ return k >= 0 ? source[k]! : '';119+ })();120+ let k = j;121+ while (k < n && (source[k] === ' ' || source[k] === '\t')) k++;122+ const nextChar = k < n ? source[k]! : '';123+124+ flush();125+ if (KEYWORDS.has(word)) emit(word, 'tok-keyword');126+ else if (LITERALS.has(word)) emit(word, 'tok-literal');127+ else if (BUILTINS.has(word) && prevChar !== '.') emit(word, 'tok-builtin');128+ else if (nextChar === '(') emit(word, 'tok-fn');129+ else if (prevChar === '.') emit(word, 'tok-prop');130+ else emit(word);131+ i = j;132+ continue;133+ }134+135+ plain += c;136+ i++;137+ }138+ flush();139+ target.append(frag);140+}@@ -163,7 +163,29 @@ button.heart-btn:active:not(:disabled) { background: none; color: var(--accent);
163163 }164164 165165 /* 1-bit-adjacent highlighter tones: dim comments, accent strings, pale keywords */166-.hl-com { color: var(--text-dim); font-style: italic; }167-.hl-str { color: var(--accent); }168-.hl-num { color: var(--ok); }169-.hl-kw { color: var(--border-bright); font-weight: 700; }166+167+/* Proof rows: participant chips in place order, crown on the winner */168+td.proof-results { white-space: nowrap; padding: 0.45rem 0.5rem; }169+.proof-chip {170+ position: relative;171+ display: inline-flex; align-items: center; justify-content: center;172+ min-width: 2.1em; height: 2.1em; padding: 0 0.3em;173+ margin-right: 0.45em; margin-top: 0.25em;174+ border: 1px solid var(--border-bright);175+ color: var(--text); font-family: var(--pixel);176+ letter-spacing: 0.05em; cursor: default;177+}178+.proof-chip.self { border-color: var(--accent); color: var(--accent); }179+.proof-chip.winner { border-color: var(--accent); }180+.proof-chip .crown {181+ position: absolute; top: -0.85em; right: -0.4em;182+ color: var(--accent); font-size: 0.95em; line-height: 1;183+}184+185+/* Date pickers: template text stays quiet until a date is actually set */186+input[type='date'].date-input { color: var(--text-dim); }187+input[type='date'].date-input.has-value { color: var(--text); }188+input[type='date'].date-input::-webkit-calendar-picker-indicator {189+ opacity: 0.35;190+ filter: invert(0.75);191+}@@ -1,5 +1,6 @@
11 /** Browse: public script gallery — ranked list + per-script proof detail. */22 import './browse.css';3+import { renderHighlighted } from '../highlight';34 import type { BrowseWindow, ModelRecord, PublicScriptDetail, PublicScriptListing } from '@tribes/protocol';45 import { social, type BrowseQuery, type BrowseSort } from '../api-social';56 import { h, clear } from '../dom';@@ -15,6 +16,19 @@ export function renderBrowse(main: HTMLElement, ctx: AppCtx, id?: string): void
1516 // Shared bits1617 // --------------------------------------------------------------------------1718 19+/** Two stable initials for a chip ("Stranglehold Raider" -> "SR"). */20+function chipInitials(title: string): string {21+ const words = title.split(/[^A-Za-z0-9]+/).filter((w) => w.length > 0);22+ if (words.length === 0) return '??';23+ if (words.length === 1) return words[0]!.slice(0, 2).toUpperCase();24+ return (words[0]![0]! + words[1]![0]!).toUpperCase();25+}26+27+function ordinal(n: number): string {28+ const suffix = n === 1 ? 'st' : n === 2 ? 'nd' : n === 3 ? 'rd' : 'th';29+ return `${n}${suffix}`;30+}31+1832 function fmtDate(iso: string): string {1933 const d = new Date(iso);2034 return d.toLocaleDateString(undefined, { year: '2-digit', month: 'short', day: 'numeric' });@@ -135,8 +149,14 @@ function renderList(main: HTMLElement, ctx: AppCtx): void {
135149 );136150 const labList = h('datalist', { id: 'browse-labs' });137151 const labInput = h('input', { type: 'text', placeholder: 'lab', list: 'browse-labs', class: 'lab-input', oninput: debounced });138- const afterInput = h('input', { type: 'date', class: 'date-input', title: 'model released after', onchange: requery });139- const beforeInput = h('input', { type: 'date', class: 'date-input', title: 'model released before', onchange: requery });152+ const dateChanged = (el: HTMLInputElement) => () => {153+ el.classList.toggle('has-value', el.value !== '');154+ requery();155+ };156+ const afterInput = h('input', { type: 'date', class: 'date-input', title: 'model released after' });157+ afterInput.addEventListener('change', dateChanged(afterInput));158+ const beforeInput = h('input', { type: 'date', class: 'date-input', title: 'model released before' });159+ beforeInput.addEventListener('change', dateChanged(beforeInput));140160 const searchInput = h('input', { type: 'text', placeholder: 'search scripts', class: 'search-input', oninput: debounced });141161 142162 // datalist of unique labs from the public model catalog@@ -383,12 +403,17 @@ function paintDetail(scroll: HTMLElement, ctx: AppCtx, d: PublicScriptDetail): v
383403 h(384404 'td',385405 { class: 'proof-results' },386- p.results.map((r, i) =>406+ // Chips, not sentences: one tile per tribe in place order,407+ // crown on the winner, everything else lives in the hover.408+ p.results.map((r) =>387409 h(388410 'span',389- { class: `proof-part${r.scriptId === d.id ? ' self' : ''}` },390- i > 0 ? ' - ' : '',391- `${r.place}. ${r.title} (${r.score})`,411+ {412+ class: `proof-chip${r.scriptId === d.id ? ' self' : ''}${r.place === 1 ? ' winner' : ''}`,413+ title: `${ordinal(r.place)} - ${r.title} - ${r.score} pts`,414+ },415+ r.place === 1 ? h('span', { class: 'crown' }, '♛') : null,416+ chipInitials(r.title),392417 ),393418 ),394419 ),@@ -479,7 +504,7 @@ function paintDetail(scroll: HTMLElement, ctx: AppCtx, d: PublicScriptDetail): v
479504 // ---- shared source480505 if (d.source !== undefined) {481506 const pre = h('pre', { class: 'mono source-pane' });482- pre.innerHTML = highlight(d.source);507+ renderHighlighted(pre, d.source);483508 scroll.append(h('div', { class: 'card detail-card' }, h('h2', null, 'Source'), pre));484509 }485510 }@@ -493,31 +518,3 @@ function kpi(label: string, value: string): HTMLElement {
493518 // Escapes HTML, then wraps comments/strings/numbers/keywords in spans.494519 // --------------------------------------------------------------------------495520 496-const HL_KEYWORDS = new Set([497- 'const', 'let', 'var', 'function', 'return', 'if', 'else', 'for', 'while', 'do',498- 'switch', 'case', 'default', 'break', 'continue', 'new', 'typeof', 'instanceof',499- 'in', 'of', 'class', 'extends', 'try', 'catch', 'finally', 'throw', 'delete',500- 'void', 'yield', 'async', 'await', 'null', 'undefined', 'true', 'false', 'this',501-]);502-503-function escapeHtml(s: string): string {504- return s.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');505-}506-507-function highlight(src: string): string {508- const re =509- /(\/\/[^\n]*|\/\*[\s\S]*?(?:\*\/|$))|("(?:[^"\\\n]|\\.)*"|'(?:[^'\\\n]|\\.)*'|`(?:[^`\\]|\\[\s\S])*`)|(\b(?:0[xXbBoO][\da-fA-F_]+|\d[\d_]*(?:\.[\d_]*)?(?:[eE][+-]?\d+)?)\b)|([A-Za-z_$][\w$]*)/g;510- let out = '';511- let last = 0;512- for (let m = re.exec(src); m !== null; m = re.exec(src)) {513- out += escapeHtml(src.slice(last, m.index));514- last = re.lastIndex;515- const tok = m[0] ?? '';516- if (m[1] !== undefined) out += `<span class="hl-com">${escapeHtml(tok)}</span>`;517- else if (m[2] !== undefined) out += `<span class="hl-str">${escapeHtml(tok)}</span>`;518- else if (m[3] !== undefined) out += `<span class="hl-num">${escapeHtml(tok)}</span>`;519- else if (HL_KEYWORDS.has(tok)) out += `<span class="hl-kw">${tok}</span>`;520- else out += escapeHtml(tok);521- }522- return out + escapeHtml(src.slice(last));523-}@@ -2,6 +2,7 @@
22 import type { ModelRecord, ScriptRecord, ValidationReport } from '@tribes/protocol';33 import { LIMITS } from '@tribes/protocol';44 import { api } from '../api';5+import { renderHighlighted } from '../highlight';56 import { h, clear } from '../dom';67 import type { AppCtx } from '../main';78 import { signInCard, honorReturnRedirect } from './auth-card';@@ -230,30 +231,6 @@ const JS_KEYWORDS = new Set(
230231 ).split(' '),231232 );232233 233-function escapeHtml(s: string): string {234- return s.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');235-}236-237-/** Tokenize → escape → wrap: comments, strings/templates, numbers, keywords. */238-export function highlightJs(src: string): string {239- const re =240- /(\/\/[^\n]*|\/\*[\s\S]*?(?:\*\/|$))|(`(?:\\[\s\S]|[^\\`])*`|'(?:\\.|[^\\'\n])*'|"(?:\\.|[^\\"\n])*")|((?:\b\d[\d_]*(?:\.\d[\d_]*)?|\B\.\d[\d_]*)(?:[eE][+-]?\d+)?n?\b|\b0[xXbBoO][\da-fA-F_]+n?\b)|(\b[A-Za-z_$][\w$]*\b)/g;241- let out = '';242- let last = 0;243- let m: RegExpExecArray | null;244- while ((m = re.exec(src))) {245- out += escapeHtml(src.slice(last, m.index));246- const [full, comment, str, num, word] = m;247- if (comment) out += `<span class="tok-comment">${escapeHtml(comment)}</span>`;248- else if (str) out += `<span class="tok-string">${escapeHtml(str)}</span>`;249- else if (num) out += `<span class="tok-number">${escapeHtml(num)}</span>`;250- else if (word && JS_KEYWORDS.has(word)) out += `<span class="tok-keyword">${escapeHtml(word)}</span>`;251- else out += escapeHtml(full);252- last = m.index + full.length;253- }254- out += escapeHtml(src.slice(last));255- return out;256-}257234 258235 async function paintViewer(host: HTMLElement, s: ScriptRecord, onClose: () => void) {259236 host.append(h('p', { class: 'muted' }, `Loading ${s.name}…`));@@ -261,7 +238,7 @@ async function paintViewer(host: HTMLElement, s: ScriptRecord, onClose: () => vo
261238 const source = await api.getLogicSource(s.id);262239 clear(host);263240 const pre = h('pre', { class: 'mono source-view' });264- pre.innerHTML = highlightJs(source);241+ renderHighlighted(pre, source);265242 host.append(266243 h(267244 'div',@@ -313,10 +313,15 @@ pre.source-view {
313313 overflow: auto;314314 scrollbar-gutter: stable;315315 }316+/* Syntax highlighting (web/src/highlight.ts) — one palette, both viewers */316317 .tok-comment { color: var(--text-dim); font-style: italic; }317318 .tok-string { color: var(--ok); }318-.tok-number { color: var(--accent-dim); }319+.tok-number { color: #d4a3e8; }319320 .tok-keyword { color: var(--accent); }321+.tok-literal { color: #d4a3e8; }322+.tok-builtin { color: #6db4e8; }323+.tok-fn { color: #e8d76d; }324+.tok-prop { color: var(--text); }320325 321326 /* ------- create: trash rows ------- */322327 .trash-row {