eyelang 1.1.12 → 1.1.14

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "eyelang",
3
- "version": "1.1.12",
3
+ "version": "1.1.14",
4
4
  "description": "A small Prolog-syntax-subset logic programming language for rules, goals, answers, and proofs.",
5
5
  "type": "module",
6
6
  "main": "./index.js",
package/playground.html CHANGED
@@ -16,15 +16,24 @@
16
16
  --accent: #0969da;
17
17
  --accent-strong: #0757b8;
18
18
  --danger: #b42318;
19
+ --editor-bg: #ffffff;
20
+ --editor-text: #172033;
21
+ --editor-caret: #111827;
22
+ --editor-selection: rgba(9, 105, 218, 0.18);
23
+ --editor-error-line: #fee2e2;
24
+ --editor-error-border: #dc2626;
25
+ --line-number-bg: #f8fafc;
26
+ --line-number-text: #64748b;
27
+ --line-number-border: #e2e8f0;
19
28
  --code-bg: #0f172a;
20
29
  --code-text: #dbeafe;
21
- --comment: #94a3b8;
22
- --string: #86efac;
23
- --number: #fbbf24;
24
- --variable: #93c5fd;
25
- --keyword: #c084fc;
26
- --predicate: #f0abfc;
27
- --punctuation: #cbd5e1;
30
+ --comment: #64748b;
31
+ --string: #047857;
32
+ --number: #b45309;
33
+ --variable: #1d4ed8;
34
+ --keyword: #7c3aed;
35
+ --predicate: #be123c;
36
+ --punctuation: #475569;
28
37
  }
29
38
 
30
39
  @media (prefers-color-scheme: dark) {
@@ -50,15 +59,15 @@
50
59
  line-height: 1.45;
51
60
  }
52
61
 
53
- header {
54
- padding: 1rem clamp(1rem, 4vw, 2rem);
55
- border-bottom: 1px solid var(--border);
56
- background: var(--panel);
57
- position: sticky;
58
- top: 0;
59
- z-index: 5;
62
+ header,
63
+ main,
64
+ footer {
65
+ width: min(100% - 2rem, 72rem);
66
+ margin: 0 auto;
60
67
  }
61
68
 
69
+ header { padding: 1.25rem 0 0.9rem; }
70
+
62
71
  h1 {
63
72
  margin: 0;
64
73
  font-size: clamp(1.35rem, 4vw, 2rem);
@@ -67,21 +76,17 @@
67
76
  header p { margin: 0.35rem 0 0; color: var(--muted); }
68
77
 
69
78
  main {
70
- display: grid;
71
- grid-template-columns: minmax(0, 1fr) minmax(18rem, 32rem);
72
- gap: 1rem;
73
- padding: 1rem clamp(1rem, 4vw, 2rem) 2rem;
74
- max-width: 1500px;
75
- margin: 0 auto;
79
+ display: block;
80
+ padding: 0 0 2rem;
76
81
  }
77
82
 
78
- section,
79
- aside {
83
+ section {
80
84
  background: var(--panel);
81
85
  border: 1px solid var(--border);
82
86
  border-radius: 0.9rem;
83
87
  box-shadow: 0 8px 28px rgba(15, 23, 42, 0.06);
84
88
  overflow: hidden;
89
+ margin: 1rem 0;
85
90
  }
86
91
 
87
92
  .panel-body { padding: 1rem; }
@@ -89,7 +94,8 @@
89
94
  .toolbar,
90
95
  .example-row,
91
96
  .button-row,
92
- .option-row {
97
+ .option-row,
98
+ .background-row {
93
99
  display: flex;
94
100
  flex-wrap: wrap;
95
101
  gap: 0.6rem;
@@ -102,6 +108,18 @@
102
108
  background: var(--panel-soft);
103
109
  }
104
110
 
111
+ .toolbar:last-child { border-bottom: 0; }
112
+
113
+ details summary {
114
+ cursor: pointer;
115
+ font-weight: 700;
116
+ padding: 0.85rem 1rem;
117
+ background: var(--panel-soft);
118
+ border-bottom: 1px solid var(--border);
119
+ }
120
+
121
+ details:not([open]) summary { border-bottom: 0; }
122
+
105
123
  label { font-weight: 650; }
106
124
 
107
125
  select,
@@ -147,7 +165,8 @@
147
165
  button.primary:hover { background: var(--accent-strong); }
148
166
  button:disabled { cursor: not-allowed; opacity: 0.6; }
149
167
 
150
- .option-row label {
168
+ .option-row label,
169
+ .background-row label {
151
170
  display: inline-flex;
152
171
  gap: 0.35rem;
153
172
  align-items: center;
@@ -168,17 +187,56 @@
168
187
 
169
188
  .editor {
170
189
  position: relative;
171
- min-height: 58vh;
172
- background: var(--code-bg);
190
+ min-height: 56vh;
191
+ background: var(--editor-bg);
173
192
  overflow: hidden;
174
193
  }
175
194
 
195
+ .line-numbers,
196
+ .editor pre,
197
+ .editor textarea {
198
+ font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, "Liberation Mono", monospace;
199
+ font-size: 0.95rem;
200
+ line-height: 1.55;
201
+ }
202
+
203
+ .line-numbers {
204
+ position: absolute;
205
+ inset: 0 auto 0 0;
206
+ width: 3.6rem;
207
+ padding: 1rem 0.65rem 1rem 0.35rem;
208
+ overflow: hidden;
209
+ text-align: right;
210
+ color: var(--line-number-text);
211
+ background: var(--line-number-bg);
212
+ border-right: 1px solid var(--line-number-border);
213
+ user-select: none;
214
+ pointer-events: none;
215
+ z-index: 3;
216
+ }
217
+
218
+ .line-numbers-inner { will-change: transform; }
219
+
220
+ .line-number {
221
+ display: block;
222
+ min-height: 1.55em;
223
+ padding-right: 0.1rem;
224
+ }
225
+
226
+ .line-number.error {
227
+ color: var(--danger);
228
+ background: var(--editor-error-line);
229
+ font-weight: 700;
230
+ margin: 0 -0.65rem 0 -0.35rem;
231
+ padding: 0 0.65rem 0 0.35rem;
232
+ }
233
+
176
234
  .editor pre,
177
235
  .editor textarea {
178
236
  position: absolute;
179
237
  inset: 0;
180
238
  margin: 0;
181
- padding: 1rem;
239
+ padding: 1rem 1rem 1rem 4.35rem;
182
240
  border: 0;
183
241
  width: 100%;
184
242
  height: 100%;
@@ -186,25 +244,36 @@
186
244
  overflow: auto;
187
245
  white-space: pre;
188
246
  tab-size: 2;
189
- font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, "Liberation Mono", monospace;
190
- font-size: 0.95rem;
191
- line-height: 1.55;
247
+ }
248
+
249
+ .error-line-marker {
250
+ position: absolute;
251
+ left: 0;
252
+ right: 0;
253
+ top: 0;
254
+ display: none;
255
+ pointer-events: none;
256
+ background: var(--editor-error-line);
257
+ border-left: 0.28rem solid var(--editor-error-border);
258
+ z-index: 0;
192
259
  }
193
260
 
194
261
  .editor pre {
195
262
  pointer-events: none;
196
- color: var(--code-text);
263
+ color: var(--editor-text);
264
+ z-index: 1;
197
265
  }
198
266
 
199
267
  .editor textarea {
200
268
  background: transparent;
201
- color: rgba(255, 255, 255, 0.02);
202
- caret-color: white;
269
+ color: rgba(17, 24, 39, 0.025);
270
+ caret-color: var(--editor-caret);
203
271
  outline: none;
204
- -webkit-text-fill-color: rgba(255, 255, 255, 0.02);
272
+ z-index: 2;
273
+ -webkit-text-fill-color: rgba(17, 24, 39, 0.025);
205
274
  }
206
275
 
207
- .editor textarea::selection { background: rgba(96, 165, 250, 0.35); }
276
+ .editor textarea::selection { background: var(--editor-selection); }
208
277
 
209
278
  .tok-comment { color: var(--comment); font-style: italic; }
210
279
  .tok-string { color: var(--string); }
@@ -250,31 +319,34 @@
250
319
 
251
320
  footer {
252
321
  color: var(--muted);
253
- padding: 0 1rem 1.5rem;
322
+ padding: 0 0 1.5rem;
254
323
  text-align: center;
255
324
  font-size: 0.95rem;
256
325
  }
257
326
 
258
327
  footer a { color: var(--accent); }
259
328
 
260
- @media (max-width: 900px) {
261
- header { position: static; }
262
- main { grid-template-columns: 1fr; }
263
- .editor { min-height: 54vh; }
264
- .output { max-height: 42vh; }
265
- }
266
-
267
329
  @media (max-width: 560px) {
330
+ header,
331
+ main,
332
+ footer { width: min(100% - 1rem, 72rem); }
268
333
  .toolbar,
269
334
  .panel-body,
270
- .editor-label { padding: 0.8rem; }
335
+ .editor-label,
336
+ details summary { padding: 0.8rem; }
271
337
  button,
272
338
  select,
273
339
  input[type="url"],
274
340
  input[type="search"] { width: 100%; }
275
341
  .button-row > button { flex: 1 1 8rem; }
342
+ .editor { min-height: 58vh; }
343
+ .line-numbers,
276
344
  .editor pre,
277
- .editor textarea { font-size: 0.9rem; padding: 0.8rem; }
345
+ .editor textarea { font-size: 0.9rem; }
346
+ .line-numbers { width: 3.2rem; padding: 0.8rem 0.55rem 0.8rem 0.25rem; }
347
+ .line-number.error { margin: 0 -0.55rem 0 -0.25rem; padding: 0 0.55rem 0 0.25rem; }
348
+ .editor pre,
349
+ .editor textarea { padding: 0.8rem 0.8rem 0.8rem 3.85rem; }
278
350
  }
279
351
  </style>
280
352
  </head>
@@ -285,45 +357,62 @@
285
357
  </header>
286
358
 
287
359
  <main>
288
- <section aria-labelledby="editor-heading">
360
+ <section aria-labelledby="examples-heading">
289
361
  <div class="toolbar" aria-label="Example loader">
290
- <label for="example-search">Load example</label>
362
+ <label id="examples-heading" for="example-search">Load an example</label>
291
363
  <input id="example-search" type="search" placeholder="Filter examples" autocomplete="off">
292
364
  <select id="examples" aria-label="Examples"></select>
293
- <button id="load-example" type="button">Load</button>
294
- </div>
295
-
296
- <div class="toolbar" aria-label="URL loader">
297
- <label for="source-url">Load from URL</label>
298
- <input id="source-url" type="url" placeholder="https://example.org/program.pl">
299
- <button id="load-url" type="button">Load URL</button>
365
+ <button id="load-example" type="button">Load example</button>
300
366
  </div>
367
+ </section>
301
368
 
302
- <div class="toolbar option-row" aria-label="Run options">
303
- <label><input id="proof" type="checkbox"> proof explanations</label>
304
- <label><input id="stats" type="checkbox"> show stats</label>
305
- </div>
369
+ <section aria-labelledby="advanced-heading">
370
+ <details>
371
+ <summary id="advanced-heading">⚙ Advanced configuration</summary>
372
+ <div class="toolbar" aria-label="URL loader">
373
+ <label for="source-url">Load Eyelang from URL</label>
374
+ <input id="source-url" type="url" placeholder="https://example.org/program.pl">
375
+ <button id="load-url" type="button">Load from URL</button>
376
+ </div>
377
+ <div class="toolbar background-row">
378
+ <label><input id="load-background" type="checkbox"> Load into background knowledge instead of overwriting the editor</label>
379
+ <span id="background-status" class="hint">No background knowledge loaded.</span>
380
+ <button id="clear-background" type="button">Clear background</button>
381
+ </div>
382
+ <div class="toolbar option-row" aria-label="Run options">
383
+ <label><input id="proof" type="checkbox"> Show proof explanations</label>
384
+ <label><input id="stats" type="checkbox"> Show stats</label>
385
+ </div>
386
+ <div class="panel-body">
387
+ <p class="hint">Enter any Eyelang file URL and click “Load from URL”. Background knowledge is prepended when the program runs.</p>
388
+ </div>
389
+ </details>
390
+ </section>
306
391
 
392
+ <section aria-labelledby="editor-heading">
307
393
  <div class="editor-shell">
308
394
  <div class="editor-label">
309
395
  <strong id="editor-heading">Editable Eyelang program</strong>
310
396
  <span id="source-name" class="hint">custom program</span>
311
397
  </div>
312
398
  <div class="editor">
399
+ <div id="line-numbers" class="line-numbers" aria-hidden="true"><div class="line-numbers-inner"></div></div>
400
+ <div id="error-line-marker" class="error-line-marker" aria-hidden="true"></div>
313
401
  <pre id="highlight" aria-hidden="true"><code></code></pre>
314
402
  <textarea id="source" spellcheck="false" autocapitalize="off" autocomplete="off" aria-label="Editable Eyelang source"></textarea>
315
403
  </div>
316
404
  </div>
317
- </section>
318
-
319
- <aside aria-labelledby="output-heading">
320
405
  <div class="toolbar button-row">
321
- <button id="run" type="button" class="primary">Run</button>
406
+ <button id="run" type="button" class="primary">Run reasoning</button>
322
407
  <button id="stop" type="button" disabled>Stop</button>
323
408
  <button id="copy-source" type="button">Copy source</button>
324
409
  <button id="copy-output" type="button">Copy output</button>
325
410
  <button id="share" type="button">Copy share link</button>
411
+ <button id="create-gist" type="button">Create Gist share</button>
326
412
  </div>
413
+ </section>
414
+
415
+ <section aria-labelledby="output-heading">
327
416
  <div class="editor-label">
328
417
  <strong id="output-heading">Output</strong>
329
418
  <span id="elapsed" class="hint">Idle.</span>
@@ -336,7 +425,7 @@
336
425
  <div class="panel-body">
337
426
  <p class="hint">For local use, serve the checkout with a small HTTP server first; browser modules and example loading usually do not work from <code>file://</code>.</p>
338
427
  </div>
339
- </aside>
428
+ </section>
340
429
  </main>
341
430
 
342
431
  <footer>
@@ -477,6 +566,9 @@
477
566
  const FALLBACK_SOURCE = `materialize(answer, 1).
478
567
  answer(ok) :- eq(ok, ok).
479
568
  `;
569
+ const HIGHLIGHT_LIMIT = 200000;
570
+ const MAX_SHARE_URL_LENGTH = 1900;
571
+ const GIST_STATE_FILENAME = 'eyelang-playground-state.json';
480
572
  const KEYWORDS = new Set(['materialize', 'memoize']);
481
573
  const BUILTINS = new Set([
482
574
  "abs",
@@ -561,33 +653,52 @@ answer(ok) :- eq(ok, ok).
561
653
 
562
654
  const source = document.querySelector('#source');
563
655
  const highlight = document.querySelector('#highlight');
656
+ const lineNumbers = document.querySelector('#line-numbers');
657
+ const lineNumbersInner = lineNumbers.firstElementChild;
658
+ const errorLineMarker = document.querySelector('#error-line-marker');
564
659
  const exampleSelect = document.querySelector('#examples');
565
660
  const exampleSearch = document.querySelector('#example-search');
566
661
  const output = document.querySelector('#output');
567
662
  const status = document.querySelector('#status');
568
663
  const elapsed = document.querySelector('#elapsed');
569
664
  const sourceName = document.querySelector('#source-name');
665
+ const backgroundStatus = document.querySelector('#background-status');
666
+ const loadBackground = document.querySelector('#load-background');
570
667
  const proof = document.querySelector('#proof');
571
668
  const stats = document.querySelector('#stats');
572
669
  const runButton = document.querySelector('#run');
573
670
  const stopButton = document.querySelector('#stop');
671
+ let backgroundSource = '';
672
+ let backgroundName = '';
574
673
  let activeWorker = null;
575
674
  let activeWorkerUrl = null;
675
+ let renderToken = 0;
676
+ let syntaxErrorLine = null;
677
+ let sourceReference = { kind: 'custom' };
678
+ let sourceDirty = false;
679
+ let backgroundReference = null;
576
680
 
577
681
  populateExamples(EXAMPLES);
578
- restoreFromHash() || loadExample('ancestor');
682
+ await restoreFromHashOrLoadDefault();
579
683
  loadVersion();
580
684
 
581
- source.addEventListener('input', render);
685
+ source.addEventListener('input', () => {
686
+ sourceDirty = true;
687
+ sourceReference = { kind: 'custom' };
688
+ clearSyntaxError();
689
+ render();
690
+ });
582
691
  source.addEventListener('scroll', syncScroll);
583
692
  exampleSearch.addEventListener('input', () => populateExamples(filterExamples(exampleSearch.value)));
584
693
  document.querySelector('#load-example').addEventListener('click', () => loadExample(exampleSelect.value));
585
694
  document.querySelector('#load-url').addEventListener('click', loadUrl);
695
+ document.querySelector('#clear-background').addEventListener('click', clearBackground);
586
696
  document.querySelector('#run').addEventListener('click', runProgram);
587
697
  document.querySelector('#stop').addEventListener('click', stopProgram);
588
698
  document.querySelector('#copy-source').addEventListener('click', () => copyText(source.value, 'Source copied.'));
589
699
  document.querySelector('#copy-output').addEventListener('click', () => copyText(output.textContent, 'Output copied.'));
590
700
  document.querySelector('#share').addEventListener('click', copyShareLink);
701
+ document.querySelector('#create-gist').addEventListener('click', createGistShare);
591
702
 
592
703
  function populateExamples(names) {
593
704
  const current = exampleSelect.value;
@@ -614,7 +725,7 @@ answer(ok) :- eq(ok, ok).
614
725
  const exampleUrl = new URL(`./examples/${name}.pl`, location.href);
615
726
  const response = await fetch(exampleUrl, { cache: 'no-store' });
616
727
  if (!response.ok) throw new Error(`${response.status} ${response.statusText}`);
617
- setSource(await response.text(), `examples/${name}.pl`);
728
+ setSource(await response.text(), `examples/${name}.pl`, { kind: 'example', name });
618
729
  setStatus(`Loaded examples/${name}.pl.`);
619
730
  } catch (error) {
620
731
  setSource(FALLBACK_SOURCE, 'fallback program');
@@ -629,27 +740,75 @@ answer(ok) :- eq(ok, ok).
629
740
  setStatus(`Loading ${url}…`);
630
741
  const response = await fetch(url);
631
742
  if (!response.ok) throw new Error(`${response.status} ${response.statusText}`);
632
- setSource(await response.text(), url);
633
- setStatus(`Loaded ${url}.`);
743
+ const text = await response.text();
744
+ if (loadBackground.checked) {
745
+ backgroundSource = text;
746
+ backgroundName = url;
747
+ backgroundReference = { kind: 'url', url };
748
+ updateBackgroundStatus();
749
+ setStatus(`Loaded background knowledge from ${url}.`);
750
+ } else {
751
+ setSource(text, url, { kind: 'url', url });
752
+ setStatus(`Loaded ${url}.`);
753
+ }
634
754
  } catch (error) {
635
755
  setStatus(`Could not load URL: ${formatError(error)}`, true);
636
756
  }
637
757
  }
638
758
 
639
- function setSource(text, name) {
759
+ function clearBackground() {
760
+ backgroundSource = '';
761
+ backgroundName = '';
762
+ backgroundReference = null;
763
+ updateBackgroundStatus();
764
+ setStatus('Background knowledge cleared.');
765
+ }
766
+
767
+ function updateBackgroundStatus() {
768
+ backgroundStatus.textContent = backgroundSource
769
+ ? `Background loaded from ${backgroundName} (${backgroundSource.length.toLocaleString()} chars).`
770
+ : 'No background knowledge loaded.';
771
+ }
772
+
773
+ function setSource(text, name, reference = { kind: 'custom' }) {
640
774
  source.value = text;
641
775
  sourceName.textContent = name;
776
+ sourceReference = reference;
777
+ sourceDirty = false;
778
+ clearSyntaxError();
642
779
  render();
643
780
  }
644
781
 
645
782
  function render() {
646
- highlight.firstElementChild.innerHTML = colorize(source.value) + (source.value.endsWith('\n') ? ' ' : '');
647
- syncScroll();
783
+ const token = ++renderToken;
784
+ const text = source.value;
785
+ requestAnimationFrame(() => {
786
+ if (token !== renderToken) return;
787
+ highlight.firstElementChild.innerHTML = renderHighlightedSource(text);
788
+ updateLineNumbers(text);
789
+ syncScroll();
790
+ });
791
+ }
792
+
793
+ function renderHighlightedSource(text) {
794
+ const rendered = text.length > HIGHLIGHT_LIMIT ? escapeHtml(text) : colorize(text);
795
+ return rendered + (text.endsWith('\n') ? ' ' : '');
648
796
  }
649
797
 
650
798
  function syncScroll() {
651
799
  highlight.scrollTop = source.scrollTop;
652
800
  highlight.scrollLeft = source.scrollLeft;
801
+ lineNumbersInner.style.transform = `translateY(${-source.scrollTop}px)`;
802
+ updateErrorLineMarker();
803
+ }
804
+
805
+ function updateLineNumbers(text = source.value) {
806
+ const count = Math.max(1, text.split('\n').length);
807
+ lineNumbersInner.innerHTML = Array.from({ length: count }, (_, index) => {
808
+ const line = index + 1;
809
+ const className = line === syntaxErrorLine ? 'line-number error' : 'line-number';
810
+ return `<span class="${className}">${line}</span>`;
811
+ }).join('');
653
812
  }
654
813
 
655
814
  function colorize(text) {
@@ -708,6 +867,7 @@ answer(ok) :- eq(ok, ok).
708
867
 
709
868
  function runProgram() {
710
869
  stopProgram(false);
870
+ clearSyntaxError();
711
871
  output.textContent = '';
712
872
  elapsed.textContent = 'Running…';
713
873
  setStatus('Running Eyelang…');
@@ -737,20 +897,83 @@ answer(ok) :- eq(ok, ok).
737
897
  activeWorker.onmessage = (event) => finishRun(event.data);
738
898
  activeWorker.onerror = (event) => finishRun({ ok: false, error: event.message || 'worker error' });
739
899
  activeWorker.postMessage({
740
- source: source.value,
900
+ source: combinedSource(),
741
901
  options: { proof: proof.checked, stats: stats.checked },
742
902
  });
743
903
  }
744
904
 
905
+ function combinedSource() {
906
+ if (!backgroundSource.trim()) return source.value;
907
+ return `${backgroundSource}\n${source.value}`;
908
+ }
909
+
910
+ function extractParseErrorLine(text) {
911
+ const match = String(text).match(/parse line (\d+)/i);
912
+ return match ? Number(match[1]) : null;
913
+ }
914
+
915
+ function editorLineForParseLine(parseLine) {
916
+ if (!Number.isFinite(parseLine) || parseLine < 1) return null;
917
+ if (!backgroundSource.trim()) return parseLine;
918
+ const backgroundLines = backgroundSource.split('\n').length;
919
+ const editorLine = parseLine - backgroundLines;
920
+ return editorLine >= 1 ? editorLine : null;
921
+ }
922
+
923
+ function markSyntaxErrorLine(line) {
924
+ syntaxErrorLine = line;
925
+ updateLineNumbers();
926
+ revealLine(line);
927
+ updateErrorLineMarker();
928
+ }
929
+
930
+ function clearSyntaxError() {
931
+ syntaxErrorLine = null;
932
+ updateLineNumbers();
933
+ updateErrorLineMarker();
934
+ }
935
+
936
+ function revealLine(line) {
937
+ const metrics = editorMetrics();
938
+ const y = metrics.paddingTop + (line - 1) * metrics.lineHeight;
939
+ const top = source.scrollTop;
940
+ const bottom = top + source.clientHeight - metrics.lineHeight;
941
+ if (y < top || y > bottom) {
942
+ source.scrollTop = Math.max(0, y - source.clientHeight / 3);
943
+ }
944
+ }
945
+
946
+ function updateErrorLineMarker() {
947
+ if (syntaxErrorLine == null) {
948
+ errorLineMarker.style.display = 'none';
949
+ return;
950
+ }
951
+ const metrics = editorMetrics();
952
+ errorLineMarker.style.display = 'block';
953
+ errorLineMarker.style.height = `${metrics.lineHeight}px`;
954
+ errorLineMarker.style.top = `${metrics.paddingTop + (syntaxErrorLine - 1) * metrics.lineHeight - source.scrollTop}px`;
955
+ }
956
+
957
+ function editorMetrics() {
958
+ const style = getComputedStyle(source);
959
+ const fontSize = parseFloat(style.fontSize) || 15;
960
+ const lineHeight = parseFloat(style.lineHeight) || fontSize * 1.55;
961
+ const paddingTop = parseFloat(style.paddingTop) || 0;
962
+ return { lineHeight, paddingTop };
963
+ }
964
+
745
965
  function finishRun(message) {
746
- const worker = activeWorker;
747
966
  cleanupWorker();
748
967
  runButton.disabled = false;
749
968
  stopButton.disabled = true;
750
969
  if (!message.ok) {
751
- output.textContent = message.error || 'Unknown error.';
970
+ const errorText = message.error || 'Unknown error.';
971
+ const parseLine = extractParseErrorLine(errorText);
972
+ const editorLine = editorLineForParseLine(parseLine);
973
+ if (editorLine != null) markSyntaxErrorLine(editorLine);
974
+ output.textContent = errorText;
752
975
  elapsed.textContent = 'Failed.';
753
- setStatus('Run failed.', true);
976
+ setStatus(editorLine == null ? 'Run failed.' : `Syntax error on line ${editorLine}.`, true);
754
977
  return;
755
978
  }
756
979
  let text = message.stdout || '(no materialized output)\n';
@@ -758,7 +981,7 @@ answer(ok) :- eq(ok, ok).
758
981
  output.textContent = text;
759
982
  const seconds = (message.elapsedMs / 1000).toFixed(3);
760
983
  elapsed.textContent = `${seconds} sec`;
761
- setStatus(worker ? 'Run complete.' : 'Run complete.');
984
+ setStatus('Run complete.');
762
985
  }
763
986
 
764
987
  function stopProgram(report = true) {
@@ -791,30 +1014,176 @@ answer(ok) :- eq(ok, ok).
791
1014
  }
792
1015
 
793
1016
  async function copyShareLink() {
794
- const payload = JSON.stringify({ source: source.value, proof: proof.checked, stats: stats.checked });
795
- const data = new TextEncoder().encode(payload);
1017
+ const link = buildShareLink();
1018
+ if (link == null) {
1019
+ setStatus('This program is too large for a reliable URL. Use “Create Gist share” instead.', true);
1020
+ return;
1021
+ }
1022
+ await copyText(link, 'Share link copied.');
1023
+ }
1024
+
1025
+ function buildShareLink() {
1026
+ const referenced = buildReferenceShareLink();
1027
+ if (referenced && referenced.length <= MAX_SHARE_URL_LENGTH) return referenced;
1028
+ const embedded = `${basePlaygroundUrl()}#state=${encodeState(currentShareState())}`;
1029
+ return embedded.length <= MAX_SHARE_URL_LENGTH ? embedded : null;
1030
+ }
1031
+
1032
+ function buildReferenceShareLink() {
1033
+ const params = new URLSearchParams();
1034
+ if (proof.checked) params.set('proof', '1');
1035
+ if (stats.checked) params.set('stats', '1');
1036
+ if (backgroundSource.trim()) {
1037
+ if (backgroundReference?.kind !== 'url') return null;
1038
+ params.set('background-url', backgroundReference.url);
1039
+ }
1040
+ if (!sourceDirty && sourceReference.kind === 'example') params.set('example', sourceReference.name);
1041
+ else if (!sourceDirty && sourceReference.kind === 'url') params.set('url', sourceReference.url);
1042
+ else return null;
1043
+ return `${basePlaygroundUrl()}#${params.toString()}`;
1044
+ }
1045
+
1046
+ async function createGistShare() {
1047
+ const stateText = JSON.stringify(currentShareState(), null, 2);
1048
+ const token = prompt('Optional GitHub token with gist scope. It is only sent to api.github.com and is not stored. Leave blank to copy Gist-ready state instead.');
1049
+ if (token === null) return;
1050
+ if (!token.trim()) {
1051
+ await copyText(stateText, `Gist-ready state copied. Create a Gist file named ${GIST_STATE_FILENAME}, paste it, then share its raw URL with #state-url=...`);
1052
+ window.open('https://gist.github.com/', '_blank', 'noopener');
1053
+ return;
1054
+ }
1055
+
1056
+ try {
1057
+ setStatus('Creating GitHub Gist…');
1058
+ const files = {
1059
+ [GIST_STATE_FILENAME]: { content: stateText },
1060
+ 'program.pl': { content: source.value },
1061
+ };
1062
+ if (backgroundSource.trim()) files['background.pl'] = { content: backgroundSource };
1063
+ const response = await fetch('https://api.github.com/gists', {
1064
+ method: 'POST',
1065
+ headers: {
1066
+ Accept: 'application/vnd.github+json',
1067
+ Authorization: `Bearer ${token.trim()}`,
1068
+ 'Content-Type': 'application/json',
1069
+ },
1070
+ body: JSON.stringify({
1071
+ description: 'Eyelang playground share',
1072
+ public: false,
1073
+ files,
1074
+ }),
1075
+ });
1076
+ const gist = await response.json().catch(() => ({}));
1077
+ if (!response.ok) throw new Error(gist.message || `${response.status} ${response.statusText}`);
1078
+ const rawUrl = gist.files?.[GIST_STATE_FILENAME]?.raw_url;
1079
+ if (!rawUrl) throw new Error('Gist response did not include a raw state URL.');
1080
+ await copyText(`${basePlaygroundUrl()}#state-url=${encodeURIComponent(rawUrl)}`, 'Gist share link copied.');
1081
+ } catch (error) {
1082
+ await copyText(stateText, `Could not create Gist: ${formatError(error)}. Gist-ready state copied instead.`);
1083
+ }
1084
+ }
1085
+
1086
+ function currentShareState() {
1087
+ return {
1088
+ source: source.value,
1089
+ proof: proof.checked,
1090
+ stats: stats.checked,
1091
+ backgroundSource,
1092
+ backgroundName,
1093
+ };
1094
+ }
1095
+
1096
+ function encodeState(payload) {
1097
+ const data = new TextEncoder().encode(JSON.stringify(payload));
796
1098
  let binary = '';
797
1099
  for (const byte of data) binary += String.fromCharCode(byte);
798
- const encoded = btoa(binary).replaceAll('+', '-').replaceAll('/', '_').replace(/=+$/, '');
799
- await copyText(`${location.href.split('#')[0]}#state=${encoded}`, 'Share link copied.');
1100
+ return btoa(binary).replaceAll('+', '-').replaceAll('/', '_').replace(/=+$/, '');
1101
+ }
1102
+
1103
+ function decodeState(encoded) {
1104
+ const base64 = encoded.replaceAll('-', '+').replaceAll('_', '/');
1105
+ const padded = base64 + '='.repeat((4 - base64.length % 4) % 4);
1106
+ const bytes = Uint8Array.from(atob(padded), (ch) => ch.charCodeAt(0));
1107
+ return JSON.parse(new TextDecoder().decode(bytes));
1108
+ }
1109
+
1110
+ function basePlaygroundUrl() {
1111
+ return location.href.split('#')[0];
1112
+ }
1113
+
1114
+ async function restoreFromHashOrLoadDefault() {
1115
+ if (!(await restoreFromHash())) await loadExample('ancestor');
800
1116
  }
801
1117
 
802
- function restoreFromHash() {
803
- if (!location.hash.startsWith('#state=')) return false;
1118
+ async function restoreFromHash() {
1119
+ if (!location.hash || location.hash === '#') return false;
1120
+ const params = new URLSearchParams(location.hash.slice(1));
804
1121
  try {
805
- const encoded = location.hash.slice(7).replaceAll('-', '+').replaceAll('_', '/');
806
- const padded = encoded + '='.repeat((4 - encoded.length % 4) % 4);
807
- const bytes = Uint8Array.from(atob(padded), (ch) => ch.charCodeAt(0));
808
- const payload = JSON.parse(new TextDecoder().decode(bytes));
809
- setSource(String(payload.source || ''), 'shared program');
810
- proof.checked = Boolean(payload.proof);
811
- stats.checked = Boolean(payload.stats);
812
- setStatus('Loaded shared program.');
813
- return true;
1122
+ if (params.has('state')) {
1123
+ applySharedState(decodeState(params.get('state')), 'shared program');
1124
+ setStatus('Loaded shared program.');
1125
+ return true;
1126
+ }
1127
+ if (params.has('state-url')) {
1128
+ const stateUrl = params.get('state-url');
1129
+ const response = await fetch(stateUrl);
1130
+ if (!response.ok) throw new Error(`${response.status} ${response.statusText}`);
1131
+ applySharedState(await response.json(), `shared state from ${stateUrl}`);
1132
+ setStatus('Loaded shared Gist state.');
1133
+ return true;
1134
+ }
1135
+ await restoreBackgroundUrl(params);
1136
+ applyOptionParams(params);
1137
+ if (params.has('example')) {
1138
+ await loadExample(params.get('example'));
1139
+ applyOptionParams(params);
1140
+ return true;
1141
+ }
1142
+ if (params.has('url')) {
1143
+ await loadSourceFromUrl(params.get('url'), false);
1144
+ applyOptionParams(params);
1145
+ return true;
1146
+ }
814
1147
  } catch (error) {
815
1148
  setStatus(`Could not read shared state: ${formatError(error)}`, true);
816
1149
  return false;
817
1150
  }
1151
+ return false;
1152
+ }
1153
+
1154
+ async function restoreBackgroundUrl(params) {
1155
+ const url = params.get('background-url');
1156
+ if (!url) return;
1157
+ await loadSourceFromUrl(url, true);
1158
+ }
1159
+
1160
+ async function loadSourceFromUrl(url, asBackground) {
1161
+ const response = await fetch(url);
1162
+ if (!response.ok) throw new Error(`${response.status} ${response.statusText}`);
1163
+ const text = await response.text();
1164
+ if (asBackground) {
1165
+ backgroundSource = text;
1166
+ backgroundName = url;
1167
+ backgroundReference = { kind: 'url', url };
1168
+ updateBackgroundStatus();
1169
+ } else {
1170
+ setSource(text, url, { kind: 'url', url });
1171
+ }
1172
+ }
1173
+
1174
+ function applySharedState(payload, label) {
1175
+ setSource(String(payload.source || ''), label, { kind: 'custom' });
1176
+ proof.checked = Boolean(payload.proof);
1177
+ stats.checked = Boolean(payload.stats);
1178
+ backgroundSource = String(payload.backgroundSource || '');
1179
+ backgroundName = String(payload.backgroundName || 'shared background');
1180
+ backgroundReference = null;
1181
+ updateBackgroundStatus();
1182
+ }
1183
+
1184
+ function applyOptionParams(params) {
1185
+ proof.checked = params.get('proof') === '1' || params.get('proof') === 'true';
1186
+ stats.checked = params.get('stats') === '1' || params.get('stats') === 'true';
818
1187
  }
819
1188
 
820
1189
  async function loadVersion() {
package/src/solver.js CHANGED
@@ -38,50 +38,122 @@ export class Solver {
38
38
  }
39
39
 
40
40
  *solve(goals, env = new Env(), depth = 0) {
41
- this.stats.solve_goals_calls++;
42
- this.stats.max_depth = Math.max(this.stats.max_depth, depth);
43
- this.stats.max_goal_count = Math.max(this.stats.max_goal_count, goals.length);
44
- if (depth > this.maxDepth || this.solutionsSeen >= this.solutionLimit) return;
45
41
  if (!Array.isArray(goals)) goals = [goals];
46
42
 
47
- if (goals.length === 0) {
48
- this.solutionsSeen++;
49
- this.stats.completed_goal_lists++;
50
- yield env;
51
- return;
52
- }
43
+ const savedActive = this.active;
44
+ try {
45
+ const stack = [{ kind: 'goals', goals, env, depth, active: savedActive.slice() }];
46
+ while (stack.length) {
47
+ const frame = stack.pop();
48
+ if (frame.kind === 'completeMemo') {
49
+ frame.entry.computing = false;
50
+ frame.entry.complete = true;
51
+ continue;
52
+ }
53
53
 
54
- // eyelang normally solves left-to-right, but ready deterministic builtins can
55
- // be run early as pure filters. This gives large pruning wins without
56
- // reordering user predicates or nondeterministic generators.
57
- const selectedIndex = selectReadyDeterministicBuiltin(goals, env, this.registry);
58
- const goal = goals[selectedIndex];
59
- const rest = selectedIndex === 0 ? goals.slice(1) : [...goals.slice(0, selectedIndex), ...goals.slice(selectedIndex + 1)];
60
- if (goal.type === COMPOUND && goal.name === ',' && goal.arity === 2) {
61
- yield* this.solve([...flattenConjunction(goal), ...rest], env, depth + 1);
62
- return;
63
- }
54
+ goals = frame.goals;
55
+ env = frame.env;
56
+ depth = frame.depth;
57
+ let active = frame.active;
64
58
 
65
- const def = goal.type === COMPOUND ? this.registry.get(goal.name, goal.arity) : null;
66
- if (def && builtinIsReadyOrAuthoritative(def, this, goal, env)) {
67
- let produced = false;
68
- for (const next of def.handler({ solver: this, goal, env })) {
69
- produced = true;
70
- yield* this.solve(rest, next, depth + 1);
71
- if (this.solutionsSeen >= this.solutionLimit) return;
59
+ while (true) {
60
+ this.stats.solve_goals_calls++;
61
+ this.stats.max_depth = Math.max(this.stats.max_depth, depth);
62
+ this.stats.max_goal_count = Math.max(this.stats.max_goal_count, goals.length);
63
+ if (depth > this.maxDepth || this.solutionsSeen >= this.solutionLimit) break;
64
+
65
+ if (goals.length === 0) {
66
+ this.solutionsSeen++;
67
+ this.stats.completed_goal_lists++;
68
+ this.active = active;
69
+ yield env;
70
+ break;
71
+ }
72
+
73
+ const first = goals[0];
74
+ if (first?.kind === 'releaseActive') {
75
+ active = active.slice(0, -1);
76
+ goals = goals.slice(1);
77
+ continue;
78
+ }
79
+ if (first?.kind === 'memoStore') {
80
+ rememberMemoAnswer(first.entry, first.goal, env);
81
+ goals = goals.slice(1);
82
+ continue;
83
+ }
84
+
85
+ // eyelang normally solves left-to-right, but ready deterministic builtins can
86
+ // be run early as pure filters. Stop at internal sentinels so rule-body
87
+ // active guards are released before the caller's remaining goals are seen.
88
+ const selectedIndex = selectReadyDeterministicBuiltin(goals, env, this.registry);
89
+ const goal = goals[selectedIndex];
90
+ const rest = selectedIndex === 0 ? goals.slice(1) : [...goals.slice(0, selectedIndex), ...goals.slice(selectedIndex + 1)];
91
+ if (goal.type === COMPOUND && goal.name === ',' && goal.arity === 2) {
92
+ goals = [...flattenConjunction(goal), ...rest];
93
+ depth++;
94
+ continue;
95
+ }
96
+
97
+ const def = goal.type === COMPOUND ? this.registry.get(goal.name, goal.arity) : null;
98
+ this.active = active;
99
+ if (def && builtinIsReadyOrAuthoritative(def, this, goal, env)) {
100
+ const nextEnvs = [];
101
+ for (const next of def.handler({ solver: this, goal, env })) nextEnvs.push(next);
102
+ if (def.deterministic) {
103
+ if (nextEnvs.length) this.stats.deterministic_builtin_successes++;
104
+ else this.stats.deterministic_builtin_failures++;
105
+ }
106
+ if (nextEnvs.length === 0) break;
107
+ if (nextEnvs.length === 1) {
108
+ goals = rest;
109
+ env = nextEnvs[0];
110
+ depth++;
111
+ continue;
112
+ }
113
+ for (let i = nextEnvs.length - 1; i >= 0; i--) {
114
+ stack.push({ kind: 'goals', goals: rest, env: nextEnvs[i], depth: depth + 1, active });
115
+ }
116
+ break;
117
+ }
118
+
119
+ this.stats.solve_one_goal_calls++;
120
+ if (goal.type !== COMPOUND) break;
121
+ const group = this.program.findGroup(goal.name, goal.arity);
122
+ if (!group) break;
123
+
124
+ if (group.memoized) {
125
+ const key = memoKey(goal, env);
126
+ if (key.hasBound) {
127
+ const mapKey = `${goal.name}/${goal.arity}:${key.text}`;
128
+ let entry = this.memo.get(mapKey);
129
+ if (!entry) {
130
+ entry = { computing: false, complete: false, answers: [], answerKeys: new Set() };
131
+ this.memo.set(mapKey, entry);
132
+ }
133
+ if (entry.complete) {
134
+ pushMemoAnswerFrames(stack, entry, goal, rest, env, depth, active, this);
135
+ break;
136
+ }
137
+ if (!entry.computing) {
138
+ entry.computing = true;
139
+ stack.push({ kind: 'completeMemo', entry });
140
+ pushUserGoalUncachedFrames(stack, this, group, goal, [{ kind: 'memoStore', entry, goal }, ...rest], env, depth, active);
141
+ break;
142
+ }
143
+ }
144
+ }
145
+
146
+ pushUserGoalUncachedFrames(stack, this, group, goal, rest, env, depth, active);
147
+ break;
72
148
  }
73
- if (def.deterministic) {
74
- if (produced) this.stats.deterministic_builtin_successes++;
75
- else this.stats.deterministic_builtin_failures++;
76
149
  }
77
- return;
150
+ } finally {
151
+ this.active = savedActive;
78
152
  }
79
-
80
- yield* this.solveUserGoal(goal, rest, env, depth);
81
153
  }
82
154
 
83
155
  activeVariant(goal, env) {
84
- return this.active.some((entry) => variantTerms(goal, env, entry.goal, entry.env));
156
+ return activeVariantIn(goal, env, this.active);
85
157
  }
86
158
 
87
159
  *solveUserGoal(goal, rest, env, depth) {
@@ -180,6 +252,61 @@ export class Solver {
180
252
  }
181
253
 
182
254
 
255
+ function pushMemoAnswerFrames(stack, entry, goal, rest, env, depth, active, solver) {
256
+ for (let answerIndex = entry.answers.length - 1; answerIndex >= 0; answerIndex--) {
257
+ const answerArgs = entry.answers[answerIndex];
258
+ const next = env.clone();
259
+ let ok = true;
260
+ for (let i = 0; i < goal.arity; i++) {
261
+ solver.stats.unify_calls++;
262
+ if (!unify(goal.args[i], answerArgs[i], next)) { ok = false; break; }
263
+ }
264
+ if (ok) stack.push({ kind: 'goals', goals: rest, env: next, depth: depth + 1, active });
265
+ }
266
+ }
267
+
268
+ function pushUserGoalUncachedFrames(stack, solver, group, goal, rest, env, depth, active) {
269
+ if (activeVariantIn(goal, env, active)) return;
270
+ const candidates = selectClauseCandidates(group, goal, env);
271
+ const frames = [];
272
+ for (const pass of [candidates.primary, candidates.fallback]) {
273
+ for (const clause of pass) {
274
+ if (headCannotMatch(goal, clause.head, env)) continue;
275
+ const id = nextFreshId();
276
+ const freshHead = freshTerm(clause.head, id);
277
+ const freshBody = clause.body.map((term) => freshTerm(term, id));
278
+ const next = env.clone();
279
+ solver.stats.unify_calls++;
280
+ if (!unify(goal, freshHead, next)) continue;
281
+ if (freshBody.length === 0) {
282
+ frames.push({ kind: 'goals', goals: rest, env: next, depth: depth + 1, active });
283
+ } else {
284
+ frames.push({
285
+ kind: 'goals',
286
+ goals: [...freshBody, { kind: 'releaseActive' }, ...rest],
287
+ env: next,
288
+ depth: depth + 1,
289
+ active: [...active, { goal, env }],
290
+ });
291
+ }
292
+ }
293
+ }
294
+ for (let i = frames.length - 1; i >= 0; i--) stack.push(frames[i]);
295
+ }
296
+
297
+ function rememberMemoAnswer(entry, goal, env) {
298
+ const answerArgs = goal.args.map((arg) => importResolved(arg, env));
299
+ const key = answerArgs.map((arg) => termToString(arg, new Env(), true)).join('\x1f');
300
+ if (entry.answerKeys.has(key)) return;
301
+ entry.answerKeys.add(key);
302
+ entry.answers.push(answerArgs);
303
+ }
304
+
305
+ function activeVariantIn(goal, env, active) {
306
+ return active.some((entry) => variantTerms(goal, env, entry.goal, entry.env));
307
+ }
308
+
309
+
183
310
  function builtinIsReadyOrAuthoritative(def, solver, goal, env) {
184
311
  if (typeof def.shouldUse === 'function' && !def.shouldUse({ solver, goal, env })) return false;
185
312
  if (typeof def.ready !== 'function') return true;
@@ -190,6 +317,7 @@ function builtinIsReadyOrAuthoritative(def, solver, goal, env) {
190
317
  function selectReadyDeterministicBuiltin(goals, env, registry) {
191
318
  for (let i = 0; i < goals.length; i++) {
192
319
  const goal = goals[i];
320
+ if (goal?.kind === 'releaseActive' || goal?.kind === 'memoStore') return 0;
193
321
  if (goal.type !== COMPOUND) continue;
194
322
  const def = registry.get(goal.name, goal.arity);
195
323
  if (!def?.deterministic || typeof def.ready !== 'function') continue;
@@ -360,6 +360,16 @@ function apiCases() {
360
360
  assertEqual(result.stdout, 'q(a, b).\n', 'stdout');
361
361
  },
362
362
  },
363
+ {
364
+ name: 'run keeps recursive materializations independent in one solver',
365
+ run: () => {
366
+ const text = fs.readFileSync(path.join(packageRoot, 'examples', 'alignment-demo.pl'), 'utf8');
367
+ const program = Program.parseSources([{ text, filename: 'alignment-demo.pl' }]);
368
+ const result = run(program);
369
+ assertIncludes(result.stdout, 'broaderTransitive(anpr_passenger_car, ref_car).\n', 'stdout');
370
+ assertIncludes(result.stdout, 'narrowerOrEqualOf(anpr_passenger_car, ref_car).\n', 'stdout');
371
+ },
372
+ },
363
373
  {
364
374
  name: 'makeProgram creates indexed programs',
365
375
  run: () => {
@@ -525,6 +535,20 @@ function whiteBoxCases() {
525
535
  assertEqual(group.recursive, true, 'collatz/2 recursive');
526
536
  },
527
537
  },
538
+ {
539
+ name: 'collatz example remains stack-safe for browser-sized stacks',
540
+ run: () => {
541
+ // Use a deliberately tiny stack to catch browser-worker recursion regressions.
542
+ const result = spawnSync(process.execPath, ['--stack-size=100', bin, 'examples/collatz-1000.pl'], {
543
+ cwd: packageRoot,
544
+ encoding: 'utf8',
545
+ });
546
+ assertEqual(result.status, 0, `exit status${result.stderr ? `\nstderr: ${result.stderr}` : ''}`);
547
+ assertEqual(result.stderr, '', 'stderr');
548
+ assertIncludes(result.stdout, 'collatzTrajectory(1000, [1000, 500, 250, 125', 'stdout');
549
+ assertIncludes(result.stdout, 'collatzTrajectory(1, [1]).\n', 'stdout');
550
+ },
551
+ },
528
552
  ];
529
553
  }
530
554
 
@@ -625,17 +649,44 @@ function playgroundStaticIssues() {
625
649
  if (!pkg.files?.includes('playground.html')) issues.push('package files must include playground.html');
626
650
  if (!readme.includes('[Playground](https://eyereasoner.github.io/eyelang/playground)')) issues.push('README must link to the GitHub Pages playground URL');
627
651
  if (!html.includes('<meta name="viewport" content="width=device-width, initial-scale=1">')) issues.push('missing mobile viewport meta');
628
- if (!html.includes('@media (max-width: 900px)') || !html.includes('main { grid-template-columns: 1fr; }')) {
629
- issues.push('playground must collapse the editor/output grid on tablet/mobile widths');
652
+ if (!html.includes('main {') || !html.includes('display: block;')) {
653
+ issues.push('playground must use a simple vertical layout');
630
654
  }
631
655
  if (!html.includes('@media (max-width: 560px)') || !html.includes('button,') || !html.includes('width: 100%')) {
632
656
  issues.push('playground must make controls usable at phone widths');
633
657
  }
658
+ if (!html.includes('<summary id="advanced-heading">⚙ Advanced configuration</summary>')) {
659
+ issues.push('playground must keep URL/proof controls inside advanced configuration');
660
+ }
661
+ if (!html.includes('id="load-background"') || !html.includes('backgroundSource') || !html.includes('combinedSource()')) {
662
+ issues.push('playground must support loading URL content as background knowledge');
663
+ }
664
+ if (!html.includes('HIGHLIGHT_LIMIT') || !html.includes('text.length > HIGHLIGHT_LIMIT')) {
665
+ issues.push('playground must avoid full syntax coloring for very large examples');
666
+ }
634
667
  if (!html.includes('<script type="module">')) issues.push('playground script must be an ES module');
635
668
  if (!html.includes("new URL('./src/index.js', location.href)")) issues.push('playground must import the public browser API');
636
669
  if (!html.includes('class="editor"') || !html.includes('id="highlight"') || !html.includes('id="source"')) {
637
670
  issues.push('playground must include layered syntax-colored editor');
638
671
  }
672
+ if (!html.includes('--editor-bg: #ffffff') || !html.includes('background: var(--editor-bg)')) {
673
+ issues.push('playground editor must use a light editor background');
674
+ }
675
+ if (!html.includes('id="error-line-marker"') || !html.includes('extractParseErrorLine') || !html.includes('markSyntaxErrorLine') || !html.includes('--editor-error-line')) {
676
+ issues.push('playground must highlight syntax-error lines in the editor');
677
+ }
678
+ if (!html.includes('id="line-numbers"') || !html.includes('updateLineNumbers') || !html.includes('lineNumbersInner.style.transform') || !html.includes('--line-number-bg')) {
679
+ issues.push('playground editor must include synced line numbers');
680
+ }
681
+ if (!html.includes('MAX_SHARE_URL_LENGTH') || !html.includes('buildReferenceShareLink') || !html.includes("params.set('example'") || !html.includes("params.set('url'")) {
682
+ issues.push('playground share links must avoid embedding large example or URL-loaded sources');
683
+ }
684
+ if (!html.includes('id="create-gist"') || !html.includes('createGistShare') || !html.includes('GIST_STATE_FILENAME') || !html.includes("fetch('https://api.github.com/gists'")) {
685
+ issues.push('playground must support Gist-backed sharing for large programs');
686
+ }
687
+ if (!html.includes("params.has('state-url')") || !html.includes('#state-url=')) {
688
+ issues.push('playground must restore state from raw Gist state URLs');
689
+ }
639
690
  if (!html.includes('id="example-search"') || !html.includes('id="examples"')) issues.push('playground must include searchable examples');
640
691
  const scriptMatch = html.match(new RegExp('<script type="module">\\n([\\s\\S]*?)\\n <\\/script>'));
641
692
  if (scriptMatch == null) {