diff options
| author | eunhhu <[email protected]> | 2026-09-24 17:14:54 +0100 |
|---|---|---|
| committer | eunhhu <[email protected]> | 2026-09-24 17:14:54 +0100 |
| commit | b1ce1927022d374198441afafeb27199fc8a052a (patch) | |
| tree | 6021f175f4c67ed03b44240eaa2f90d846f12f6f /prototypes/workbench/web | |
| parent | d34f9426d5317a1948f12f229439946728bc342a (diff) | |
| download | pure-b1ce1927022d374198441afafeb27199fc8a052a.tar.gz pure-b1ce1927022d374198441afafeb27199fc8a052a.zip | |
Establish native micro runtime checkpoint
Diffstat (limited to 'prototypes/workbench/web')
| -rw-r--r-- | prototypes/workbench/web/app.js | 411 | ||||
| -rw-r--r-- | prototypes/workbench/web/baseline.html | 108 | ||||
| -rw-r--r-- | prototypes/workbench/web/baseline.mjs | 68 | ||||
| -rw-r--r-- | prototypes/workbench/web/compiler.mjs | 496 | ||||
| -rwxr-xr-x | prototypes/workbench/web/core.wasm | bin | 0 -> 61073 bytes | |||
| -rw-r--r-- | prototypes/workbench/web/document.css | 3 | ||||
| -rw-r--r-- | prototypes/workbench/web/document.html | 32 | ||||
| -rw-r--r-- | prototypes/workbench/web/document.mjs | 132 | ||||
| -rw-r--r-- | prototypes/workbench/web/index.html | 95 | ||||
| -rw-r--r-- | prototypes/workbench/web/output.mjs | 103 | ||||
| -rw-r--r-- | prototypes/workbench/web/project.mjs | 47 | ||||
| -rw-r--r-- | prototypes/workbench/web/runtime.mjs | 350 | ||||
| -rw-r--r-- | prototypes/workbench/web/scene-input.mjs | 84 | ||||
| -rw-r--r-- | prototypes/workbench/web/split-input.mjs | 57 | ||||
| -rw-r--r-- | prototypes/workbench/web/style.css | 10 |
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 Binary files differnew file mode 100755 index 0000000..7aa443c --- /dev/null +++ b/prototypes/workbench/web/core.wasm 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="생각을 적어보세요. 내용은 오른쪽 읽기 화면에 표시됩니다."></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="여기서 시작하세요. 아래 ‘언어 안내’에서 작은 조각을 가져올 수 있습니다."></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>< <= > >= == !=</code>, <code>&& || !</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} |
