aboutsummaryrefslogtreecommitdiffstats
path: root/prototypes/workbench/web
diff options
context:
space:
mode:
Diffstat (limited to 'prototypes/workbench/web')
-rw-r--r--prototypes/workbench/web/app.js411
-rw-r--r--prototypes/workbench/web/baseline.html108
-rw-r--r--prototypes/workbench/web/baseline.mjs68
-rw-r--r--prototypes/workbench/web/compiler.mjs496
-rwxr-xr-xprototypes/workbench/web/core.wasmbin0 -> 61073 bytes
-rw-r--r--prototypes/workbench/web/document.css3
-rw-r--r--prototypes/workbench/web/document.html32
-rw-r--r--prototypes/workbench/web/document.mjs132
-rw-r--r--prototypes/workbench/web/index.html95
-rw-r--r--prototypes/workbench/web/output.mjs103
-rw-r--r--prototypes/workbench/web/project.mjs47
-rw-r--r--prototypes/workbench/web/runtime.mjs350
-rw-r--r--prototypes/workbench/web/scene-input.mjs84
-rw-r--r--prototypes/workbench/web/split-input.mjs57
-rw-r--r--prototypes/workbench/web/style.css10
15 files changed, 1996 insertions, 0 deletions
diff --git a/prototypes/workbench/web/app.js b/prototypes/workbench/web/app.js
new file mode 100644
index 0000000..9e53ee3
--- /dev/null
+++ b/prototypes/workbench/web/app.js
@@ -0,0 +1,411 @@
+import { createRuntime } from './runtime.mjs';
+import { createOutput } from './output.mjs';
+import { bindSceneInput } from './scene-input.mjs';
+import { parseProject, MAX_PROJECT_BYTES } from './project.mjs';
+
+const $ = id => document.getElementById(id);
+const source = $('source');
+const MAX_SOURCE_BYTES = 32768;
+const encoder = new TextEncoder();
+const output = createOutput(document);
+const snippets = {
+ state: 'state count = 0\nstate x = 80\n',
+ input: 'input tap = Space\non tap.down {\n count = count + 1\n}\n',
+ view: 'box tile(x, 180, 40, 40, "#7ed9ae")\ntext label(40, 60, count, "Count: ")\n',
+ frame: 'on frame {\n x = x + 30 * dt\n if x > 600 { x = 0 }\n}\n',
+};
+let runtime = null;
+let appliedSource = '';
+let running = false;
+let previousTime = null;
+let accumulator = 0;
+let droppedTicks = 0;
+let diagnosticLocation = null;
+let draftGeneration = 0;
+const keyboardSources = new Map();
+const pointerSources = new Map();
+const inputButtons = new Map();
+const sceneInput = bindSceneInput($('scene'), () => runtime, safeAction);
+
+const bytes = text => encoder.encode(text).byteLength;
+const dirty = () => source.value !== appliedSource;
+// DOM is the published value. Avoid rewriting unchanged diagnostics/controls.
+function text(id, value) {
+ const node = $(id);
+ if (node.textContent !== value) node.textContent = value;
+}
+function pressed(node, value) {
+ const next = String(value);
+ if (node.getAttribute('aria-pressed') !== next) node.setAttribute('aria-pressed', next);
+}
+function draftStatus() {
+ const changed = dirty();
+ text('draft-status', changed ? '미적용 변경' : runtime?.currentProgram ? '적용됨' : '빈 작업물');
+ $('draft-status').classList.toggle('dirty', changed);
+}
+
+function setDiagnostic(message, kind = '', location = null) {
+ $('diagnostic-text').textContent = message;
+ $('diagnostic').className = `diagnostic ${kind}`;
+ diagnosticLocation = location;
+ $('error-location').hidden = !location;
+ source.setAttribute('aria-invalid', kind === 'error' && location ? 'true' : 'false');
+}
+
+function cursorPosition() {
+ const before = source.value.slice(0, source.selectionStart);
+ const lines = before.split('\n');
+ text('cursor-position', `${lines.length}행 · ${lines.at(-1).length + 1}열`);
+}
+
+function draftChanged() {
+ draftGeneration++;
+ // Read scroll position before writes; this path runs only for source edits.
+ const scrollTop = source.scrollTop;
+ const count = source.value.split('\n').length;
+ const size = bytes(source.value);
+ text('line-numbers', Array.from({ length: count }, (_, i) => i + 1).join('\n'));
+ $('line-numbers').scrollTop = scrollTop;
+ text('source-size', `${size.toLocaleString('en-US')} / 32,768 bytes`);
+ $('source-size').style.color = size > MAX_SOURCE_BYTES ? 'var(--danger)' : '';
+ draftStatus();
+ cursorPosition();
+}
+
+function pause() {
+ running = false;
+ previousTime = null;
+ accumulator = 0;
+ text('play', '▶ 실행');
+ pressed($('play'), false);
+}
+
+function safeAction(action) {
+ if (!runtime) return;
+ try { action(); }
+ catch (error) { pause(); setDiagnostic(error.message, 'error', error.line ? { line: error.line, column: error.column || 1 } : null); }
+ refresh();
+}
+
+function releaseEverything() {
+ keyboardSources.clear();
+ pointerSources.clear();
+ sceneInput.releaseCaptures();
+ if (runtime) safeAction(() => runtime.releaseAll());
+}
+
+function loseFocus() {
+ pause();
+ releaseEverything();
+ refresh();
+}
+
+function refresh() {
+ if (!runtime) return;
+ const snapshot = runtime.present(output);
+ if (snapshot.lastError) pause();
+ const held = new Set((snapshot.held || []).map(input => typeof input === 'string' ? input : input.name));
+ for (const [name, button] of inputButtons) {
+ button.classList.toggle('held', held.has(name));
+ pressed(button, held.has(name));
+ }
+ text('scene-status', runtime.currentProgram ? (running ? '실행 중' : '정지') : '적용 전');
+ text('clock-status', `${snapshot.tick || 0} frame · ${running ? '60 Hz' : '정지'}`);
+ text('revision', `revision ${snapshot.revision || 0}`);
+ for (const id of ['play', 'step', 'reset', 'download-layout']) {
+ if ($(id).disabled !== !runtime.currentProgram) $(id).disabled = !runtime.currentProgram;
+ }
+ const event = snapshot.lastEvent;
+ text('last-event', event ? `${event.name}${event.phase && event.phase !== 'frame' ? `.${event.phase}` : ''}${event.target ? ` → ${event.target}` : ''} · ${event.status ? '실패' : '완료'}${event.line ? ` · ${event.line}행` : ''}` : '아직 없음');
+ if ($('runtime-error').hidden !== !snapshot.lastError) $('runtime-error').hidden = !snapshot.lastError;
+ const displayError = snapshot.lastError === 100 || snapshot.lastError === 101;
+ let message = snapshot.lastError ? `${displayError ? '표시 오류' : '마지막 실패'} · ${snapshot.errorLine ? `${snapshot.errorLine}행 · ` : ''}${snapshot.errorMessage || `실행 오류 ${snapshot.lastError}`}${snapshot.lastError === 101 ? ' — 이번 장면을 표시하지 않았습니다. 상태 변경은 유지됩니다.' : displayError ? ' — 해당 도형을 표시할 수 없습니다.' : ' — 실패한 이벤트의 상태 변경은 취소됐습니다. 코드를 적용하거나 초깃값으로 되돌리면 이 기록을 지웁니다.'}` : '';
+ if (!displayError && snapshot.displayError?.code === 101)
+ message += ` · ${snapshot.displayError.message}`;
+ text('runtime-error', message);
+ output.costs(snapshot, runtime.currentProgram?.metrics, droppedTicks);
+}
+
+function endPointer(event) {
+ const entry = pointerSources.get(event.pointerId);
+ if (!entry) return;
+ event.preventDefault();
+ pointerSources.delete(event.pointerId);
+ safeAction(() => runtime.up(entry.name, entry.source));
+}
+
+function rebuildInputControls() {
+ inputButtons.clear();
+ const children = [];
+ for (const input of runtime.currentProgram?.inputs || []) {
+ if (input.kind === 'value') {
+ const form = document.createElement('form'), label = document.createElement('label');
+ const field = document.createElement('input'), button = document.createElement('button');
+ label.textContent = input.name + ' '; field.type = 'number'; field.step = 'any'; field.value = '0'; field.required = true;
+ button.type = 'submit'; button.textContent = '값 전달'; label.append(field); form.append(label, button);
+ form.addEventListener('submit', event => { event.preventDefault(); safeAction(() => runtime.change(input.name, field.valueAsNumber, 'control')); });
+ children.push(form); continue;
+ }
+ if (input.target || input.routes) {
+ const note = document.createElement('p');
+ note.className = 'empty-note';
+ note.textContent = `${input.name} · 장면의 ${input.target || input.routes.map(route => route.target).join(', ')}을 잡아 조작하세요.`;
+ children.push(note);
+ continue;
+ }
+ const button = document.createElement('button');
+ button.type = 'button';
+ button.className = 'input-button';
+ button.dataset.input = input.name;
+ button.setAttribute('aria-pressed', 'false');
+ const name = document.createElement('span'); name.textContent = input.name;
+ const key = document.createElement('kbd'); key.textContent = input.key;
+ button.append(name, key);
+ button.addEventListener('pointerdown', event => {
+ if (event.button !== 0) return;
+ event.preventDefault();
+ const identity = `pointer:${event.pointerId}`;
+ pointerSources.set(event.pointerId, { name: input.name, source: identity });
+ button.setPointerCapture(event.pointerId);
+ safeAction(() => runtime.down(input.name, identity));
+ });
+ button.addEventListener('pointerup', endPointer);
+ button.addEventListener('pointercancel', endPointer);
+ button.addEventListener('lostpointercapture', endPointer);
+ // Keyboard activation and assistive technology clicks are complete taps.
+ // Pointer clicks already delivered down/up via the handlers above.
+ button.addEventListener('click', event => {
+ if (event.detail !== 0) return;
+ safeAction(() => { runtime.down(input.name, 'activation'); runtime.up(input.name, 'activation'); });
+ });
+ inputButtons.set(input.name, button);
+ children.push(button);
+ }
+ if (!children.length) {
+ const message = document.createElement('p');
+ message.className = 'empty-note';
+ message.textContent = '입력을 선언하면 여기에 버튼이 생깁니다.';
+ children.push(message);
+ }
+ $('input-controls').replaceChildren(...children);
+}
+
+function isEditing(target) {
+ return target instanceof Element && Boolean(target.closest('textarea,input,select,button,[contenteditable="true"],[role="textbox"]'));
+}
+
+$('scene').addEventListener('pointerdown', () => $('scene').focus());
+
+document.addEventListener('keydown', event => {
+ if (!runtime || event.repeat || event.isComposing || event.ctrlKey || event.metaKey || event.altKey || isEditing(event.target)) return;
+ const input = runtime.currentProgram?.inputs.find(item => item.key === event.code);
+ if (!input) return;
+ event.preventDefault();
+ if (keyboardSources.has(event.code)) return;
+ keyboardSources.set(event.code, input.name);
+ safeAction(() => runtime.down(input.name, `keyboard:${event.code}`));
+});
+
+document.addEventListener('keyup', event => {
+ const name = keyboardSources.get(event.code);
+ if (!name) return;
+ event.preventDefault();
+ keyboardSources.delete(event.code);
+ safeAction(() => runtime.up(name, `keyboard:${event.code}`));
+});
+
+source.addEventListener('input', () => {
+ draftChanged();
+ if (diagnosticLocation) setDiagnostic('코드를 수정했습니다. 적용해서 다시 확인하세요.');
+});
+source.addEventListener('scroll', () => { $('line-numbers').scrollTop = source.scrollTop; });
+for (const name of ['click', 'keyup', 'select']) source.addEventListener(name, cursorPosition);
+source.addEventListener('keydown', event => {
+ if (event.key === 'Tab' && !event.shiftKey && !event.ctrlKey && !event.metaKey && !event.altKey) {
+ event.preventDefault();
+ source.setRangeText(' ', source.selectionStart, source.selectionEnd, 'end');
+ draftChanged();
+ }
+});
+
+$('apply').addEventListener('click', () => {
+ if (!runtime) return;
+ try {
+ runtime.apply(source.value);
+ appliedSource = source.value;
+ rebuildInputControls();
+ setDiagnostic('코드를 적용했습니다. 같은 이름의 상태는 유지됩니다.', 'success');
+ } catch (error) {
+ const location = error.line ? { line: error.line, column: error.column || 1 } : null;
+ setDiagnostic(`${location ? `${location.line}행 ${location.column}열 · ` : ''}${error.message} · 기존 장면은 그대로 유지됩니다.`, 'error', location);
+ }
+ draftStatus();
+ refresh();
+});
+
+$('error-location').addEventListener('click', () => {
+ if (!diagnosticLocation) return;
+ const lines = source.value.split('\n');
+ const lineIndex = Math.min(lines.length - 1, diagnosticLocation.line - 1);
+ const start = lines.slice(0, lineIndex).reduce((sum, line) => sum + line.length + 1, 0);
+ const at = start + Math.min(lines[lineIndex].length, diagnosticLocation.column - 1);
+ source.focus();
+ source.setSelectionRange(at, Math.min(at + 1, source.value.length));
+ const lineHeight = parseFloat(getComputedStyle(source).lineHeight);
+ source.scrollTop = Math.max(0, lineIndex * lineHeight - source.clientHeight / 3);
+ $('line-numbers').scrollTop = source.scrollTop;
+ cursorPosition();
+});
+
+$('play').addEventListener('click', () => {
+ if (running) pause();
+ else { running = true; previousTime = null; accumulator = 0; $('play').textContent = 'Ⅱ 정지'; $('play').setAttribute('aria-pressed', 'true'); }
+ refresh();
+});
+$('step').addEventListener('click', () => { pause(); safeAction(() => runtime.frame()); });
+$('reset').addEventListener('click', () => {
+ pause(); releaseEverything(); droppedTicks = 0;
+ safeAction(() => runtime.reset());
+ setDiagnostic('적용된 코드의 초깃값으로 되돌렸습니다. 편집 중인 코드는 유지됩니다.');
+});
+
+function replaceDraft(text, message) {
+ source.value = text;
+ source.scrollTop = 0;
+ source.setSelectionRange(0, 0);
+ setDiagnostic(message);
+ draftChanged();
+}
+
+function mayReplaceDraft() {
+ return !source.value || window.confirm('현재 편집 중인 코드를 바꿀까요? 저장하지 않은 내용은 사라집니다. 실행 장면은 적용 전까지 유지됩니다.');
+}
+
+$('new').addEventListener('click', () => {
+ if (mayReplaceDraft()) replaceDraft('', '편집기만 비웠습니다. 빈 코드를 적용하면 장면도 비워집니다.');
+});
+
+for (const button of document.querySelectorAll('.insert-snippet')) {
+ button.addEventListener('click', () => {
+ const at = source.selectionStart;
+ const prefix = at > 0 && source.value[at - 1] !== '\n' ? '\n' : '';
+ source.setRangeText(prefix + snippets[button.dataset.snippet], source.selectionStart, source.selectionEnd, 'end');
+ source.focus();
+ draftChanged();
+ setDiagnostic('조각을 삽입했습니다. 이름과 동작을 원하는 대로 바꾸고 적용하세요.');
+ });
+}
+
+$('load-example').addEventListener('click', async () => {
+ if (!mayReplaceDraft()) return;
+ const selected = $('example').value;
+ const before = source.value;
+ const button = $('load-example');
+ button.disabled = true;
+ try {
+ const response = await fetch(`./examples/${selected}.pure`);
+ if (!response.ok) throw new Error(`예제를 불러오지 못했습니다 (${response.status}).`);
+ const text = await response.text();
+ if (bytes(text) > MAX_SOURCE_BYTES) throw new Error('예제가 코드 크기 제한을 넘었습니다.');
+ if (source.value !== before) throw new Error('불러오는 동안 코드가 바뀌었습니다. 변경 내용을 지키기 위해 불러오기를 취소했습니다.');
+ replaceDraft(text, '예제를 편집기에 불러왔습니다. 아직 적용하지 않았습니다.');
+ } catch (error) { setDiagnostic(error.message, 'error'); }
+ finally { button.disabled = false; }
+});
+
+function download(filename, content, mime, message = '편집 중인 코드를 파일로 저장했습니다. 적용되지 않은 변경도 포함합니다.') {
+ const url = URL.createObjectURL(new Blob([content], { type: mime }));
+ const link = document.createElement('a');
+ link.href = url; link.download = filename;
+ document.body.append(link); link.click(); link.remove();
+ setTimeout(() => URL.revokeObjectURL(url), 1000);
+ setDiagnostic(message);
+}
+$('download').addEventListener('click', () => download('my-work.pure', source.value, 'text/plain;charset=utf-8'));
+$('download-project').addEventListener('click', () => download('my-work.pure.json', JSON.stringify({ format: 'pure-workbench', version: 1, source: source.value }, null, 2) + '\n', 'application/json'));
+$('download-layout').addEventListener('click', () => {
+ try {
+ if (running) throw new Error('시간 진행을 정지한 뒤 배치를 저장하세요.');
+ const project = runtime.exportDocument(source.value);
+ download('my-layout.pure.json', JSON.stringify(project, null, 2) + '\n', 'application/json',
+ '적용된 코드·현재 배치·미적용 초안을 함께 저장했습니다.');
+ } catch (error) { setDiagnostic(error.message, 'error'); }
+});
+$('upload').addEventListener('click', () => $('file-input').click());
+$('file-input').addEventListener('change', async event => {
+ const file = event.target.files[0];
+ event.target.value = '';
+ if (!file) return;
+ try {
+ if (file.size > MAX_PROJECT_BYTES) throw new Error('파일이 너무 큽니다. 코드 제한은 32 KiB입니다.');
+ const before = draftGeneration;
+ const active = runtime?.currentProgram;
+ const events = runtime?.snapshot().successfulEvents;
+ let content = await file.text();
+ if (file.name.toLowerCase().endsWith('.json')) {
+ const project = parseProject(content);
+ if (project.version === 2) {
+ if (!runtime) throw new Error('실행기가 준비되지 않았습니다.');
+ if (draftGeneration !== before || runtime.currentProgram !== active || runtime.snapshot().successfulEvents !== events)
+ throw new Error('파일을 읽는 동안 작업이 바뀌었습니다. 정지한 뒤 다시 불러오세요.');
+ if (running) throw new Error('시간 진행을 정지한 뒤 배치를 복원하세요.');
+ if ((source.value || appliedSource) && !window.confirm('현재 코드와 배치를 파일의 내용으로 복원할까요? 저장하지 않은 작업은 사라집니다.')) return;
+ const restored = runtime.restoreDocument(project);
+ pause(); droppedTicks = 0;
+ appliedSource = restored.source;
+ rebuildInputControls();
+ replaceDraft(restored.draft, '배치를 복원했습니다. 미적용 초안도 보존했고, 시간은 정지 상태입니다.');
+ refresh();
+ return;
+ }
+ content = project.source;
+ }
+ if (bytes(content) > MAX_SOURCE_BYTES) throw new Error('코드가 32 KiB 제한을 넘었습니다.');
+ if (draftGeneration !== before) throw new Error('파일을 읽는 동안 코드가 바뀌었습니다. 불러오기를 다시 시도하세요.');
+ if (mayReplaceDraft()) replaceDraft(content, `${file.name}을 편집기에 불러왔습니다. 아직 적용하지 않았습니다.`);
+ } catch (error) { setDiagnostic(error.message, 'error'); }
+});
+
+window.addEventListener('blur', loseFocus);
+document.addEventListener('visibilitychange', () => { if (document.hidden) loseFocus(); });
+window.addEventListener('beforeunload', event => {
+ // Applying is not persistence: even applied code can be lost when leaving.
+ if (source.value || appliedSource) { event.preventDefault(); event.returnValue = ''; }
+});
+
+function animate(time) {
+ if (running && runtime) {
+ if (previousTime !== null) {
+ accumulator += Math.max(0, time - previousTime);
+ const wanted = Math.floor(accumulator / (1000 / 60));
+ if (wanted > 0) {
+ const steps = Math.min(6, wanted);
+ accumulator -= wanted * (1000 / 60);
+ droppedTicks += wanted - steps;
+ safeAction(() => runtime.frame(steps));
+ }
+ }
+ previousTime = running ? time : null;
+ }
+ requestAnimationFrame(animate);
+}
+
+Object.defineProperty(window, 'pureWorkbench', {
+ configurable: false,
+ writable: false,
+ value: Object.freeze({ snapshot: () => structuredClone({ ...(runtime?.snapshot() || {}), ready: Boolean(runtime), source: source.value, appliedSource, dirty: dirty(), running, droppedTicks, diagnostic: $('diagnostic-text').textContent }) }),
+});
+
+draftChanged();
+requestAnimationFrame(animate);
+try {
+ runtime = await createRuntime();
+ $('ready-status').textContent = '실행기 준비됨';
+ $('ready-dot').classList.add('ready');
+ $('apply').disabled = false;
+ refresh();
+} catch (error) {
+ $('ready-status').textContent = '실행기 준비 실패';
+ $('ready-dot').classList.add('failed');
+ setDiagnostic(`실행기를 열지 못했습니다. ${error.message}`, 'error');
+}
diff --git a/prototypes/workbench/web/baseline.html b/prototypes/workbench/web/baseline.html
new file mode 100644
index 0000000..cc9f569
--- /dev/null
+++ b/prototypes/workbench/web/baseline.html
@@ -0,0 +1,108 @@
+<!doctype html>
+<html lang="ko">
+<meta charset="utf-8">
+<meta name="viewport" content="width=device-width, initial-scale=1">
+<title>Pure 비교 실험 · 일반 JavaScript</title>
+<style>
+ :root { color-scheme: light; font: 16px/1.6 system-ui, sans-serif; color: #21352c; background: #f4f2eb; }
+ body { max-width: 900px; margin: auto; padding: 24px; }
+ h1 { font-size: 1.6rem; margin-bottom: 8px; }
+ a { color: #285d40; }
+ p { max-width: 75ch; }
+ svg { display: block; width: 100%; border: 1px solid #ccd1c6; border-radius: 12px; background: #fffdf6; }
+ .controls { display: flex; flex-wrap: wrap; align-items: center; gap: 10px; margin: 18px 0; }
+ button { padding: 10px 16px; border: 1px solid #7d907f; border-radius: 6px; background: #fffdf6; font: inherit; cursor: pointer; }
+ button:focus-visible, a:focus-visible { outline: 3px solid #c78638; outline-offset: 3px; }
+ #jump { touch-action: none; background: #356548; color: white; }
+ #jump[aria-pressed="true"] { background: #945e1d; }
+ pre { white-space: pre-wrap; background: #e9eadf; padding: 16px; border-radius: 8px; }
+ .muted { color: #5c695f; }
+</style>
+<a href="./">← 빈 작업물 제작기로</a>
+<h1>일반 JavaScript로 작성한 충전 점프</h1>
+<p>동일한 충전·점프 동작을 직접 작성한 비교용 앱입니다. 실행을 켜고 Space 또는 아래 버튼을 누르다가 놓아보세요. 정지 중에는 한 프레임씩 진행할 수 있습니다.</p>
+<svg viewBox="0 0 640 360" role="img" aria-label="충전 정도와 점프 위치를 보여주는 장면">
+ <rect x="40" y="261" width="560" height="4" fill="#356548"></rect>
+ <circle id="actor" cx="180" cy="240" r="20" fill="#c78638"></circle>
+ <rect id="meter" x="80" y="80" width="0" height="12" fill="#c78638"></rect>
+ <text id="amount" x="80" y="60" fill="#21352c" font-size="16">Charge: 0</text>
+</svg>
+<div class="controls">
+ <button id="jump" aria-pressed="false">누르고 있다 놓기 · Space</button>
+ <button id="run" aria-pressed="false">실행</button>
+ <button id="step">한 프레임</button>
+ <button id="reset">초기화</button>
+ <span id="status" role="status">정지 · 0 프레임</span>
+</div>
+<pre id="state" aria-label="현재 상태"></pre>
+<p class="muted">이 앱은 고정된 JavaScript 프로그램입니다. 코드를 편집하고 다시 불러와 동작을 바꿀 수 있지만, 제작기의 정의 적용·상태 보존 기능은 구현하지 않았습니다. 아래 파일과 충전 예제를 비교할 수 있습니다.</p>
+<p><a href="./baseline.mjs">행동과 상태 소스 · baseline.mjs</a> · <a href="./examples/charge.pure">동일 동작의 Pure 정의 · charge.pure</a></p>
+<script type="module">
+ import { createChargeModel } from './baseline.mjs';
+ const model = createChargeModel();
+ const element = id => document.getElementById(id);
+ let running = false, last = null, accumulated = 0;
+ function render() {
+ const { states, tick, held } = model.snapshot();
+ element('actor').setAttribute('cy', states.y);
+ element('meter').setAttribute('width', states.charge * 200);
+ element('amount').textContent = `Charge: ${states.charge.toFixed(3)}`;
+ element('state').textContent = JSON.stringify(states, null, 2);
+ element('status').textContent = `${running ? '실행 중' : '정지'} · ${tick} 프레임`;
+ element('jump').setAttribute('aria-pressed', String(held.length > 0));
+ element('run').setAttribute('aria-pressed', String(running));
+ element('run').textContent = running ? '정지' : '실행';
+ }
+ element('run').addEventListener('click', () => {
+ running = !running; last = null; accumulated = 0; render();
+ });
+ element('step').addEventListener('click', () => {
+ running = false; last = null; accumulated = 0; model.frame(); render();
+ });
+ element('reset').addEventListener('click', () => {
+ running = false; last = null; accumulated = 0; model.reset(); render();
+ });
+ element('jump').addEventListener('pointerdown', event => {
+ event.preventDefault(); event.currentTarget.setPointerCapture(event.pointerId);
+ model.down('jump', `pointer:${event.pointerId}`); render();
+ });
+ for (const phase of ['pointerup', 'pointercancel', 'lostpointercapture']) {
+ element('jump').addEventListener(phase, event => {
+ model.up('jump', `pointer:${event.pointerId}`); render();
+ });
+ }
+ element('jump').addEventListener('keydown', event => {
+ if (event.code !== 'Enter') return;
+ event.preventDefault(); model.down('jump', 'button:enter'); render();
+ });
+ element('jump').addEventListener('keyup', event => {
+ if (event.code !== 'Enter') return;
+ event.preventDefault(); model.up('jump', 'button:enter'); render();
+ });
+ element('jump').addEventListener('blur', () => {
+ model.up('jump', 'button:enter'); render();
+ });
+ window.addEventListener('keydown', event => {
+ if (event.code !== 'Space') return;
+ event.preventDefault(); model.down('jump', 'keyboard'); render();
+ });
+ window.addEventListener('keyup', event => {
+ if (event.code !== 'Space') return;
+ event.preventDefault(); model.up('jump', 'keyboard'); render();
+ });
+ function suspend() {
+ running = false; last = null; accumulated = 0; model.releaseAll(); render();
+ }
+ window.addEventListener('blur', suspend);
+ document.addEventListener('visibilitychange', () => { if (document.hidden) suspend(); });
+ function animate(now) {
+ if (running && last !== null) {
+ accumulated += Math.min(now - last, 100);
+ while (accumulated >= 1000 / 60) { model.frame(); accumulated -= 1000 / 60; }
+ render();
+ }
+ last = now; requestAnimationFrame(animate);
+ }
+ render(); requestAnimationFrame(animate);
+</script>
+</html>
diff --git a/prototypes/workbench/web/baseline.mjs b/prototypes/workbench/web/baseline.mjs
new file mode 100644
index 0000000..3873e31
--- /dev/null
+++ b/prototypes/workbench/web/baseline.mjs
@@ -0,0 +1,68 @@
+// Handwritten JavaScript reference for examples/charge.pure.
+// This fixed app has no source compiler, live apply, or state-name migration.
+// Its scene and behavior must be changed by editing this JavaScript file.
+export function createChargeModel() {
+ const held = new Set();
+ let holding = 0, charge = 0, y = 240, velocity = 0, tick = 0;
+ const checkInput = name => {
+ if (name !== 'jump') throw new Error(`Unknown input: ${name}`);
+ };
+
+ const model = {
+ down(name = 'jump', source = 'external') {
+ checkInput(name);
+ const wasDown = held.size > 0;
+ held.add(source);
+ if (!wasDown && y >= 240) {
+ holding = 1;
+ charge = 0;
+ }
+ return 0;
+ },
+ up(name = 'jump', source = 'external') {
+ checkInput(name);
+ if (!held.delete(source) || held.size) return 0;
+ if (holding) {
+ velocity = -(160 + charge * 300);
+ holding = 0;
+ charge = 0;
+ }
+ return 0;
+ },
+ frame(count = 1) {
+ if (!Number.isInteger(count) || count < 0 || count > 600) {
+ throw new Error('Frame count must be an integer from 0 through 600.');
+ }
+ const dt = 1 / 60;
+ for (let frame = 0; frame < count; frame++) {
+ if (holding) charge = Math.min(Math.max(charge + dt, 0), 1);
+ velocity = velocity + 600 * dt;
+ y = y + velocity * dt;
+ if (y >= 240) { y = 240; velocity = 0; }
+ tick++;
+ }
+ return 0;
+ },
+ releaseAll() {
+ for (const source of [...held]) model.up('jump', source);
+ return 0;
+ },
+ reset() {
+ held.clear();
+ holding = 0; charge = 0; y = 240; velocity = 0; tick = 0;
+ },
+ snapshot() {
+ return {
+ states: { holding, charge, y, velocity }, tick,
+ held: held.size ? ['jump'] : [],
+ views: [
+ { id: 'floor', type: 'box', x: 40, y: 261, width: 560, height: 4, color: '#356548' },
+ { id: 'actor', type: 'circle', x: 180, y, radius: 20, color: '#c78638' },
+ { id: 'meter', type: 'box', x: 80, y: 80, width: charge * 200, height: 12, color: '#c78638' },
+ { id: 'amount', type: 'text', x: 80, y: 60, value: charge, prefix: 'Charge: ' },
+ ],
+ };
+ },
+ };
+ return Object.freeze(model);
+}
diff --git a/prototypes/workbench/web/compiler.mjs b/prototypes/workbench/web/compiler.mjs
new file mode 100644
index 0000000..6aec335
--- /dev/null
+++ b/prototypes/workbench/web/compiler.mjs
@@ -0,0 +1,496 @@
+// Pure's experimental scalar interaction language. No host code is evaluated.
+const LIMITS = Object.freeze({ sourceBytes: 32768, states: 32, inputs: 8,
+ views: 24, handlers: 64, instructions: 512, depth: 64, stack: 64 });
+const RESERVED = new Set(['frame', 'dt', 'event_x', 'event_y', 'event_value', 'min', 'max', 'clamp']);
+const BINARY = new Map([
+ ['||', [1, 15]], ['&&', [2, 14]], ['==', [3, 12]], ['!=', [3, 13]],
+ ['<', [4, 8]], ['<=', [4, 9]], ['>', [4, 10]], ['>=', [4, 11]],
+ ['+', [5, 3]], ['-', [5, 4]], ['*', [6, 5]], ['/', [6, 6]],
+]);
+const FUNCTIONS = { min: [2, 17], max: [2, 18], clamp: [3, 19] };
+const VIEW_FIELDS = { box: ['x', 'y', 'width', 'height'],
+ circle: ['x', 'y', 'radius'], text: ['x', 'y', 'value'] };
+
+function fail(token, message) {
+ const error = new Error(`${message} (${token.line}:${token.column})`);
+ error.line = token.line;
+ error.column = token.column;
+ throw error;
+}
+
+function lex(source) {
+ const tokens = [];
+ let offset = 0, line = 1, column = 1;
+ const advance = () => {
+ const char = source[offset++];
+ if (char === '\n') { line++; column = 1; } else column++;
+ return char;
+ };
+ while (offset < source.length) {
+ const char = source[offset];
+ if (char === ' ' || char === '\t' || char === '\r') { advance(); continue; }
+ if (char === '#') { while (offset < source.length && source[offset] !== '\n') advance(); continue; }
+ const token = { line, column };
+ if (char === '\n') { advance(); tokens.push({ ...token, kind: 'nl' }); continue; }
+ if (char === '"') {
+ advance();
+ let value = '', closed = false;
+ while (offset < source.length) {
+ const next = advance();
+ if (next === '"') { closed = true; break; }
+ if (next === '\n' || next === '\r') fail(token, 'Strings must fit on one line');
+ if (next === '\\') {
+ const escaped = advance();
+ const escapes = { n: '\n', r: '\r', t: '\t', '"': '"', '\\': '\\' };
+ if (!Object.hasOwn(escapes, escaped)) fail(token, 'Unsupported string escape');
+ value += escapes[escaped];
+ } else value += next;
+ }
+ if (!closed) fail(token, 'Unterminated string');
+ tokens.push({ ...token, kind: 'string', value }); continue;
+ }
+ const remaining = source.slice(offset);
+ const number = remaining.match(/^(?:\d+(?:\.\d*)?|\.\d+)(?:[eE][+-]?\d+)?/);
+ if (number) {
+ const value = Number(number[0]);
+ if (!Number.isFinite(value)) fail(token, 'Number must be finite');
+ for (let i = 0; i < number[0].length; i++) advance();
+ tokens.push({ ...token, kind: 'number', value }); continue;
+ }
+ const name = remaining.match(/^[A-Za-z_][A-Za-z_0-9]*/);
+ if (name) {
+ for (let i = 0; i < name[0].length; i++) advance();
+ tokens.push({ ...token, kind: 'name', value: name[0] }); continue;
+ }
+ const pair = source.slice(offset, offset + 2);
+ if (['<=', '>=', '==', '!=', '&&', '||'].includes(pair)) {
+ advance(); advance(); tokens.push({ ...token, kind: pair }); continue;
+ }
+ if ('=+-*/!<>(){},.;:'.includes(char)) {
+ advance(); tokens.push({ ...token, kind: char }); continue;
+ }
+ fail(token, `Unexpected character ${JSON.stringify(char)}`);
+ }
+ tokens.push({ kind: 'eof', line, column });
+ return tokens;
+}
+
+class Parser {
+ constructor(tokens) { this.tokens = tokens; this.position = 0; this.depth = 0; }
+ peek() { return this.tokens[this.position]; }
+ take() { return this.tokens[this.position++]; }
+ is(kind, value) { return this.peek().kind === kind && (value === undefined || this.peek().value === value); }
+ expect(kind, description = kind) {
+ if (!this.is(kind)) fail(this.peek(), `Expected ${description}`);
+ return this.take();
+ }
+ newlines() { while (this.is('nl')) this.take(); }
+ separators() { while (this.is('nl') || this.is(';')) this.take(); }
+ end() {
+ if (!['nl', ';', '}', 'eof'].includes(this.peek().kind)) fail(this.peek(), 'Expected newline or semicolon');
+ this.separators();
+ }
+ reference(token) {
+ if (!this.is('.')) return token.value;
+ this.take();
+ return `${token.value}.${this.expect('name', 'field name').value}`;
+ }
+ initial() {
+ let sign = 1;
+ if (this.is('-') || this.is('+')) sign = this.take().kind === '-' ? -1 : 1;
+ return sign * this.expect('number', 'numeric state initializer').value;
+ }
+ node(token, properties, children = []) {
+ const depth = 1 + Math.max(0, ...children.map(child => child.depth));
+ if (depth > LIMITS.depth) fail(token, 'Expression nesting exceeds 64');
+ return { token, depth, ...properties };
+ }
+ expression(minimum = 0, multiline = false) {
+ if (++this.depth > LIMITS.depth) fail(this.peek(), 'Expression nesting exceeds 64');
+ if (multiline) this.newlines();
+ const token = this.take();
+ let left;
+ if (token.kind === 'number') left = this.node(token, { kind: 'constant', value: token.value });
+ else if (token.kind === '-' || token.kind === '!') {
+ const child = this.expression(7, multiline);
+ left = this.node(token, { kind: 'unary', op: token.kind === '-' ? 7 : 16, child }, [child]);
+ } else if (token.kind === '(') {
+ left = this.expression(0, true); this.newlines(); this.expect(')');
+ } else if (token.kind === 'name') {
+ if (Object.hasOwn(FUNCTIONS, token.value) && this.is('(')) {
+ this.take();
+ const args = [];
+ this.newlines();
+ if (!this.is(')')) {
+ for (;;) {
+ args.push(this.expression(0, true)); this.newlines();
+ if (!this.is(',')) break;
+ this.take();
+ }
+ }
+ this.expect(')');
+ if (args.length !== FUNCTIONS[token.value][0]) fail(token, `${token.value} expects ${FUNCTIONS[token.value][0]} arguments`);
+ left = this.node(token, { kind: 'call', op: FUNCTIONS[token.value][1], args }, args);
+ } else left = this.node(token, { kind: 'reference', name: this.reference(token) });
+ } else fail(token, 'Expected a number, state, or expression');
+ for (;;) {
+ if (multiline) this.newlines();
+ const entry = BINARY.get(this.peek().kind);
+ if (!entry || entry[0] < minimum) break;
+ const operator = this.take();
+ const right = this.expression(entry[0] + 1, multiline);
+ left = this.node(operator, { kind: 'binary', op: entry[1], left, right }, [left, right]);
+ }
+ this.depth--;
+ return left;
+ }
+ block(depth = 0) {
+ if (depth >= LIMITS.depth) fail(this.peek(), 'Handler nesting exceeds 64');
+ this.newlines(); this.expect('{'); this.separators();
+ const statements = [];
+ while (!this.is('}')) {
+ const token = this.expect('name', 'assignment, if, or closing brace');
+ if (token.value === 'if' && !this.is('=')) {
+ const condition = this.expression();
+ const body = this.block(depth + 1);
+ statements.push({ kind: 'if', token, condition, body });
+ } else {
+ const name = this.reference(token);
+ this.expect('=');
+ statements.push({ kind: 'assign', token, name, expression: this.expression() });
+ }
+ this.end();
+ }
+ this.take();
+ return statements;
+ }
+ parse() {
+ const declarations = [];
+ this.separators();
+ while (!this.is('eof')) {
+ const token = this.expect('name', 'declaration');
+ const kind = token.value;
+ if (kind === 'state') {
+ const name = this.expect('name', 'state name'); this.expect('=');
+ declarations.push({ kind, token, name, initial: this.initial() });
+ } else if (kind === 'record') {
+ const name = this.expect('name', 'record type'); this.newlines(); this.expect('{'); this.separators();
+ const fields = [];
+ while (!this.is('}')) {
+ const field = this.expect('name', 'field name'); this.expect('=');
+ fields.push({name:field, initial:this.initial()}); this.end();
+ }
+ this.take(); declarations.push({kind,token,name,fields});
+ } else if (kind === 'item') {
+ const name = this.expect('name', 'item name'); this.expect('=');
+ const type = this.expect('name', 'record type'); this.expect('('); this.newlines();
+ const fields = [];
+ while (!this.is(')')) {
+ const field = this.expect('name', 'field name'); this.expect('=');
+ fields.push({name:field, initial:this.initial()}); this.newlines();
+ if (!this.is(',')) break;
+ this.take(); this.newlines();
+ }
+ this.expect(')'); declarations.push({kind,token,name,type,fields});
+ } else if (kind === 'rule') {
+ const name = this.expect('name', 'rule name'); this.expect('(');
+ const parameter = this.expect('name', 'record parameter'); this.expect(':');
+ const type = this.expect('name', 'record type'); this.expect(')');
+ declarations.push({kind,token,name,parameter,type,body:this.block()});
+ } else if (kind === 'input') {
+ const name = this.expect('name', 'input name'); this.expect('=');
+ const key = this.expect('name', 'keyboard code or pointer(view)');
+ if (key.value === 'value') {
+ declarations.push({kind,token,name,valueInput:true}); this.end(); continue;
+ }
+ if (key.value === 'pointer') {
+ if (this.is('{')) {
+ this.take(); this.separators(); const routes = [];
+ while (!this.is('}')) {
+ const target = this.expect('name', 'view ID'); this.expect(':');
+ const item = this.expect('name', 'item name'); routes.push({target,item}); this.end();
+ }
+ this.take(); declarations.push({kind,token,name,routes}); this.end(); continue;
+ }
+ this.expect('(');
+ const target = this.expect('name', 'view ID'); this.expect(')');
+ declarations.push({ kind, token, name, target });
+ this.end(); continue;
+ }
+ if (!/^(?:Space|Enter|Arrow(?:Left|Right|Up|Down)|Key[A-Z]|Digit[0-9])$/.test(key.value)) fail(key, 'Unsupported keyboard code');
+ declarations.push({ kind, token, name, key });
+ } else if (Object.hasOwn(VIEW_FIELDS, kind)) {
+ const name = this.expect('name', 'view ID'); this.expect('(');
+ const expressions = [];
+ for (let i = 0; i < VIEW_FIELDS[kind].length; i++) {
+ if (i) { this.newlines(); this.expect(','); }
+ expressions.push(this.expression(0, true));
+ }
+ this.newlines();
+ let color, prefix = '';
+ if (kind !== 'text') {
+ this.expect(','); this.newlines();
+ const value = this.expect('string', 'literal hex color');
+ if (!/^#[0-9a-fA-F]{6}$/.test(value.value)) fail(value, 'Color must use six hexadecimal digits, such as "#356548"');
+ color = value.value;
+ } else if (this.is(',')) { this.take(); this.newlines(); prefix = this.expect('string', 'literal text prefix').value; }
+ this.newlines(); this.expect(')');
+ declarations.push({ kind: 'view', type: kind, token, name, expressions, color, prefix });
+ } else if (kind === 'on') {
+ const event = this.expect('name', 'input name or frame');
+ let edge;
+ if (event.value !== 'frame') {
+ this.expect('.'); edge = this.expect('name', 'down, up, move or cancel');
+ if (!['down', 'up', 'move', 'cancel', 'change'].includes(edge.value)) fail(edge, 'Expected down or up, move, cancel or change');
+ }
+ if (this.is('name', 'use')) {
+ this.take(); const rule = this.expect('name', 'rule name'); this.expect('(');
+ const item = this.expect('name', 'item name or input.target');
+ const argument = this.reference(item); this.expect(')');
+ declarations.push({kind:'handler',token,event,edge,rule,item,argument});
+ } else declarations.push({ kind: 'handler', token, event, edge, body: this.block() });
+ } else fail(token, `Unknown declaration ${kind}`);
+ this.end();
+ }
+ return declarations;
+ }
+}
+
+export function compile(source) {
+ if (typeof source !== 'string') fail({ line: 1, column: 1 }, 'Source must be a string');
+ const sourceBytes = new TextEncoder().encode(source).length;
+ if (sourceBytes > LIMITS.sourceBytes) fail({ line: 1, column: 1 }, 'Source exceeds 32768 UTF-8 bytes');
+ const declarations = new Parser(lex(source)).parse();
+ const program = { states: [], inputs: [], instructions: [], handlers: [], views: [], pointerTargets: [], metrics: {} };
+ const states = new Map(), inputs = new Map(), views = new Set(), keys = new Set(), targets = new Set();
+ const records = new Map(), items = new Map(), rules = new Map();
+ const scalarNames = new Set(declarations.filter(d => d.kind === 'state').map(d => d.name.value));
+ for (const declaration of declarations) {
+ if (!['record','rule'].includes(declaration.kind)) continue;
+ const {name,fields,kind} = declaration, symbols = kind === 'record' ? records : rules;
+ if (RESERVED.has(name.value)) fail(name, `Reserved name ${name.value}`);
+ if (symbols.has(name.value)) fail(name, `Duplicate ${kind} ${name.value}`);
+ if (kind === 'record') {
+ if (!fields.length || fields.length > 8) fail(name, 'A record needs 1..8 fields');
+ const used = new Set();
+ for (const field of fields) {
+ if (used.has(field.name.value)) fail(field.name, `Duplicate field ${field.name.value}`);
+ if (RESERVED.has(field.name.value)) fail(field.name, `Reserved name ${field.name.value}`);
+ used.add(field.name.value);
+ }
+ }
+ symbols.set(name.value, declaration);
+ }
+ let handlerCount = 0;
+ for (const declaration of declarations) {
+ const { kind, name, token } = declaration;
+ if (name && RESERVED.has(name.value)) fail(name, `Reserved name ${name.value}`);
+ if (kind === 'record' || kind === 'rule') continue;
+ if (kind === 'item') {
+ if (items.has(name.value) || scalarNames.has(name.value)) fail(name, `Duplicate state/item ${name.value}`);
+ const record = records.get(declaration.type.value);
+ if (!record) fail(declaration.type, `Unknown record ${declaration.type.value}`);
+ const overrides = new Map();
+ for (const field of declaration.fields) {
+ if (!record.fields.some(f => f.name.value === field.name.value)) fail(field.name, `Unknown field ${field.name.value}`);
+ if (overrides.has(field.name.value)) fail(field.name, `Duplicate field ${field.name.value}`);
+ overrides.set(field.name.value, field.initial);
+ }
+ const base = states.size;
+ for (const field of record.fields) {
+ if (states.size >= LIMITS.states) fail(name, 'At most 32 states are allowed, including item fields');
+ const qualified = `${name.value}.${field.name.value}`, index = states.size;
+ states.set(qualified,index);
+ program.states.push({name:qualified,initial:overrides.has(field.name.value) ? overrides.get(field.name.value) : field.initial,line:token.line});
+ }
+ items.set(name.value,{type:declaration.type.value,base,count:record.fields.length});
+ } else if (kind === 'state' || kind === 'input') {
+ const symbols = kind === 'state' ? states : inputs;
+ if (symbols.has(name.value)) fail(name, `Duplicate ${kind} ${name.value}`);
+ if (symbols.size >= LIMITS[`${kind}s`]) fail(name, `At most ${LIMITS[`${kind}s`]} ${kind}s are allowed`);
+ symbols.set(name.value, symbols.size);
+ if (kind === 'state') program.states.push({ name: name.value, initial: declaration.initial, line: token.line });
+ else if (declaration.valueInput) {
+ program.inputs.push({name:name.value,kind:'value',line:token.line});
+ } else if (declaration.routes) {
+ if (!declaration.routes.length) fail(name, 'A pointer group needs at least one target');
+ program.inputs.push({name:name.value, routes:[], line:token.line});
+ } else if (declaration.target) {
+ const target = declaration.target;
+ if (targets.has(target.value)) fail(target, `Duplicate pointer target ${target.value}`);
+ targets.add(target.value);
+ program.inputs.push({ name: name.value, target: target.value, line: token.line });
+ } else {
+ if (keys.has(declaration.key.value)) fail(declaration.key, `Duplicate keyboard code ${declaration.key.value}`);
+ keys.add(declaration.key.value);
+ program.inputs.push({ name: name.value, key: declaration.key.value, line: token.line });
+ }
+ } else if (kind === 'view') {
+ if (views.has(name.value)) fail(name, `Duplicate view ID ${name.value}`);
+ if (views.size >= LIMITS.views) fail(name, 'At most 24 views are allowed');
+ views.add(name.value);
+ } else if (++handlerCount > LIMITS.handlers) fail(token, 'At most 64 handlers are allowed');
+ }
+ for (const declaration of declarations) {
+ if (declaration.target && !views.has(declaration.target.value))
+ fail(declaration.target, `Unknown pointer target ${declaration.target.value}`);
+ }
+ const routedItems = new Set();
+ for (const declaration of declarations) {
+ if (!declaration.routes) continue;
+ const input = inputs.get(declaration.name.value), resolved = program.inputs[input];
+ let type;
+ for (const {target,item:token} of declaration.routes) {
+ if (!views.has(target.value)) fail(target, `Unknown pointer target ${target.value}`);
+ if (targets.has(target.value)) fail(target, `Duplicate pointer target ${target.value}`);
+ const item = items.get(token.value);
+ if (!item) fail(token, `Unknown item ${token.value}`);
+ if (type && type !== item.type) fail(token, 'Pointer group targets must share one record type');
+ if (routedItems.has(token.value)) fail(token, `Item ${token.value} already has a pointer route`);
+ targets.add(target.value); routedItems.add(token.value); type = item.type;
+ const route = program.pointerTargets.length;
+ if (route >= 24) fail(target, 'At most 24 pointer targets are allowed');
+ program.pointerTargets.push({input,base:item.base,count:item.count});
+ resolved.routes.push({target:target.value, item:token.value, route});
+ }
+ resolved.type = type;
+ }
+ for (const rule of rules.values()) {
+ if (!records.has(rule.type.value)) fail(rule.type, `Unknown record ${rule.type.value}`);
+ if (RESERVED.has(rule.parameter.value)) fail(rule.parameter, `Reserved name ${rule.parameter.value}`);
+ }
+ let pointHandler = false, valueHandler = false;
+ let parameters = null;
+ let parameterPrefix = null;
+ let stack = 0, maxStack = 0;
+ const emit = (op, token, arg = 0, value = 0, delta = 0) => {
+ if (program.instructions.length >= LIMITS.instructions) fail(token, 'At most 512 instructions are allowed');
+ stack += delta;
+ if (stack < 0 || stack > LIMITS.stack) fail(token, 'Expression stack exceeds 64');
+ maxStack = Math.max(maxStack, stack);
+ program.instructions.push({ op, arg, value, line: token.line });
+ };
+ const expression = (node, binding = false) => {
+ const { token } = node;
+ if (node.kind === 'constant') emit(0, token, 0, node.value, 1);
+ else if (node.kind === 'reference') {
+ if (node.name === 'event_value') {
+ if (binding || !valueHandler) fail(token, 'event_value is only available in value change handlers');
+ emit(26, token, 0, 0, 1);
+ } else if (node.name === 'event_x' || node.name === 'event_y') {
+ if (binding || !pointHandler) fail(token, 'event_x and event_y are only available in pointer handlers');
+ emit(node.name === 'event_x' ? 22 : 23, token, 0, 0, 1);
+ } else if (node.name === 'dt') {
+ if (binding) fail(token, 'dt is only available in handlers');
+ emit(2, token, 0, 0, 1);
+ } else {
+ if (parameterPrefix && node.name.startsWith(parameterPrefix) && !parameters.has(node.name))
+ fail(token, `Unknown parameter field ${node.name}`);
+ if (parameters?.has(node.name)) {
+ emit(24, token, parameters.get(node.name), 0, 1);
+ return;
+ }
+ if (!states.has(node.name)) fail(token, `Unknown state ${node.name}`);
+ emit(1, token, states.get(node.name), 0, 1);
+ }
+ } else if (node.kind === 'unary') { expression(node.child, binding); emit(node.op, token); }
+ else if (node.kind === 'binary') {
+ expression(node.left, binding); expression(node.right, binding); emit(node.op, token, 0, 0, -1);
+ } else {
+ for (const arg of node.args) expression(arg, binding);
+ emit(node.op, token, 0, 0, 1 - node.args.length);
+ }
+ };
+ const statements = body => {
+ for (const statement of body) {
+ if (statement.kind === 'assign') {
+ if (parameterPrefix && statement.name.startsWith(parameterPrefix) && !parameters.has(statement.name))
+ fail(statement.token, `Unknown parameter field ${statement.name}`);
+ const parameter = parameters?.get(statement.name);
+ if (parameter === undefined && !states.has(statement.name)) fail(statement.token, `Unknown state ${statement.name}`);
+ expression(statement.expression);
+ emit(parameter === undefined ? 20 : 25, statement.token, parameter === undefined ? states.get(statement.name) : parameter, 0, -1);
+ } else {
+ expression(statement.condition);
+ const branch = program.instructions.length;
+ emit(21, statement.token, 0, 0, -1);
+ statements(statement.body);
+ program.instructions[branch].arg = program.instructions.length;
+ }
+ }
+ };
+ const bodies = new Map();
+ function ruleBody(rule) {
+ if (bodies.has(rule.name.value)) return bodies.get(rule.name.value);
+ parameterPrefix = `${rule.parameter.value}.`;
+ parameters = new Map(records.get(rule.type.value).fields.map((field,index) => [`${rule.parameter.value}.${field.name.value}`,index]));
+ // A shared body may read coordinates; every binding is checked below.
+ pointHandler = true; valueHandler = true; stack = 0;
+ const start = program.instructions.length;
+ statements(rule.body);
+ const length = program.instructions.length - start;
+ const point = program.instructions.slice(start).some(op => op.op === 22 || op.op === 23);
+ parameters = null; parameterPrefix = null;
+ const value = program.instructions.slice(start).some(op => op.op === 26);
+ const body = {start,length,point,value}; bodies.set(rule.name.value,body); return body;
+ }
+ for (const declaration of declarations) {
+ if (declaration.kind === 'handler') {
+ let event = 0;
+ pointHandler = false; valueHandler = false;
+ if (declaration.event.value !== 'frame') {
+ if (!inputs.has(declaration.event.value)) fail(declaration.event, `Unknown input ${declaration.event.value}`);
+ const index = inputs.get(declaration.event.value), edge = declaration.edge.value;
+ pointHandler = Boolean(program.inputs[index].target || program.inputs[index].routes);
+ valueHandler = program.inputs[index].kind === 'value';
+ if (valueHandler !== (edge === 'change')) fail(declaration.edge, 'Value inputs require change; change requires a value input');
+ if (!pointHandler && (edge === 'move' || edge === 'cancel')) fail(declaration.edge, 'move and cancel require a pointer input');
+ event = edge === 'change' ? 33 + index : edge === 'move' ? 17 + index : edge === 'cancel' ? 25 + index : 1 + index * 2 + (edge === 'up' ? 1 : 0);
+ }
+ if (declaration.rule) {
+ const rule = rules.get(declaration.rule.value);
+ const routed = declaration.argument.includes('.');
+ const input = program.inputs[inputs.get(declaration.event.value)];
+ if (routed && (!input?.routes || declaration.argument !== `${declaration.event.value}.target`))
+ fail(declaration.item, 'Use the current pointer group target');
+ const item = routed ? {type:input.type, count:records.get(input.type).fields.length} : items.get(declaration.item.value);
+ if (!rule) fail(declaration.rule, `Unknown rule ${declaration.rule.value}`);
+ if (!item) fail(declaration.item, `Unknown item ${declaration.item.value}`);
+ if (item.type !== rule.type.value) fail(declaration.item, `Rule ${rule.name.value} requires ${rule.type.value}`);
+ const pointer = pointHandler, value = valueHandler;
+ const body = ruleBody(rule);
+ if (body.value && !value) fail(declaration.rule, 'Value-reading rules require a value input');
+ if (body.point && !pointer) fail(declaration.rule, 'Coordinate-reading rules require a pointer input');
+ program.handlers.push({event,start:body.start,length:body.length,line:declaration.token.line,parameterBase:routed ? 255 : item.base,parameterCount:item.count});
+ } else {
+ const start = program.instructions.length;
+ stack = 0; statements(declaration.body);
+ program.handlers.push({ event, start, length: program.instructions.length - start, line: declaration.token.line });
+ }
+ } else if (declaration.kind === 'view') {
+ const view = { id: declaration.name.value, type: declaration.type, line: declaration.token.line, bindings: {} };
+ if (view.type === 'text') view.prefix = declaration.prefix;
+ else view.color = declaration.color;
+ declaration.expressions.forEach((node, index) => {
+ const start = program.instructions.length;
+ stack = 0; expression(node, true);
+ view.bindings[VIEW_FIELDS[view.type][index]] = { start, length: program.instructions.length - start };
+ });
+ program.views.push(view);
+ }
+ }
+ // Unused definitions still receive name/type checking, but reserve no VM body.
+ for (const rule of rules.values()) {
+ if (bodies.has(rule.name.value)) continue;
+ const instructions = program.instructions, previousMax = maxStack;
+ program.instructions = [];
+ ruleBody(rule);
+ program.instructions = instructions; maxStack = previousMax;
+ }
+ program.metrics = { sourceBytes, sourceLines: source === '' ? 0 : source.split('\n').length,
+ states: states.size, inputs: inputs.size, views: views.size,
+ handlers: program.handlers.length, instructions: program.instructions.length, maxStack };
+ return program;
+}
+
+export { LIMITS };
diff --git a/prototypes/workbench/web/core.wasm b/prototypes/workbench/web/core.wasm
new file mode 100755
index 0000000..7aa443c
--- /dev/null
+++ b/prototypes/workbench/web/core.wasm
Binary files differ
diff --git a/prototypes/workbench/web/document.css b/prototypes/workbench/web/document.css
new file mode 100644
index 0000000..7e7ed97
--- /dev/null
+++ b/prototypes/workbench/web/document.css
@@ -0,0 +1,3 @@
+:root{color-scheme:dark;--bg:#101815;--panel:#19241e;--line:#35483c;--text:#e5eade;--muted:#a6b3a7;--accent:#c9f08b;font-family:system-ui,-apple-system,sans-serif;color:var(--text);background:var(--bg)}
+*{box-sizing:border-box}body{margin:0}header{display:flex;justify-content:space-between;align-items:center;border-bottom:1px solid var(--line);padding:22px max(24px,calc((100vw - 1500px)/2))}.brand{font-size:22px;font-weight:700;letter-spacing:-1px;text-decoration:none;color:var(--accent)}.brand span{font-size:14px;color:var(--muted);font-weight:400;letter-spacing:0}.badge,.eyebrow{color:var(--muted);font-size:12px;letter-spacing:.1em}main{max-width:1550px;margin:auto;padding:28px 24px}.intro{display:flex;justify-content:space-between;align-items:center;gap:16px;margin-bottom:25px}h1{font-size:clamp(23px,3vw,34px);font-weight:550;letter-spacing:-.04em;word-break:keep-all;margin:8px 0}.intro p:not(.eyebrow){color:var(--muted);font-size:14px;line-height:1.65;margin-bottom:0}button,input,textarea{font:inherit;color:inherit}button{background:#273a2e;border:1px solid #456049;padding:9px 14px;border-radius:7px;cursor:pointer;white-space:nowrap}button:hover{background:#334d3d}button:disabled{opacity:.45;cursor:default}input:not([type=range]){background:var(--bg);border:1px solid var(--line);border-radius:5px;padding:8px;max-width:180px}button:focus-visible,input:focus-visible,textarea:focus-visible,[tabindex]:focus-visible,summary:focus-visible{outline:2px solid var(--accent);outline-offset:3px}.toolbar{display:flex;gap:10px;align-items:center;flex-wrap:wrap;background:var(--panel);padding:14px;border:1px solid var(--line);border-radius:9px 9px 0 0}.toolbar label{font-size:13px;color:var(--muted)}#document-status{font-size:12px;color:var(--muted);margin-left:auto;max-width:100%;overflow-wrap:anywhere}.layout-tools{display:flex;align-items:center;gap:12px;padding:15px 0;font-size:13px}.layout-tools input{width:145px;accent-color:var(--accent)}.layout-tools output{min-width:42px;font-variant-numeric:tabular-nums}.layout-tools button{padding:5px 10px}.hint{color:var(--muted);font-size:12px;margin-left:auto}.panes{display:grid;grid-template-columns:minmax(0,calc((100% - 18px)*var(--split))) 18px minmax(0,1fr);height:clamp(400px,65vh,850px)}.pane{min-width:0;min-height:0;display:flex;flex-direction:column;background:var(--panel);border:1px solid var(--line);border-radius:8px;overflow:hidden}.pane-heading{display:flex;align-items:center;justify-content:space-between;padding:13px 18px;border-bottom:1px solid var(--line);flex:0 0 auto}.pane-heading h2{font-size:14px;font-weight:550;margin:0}.pane-heading span{font-size:10px;letter-spacing:.12em;color:var(--muted)}#document-text{width:100%;flex:1;min-height:0;resize:none;background:transparent;border:0;padding:22px;font:15px/1.8 ui-monospace,monospace;outline-offset:-3px;tab-size:4}#document-text::placeholder{color:#84998b}#reading-scroll{flex:1;min-height:0;overflow:auto;overscroll-behavior:contain;outline-offset:-3px;padding:26px clamp(18px,3vw,42px);font-size:17px;line-height:1.95;background:#e5e8dc;color:#26352d}#reading-text{white-space:pre-wrap;overflow-wrap:anywhere}#reading-empty{color:#637566;margin:0}#divider{cursor:col-resize;touch-action:none;display:flex;align-items:center;justify-content:center;outline-offset:-2px}#divider span{height:44px;width:4px;background:#617768;border-radius:4px}#divider:hover span,#divider.dragging span,#divider:focus-visible span{background:var(--accent)}#divider[aria-disabled=true]{pointer-events:none}.policy{margin-top:22px;border:1px solid var(--line);padding:14px 18px;border-radius:8px}.policy summary{cursor:pointer;color:var(--muted);font-size:13px}.policy p{font-size:13px;color:var(--muted);line-height:1.7}.policy textarea{display:block;width:100%;height:160px;resize:vertical;background:#121b16;border:1px solid var(--line);border-radius:5px;font:13px/1.65 ui-monospace,monospace;padding:12px;margin:12px 0}.policy span{font-size:12px;margin-left:12px;color:var(--muted)}footer{font-size:12px;color:var(--muted);line-height:1.7;padding:22px 0}a{color:var(--accent)}.sr-only{position:absolute;width:1px;height:1px;padding:0;margin:-1px;overflow:hidden;clip:rect(0,0,0,0);white-space:nowrap;border:0}[hidden]{display:none!important}
+@media(max-width:720px){header{padding:18px}.badge{display:none}main{padding:20px 16px}.intro{align-items:flex-start}.intro button{font-size:12px;padding:8px}.toolbar input:not([type=range]){max-width:155px}.toolbar label{width:100%}#document-status{width:100%;margin:2px 0}.layout-tools{display:none}.panes{display:flex;flex-direction:column;height:auto;gap:18px}.pane{height:430px}.read-pane{height:500px}#divider{display:none}.policy span{display:block;margin:10px 0 0}.hint{display:none}#reading-scroll{padding:22px}}
diff --git a/prototypes/workbench/web/document.html b/prototypes/workbench/web/document.html
new file mode 100644
index 0000000..7e96674
--- /dev/null
+++ b/prototypes/workbench/web/document.html
@@ -0,0 +1,32 @@
+<!doctype html>
+<html lang="ko">
+<head>
+ <meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
+ <link rel="icon" href="data:,">
+ <title>Pure · 문서 작업</title><link rel="stylesheet" href="./document.css">
+</head>
+<body>
+ <header><a class="brand" href="./">pure <span>/ 문서 작업</span></a><span class="badge">텍스트 워크스페이스</span></header>
+ <main>
+ <div class="intro"><div><p class="eyebrow">WRITE / READ</p><h1>쓰고, 읽고, 공간을 조절하세요.</h1><p>가운데 경계를 움직여 편한 폭을 만드세요. 좁은 화면에서는 위아래로 이어집니다.</p></div><button id="sample" type="button">예제 문서</button></div>
+ <div class="toolbar" aria-label="문서 도구">
+ <label>파일 이름 <input id="filename" value="notes.txt" maxlength="120" aria-label="파일 이름"></label>
+ <button id="open" type="button">텍스트 열기</button><button id="save" type="button">.txt 저장</button>
+ <input id="file-input" type="file" accept=".txt,text/plain" hidden>
+ <span id="document-status" role="status">UTF-8 텍스트 파일을 열거나 바로 작성하세요.</span>
+ </div>
+ <div class="layout-tools">
+ <label for="split-range">편집 영역</label><input id="split-range" type="range" min="20" max="80" value="50" step="1" disabled><output id="split-output" for="split-range">50%</output>
+ <button id="reset-split" type="button" disabled>반반으로</button><span class="hint">경계에서 ← → · Shift로 크게 · Esc로 취소</span>
+ </div>
+ <div id="panes" class="panes" style="--split:0.5">
+ <section id="editor-pane" class="pane edit-pane" aria-labelledby="editor-title"><div class="pane-heading"><h2 id="editor-title">작성</h2><span>TEXT</span></div><label for="document-text" class="sr-only">문서 내용</label><textarea id="document-text" spellcheck="true" placeholder="생각을 적어보세요.&#10;&#10;내용은 오른쪽 읽기 화면에 표시됩니다."></textarea></section>
+ <div id="divider" role="separator" tabindex="0" aria-orientation="vertical" aria-labelledby="editor-title" aria-controls="editor-pane" aria-valuemin="20" aria-valuemax="80" aria-valuenow="50" aria-disabled="true"><span></span></div>
+ <section class="pane read-pane" aria-labelledby="reading-title"><div class="pane-heading"><h2 id="reading-title">읽기</h2><span>PREVIEW</span></div><div id="reading-scroll" tabindex="0" aria-label="문서 읽기"><div id="reading-text"></div><p id="reading-empty">작성한 내용이 여기에 나타납니다.</p></div></section>
+ </div>
+ <details class="policy"><summary>분할 규칙 수정</summary><p>비율을 받는 규칙을 수정할 수 있습니다. 문서 내용은 유지됩니다. 숫자 입력 이름은 split, 결과 상태는 ratio입니다.</p><label for="policy-source" class="sr-only">Pure 분할 규칙</label><textarea id="policy-source" spellcheck="false"></textarea><button id="apply-policy" type="button" disabled>규칙 적용</button><span id="policy-status" role="status">실행기를 준비하고 있습니다.</span></details>
+ <footer>텍스트는 일반 .txt 파일로 저장됩니다. 줄바꿈과 글자 배치는 브라우저가 처리합니다. <a href="./">기존 작업 공간 ↗</a></footer>
+ </main>
+ <script type="module" src="./document.mjs"></script>
+</body>
+</html>
diff --git a/prototypes/workbench/web/document.mjs b/prototypes/workbench/web/document.mjs
new file mode 100644
index 0000000..1f19322
--- /dev/null
+++ b/prototypes/workbench/web/document.mjs
@@ -0,0 +1,132 @@
+import {createRuntime} from './runtime.mjs';
+import {compile} from './compiler.mjs';
+import {bindSplit} from './split-input.mjs';
+const $=id=>document.getElementById(id), text=$('document-text'), reading=$('reading-text');
+const MAX_BYTES=2*1024*1024, stats={geometryReads:0,previewWrites:0,ratioWrites:0};
+let runtime=null, splitter=null, previewFrame=0, composing=false, generation=0, request=0, dirty=false;
+const message=(id,value)=>{$(id).textContent=value;};
+const narrow=matchMedia('(max-width:720px)');
+const sample=`작업 노트 — 인터페이스의 경계
+
+1. 이 문서에서 해볼 일
+
+창의 폭을 줄여보세요. 읽기 화면의 글자는 새 폭에 맞춰 줄바꿈됩니다. 화면이 좁아지면 작성과 읽기가 위아래로 놓입니다. 문서 자체를 다른 형식으로 바꿀 필요는 없습니다.
+
+넓은 화면에서는 두 영역 사이의 경계를 잡아 움직일 수 있습니다. 경계에 키보드 초점을 두고 방향키를 눌러도 됩니다. 손을 놓으면 현재 비율을 유지하고, 움직이는 도중 Esc를 누르면 시작한 비율로 돌아갑니다.
+
+2. 작성과 읽기
+
+왼쪽은 브라우저의 텍스트 입력입니다. 이 문장을 고치고, 몇 줄을 더 적어보세요. 한글을 조합하고 복사하거나 붙여넣고, 실행 취소를 해볼 수 있습니다. 읽기 화면에서는 같은 글을 다른 폭으로 살펴봅니다.
+
+양쪽의 스크롤은 독립적입니다. 읽던 곳을 옮긴다고 작성 커서가 움직이지 않습니다. 분할 비율을 바꿀 때도 글자나 커서, 문서를 새로 만들지 않습니다.
+
+3. 연결을 유지하기
+
+일반 UTF-8 텍스트 파일을 열 수 있습니다. 작성한 결과도 .txt로 내려받습니다. 다른 편집기에서 그대로 읽을 수 있는 경로를 유지하는 것이 이 작업의 조건입니다.
+
+아래 문자열은 코드가 아니라 문서의 일부입니다.
+<div>이 글은 화면의 HTML 요소로 해석되지 않습니다.</div>
+
+4. 작업 메모
+
+여기에 실제 프로젝트의 메모를 적어도 됩니다.
+
+하려던 일:
+
+실제로 일어난 일:
+
+기대했던 일:
+
+다음 수정:
+
+5. 남겨둘 질문
+
+입력이 바뀌었을 때 꼭 다시 계산해야 하는 것은 무엇일까요? 문서가 바뀌지 않았는데 문서를 다시 전달할 이유가 있을까요? 화면의 폭과 문서의 의미를 같은 상태로 보관할 필요가 있을까요?
+
+이 화면이 답하는 범위는 작습니다. 실제 브라우저의 편집과 배치를 사용하면서, 조작 규칙을 어디에 둘 수 있는지를 확인합니다.
+`;
+function publishText() {
+ previewFrame=0;
+ if(composing)return;
+ reading.textContent=text.value;stats.previewWrites++;
+ $('reading-empty').hidden=text.value.length!==0;
+}
+function scheduleText(){if(!previewFrame)previewFrame=requestAnimationFrame(publishText);}
+text.addEventListener('compositionstart',()=>{composing=true;});
+text.addEventListener('compositionend',()=>{composing=false;scheduleText();});
+text.addEventListener('input',()=>{generation++;dirty=true;scheduleText();message('document-status','편집 중 · 저장하지 않은 변경이 있습니다.');});
+function replaceText(value,name) {
+ text.value=value;text.scrollTop=0;$('reading-scroll').scrollTop=0;
+ if(name)$('filename').value=name;
+ generation++;dirty=false;scheduleText();
+}
+$('sample').addEventListener('click',()=>{
+ if(dirty&&!confirm('편집한 내용을 예제 문서로 바꿀까요?'))return;
+ request++;replaceText(sample,'interface-notes.txt');dirty=true;message('document-status','예제 문서를 불러왔습니다. 자유롭게 고쳐보세요.');
+});
+$('open').addEventListener('click',()=>$('file-input').click());
+$('file-input').addEventListener('change',async event=>{
+ const file=event.target.files[0];event.target.value='';if(!file)return;
+ if(dirty&&!confirm('저장하지 않은 내용을 파일의 내용으로 바꿀까요?'))return;
+ const ticket=++request, version=generation;
+ try {
+ if(file.size>MAX_BYTES)throw new Error('2 MiB 이하의 텍스트 파일을 선택하세요.');
+ const bytes=await file.arrayBuffer();
+ if(ticket!==request||version!==generation)throw new Error('파일을 읽는 동안 문서가 바뀌어 열기를 취소했습니다.');
+ if(bytes.byteLength>MAX_BYTES)throw new Error('파일 크기 한도를 넘었습니다.');
+ const value=new TextDecoder('utf-8',{fatal:true}).decode(bytes);
+ replaceText(value,file.name);message('document-status',`${file.name} 파일을 열었습니다.`);
+ } catch(error){if(ticket===request)message('document-status',error instanceof TypeError?'UTF-8 텍스트로 읽을 수 없습니다. 현재 문서는 유지됩니다.':error.message);}
+});
+$('save').addEventListener('click',()=>{
+ const bytes=new TextEncoder().encode(text.value);
+ if(bytes.byteLength>MAX_BYTES){message('document-status','저장은 UTF-8 기준 2 MiB까지 지원합니다. 문서는 그대로 유지됩니다.');return;}
+ const name=$('filename').value.trim()||'notes.txt';
+ const url=URL.createObjectURL(new Blob([bytes],{type:'text/plain;charset=utf-8'}));
+ const link=document.createElement('a');link.href=url;link.download=name.endsWith('.txt')?name:`${name}.txt`;
+ link.click();setTimeout(()=>URL.revokeObjectURL(url),1000);dirty=false;message('document-status','텍스트 파일을 내려받았습니다.');
+});
+window.addEventListener('beforeunload',event=>{if(dirty){event.preventDefault();event.returnValue='';}});
+const output={
+ begin(){},view(){},value(){},end(){},
+ state(index,value){
+ if(runtime.currentProgram.states[index].name!=='ratio')return;
+ // Invalid authored output never produces a broken grid or invalid ARIA value.
+ if(value<20||value>80)throw new Error('이 화면의 분할 비율은 20~80% 범위여야 합니다. 규칙을 수정하세요.');
+ const ratio=String(value);
+ if($('divider').getAttribute('aria-valuenow')===ratio)return;
+ $('divider').setAttribute('aria-valuenow',ratio);
+ $('panes').style.setProperty('--split',String(value/100));
+ $('split-range').value=ratio;$('split-output').value=`${Math.round(value)}%`;stats.ratioWrites++;
+ },
+};
+function publish(){const result=runtime.present(output);if(result.lastError)throw new Error(result.errorMessage);}
+function writeRatio(value,source) {
+ if(!runtime)return;
+ try {runtime.change('split',value,source);publish();message('policy-status','적용된 규칙으로 분할 비율을 조절합니다.');}
+ catch(error){$('split-range').value=$('divider').getAttribute('aria-valuenow');message('policy-status',error.message);}
+}
+function applyPolicy() {
+ if(splitter?.isDragging())throw new Error('경계를 놓거나 취소한 뒤 규칙을 적용하세요.');
+ const source=$('policy-source').value, p=compile(source), ratio=p.states.find(state=>state.name==='ratio');
+ if(!ratio||ratio.initial<20||ratio.initial>80||!p.inputs.some(input=>input.name==='split'&&input.kind==='value')||!p.inputs.some(input=>input.name==='reset'&&input.key==='KeyR')||p.views.length)
+ throw new Error('ratio 상태(초깃값 20~80), split 숫자 입력, reset = KeyR 입력을 유지하세요. 이 화면은 HTML로 표시합니다.');
+ runtime.apply(source);publish();message('policy-status','규칙을 적용했습니다. 문서와 편집 커서는 유지됩니다.');
+}
+$('apply-policy').addEventListener('click',()=>{try{applyPolicy();}catch(error){message('policy-status',error.message);}});
+$('split-range').addEventListener('input',event=>{splitter?.cancel();writeRatio(event.target.valueAsNumber,'range');});
+$('reset-split').addEventListener('click',()=>{
+ splitter?.cancel();if(!runtime)return;
+ try{runtime.down('reset','control');runtime.up('reset','control');publish();message('policy-status','분할 비율을 초기화했습니다.');}catch(error){message('policy-status',error.message);}
+});
+try {
+ const [instance,response]=await Promise.all([createRuntime(),fetch('./examples/document.pure',{cache:'no-store'})]);
+ if(!response.ok)throw new Error('분할 규칙을 불러올 수 없습니다.');
+ runtime=instance;$('policy-source').value=await response.text();applyPolicy();
+ splitter=bindSplit({container:$('panes'),handle:$('divider'),narrow,stats,
+ readRatio:()=>Number($('divider').getAttribute('aria-valuenow')),writeRatio});
+ for(const id of ['split-range','reset-split','apply-policy'])$(id).disabled=false;
+ $('divider').setAttribute('aria-disabled','false');
+} catch(error){message('policy-status',`분할 실행기를 열지 못했습니다: ${error.message} 문서 작성과 파일 저장은 계속 사용할 수 있습니다.`);}
+window.pureDocument=Object.freeze({snapshot:()=>({ready:Boolean(splitter),stats:{...stats},
+ runtime:runtime?.snapshot()??null,dragging:splitter?.isDragging()??false,composing,dirty,generation})});
diff --git a/prototypes/workbench/web/index.html b/prototypes/workbench/web/index.html
new file mode 100644
index 0000000..089302c
--- /dev/null
+++ b/prototypes/workbench/web/index.html
@@ -0,0 +1,95 @@
+<!doctype html>
+<html lang="ko">
+<head>
+ <meta charset="utf-8">
+ <meta name="viewport" content="width=device-width, initial-scale=1">
+ <meta name="color-scheme" content="dark">
+ <title>Pure · 빈 작업물에서 시작하기</title>
+ <link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'%3E%3Crect width='32' height='32' rx='7' fill='%23151918'/%3E%3Ctext x='9' y='23' fill='%23c9f08b' font-size='24' font-family='sans-serif'%3Ep%3C/text%3E%3C/svg%3E">
+ <link rel="stylesheet" href="./style.css">
+</head>
+<body>
+ <header class="masthead">
+ <a class="wordmark" href="./" aria-label="Pure 작업 공간">pure<span> / workbench</span></a>
+ <span class="experiment">제작 경험 실험 004</span>
+ <div class="connection"><span id="ready-dot" class="dot"></span><span id="ready-status" role="status">실행기 준비 중</span></div>
+ </header>
+
+ <main>
+ <section class="intro" aria-labelledby="title">
+ <div><p class="eyebrow">빈 코드 · 직접 만든 동작</p><h1 id="title">무엇을 움직이게 할까요?</h1></div>
+ <p>상태를 만들고, 입력에 동작을 연결하세요.<br>코드를 <strong>적용</strong>하면 장면이 바뀝니다. 같은 이름의 상태는 이어집니다.</p>
+ </section>
+
+ <div class="workspace">
+ <section class="editor-panel panel" aria-labelledby="editor-title">
+ <div class="panel-heading"><h2 id="editor-title"><span class="section-index">01</span> 코드</h2><span id="draft-status" class="badge">빈 작업물</span></div>
+ <div class="file-tools" aria-label="파일 관리">
+ <button id="new" class="quiet" type="button">편집기 비우기</button>
+ <button id="upload" class="quiet" type="button">파일 열기</button>
+ <button id="download" class="quiet" type="button">.pure 저장</button>
+ <button id="download-project" class="quiet" type="button">코드 JSON 저장</button>
+ <button id="download-layout" class="quiet" type="button" disabled>배치 저장</button>
+ <input id="file-input" type="file" accept=".pure,.txt,.json,text/plain,application/json" hidden>
+ </div>
+ <label class="sr-only" for="source">Pure 코드 편집기</label>
+ <div class="code-editor">
+ <div id="line-numbers" class="line-numbers" aria-hidden="true">1</div>
+ <textarea id="source" spellcheck="false" autocapitalize="off" autocomplete="off" autocorrect="off" aria-describedby="editor-hint diagnostic" placeholder="여기서 시작하세요.&#10;&#10;아래 ‘언어 안내’에서 작은 조각을 가져올 수 있습니다."></textarea>
+ </div>
+ <div class="editor-footer"><span id="source-size">0 / 32,768 bytes</span><span id="cursor-position">1행 · 1열</span></div>
+ <div class="apply-row"><button id="apply" class="primary" type="button" disabled>코드 적용 <span aria-hidden="true">↗</span></button><p id="editor-hint">편집만으로 실행 중인 코드는 바뀌지 않습니다.</p></div>
+ <div id="diagnostic" class="diagnostic" role="status" aria-live="polite"><span id="diagnostic-text">아직 작성한 코드가 없습니다.</span><button id="error-location" type="button" class="quiet" hidden>문제 위치로</button></div>
+ </section>
+
+ <div class="right-column">
+ <section class="scene-panel panel" aria-labelledby="scene-title">
+ <div class="panel-heading"><h2 id="scene-title"><span class="section-index">02</span> 실행 장면</h2><span id="scene-status" class="badge">적용 전</span></div>
+ <div class="stage-wrap">
+ <svg id="scene" viewBox="0 0 640 360" role="img" tabindex="0" aria-labelledby="scene-description"><title id="scene-description">사용자가 코드로 만든 장면. 현재 비어 있습니다.</title><g id="scene-content"></g></svg>
+ <div id="empty-scene" class="empty-scene"><span class="empty-cross" aria-hidden="true">+</span><strong>아직 아무것도 없습니다</strong><span>도형이나 글자를 선언하고 코드를 적용하세요.</span></div>
+ <span class="stage-dimensions" aria-hidden="true">640 × 360</span>
+ </div>
+ <div class="transport"><button id="play" class="secondary" type="button" disabled>▶ 실행</button><button id="step" type="button" disabled>한 프레임</button><button id="reset" type="button" disabled>초깃값으로</button><span id="clock-status">0 frame · 정지</span></div>
+ <p class="control-note">정지하면 시간만 멈춥니다. 아래 입력은 정지 중에도 작동합니다.</p>
+ <div class="input-heading"><h3>내가 선언한 입력</h3><span>눌렀을 때 .down / 놓았을 때 .up</span></div>
+ <div id="input-controls" class="input-controls"><p class="empty-note">입력을 선언하면 여기에 버튼이 생깁니다.</p></div>
+ <p class="keyboard-note">장면을 클릭하면 키보드로 입력할 수 있습니다. 코드나 폼을 편집하는 동안에는 전달되지 않습니다.</p>
+ </section>
+
+ <section class="inspect-panel panel" aria-labelledby="inspect-title">
+ <div class="panel-heading"><h2 id="inspect-title"><span class="section-index">03</span> 현재 상태</h2><span id="revision" class="mono subdued">revision 0</span></div>
+ <div id="state-values" class="state-values"><p class="empty-note">선언한 상태의 값이 표시됩니다.</p></div>
+ <div class="event-line"><span class="subdued">마지막 입력</span><span id="last-event" class="mono">아직 없음</span></div>
+ <div id="runtime-error" class="runtime-error" role="status" hidden></div>
+ <details class="cost-details"><summary>실행 비용과 범위</summary><dl id="cost-values" class="cost-values"></dl><p>코어가 보고한 WebAssembly 메모리입니다. 브라우저·편집기·렌더러의 메모리와 프로세스 전체 사용량은 포함하지 않습니다. 예약 공간 안에 사용 중인 코드·상태·스테이징 공간이 포함되므로 합산하지 않습니다.</p><p id="dropped-ticks">시간 지연으로 건너뛴 프레임: 0</p></details>
+ </section>
+ </div>
+ </div>
+
+ <section class="reference panel" aria-labelledby="reference-title">
+ <div class="panel-heading"><h2 id="reference-title">언어 안내</h2><span class="subdued">조각을 조합해 자신의 동작을 만드세요.</span></div>
+ <div class="reference-grid">
+ <article><h3>1. 기억할 값</h3><p>이름은 영문으로, 처음 값은 숫자로 정합니다.</p><pre><code>state count = 0
+state x = 80</code></pre><button class="insert-snippet quiet" data-snippet="state" type="button">상태 조각 삽입</button></article>
+ <article><h3>2. 입력과 반응</h3><p>버튼 이름과 키를 선언하고 .down / .up에 연결합니다.</p><pre><code>input tap = Space
+on tap.down {
+ count = count + 1
+}</code></pre><button class="insert-snippet quiet" data-snippet="input" type="button">입력 조각 삽입</button></article>
+ <article><h3>3. 보일 것</h3><p>좌표와 크기를 상태에 연결할 수 있습니다.</p><pre><code>box tile(x, 180, 40, 40, "#7ed9ae")
+text label(40, 60, count, "Count: ")</code></pre><button class="insert-snippet quiet" data-snippet="view" type="button">장면 조각 삽입</button></article>
+ <article><h3>4. 시간에 따른 변화</h3><p>실행하면 매 프레임 반응합니다. dt는 1/60초입니다.</p><pre><code>on frame {
+ x = x + 30 * dt
+ if x > 600 { x = 0 }
+}</code></pre><button class="insert-snippet quiet" data-snippet="frame" type="button">시간 조각 삽입</button></article>
+ </div>
+ <details class="syntax-details"><summary>표현식 · 입력 키 · 제한 보기</summary><div class="syntax-columns"><div><p><strong>표현식</strong><br><code>+ - * /</code>, <code>&lt; &lt;= &gt; &gt;= == !=</code>, <code>&amp;&amp; || !</code>, 괄호, <code>min(a,b)</code>, <code>max(a,b)</code>, <code>clamp(x,lo,hi)</code>를 사용합니다. 논리 연산은 양쪽을 모두 계산합니다.</p><p><strong>도형</strong><br><code>box id(x,y,width,height,"#rrggbb")</code><br><code>circle id(x,y,radius,"#rrggbb")</code><br><code>text id(x,y,value,"접두사")</code><br>글자의 value는 숫자 표현식입니다. 문자 접두사는 생략할 수 있습니다.</p></div><div><p><strong>입력</strong><br><code>Space</code>, <code>Enter</code>, <code>ArrowLeft/Right/Up/Down</code>, <code>KeyA</code>~<code>KeyZ</code>, <code>Digit0</code>~<code>Digit9</code>. 같은 키는 하나의 입력에 연결합니다.</p><p><strong>숫자 입력</strong><br><code>input level = value</code>와 <code>on level.change { amount = event_value }</code>로 숫자 컨트롤을 연결합니다. 웹 호스트와 CLI에서도 같은 값을 전달할 수 있습니다.</p><p><strong>직접 조작</strong><br><code>input drag = pointer(tile)</code>로 도형에 입력을 연결합니다. <code>on drag.down / .move / .up / .cancel</code>에서 <code>event_x</code>, <code>event_y</code>는 장면 좌표입니다. 이동 방식과 취소 시 동작도 코드로 정합니다. 초점이나 포인터 캡처를 잃으면 <code>.cancel</code>이 전달됩니다.</p><p><strong>값과 규칙 재사용</strong><br><code>record Tile { x=0; y=0 }</code>로 필드를 정하고 <code>item green = Tile(x=80)</code>으로 대상을 만듭니다. <code>rule move(t: Tile) { t.x=event_x }</code>를 <code>on drag.move use move(green)</code>으로 연결합니다. 화면에서는 <code>green.x</code>를 읽습니다. 규칙은 연결한 대상의 값을 직접 바꿉니다.</p><p><strong>여러 도형의 입력 묶기</strong><br><code>input drag = pointer { green_box: green; blue_box: blue }</code>로 연결하고 <code>on drag.move use move(drag.target)</code>으로 현재 조작 대상을 전달합니다. 같은 레코드 타입의 대상끼리 묶으며, 각 도형은 따로 잡고 놓을 수 있습니다.</p><p><strong>이 실험의 범위</strong><br>상태 32개 · 입력 8개 · 도형/글자 24개 · 반응 64개 · 컴파일된 명령 512개 · 코드 32 KiB. 반복문, 함수 선언, 자동 충돌·물리는 없습니다. 줄바꿈 또는 세미콜론으로 구분하고 <code>#</code>으로 주석을 씁니다.</p><p>0으로 나누기 등 계산 실패 시 해당 이벤트의 상태 변경 전체를 취소합니다. 입력을 누르고 있을 때는 새 코드를 적용할 수 없습니다.</p></div></div></details>
+ <p><a href="./document.html">문서 편집 · 반응형 레이아웃 작업 화면 ↗</a></p>
+ <div class="example-row"><div><label for="example">완성된 예제 살펴보기</label><p>선택한 코드를 편집기에 불러옵니다. 자동으로 적용되지 않습니다.</p></div><select id="example"><option value="counter">카운터</option><option value="charge">누르는 동안 충전</option><option value="toggle">입력으로 전환</option><option value="direct">직접 잡아 이동</option><option value="layout">세 도형 배치</option><option value="shared-layout">공유 규칙으로 배치</option><option value="routed-layout">입력 묶어서 배치</option></select><button id="load-example" type="button">예제 불러오기</button></div>
+ <div class="baseline-row"><a href="./baseline.html" target="_blank" rel="noopener">같은 동작을 일반 JavaScript로 만든 비교 앱 ↗</a><span>충전 예제와 비교할 수 있습니다. 새 탭에서 열립니다.</span></div>
+ </section>
+ <footer class="page-footer"><span>Pure · authoring experiment</span><p>원하는 동작을 만들어보고, <strong>하려던 일 / 실제로 일어난 일 / 기대했던 일</strong>을 남겨주세요.</p></footer>
+ </main>
+ <script type="module" src="./app.js"></script>
+</body>
+</html>
diff --git a/prototypes/workbench/web/output.mjs b/prototypes/workbench/web/output.mjs
new file mode 100644
index 0000000..694fce6
--- /dev/null
+++ b/prototypes/workbench/web/output.mjs
@@ -0,0 +1,103 @@
+// Bind authored declarations to concrete DOM destinations once per apply.
+// The DOM holds the published values; no parallel scene/value model is kept.
+const NS = 'http://www.w3.org/2000/svg';
+const number = value => Number.isInteger(value) ? String(value) : Number(value.toPrecision(7)).toString();
+const attributes = { box: { x: 'x', y: 'y', width: 'width', height: 'height' },
+ circle: { x: 'cx', y: 'cy', radius: 'r' }, text: { x: 'x', y: 'y' } };
+
+function text(node, value) {
+ if (node.textContent !== value) node.textContent = value;
+}
+function attribute(node, name, value) {
+ const next = String(value);
+ if (node.getAttribute(name) !== next) node.setAttribute(name, next);
+}
+
+export function createOutput(document) {
+ const get = id => document.getElementById(id);
+ const scene = get('scene-content'), states = get('state-values');
+ const empty = get('empty-scene'), description = get('scene-description');
+ let active = undefined, nodes = [], amounts = [];
+ const costNames = ['Wasm 선형 메모리', '코어 예약 공간', '사용 중인 명령·연결',
+ '현재 상태 값', '새 코드 준비 공간 (예약에 포함)', '평가 스택 용량',
+ '마지막 이벤트 실행 명령', '적용된 소스 크기'];
+ const costs = costNames.map(label => {
+ const dt = document.createElement('dt'), dd = document.createElement('dd');
+ dt.textContent = label;
+ return { dt, dd };
+ });
+ get('cost-values').replaceChildren(...costs.flatMap(({dt, dd}) => [dt, dd]));
+
+ return {
+ begin(program) {
+ if (program === active) return;
+ active = program;
+ const targets = new Map();
+ for (const input of program?.inputs ?? []) {
+ if (input.target) targets.set(input.target, {name:input.name});
+ for (const route of input.routes ?? []) targets.set(route.target, {name:input.name,target:route.target});
+ }
+ scene.ownerSVGElement.classList.toggle('direct-input', targets.size > 0);
+ nodes = (program?.views ?? []).map(view => {
+ const node = document.createElementNS(NS, {box:'rect', circle:'circle', text:'text'}[view.type]);
+ node.setAttribute('data-view-id', view.id);
+ if (targets.has(view.id)) {
+ const input = targets.get(view.id);
+ node.setAttribute('data-point-input', input.name);
+ if (input.target) node.setAttribute('data-point-target', input.target);
+ }
+ node.setAttribute('fill', view.color ?? '#e7ece6');
+ if (view.type === 'text') {
+ node.setAttribute('font-family', 'ui-monospace, monospace');
+ node.setAttribute('font-size', '20');
+ }
+ return node;
+ });
+ scene.replaceChildren(...nodes);
+ const holders = [];
+ amounts = (program?.states ?? []).map(state => {
+ const dl = document.createElement('dl'), dt = document.createElement('dt'), dd = document.createElement('dd');
+ dl.className = 'state-value'; dt.textContent = state.name; dd.dataset.state = state.name;
+ dl.append(dt, dd); holders.push(dl);
+ return dd;
+ });
+ if (!holders.length) {
+ const note = document.createElement('p');
+ note.className = 'empty-note'; note.textContent = '선언한 상태의 값이 표시됩니다.';
+ holders.push(note);
+ }
+ states.replaceChildren(...holders);
+ },
+ state(index, value) { text(amounts[index], number(value)); },
+ view(index, valid) {
+ const node = nodes[index];
+ if (valid) { if (node.hasAttribute('display')) node.removeAttribute('display'); }
+ else attribute(node, 'display', 'none');
+ },
+ value(index, name, value) {
+ const view = active.views[index], node = nodes[index];
+ if (name === 'value') text(node, `${view.prefix || ''}${number(value)}`);
+ else attribute(node, attributes[view.type][name], value);
+ },
+ end(visible) {
+ const hidden = visible !== 0;
+ if (empty.hidden !== hidden) empty.hidden = hidden;
+ const failed = !visible && nodes.length > 0;
+ text(empty.querySelector('strong'), failed ? '장면을 표시할 수 없습니다' : '아직 아무것도 없습니다');
+ text(empty.querySelector('span:last-child'), failed ? '현재 상태 아래의 표시 오류를 확인하세요.' : '도형이나 글자를 선언하고 코드를 적용하세요.');
+ text(description, visible ? `사용자가 만든 장면. 도형과 글자 ${visible}개.` : failed ? '표시 오류로 장면을 표시할 수 없습니다.' : '사용자가 코드로 만든 장면. 현재 비어 있습니다.');
+ },
+ costs(status, metrics, droppedTicks) {
+ const m = status.memory;
+ const values = [m.linearBytes, m.reservedEngineBytes, m.activeIRBytes, m.activeStateBytes,
+ m.stagingReservedBytes, m.evalStackBytes, status.lastOps, metrics?.sourceBytes];
+ for (let i = 0; i < values.length; i++) {
+ const value = values[i], {dt, dd} = costs[i], hidden = !Number.isFinite(value);
+ if (dt.hidden !== hidden) dt.hidden = hidden;
+ if (dd.hidden !== hidden) dd.hidden = hidden;
+ if (!hidden) text(dd, i === 6 ? `${value}개` : `${value.toLocaleString('en-US')} B`);
+ }
+ text(get('dropped-ticks'), `시간 지연으로 건너뛴 프레임: ${droppedTicks}`);
+ },
+ };
+}
diff --git a/prototypes/workbench/web/project.mjs b/prototypes/workbench/web/project.mjs
new file mode 100644
index 0000000..861e0fc
--- /dev/null
+++ b/prototypes/workbench/web/project.mjs
@@ -0,0 +1,47 @@
+import { compile } from './compiler.mjs';
+
+// Two escaped source strings plus up to 32 generated item.field names. A long
+// schema field can appear in every item without repeating in the source text.
+export const MAX_PROJECT_BYTES = (2 * 6 + 32) * 32768 + 8192;
+const encoder = new TextEncoder();
+function sourceText(value, label) {
+ if (typeof value !== 'string' || encoder.encode(value).length > 32768)
+ throw new Error(`${label}: UTF-8 32 KiB 이하의 코드가 필요합니다.`);
+ return value;
+}
+
+// No runtime mutation. Validate every saved value before opening core staging.
+export function validateDocument(document) {
+ if (!document || typeof document !== 'object' || Array.isArray(document) ||
+ document.format !== 'pure-workbench' || document.version !== 2)
+ throw new Error('배치 파일 형식이 맞지 않습니다. pure-workbench version 2가 필요합니다.');
+ const source = sourceText(document.source, '적용된 코드');
+ const draft = sourceText(Object.hasOwn(document, 'draft') ? document.draft : source, '편집 중인 코드');
+ const program = compile(source);
+ if (!Array.isArray(document.states) || document.states.length !== program.states.length)
+ throw new Error('저장된 상태 목록이 적용된 코드와 일치하지 않습니다.');
+ const values = new Map();
+ for (const entry of document.states) {
+ if (!entry || typeof entry !== 'object' || Array.isArray(entry) || typeof entry.name !== 'string' || values.has(entry.name))
+ throw new Error('상태 이름이 잘못됐거나 중복됐습니다.');
+ const value = entry.value === '-0' ? -0 : entry.value;
+ if (typeof value !== 'number' || !Number.isFinite(value)) throw new Error('상태 값은 유한한 숫자여야 합니다.');
+ values.set(entry.name, value);
+ }
+ for (const state of program.states)
+ if (!values.has(state.name)) throw new Error(`저장된 상태가 없습니다: ${state.name}`);
+ return { source, draft, program, values: program.states.map(state => values.get(state.name)) };
+}
+
+export function parseProject(text) {
+ if (typeof text !== 'string' || encoder.encode(text).length > MAX_PROJECT_BYTES)
+ throw new Error('작업 파일이 너무 큽니다.');
+ let project;
+ try { project = JSON.parse(text); } catch { throw new Error('올바른 JSON 파일이 아닙니다.'); }
+ if (!project || typeof project !== 'object' || Array.isArray(project) || project.format !== 'pure-workbench')
+ throw new Error('Pure 작업물 형식이 아닙니다.');
+ if (project.version === 1) sourceText(project.source, '코드');
+ else if (project.version === 2) validateDocument(project);
+ else throw new Error('지원하지 않는 작업 파일 버전입니다.');
+ return project;
+}
diff --git a/prototypes/workbench/web/runtime.mjs b/prototypes/workbench/web/runtime.mjs
new file mode 100644
index 0000000..d8d6618
--- /dev/null
+++ b/prototypes/workbench/web/runtime.mjs
@@ -0,0 +1,350 @@
+import { compile } from './compiler.mjs';
+import { validateDocument } from './project.mjs';
+
+const messages = [
+ '', '실행기의 용량 한도를 넘었습니다.', '실행 정의가 유효하지 않습니다.',
+ '계산 스택의 범위를 벗어났습니다.', '0으로 나눌 수 없습니다.',
+ '계산 결과가 유한한 수의 범위를 벗어났습니다.', '조건 분기 범위가 유효하지 않습니다.',
+ '존재하지 않는 상태를 참조했습니다.', '시간 간격이 유효하지 않습니다.',
+ '정의되지 않은 입력 사건입니다.',
+];
+const sourceLimit = 32768;
+
+function problem(message, line = null, column = null) {
+ return Object.assign(new Error(message), { line, column });
+}
+
+function freezeProgram(value) {
+ if (value && typeof value === 'object') {
+ for (const child of Object.values(value)) freezeProgram(child);
+ Object.freeze(value);
+ }
+ return value;
+}
+
+export async function createRuntime(wasmBytes, evaluationOptions = {}) {
+ if (wasmBytes === undefined) {
+ const response = await fetch(new URL('./core.wasm', import.meta.url), { cache: 'no-store' });
+ if (!response.ok) throw new Error(`실행기를 불러오지 못했습니다 (HTTP ${response.status}).`);
+ wasmBytes = await response.arrayBuffer();
+ }
+ const module = await WebAssembly.compile(wasmBytes);
+ if (WebAssembly.Module.imports(module).length) throw new Error('이 실행기는 호스트 함수 없이 동작해야 합니다.');
+ const { exports: core } = await WebAssembly.instantiate(module, {});
+ if (typeof core.eval_bounded !== 'function' || typeof core.abort_program !== 'function' || typeof core.dispatch_point !== 'function' || typeof core.put_restored_value !== 'function' || typeof core.commit_restored_program !== 'function' || typeof core.bind_handler !== 'function' || typeof core.put_pointer_target !== 'function' || typeof core.bind_target_handler !== 'function' || typeof core.dispatch_target !== 'function' || typeof core.dispatch_value !== 'function')
+ throw new Error('실행기 버전이 맞지 않습니다. 새로고침하거나 core.wasm을 다시 빌드하세요.');
+ core.reset_all();
+ let program = null;
+ let appliedSource = '';
+ let lastEvent = null;
+ // Keep a failed event visible through its release/focus-loss cleanup.
+ // The active program can clear this diagnostic by apply or explicit reset.
+ let lastFailure = null;
+ const held = new Map();
+ const points = new Map();
+ let presenting = false;
+ let bindings = [];
+ let pointRoutes = new Map();
+ // One bounded publication buffer, never exposed to callers. No state mirror.
+ let displayValues = null, displayValid = null;
+ function available() {
+ if (presenting) throw new Error('출력 전달 중에는 실행기를 다시 호출할 수 없습니다.');
+ }
+ let evaluationPolicy = { cacheEntries: 0, historyEntries: 8, workLimit: 2048 };
+
+ function checked(code, line = null) {
+ if (code !== 0) throw problem(messages[code] || `실행기 오류 ${code}`, line);
+ }
+ function inputIndex(name) {
+ const index = program?.inputs.findIndex((input) => input.name === name) ?? -1;
+ if (index < 0) throw new Error(`정의되지 않은 입력: ${name}`);
+ return index;
+ }
+ function dispatch(event, name, phase, source, point = null, value = undefined) {
+ const status = value !== undefined ? core.dispatch_value(event, value) : point ? (point.route === undefined ? core.dispatch_point(event, point.x, point.y)
+ : core.dispatch_target(event, point.route, point.x, point.y)) : core.dispatch(event, event === 0 ? 1 / 60 : 0);
+ const pc = core.info(2) >>> 0;
+ lastEvent = { name, phase, source, status,
+ line: program?.instructions[pc]?.line ?? null, ops: core.info(3) >>> 0 };
+ if (point) { lastEvent.x = point.x; lastEvent.y = point.y; if (point.target) lastEvent.target = point.target; }
+ if (value !== undefined) lastEvent.value = value;
+ if (status) lastFailure = { ...lastEvent };
+ return status;
+ }
+
+ function evaluateDisplay() {
+ core.begin_evaluation(evaluationPolicy.workLimit);
+ if (!bindings.length) return null;
+ displayValues ??= new Float64Array(96);
+ displayValid ??= new Uint8Array(24);
+ let error = null, slot = 0;
+ for (let i = 0; i < bindings.length; i++) {
+ const view = program.views[i];
+ displayValid[i] = 1;
+ for (const [name, binding] of bindings[i]) {
+ const value = core.eval_bounded(binding.start, binding.length);
+ if (core.evaluation_info(0) === 10) {
+ displayValid.fill(0);
+ return { code: 101, line: view.line,
+ message: '표시 계산의 작업량 한도를 넘었습니다. 한도를 늘리거나 표시식을 줄이세요.' };
+ }
+ if (!Number.isFinite(value) || ((name === 'width' || name === 'height' || name === 'radius') && value < 0)) {
+ error ??= { line: view.line, message: `${view.id}.${name}: 표시할 수 없는 값입니다. 0 나눗셈이나 음수 크기를 확인하세요.` };
+ displayValid[i] = 0;
+ }
+ displayValues[slot++] = value;
+ }
+ }
+ return error;
+ }
+
+ function status(viewError) {
+ const executionError = lastFailure?.status || 0;
+ return {
+ tick: core.info(0) >>> 0, revision: core.info(4) >>> 0,
+ displayError: viewError ? { code: viewError.code || 100, ...viewError } : null,
+ lastError: executionError || (viewError ? viewError.code || 100 : 0),
+ errorMessage: executionError ? messages[executionError] || `실행 오류 ${executionError}` : viewError?.message || '',
+ errorLine: executionError ? lastFailure.line : viewError?.line ?? null,
+ lastOps: core.info(3) >>> 0, successfulEvents: core.info(9) >>> 0,
+ held: [...held.keys()], lastEvent: lastEvent ? { ...lastEvent } : null,
+ lastFailure: lastFailure ? { ...lastFailure } : null,
+ memory: {
+ linearBytes: core.memory.buffer.byteLength,
+ reservedEngineBytes: core.layout(0), programReservedBytes: core.layout(1),
+ instructionBytes: core.layout(2), handlerBytes: core.layout(3),
+ activeIRBytes: core.layout(4), activeStateBytes: core.layout(5),
+ stagingReservedBytes: core.layout(6), evalStackBytes: core.layout(7),
+ pointerTargetBytes: core.layout(8), pointerTargets: core.layout(9),
+ },
+ };
+ }
+
+ function load(next, source, restored = null) {
+ const oldIds = new Map(restored ? [] : program?.states.map((state, index) => [state.name, index]) ?? []);
+ checked(core.begin_program(next.states.length, next.inputs.length));
+ try {
+ for (let index = 0; index < next.states.length; index++) {
+ const state = next.states[index];
+ checked(core.put_state(index, state.initial, oldIds.get(state.name) ?? -1), state.line);
+ if (restored) checked(core.put_restored_value(index, restored[index]), state.line);
+ }
+ for (const op of next.instructions) checked(core.put_instruction(op.op, op.arg, op.value), op.line);
+ for (const target of next.pointerTargets) checked(core.put_pointer_target(target.input, target.base, target.count));
+ for (const [index, handler] of next.handlers.entries()) {
+ checked(core.put_handler(handler.event, handler.start, handler.length), handler.line);
+ if (handler.parameterCount)
+ checked(handler.parameterBase === 255 ? core.bind_target_handler(index, handler.parameterCount)
+ : core.bind_handler(index, handler.parameterBase, handler.parameterCount), handler.line);
+ }
+ checked(restored ? core.commit_restored_program() : core.commit_program());
+ } catch (error) {
+ core.abort_program();
+ throw error;
+ }
+ pointRoutes = new Map(next.inputs.flatMap(input => (input.routes ?? []).map(route => [route.target, route])));
+ program = next;
+ bindings = next.views.map(view => Object.entries(view.bindings));
+ appliedSource = source;
+ lastEvent = null;
+ lastFailure = null;
+ }
+
+ const api = {
+ get currentProgram() { return program; },
+ configureEvaluation({ cacheEntries = evaluationPolicy.cacheEntries,
+ historyEntries = evaluationPolicy.historyEntries, workLimit = evaluationPolicy.workLimit } = {}) {
+ available();
+ for (const [name, value, maximum] of [['cacheEntries', cacheEntries, 32],
+ ['historyEntries', historyEntries, 8], ['workLimit', workLimit, 0xffffffff]]) {
+ if (!Number.isInteger(value) || value < 0 || value > maximum)
+ throw new Error(`${name}: 0~${maximum} 사이의 정수여야 합니다.`);
+ }
+ checked(core.configure_evaluation(cacheEntries, historyEntries));
+ evaluationPolicy = { cacheEntries, historyEntries, workLimit };
+ return { ...evaluationPolicy };
+ },
+ forgetEvaluation(part = 'all') {
+ available();
+ const flags = part === 'all' ? 3 : part === 'values' ? 1 : part === 'history' ? 2 : undefined;
+ if (flags === undefined) throw new Error('삭제할 기록은 values, history, all 중 하나여야 합니다.');
+ core.forget_evaluation(flags);
+ },
+ evaluationStats() {
+ return { ...evaluationPolicy, work: core.evaluation_info(1),
+ instructions: core.evaluation_info(2), cacheHits: core.evaluation_info(3),
+ reservedBytes: core.evaluation_info(6), dedicatedBytes: core.evaluation_info(7),
+ sharedBytes: core.evaluation_info(8), reuseAvailable: Boolean(core.evaluation_info(9)),
+ workspaceTagBytes: core.evaluation_info(10) };
+ },
+ apply(source) {
+ available();
+ if (typeof source !== 'string' || new TextEncoder().encode(source).length > sourceLimit) {
+ throw problem('정의는 UTF-8 기준 32,768바이트 이하의 텍스트여야 합니다.');
+ }
+ if (held.size) throw problem('누르고 있는 입력을 놓은 뒤 적용하세요. 현재 작업은 그대로 유지합니다.');
+ const next = freezeProgram(compile(source));
+ load(next, source);
+ return next;
+ },
+ restoreDocument(document) {
+ available();
+ if (held.size) throw problem('누르고 있는 입력을 놓은 뒤 복원하세요.');
+ const next = validateDocument(document);
+ load(freezeProgram(next.program), next.source, next.values);
+ return { source: next.source, draft: next.draft };
+ },
+ exportDocument(draft = appliedSource) {
+ available();
+ if (!program) throw problem('먼저 코드를 적용하세요.');
+ if (held.size) throw problem('조작을 끝낸 뒤 배치를 저장하세요.');
+ if (lastFailure) throw problem('실행 오류를 해결한 뒤 배치를 저장하세요.');
+ if (typeof draft !== 'string' || new TextEncoder().encode(draft).length > sourceLimit)
+ throw problem('편집 중인 코드는 UTF-8 32 KiB 이하여야 합니다.');
+ return { format: 'pure-workbench', version: 2, source: appliedSource, draft,
+ states: program.states.map((state, i) => {
+ const value = core.get_state(i);
+ return { name: state.name, value: Object.is(value, -0) ? '-0' : value };
+ }) };
+ },
+ reset() {
+ available();
+ held.clear();
+ points.clear();
+ core.reset_state();
+ lastEvent = null;
+ lastFailure = null;
+ },
+ frame(count = 1) {
+ available();
+ if (!Number.isInteger(count) || count < 0 || count > 600) throw new Error('프레임 수는 0~600 사이의 정수여야 합니다.');
+ if (!program) return 0;
+ for (let i = 0; i < count; i++) {
+ const code = dispatch(0, 'frame', 'frame', 'clock');
+ if (code !== 0) return code;
+ }
+ return 0;
+ },
+ down(name, source = 'external') {
+ available();
+ const index = inputIndex(name);
+ if (program.inputs[index].kind === 'value') throw new Error('숫자 입력은 change(name, value)를 사용하세요.');
+ if (program.inputs[index].target || program.inputs[index].routes) throw new Error('좌표 입력은 point(name, phase, x, y)를 사용하세요.');
+ const sources = held.get(name) || new Set();
+ if (sources.has(source)) return 0;
+ const wasDown = sources.size > 0;
+ sources.add(source);
+ held.set(name, sources);
+ return wasDown ? 0 : dispatch(1 + index * 2, name, 'down', source);
+ },
+ up(name, source = 'external') {
+ available();
+ const index = inputIndex(name);
+ if (program.inputs[index].kind === 'value') throw new Error('숫자 입력은 change(name, value)를 사용하세요.');
+ if (program.inputs[index].target || program.inputs[index].routes) throw new Error('좌표 입력은 point(name, phase, x, y)를 사용하세요.');
+ const sources = held.get(name);
+ if (!sources?.delete(source)) return 0;
+ if (sources.size) return 0;
+ held.delete(name);
+ return dispatch(2 + index * 2, name, 'up', source);
+ },
+ change(name, value, source = 'external') {
+ available();
+ const index = inputIndex(name);
+ if (program.inputs[index].kind !== 'value') throw new Error('숫자 값에 연결된 입력이 아닙니다.');
+ if (!Number.isFinite(value)) throw new Error('입력 값은 유한한 숫자여야 합니다.');
+ return dispatch(33 + index, name, 'change', source, null, value);
+ },
+ point(name, phase, x, y, source = 'external', target = undefined) {
+ available();
+ const index = inputIndex(name);
+ const input = program.inputs[index];
+ if (!input.target && !input.routes) throw new Error('포인터에 연결된 입력이 아닙니다.');
+ const route = input.routes ? pointRoutes.get(target) : undefined;
+ if (input.routes && (!route || program.pointerTargets[route.route].input !== index)) throw new Error('포인터 그룹에 연결된 도형 이름이 필요합니다.');
+ if (!input.routes && target !== undefined) throw new Error('단일 포인터 입력에는 대상 인자를 지정하지 않습니다.');
+ const owner = route ? `${name}:${target}` : name;
+ if (!['down', 'move', 'up', 'cancel'].includes(phase)) throw new Error('포인터 단계는 down, move, up, cancel 중 하나여야 합니다.');
+ if (!Number.isFinite(x) || !Number.isFinite(y)) throw new Error('포인터 좌표는 유한한 숫자여야 합니다.');
+ if (typeof source !== 'string' || source.length === 0) throw new Error('포인터 출처 이름이 필요합니다.');
+ let point = points.get(owner);
+ if (phase === 'down') {
+ if (point?.source === source) return 0;
+ if (point) throw new Error('이 입력은 다른 포인터가 사용 중입니다.');
+ point = {name, source, x, y, target, route:route?.route};
+ points.set(owner, point);
+ held.set(owner, new Set([source]));
+ } else {
+ if (!point || point.source !== source) return 0;
+ point.x = x; point.y = y;
+ if (phase === 'up' || phase === 'cancel') { points.delete(owner); held.delete(owner); }
+ }
+ const event = phase === 'move' ? 17 + index : phase === 'cancel' ? 25 + index : 1 + index * 2 + (phase === 'up' ? 1 : 0);
+ return dispatch(event, name, phase, source, point);
+ },
+ releaseAll() {
+ available();
+ let firstError = 0;
+ for (const point of [...points.values()]) {
+ const status = api.point(point.name, 'cancel', point.x, point.y, point.source, point.target);
+ if (!firstError) firstError = status;
+ }
+ for (const [name, sources] of [...held]) {
+ for (const source of [...sources]) {
+ const status = api.up(name, source);
+ if (!firstError) firstError = status;
+ }
+ }
+ return firstError;
+ },
+ exportProject() {
+ return { format: 'pure-workbench', version: 1, source: appliedSource };
+ },
+ // Synchronous trusted output adapter. Callbacks run only after the whole
+ // display has been evaluated. Runtime mutation/reentrant reads are refused.
+ // Exceptions from an adapter propagate; the next call republishes all values.
+ present(target) {
+ available();
+ presenting = true;
+ try {
+ const { begin, state, view, value, end } = target;
+ for (const callback of [begin, state, view, value, end])
+ if (typeof callback !== 'function') throw new TypeError('출력 어댑터 함수가 필요합니다.');
+ const error = evaluateDisplay();
+ begin.call(target, program);
+ for (let i = 0; i < (program?.states.length ?? 0); i++) state.call(target, i, core.get_state(i));
+ let slot = 0, visible = 0;
+ for (let i = 0; i < bindings.length; i++) {
+ const valid = Boolean(displayValid[i]);
+ view.call(target, i, valid);
+ for (const [name] of bindings[i]) {
+ if (valid) value.call(target, i, name, displayValues[slot]);
+ slot++;
+ }
+ if (valid) visible++;
+ }
+ end.call(target, visible);
+ return status(error);
+ } finally { presenting = false; }
+ },
+ snapshot() {
+ available();
+ const error = evaluateDisplay();
+ const states = Object.fromEntries((program?.states ?? []).map((state, index) => [state.name, core.get_state(index)]));
+ const views = [];
+ let slot = 0;
+ for (let i = 0; i < bindings.length; i++) {
+ const view = program.views[i];
+ const rendered = displayValid[i] ? { id: view.id, type: view.type, color: view.color, prefix: view.prefix } : null;
+ for (const [name] of bindings[i]) {
+ if (rendered) rendered[name] = displayValues[slot];
+ slot++;
+ }
+ if (rendered) views.push(rendered);
+ }
+ return { states, views, ...status(error) };
+ },
+ };
+ api.configureEvaluation(evaluationOptions);
+ return Object.freeze(api);
+}
diff --git a/prototypes/workbench/web/scene-input.mjs b/prototypes/workbench/web/scene-input.mjs
new file mode 100644
index 0000000..0daf079
--- /dev/null
+++ b/prototypes/workbench/web/scene-input.mjs
@@ -0,0 +1,84 @@
+// This adapter owns browser capture only. Authored state changes stay in the VM.
+export function bindSceneInput(scene, runtime, act) {
+ const captures = new Map();
+ const events = scene.ownerDocument.defaultView;
+ const coordinates = event => {
+ const matrix = scene.getScreenCTM();
+ if (!matrix) return null;
+ try {
+ const p = new DOMPoint(event.clientX, event.clientY).matrixTransform(matrix.inverse());
+ return Number.isFinite(p.x) && Number.isFinite(p.y) ? p : null;
+ } catch { return null; }
+ };
+ const release = id => {
+ if (scene.hasPointerCapture(id)) scene.releasePointerCapture(id);
+ };
+ function finish(event, phase, pointerId = event.pointerId) {
+ const entry = captures.get(pointerId);
+ if (!entry) return;
+ event.preventDefault();
+ // Capture loss/cancel may not carry a meaningful position. Use the last
+ // delivered coordinates. Up must use its own final position.
+ const p = phase === 'up' ? coordinates(event) : null;
+ if (p) { entry.x = p.x; entry.y = p.y; }
+ if (phase === 'up' && !p) phase = 'cancel';
+ captures.delete(pointerId);
+ release(pointerId);
+ act(() => runtime().point(entry.name, phase, entry.x, entry.y, entry.source, entry.target));
+ }
+ scene.addEventListener('pointerdown', event => {
+ if (event.button !== 0 || !runtime() || captures.has(event.pointerId)) return;
+ const target = event.target.closest?.('[data-point-input]');
+ if (!target || !scene.contains(target)) return;
+ const name = target.getAttribute('data-point-input');
+ const targetId = target.getAttribute('data-point-target') ?? undefined;
+ // Each target has one coordinate owner. A second contact on it must not
+ // steal the gesture; other authored targets can be manipulated independently.
+ if ([...captures.values()].some(entry => entry.name === name && entry.target === targetId)) return;
+ const p = coordinates(event);
+ if (!p) return;
+ event.preventDefault(); scene.focus();
+ act(() => {
+ scene.setPointerCapture(event.pointerId);
+ const entry = {name, target:targetId, source:`scene:${event.pointerId}`, pointerType:event.pointerType, x:p.x, y:p.y};
+ captures.set(event.pointerId, entry);
+ try { runtime().point(name, 'down', p.x, p.y, entry.source, entry.target); }
+ catch (error) { captures.delete(event.pointerId); release(event.pointerId); throw error; }
+ });
+ });
+ scene.addEventListener('pointermove', event => {
+ const entry = captures.get(event.pointerId);
+ if (!entry) return;
+ const p = coordinates(event);
+ if (!p) { finish(event, 'cancel'); return; }
+ event.preventDefault();
+ entry.x = p.x; entry.y = p.y;
+ act(() => runtime().point(entry.name, 'move', p.x, p.y, entry.source, entry.target));
+ });
+ // A host may deliver release outside the SVG even while it owns capture.
+ // Observe termination before target handlers or capture cleanup can cancel it.
+ events.addEventListener('pointerup', event => finish(event, 'up'), true);
+ events.addEventListener('pointercancel', event => finish(event, 'cancel'), true);
+ events.addEventListener('mouseup', event => {
+ if (event.button !== 0) return;
+ // Mouse-only fallback when the host delivers mouseup without pointerup.
+ // A normal pointerup already removed the owner; touch compatibility events
+ // must never finish a different contact.
+ for (const [id, entry] of captures) {
+ if (entry.pointerType === 'mouse') finish(event, 'up', id);
+ }
+ }, true);
+ scene.addEventListener('lostpointercapture', event => {
+ // Descendant capture transitions bubble here but do not release our owner.
+ if (event.target === scene) finish(event, 'cancel');
+ });
+ return Object.freeze({
+ releaseCaptures() {
+ const ids = [...captures.keys()];
+ captures.clear();
+ for (const id of ids) release(id);
+ // The caller subsequently invokes runtime.releaseAll(), which dispatches
+ // each pending cancel once. Clearing first makes lostcapture harmless.
+ },
+ });
+}
diff --git a/prototypes/workbench/web/split-input.mjs b/prototypes/workbench/web/split-input.mjs
new file mode 100644
index 0000000..294e9a3
--- /dev/null
+++ b/prototypes/workbench/web/split-input.mjs
@@ -0,0 +1,57 @@
+// Only gesture ownership and transient geometry live here. Layout belongs to CSS.
+export function bindSplit({container, handle, readRatio, writeRatio, narrow, stats}) {
+ const events=container.ownerDocument.defaultView, abort=new AbortController();
+ const listen=(node,type,callback,options={})=>node.addEventListener(type,callback,{...options,signal:abort.signal});
+ let drag=null, frame=0, dirty=true, rect=null, handleWidth=0;
+ const invalidate=()=>{dirty=true;};
+ const observer=new ResizeObserver(invalidate);observer.observe(container);observer.observe(handle);
+ function measure() {
+ rect=container.getBoundingClientRect();const h=handle.getBoundingClientRect();
+ handleWidth=h.width;stats.geometryReads+=2;dirty=false;return h;
+ }
+ function flush() {
+ if(frame){cancelAnimationFrame(frame);frame=0;}
+ if(!drag || drag.x===null)return;
+ if(dirty)measure();
+ const width=rect.width-handleWidth;
+ if(width>0)writeRatio(100*(drag.x-rect.left-handleWidth/2-drag.offset)/width,'pointer');
+ drag.x=null;
+ }
+ function finish(cancel) {
+ if(!drag)return;
+ if(cancel){if(frame)cancelAnimationFrame(frame);frame=0;}else flush();
+ const previous=drag;drag=null;
+ if(handle.hasPointerCapture(previous.id))handle.releasePointerCapture(previous.id);
+ handle.classList.remove('dragging');
+ if(cancel)writeRatio(previous.before,'cancel');
+ }
+ listen(handle,'pointerdown',event=>{
+ if(event.button!==0 || drag || narrow.matches)return;
+ const h=measure();if(rect.width<=handleWidth)return;
+ event.preventDefault();handle.focus();
+ handle.setPointerCapture(event.pointerId);
+ drag={id:event.pointerId,type:event.pointerType,before:readRatio(),offset:event.clientX-(h.left+h.width/2),x:null};
+ handle.classList.add('dragging');
+ });
+ listen(handle,'pointermove',event=>{
+ if(!drag || drag.id!==event.pointerId)return;
+ event.preventDefault();drag.x=event.clientX;
+ if(!frame)frame=requestAnimationFrame(()=>{frame=0;flush();});
+ });
+ listen(events,'pointerup',event=>{if(drag?.id===event.pointerId)finish(false);},{capture:true});
+ listen(events,'pointercancel',event=>{if(drag?.id===event.pointerId)finish(true);},{capture:true});
+ listen(events,'mouseup',event=>{if(event.button===0&&drag?.type==='mouse')finish(false);},{capture:true});
+ listen(handle,'lostpointercapture',event=>{if(event.target===handle&&drag?.id===event.pointerId)finish(true);});
+ listen(events,'blur',()=>finish(true));
+ listen(container.ownerDocument,'visibilitychange',()=>{if(container.ownerDocument.hidden)finish(true);});
+ listen(events,'resize',invalidate);listen(events,'scroll',invalidate,{capture:true,passive:true});
+ listen(narrow,'change',()=>{invalidate();if(narrow.matches)finish(true);});
+ listen(handle,'keydown',event=>{
+ if(event.key==='Escape'){event.preventDefault();finish(true);return;}
+ if(!['ArrowLeft','ArrowRight','Home','End'].includes(event.key))return;
+ event.preventDefault();finish(true);
+ writeRatio(event.key==='Home'?20:event.key==='End'?80:readRatio()+(event.key==='ArrowLeft'?-1:1)*(event.shiftKey?10:1),'keyboard');
+ });
+ return Object.freeze({cancel:()=>finish(true),isDragging:()=>drag!==null,
+ dispose(){finish(true);observer.disconnect();abort.abort();}});
+}
diff --git a/prototypes/workbench/web/style.css b/prototypes/workbench/web/style.css
new file mode 100644
index 0000000..702a6e4
--- /dev/null
+++ b/prototypes/workbench/web/style.css
@@ -0,0 +1,10 @@
+:root{color-scheme:dark;--bg:#151918;--panel:#1d2220;--field:#151a18;--line:#353e38;--text:#e7ece6;--muted:#a9b6aa;--faint:#7b8e7e;--accent:#c9f08b;--accent-dark:#222e18;--danger:#f6afa0;--radius:14px;font-family:Inter,ui-sans-serif,system-ui,-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;font-synthesis:none}
+*{box-sizing:border-box}body{margin:0;background:var(--bg);color:var(--text)}button,input,select,textarea{font:inherit}button,select{border:1px solid var(--line);border-radius:7px;background:#282f2a;color:var(--text);padding:9px 12px;cursor:pointer;font-size:12px;font-weight:600;min-height:36px}button:hover:not(:disabled){background:#344035;border-color:#70836d}button:disabled{opacity:.4;cursor:not-allowed}button:focus-visible,select:focus-visible,summary:focus-visible{outline:2px solid var(--accent);outline-offset:3px}button.primary{background:var(--accent);color:var(--accent-dark);border-color:var(--accent);padding:11px 19px;font-size:14px;display:flex;gap:28px;justify-content:space-between;align-items:center}button.primary:hover:not(:disabled){background:#d8f7ad}button.secondary{background:#c9f08b16;border-color:#637545;color:#d7f0b5}button.quiet{background:transparent;border-color:transparent;color:var(--muted);padding:6px 8px;min-height:30px}button.quiet:hover{color:var(--text)}.masthead{height:76px;padding:0 4.5%;display:flex;align-items:center;gap:28px;border-bottom:1px solid var(--line)}.wordmark{font-size:23px;font-weight:750;letter-spacing:-1px;color:var(--accent);text-decoration:none}.wordmark span{font-size:13px;color:var(--muted);letter-spacing:0;font-weight:400}.experiment{font-size:11px;color:var(--faint);border-left:1px solid var(--line);padding-left:24px}.connection{margin-left:auto;display:flex;gap:9px;align-items:center;font-size:11px;color:var(--muted)}.dot{width:6px;height:6px;border-radius:100%;background:#aa8e5c}.dot.ready{background:var(--accent)}.dot.failed{background:var(--danger)}main{max-width:1540px;margin:auto;padding:38px 4.5% 0}.intro{display:flex;justify-content:space-between;align-items:flex-end;gap:24px;margin:0 0 27px}.eyebrow{font-size:10px;letter-spacing:1.4px;font-weight:600;color:var(--accent);margin:0 0 10px}h1{font-size:clamp(23px,2.5vw,35px);letter-spacing:-1px;margin:0;font-weight:600}.intro>p{font-size:12px;line-height:1.9;color:var(--muted);margin:0 0 2px}.intro strong{color:var(--text);font-weight:500}.workspace{display:grid;grid-template-columns:minmax(340px,1fr) minmax(380px,1.05fr);gap:22px;align-items:start}.panel{background:var(--panel);border:1px solid var(--line);border-radius:var(--radius);overflow:hidden}.panel-heading{height:56px;padding:0 20px;display:flex;align-items:center;justify-content:space-between;gap:12px;border-bottom:1px solid var(--line)}h2{margin:0;font-size:13px;font-weight:650;display:flex;gap:10px;align-items:center}.section-index{font-family:ui-monospace,SFMono-Regular,Consolas,monospace;font-size:10px;font-weight:400;color:var(--faint)}.badge{font-size:10px;border:1px solid var(--line);border-radius:4px;padding:4px 7px;white-space:nowrap;color:var(--muted)}.badge.dirty{color:var(--accent);border-color:#5f7247}.file-tools{display:flex;align-items:center;gap:3px;padding:8px 10px;border-bottom:1px solid var(--line);flex-wrap:wrap}.code-editor{height:514px;display:flex;overflow:hidden;background:var(--field);position:relative}.line-numbers{padding:20px 10px 20px 16px;min-width:45px;text-align:right;color:#667567;user-select:none;white-space:pre;font:12px/1.85 ui-monospace,SFMono-Regular,Consolas,monospace;overflow:hidden;flex-shrink:0;border-right:1px solid #2a322b}textarea#source{border:0;border-radius:0;outline:0;resize:none;padding:20px 16px;display:block;width:100%;height:100%;color:#d4e3ca;background:transparent;white-space:pre;overflow:auto;tab-size:2;font:12px/1.85 ui-monospace,SFMono-Regular,Consolas,monospace;caret-color:var(--accent)}textarea#source:focus{background:#171e19}textarea#source::placeholder{color:#7c8b7d;font:12px/1.9 ui-sans-serif,system-ui,sans-serif;white-space:pre-wrap}textarea#source[aria-invalid=true]{box-shadow:inset 3px 0 0 var(--danger)}.editor-footer{padding:8px 15px;font-size:10px;font-family:ui-monospace,SFMono-Regular,Consolas,monospace;color:var(--faint);display:flex;justify-content:space-between;border-top:1px solid var(--line)}.apply-row{display:flex;gap:18px;align-items:center;padding:16px 18px 12px}.apply-row p{font-size:10px;color:var(--muted);line-height:1.7;margin:0;max-width:185px}.diagnostic{margin:0 18px 17px;font-size:11px;line-height:1.8;min-height:22px;color:var(--muted);display:flex;align-items:baseline;gap:6px;flex-wrap:wrap}.diagnostic.error{color:var(--danger)}.diagnostic.success{color:#c7dea9}.right-column{display:grid;gap:18px}.stage-wrap{position:relative;background-color:#121613;background-image:linear-gradient(#a6c39907 1px,transparent 1px),linear-gradient(90deg,#a6c39907 1px,transparent 1px);background-size:24px 24px}#scene{width:100%;height:auto;aspect-ratio:640/360;display:block;overflow:hidden}.empty-scene{position:absolute;inset:0;display:flex;flex-direction:column;align-items:center;justify-content:center;pointer-events:none;gap:9px;color:var(--faint);padding:24px;text-align:center}.empty-scene[hidden]{display:none}.empty-scene strong{font-size:13px;color:var(--muted);font-weight:400}.empty-scene>span:not(.empty-cross){font-size:11px}.empty-cross{font-size:31px;color:#52614d;font-weight:200;margin-bottom:7px}.stage-dimensions{position:absolute;right:14px;bottom:10px;font-size:9px;font-family:ui-monospace,SFMono-Regular,Consolas,monospace;color:#6d7f6d;pointer-events:none}.transport{display:flex;gap:7px;align-items:center;padding:14px 16px 0;flex-wrap:wrap}.transport button{font-size:11px;padding:7px 10px}.transport #clock-status{font-family:ui-monospace,SFMono-Regular,Consolas,monospace;font-size:10px;color:var(--muted);margin-left:auto}.control-note,.keyboard-note{font-size:10px;line-height:1.7;color:var(--faint);margin:10px 18px 15px}.input-heading{display:flex;justify-content:space-between;align-items:center;gap:10px;margin:0 18px;padding-top:13px;border-top:1px solid var(--line)}h3{font-size:11px;font-weight:550;margin:0}.input-heading>span{font-size:9px;color:var(--faint)}.input-controls{display:flex;flex-wrap:wrap;gap:7px;padding:11px 17px 0;min-height:39px}.input-button{display:flex;align-items:center;gap:14px;touch-action:none;user-select:none;-webkit-user-select:none}.input-button kbd{font:9px ui-monospace,SFMono-Regular,Consolas,monospace;border:1px solid #5a6957;border-radius:3px;padding:3px 4px;color:var(--muted);min-width:22px;text-align:center}.input-button.held{background:var(--accent);border-color:var(--accent);color:var(--accent-dark)}.input-button.held kbd{color:var(--accent-dark);border-color:#829a62}.empty-note{font-size:11px;line-height:1.7;color:var(--faint);margin:0}.keyboard-note{margin-bottom:13px;font-size:9px}.state-values{display:grid;grid-template-columns:repeat(auto-fit,minmax(100px,1fr));padding:15px 19px;gap:13px;max-height:210px;overflow:auto}.state-value{display:flex;flex-direction:column;gap:6px;min-width:0}.state-value dt{font:10px ui-monospace,SFMono-Regular,Consolas,monospace;color:var(--muted);overflow-wrap:anywhere}.state-value dd{font:17px ui-monospace,SFMono-Regular,Consolas,monospace;color:var(--accent);margin:0;overflow-wrap:anywhere}.state-value dl{margin:0}.mono{font-family:ui-monospace,SFMono-Regular,Consolas,monospace;font-size:10px}.subdued{color:var(--muted);font-size:10px}.event-line{padding:0 19px 13px;display:flex;gap:16px;align-items:baseline;flex-wrap:wrap}.event-line #last-event{color:var(--muted);font-size:10px;overflow-wrap:anywhere}.runtime-error{margin:0 18px 14px;padding:10px;border:1px solid #705049;border-radius:5px;background:#432c272e;color:var(--danger);font-size:11px;line-height:1.6}.cost-details{border-top:1px solid var(--line);padding:12px 19px;font-size:10px;color:var(--muted)}summary{cursor:pointer;font-size:11px;color:var(--muted)}.cost-details p{font-size:10px;line-height:1.8;color:var(--faint)}.cost-values{display:grid;grid-template-columns:1fr auto;gap:8px 20px;margin:16px 0}.cost-values dt,.cost-values dd{margin:0}.cost-values dd{text-align:right;font-family:ui-monospace,SFMono-Regular,Consolas,monospace;color:var(--text)}.reference{margin-top:25px}.reference-grid{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));padding:22px 20px;gap:24px}.reference-grid article{min-width:0;display:flex;flex-direction:column;align-items:flex-start}.reference-grid h3{font-size:12px;color:#d2ddc8}.reference-grid p{font-size:10px;line-height:1.8;color:var(--muted);min-height:36px;margin:8px 0 10px}.reference-grid pre{font:10px/1.85 ui-monospace,SFMono-Regular,Consolas,monospace;background:#151b16;color:#bacead;width:100%;padding:10px;overflow:auto;border-radius:6px;min-height:115px;margin:0 0 8px}.reference-grid .quiet{font-size:10px;padding:5px 0}.syntax-details{padding:16px 20px;border-top:1px solid var(--line)}.syntax-columns{display:grid;grid-template-columns:1fr 1fr;gap:35px;margin-top:12px}.syntax-columns p{font-size:11px;line-height:1.95;color:var(--muted)}.syntax-columns strong{color:var(--text);font-weight:500}.syntax-columns code{font-family:ui-monospace,SFMono-Regular,Consolas,monospace;font-size:10px;color:#d0dcbe;overflow-wrap:anywhere}.example-row{padding:18px 20px;border-top:1px solid var(--line);display:flex;align-items:center;gap:10px}.example-row>div{margin-right:auto}.example-row label{font-size:11px;color:var(--text)}.example-row p{font-size:10px;color:var(--faint);margin:5px 0 0}.example-row button,.example-row select{font-size:10px}.page-footer{display:flex;align-items:center;justify-content:space-between;gap:20px;padding:26px 0 30px;color:var(--faint);font-size:10px}.page-footer>span{font-family:ui-monospace,SFMono-Regular,Consolas,monospace;font-size:9px}.page-footer p{margin:0;line-height:1.8}.page-footer strong{font-weight:400;color:var(--muted)}.sr-only{position:absolute;width:1px;height:1px;padding:0;margin:-1px;overflow:hidden;clip:rect(0,0,0,0);white-space:nowrap;border:0}[hidden]{display:none!important}
+.baseline-row{border-top:1px solid var(--line);padding:15px 20px;display:flex;flex-wrap:wrap;gap:8px 16px;align-items:center}.baseline-row a{font-size:11px;color:var(--accent);text-underline-offset:3px}.baseline-row span{font-size:10px;color:var(--faint)}#scene:focus-visible{outline:2px solid var(--accent);outline-offset:-3px}
+@media(min-width:1450px){.code-editor{height:558px}.reference-grid pre{font-size:11px}}
+@media(max-width:1050px){.masthead{padding:0 25px}.experiment{display:none}main{padding:29px 25px 0}.workspace{grid-template-columns:minmax(300px,1fr) minmax(340px,1fr);gap:15px}.intro>p{font-size:11px;max-width:290px}.reference-grid{grid-template-columns:repeat(2,minmax(0,1fr));gap:25px}.reference-grid p{min-height:0}.reference-grid pre{min-height:110px;font-size:11px}.transport #clock-status{width:100%;margin:3px 0 0}.code-editor{height:533px}.panel-heading{padding:0 16px}.apply-row{gap:10px;padding-left:14px;padding-right:14px}.apply-row p{font-size:9px}.input-heading{align-items:flex-start;flex-direction:column;gap:5px}.intro{gap:20px}button.primary{gap:14px;padding:11px 13px}}
+@media(max-width:760px){.masthead{height:63px;padding:0 20px}.wordmark{font-size:21px}.wordmark span{font-size:11px}.connection{font-size:10px}main{padding:25px 18px 0}.intro{display:block;margin-bottom:22px}.intro h1{font-size:28px}.intro>p{margin-top:14px;max-width:none;font-size:11px}.workspace{grid-template-columns:minmax(0,1fr);gap:17px}.code-editor{height:340px}.editor-panel{width:100%}.transport #clock-status{width:auto;margin-left:auto}.right-column{gap:17px}.input-heading{flex-direction:row;align-items:center}.reference{margin-top:20px}.reference-grid{padding:20px 16px;gap:24px 17px}.reference-grid pre{font-size:10px}.reference>.panel-heading{height:auto;min-height:57px;padding-top:12px;padding-bottom:12px;align-items:flex-start;flex-direction:column;gap:6px}.syntax-columns{grid-template-columns:1fr;gap:0}.example-row{flex-wrap:wrap;padding:17px 16px}.example-row>div{width:100%;margin-bottom:4px}.example-row select{flex:1}.page-footer{align-items:flex-start;flex-direction:column;gap:10px;padding:23px 0}.apply-row{padding:14px 16px 10px;gap:17px}.apply-row p{font-size:10px;max-width:none}.diagnostic{margin-left:16px}.reference-grid article{overflow:hidden}}
+@media(max-width:420px){main{padding-left:13px;padding-right:13px}.masthead{padding:0 15px}.wordmark span{font-size:10px}.connection{font-size:9px;gap:6px}.intro h1{font-size:25px}.transport{gap:5px;padding-left:12px;padding-right:12px}.transport button{font-size:10px;padding:7px 8px}.transport #clock-status{font-size:9px}.input-heading>span{font-size:8px}.reference-grid{grid-template-columns:minmax(0,1fr)}.reference-grid pre{min-height:0;font-size:11px}.reference-grid p{margin-bottom:8px}.file-tools{gap:0;padding:7px 5px}.file-tools .quiet{font-size:10px;padding:6px 7px}.apply-row p{font-size:9px}.line-numbers{padding-left:10px;min-width:36px}textarea#source{padding-left:12px;font-size:11px}.line-numbers{font-size:11px}}
+@media(prefers-reduced-motion:reduce){*{scroll-behavior:auto!important}}
+
+#scene.direct-input{touch-action:none;user-select:none;-webkit-user-select:none}#scene [data-point-input]{cursor:grab}