diff --git a/system/public/prompt.ac/index.html b/system/public/prompt.ac/index.html index 8f4a37d579..c706467a85 100644 --- a/system/public/prompt.ac/index.html +++ b/system/public/prompt.ac/index.html @@ -15,7 +15,14 @@ stays booted in a hidden iframe as the command brain (prompt.mjs still parses, aliases, and halts every command via the prompt:dom-input bridge); it becomes visible only while a piece is on stage. The palettes - mirror `scheme` in disks/prompt.mjs — keep them in lockstep. */ + mirror `scheme` in disks/prompt.mjs — keep them in lockstep. + + Interaction mirrors TextInput.act() in lib/type.mjs: the line is either + activated (canType — blinking caret, curtain down) or deactivated (the + login curtain — MOTD + auth — stands). Backdrop taps and typing + activate; a backdrop tap or Escape on an empty line deactivates. The + blips (300 press, 250 deactivate, 850 run) are the same sines type.mjs + plays, WebAudio'd here because the hidden runtime never sees these keys. */ @font-face { font-family: "Berkeley Mono Web"; @@ -24,6 +31,10 @@ } :root { /* prompt.mjs scheme.dark */ + /* The runtime's mouse cursors (cursors/precise.svg + active.svg), + inlined so the shell wears them on any origin. */ + --cursor-precise: url("data:image/svg+xml;utf8,") 12 12; + --cursor-active: url("data:image/svg+xml;utf8,") 12 12; --bg: rgb(70, 50, 100); --text: #fff; --ghost: rgba(200, 30, 100, .78); /* scheme.prompt */ @@ -55,7 +66,9 @@ * { box-sizing: border-box; -webkit-tap-highlight-color: transparent; } html, body { height: 100%; margin: 0; background: var(--bg); } - body { font-family: var(--font); color: var(--text); overflow: hidden; } + body { font-family: var(--font); color: var(--text); overflow: hidden; cursor: var(--cursor-precise), auto; } + #cmd { cursor: var(--cursor-precise), text; } + button, .corner, .motd .by b { cursor: var(--cursor-active), pointer; } ::selection { background: var(--highlight); color: var(--bg); } /* ── Stage: the hosted runtime. While the DOM prompt owns the screen the @@ -118,7 +131,12 @@ font-size: clamp(20px, 2.6vw, 30px); animation: blink 1s steps(2, start) infinite; } - body:not([data-mode="prompt"]) #screen:not(.overlay) #caret { display: none; } + /* The caret only blinks while the line is activated (canType in type.mjs); + deactivated text sits dimmed behind the curtain, like the raster's. */ + #caret { display: none; } + body.active[data-mode="prompt"] #caret, + body.active[data-mode="piece"] #screen.overlay #caret { display: block; } + body:not(.active) #cmd { opacity: .55; } @keyframes blink { 50% { opacity: 0; } } #measure { position: absolute; visibility: hidden; white-space: pre; @@ -150,20 +168,27 @@ font-size: clamp(15px, 1.8vw, 21px); line-height: 1.5; text-shadow: 0 1px 2px rgba(0, 0, 0, .25); transition: opacity .35s; - cursor: default; user-select: none; + user-select: none; } - .motd.faded, body.typing .motd { opacity: 0; } + /* The login curtain — showLoginCurtain in prompt.mjs: MOTD + auth stand + only while the line is deactivated, empty, and quiet (no reply). */ + .motd.faded, + body.active .motd, body.typing .motd, + body.replying .motd, body.submitting .motd { opacity: 0; } + body.active .motd, body.typing .motd, + body.replying .motd, body.submitting .motd { pointer-events: none; } .motd .by { display: block; margin-top: .4em; font-size: .72em; color: var(--dim); } - .motd .by b { cursor: pointer; } + .motd .by b { cursor: var(--cursor-active), pointer; } .motd .by b:hover { color: var(--highlight); } .t-handle, .t-kidlisp, .t-clock, .t-painting, .t-prompt, .t-url { color: var(--highlight); } /* ── Login / I'm new / profile — ui.TextButton, vectorized. ─────────── */ - .auth { display: flex; gap: 14px; margin-top: 34px; } - body.typing .auth { opacity: 0; transition: opacity .35s; } + .auth { display: flex; gap: 14px; margin-top: 34px; transition: opacity .35s; } + body.active .auth, body.typing .auth, + body.replying .auth, body.submitting .auth { opacity: 0; pointer-events: none; } .auth button { font-family: var(--font); font-size: clamp(14px, 1.6vw, 18px); - color: #fff; padding: 7px 16px; cursor: pointer; + color: #fff; padding: 7px 16px; cursor: var(--cursor-active), pointer; border: 1px solid var(--login-edge); box-shadow: 2px 2px 0 rgba(0, 0, 0, .55); background: var(--login-bg); @@ -180,7 +205,7 @@ top: max(8px, env(safe-area-inset-top)); left: max(8px, env(safe-area-inset-left)); display: none; align-items: center; gap: 6px; - padding: 4px 10px; cursor: pointer; user-select: none; + padding: 4px 10px; cursor: var(--cursor-active), pointer; user-select: none; background: rgba(0, 0, 0, .55); border: 1px solid rgba(255, 255, 255, .3); border-radius: 6px; color: #fff; font-family: var(--font); font-size: 14px; backdrop-filter: blur(6px); -webkit-backdrop-filter: blur(6px); @@ -260,7 +285,59 @@ let pendingJump = null; let pendingSubmit = null; // command typed before the runtime's prompt was live let promptLive = false; // the runtime's prompt piece is loaded + interactive let lastSubmitted = ""; // suppress the runtime echoing a command back as a "reply" -let noticeTimer; +let noticeTimer, submitTimer; + +// ── Blips — the TextInput activation sounds from lib/type.mjs, WebAudio'd +// so the DOM prompt speaks the same tongue: 300 press/activate, 250 +// deactivate, 850 run. (The hidden runtime can't blip for keys it never sees.) +let audio; +function blip(tone, volume, dur) { + try { + audio ||= new (window.AudioContext || window.webkitAudioContext)(); + if (audio.state === "suspended") audio.resume(); + const osc = audio.createOscillator(), gain = audio.createGain(); + osc.type = "sine"; + osc.frequency.value = tone; + const t = audio.currentTime; + gain.gain.setValueAtTime(0, t); + gain.gain.linearRampToValueAtTime(volume, t + 0.005); + gain.gain.exponentialRampToValueAtTime(0.001, t + dur); + osc.connect(gain).connect(audio.destination); + osc.start(t); + osc.stop(t + dur + 0.02); + } catch {} +} +const SND = { + press: () => blip(300, 0.5, 0.12), + deactivate: () => blip(250, 0.75, 0.17), + run: () => blip(850, 0.65, 0.15), +}; + +// ── Activation — canType in type.mjs. The login curtain (MOTD + auth) +// stands while the line is deactivated and empty; tapping the backdrop or +// typing tears it down, and tapping again (or Escape on an empty line) +// raises it back. +let active = false; +function activate({ silent = false } = {}) { + if (!active) { + active = true; + document.body.classList.add("active"); + if (!silent) SND.press(); + clearReply(); // reactivating dismisses the last answer, like the raster + document.body.classList.remove("submitting"); + } + $cmd.focus(); + syncCaret(); +} +let deactivatedAt = 0; // guards against phantom refocus, as in type.mjs +function deactivate({ silent = false } = {}) { + if (!active) return; + active = false; + document.body.classList.remove("active"); + if (!silent) SND.deactivate(); + deactivatedAt = performance.now(); + $cmd.blur(); +} // Deep-link support that doesn't fight Caddy: prompt.ac/#clock stays on the // shell (hash never reaches the server), while prompt.ac/clock 301s to AC. @@ -269,6 +346,7 @@ let currentPiece = initial; const frameParams = "nogap=true&nolabel=true&shellhtml=true"; function setMode(piece) { + const wasPiece = document.body.dataset.mode === "piece"; currentPiece = piece; const onPiece = piece !== "prompt"; document.body.dataset.mode = onPiece ? "piece" : "prompt"; @@ -280,8 +358,12 @@ function setMode(piece) { const h = "#" + piece; if (onPiece && location.hash !== h) history.replaceState(null, "", h); else if (!onPiece && location.hash) history.replaceState(null, "", location.pathname); - if (onPiece) { $cmd.blur(); $ac.focus(); } - else { $cmd.focus(); syncCaret(); } + document.body.classList.remove("submitting"); // the command landed somewhere + if (onPiece) { deactivate({ silent: true }); $ac.focus(); } + // Landing home from a piece raises the login curtain (deactivated splash); + // a repeat "prompt" report mid-typing must not blur the line. + else if (wasPiece) deactivate({ silent: true }); + else syncCaret(); } setMode(initial); if (initial !== "prompt") $loading.classList.add("on"); @@ -350,10 +432,14 @@ function showReply(text, thinking) { document.body.classList.toggle("thinking", thinking); $replyText.textContent = fresh; $reply.hidden = !fresh && !thinking; + document.body.classList.toggle("replying", !$reply.hidden); + // A bare echo of the submitted command means it's still executing — keep + // the curtain held; anything else (reply, thinking, cleared line) settles it. + if (thinking || t !== lastSubmitted) document.body.classList.remove("submitting"); } function clearReply() { lastSubmitted = ""; - document.body.classList.remove("thinking"); + document.body.classList.remove("thinking", "replying"); $replyText.textContent = ""; $reply.hidden = true; } @@ -375,6 +461,7 @@ function submitCommand(value, { remember = true } = {}) { const work = whistlegraphFromInput(v); if (work) { location.assign(work.url); return; } + SND.run(); // the 850Hz execute blip from type.mjs if (currentPiece === "prompt") { // Seed and execute the real prompt TextInput. The DOM only supplies data; // command parsing, aliases, and halt() stay inside the prompt system. @@ -388,7 +475,14 @@ function submitCommand(value, { remember = true } = {}) { jumpPiece(`prompt~${v}~!autorun`); } setTyped(""); - if (currentPiece !== "prompt") { $screen.classList.remove("overlay"); $cmd.blur(); $ac.focus(); } + deactivate({ silent: true }); // the raster deactivates after running + // Hold the curtain down while the command lands (a piece load or a reply + // takes over from here; the timeout catches commands that answer with + // neither). + document.body.classList.add("submitting"); + clearTimeout(submitTimer); + submitTimer = setTimeout(() => document.body.classList.remove("submitting"), 8000); + if (currentPiece !== "prompt") { $screen.classList.remove("overlay"); $ac.focus(); } } // ── Block caret — the raster prompt's blinking block, in DOM. ────────────── @@ -426,11 +520,17 @@ $cmd.addEventListener("keydown", (e) => { } else if (e.key === "ArrowDown") { e.preventDefault(); recall(1); } else if (e.key === "Escape") { + // Escape clears a typed line; on an empty line it deactivates — the + // curtain returns at home, the slash-curtain drops back to its piece. + const hadText = $cmd.value.length > 0; setTyped(""); clearReply(); if (currentPiece !== "prompt") { $screen.classList.remove("overlay"); - $cmd.blur(); $ac.focus(); + deactivate(); + $ac.focus(); + } else if (!hadText) { + deactivate(); } } }); @@ -438,32 +538,66 @@ $cmd.addEventListener("keydown", (e) => { // At the prompt the line always owns typing (the stage is hidden + inert); // over a piece, "/" or any printable key raises the slash-prompt curtain. window.addEventListener("keydown", (e) => { - if (document.activeElement === $cmd) return; + // Skip keys the line already handled — even when its handler just blurred + // it (a submit's Enter still bubbles here with focus already gone). + if (e.target === $cmd || document.activeElement === $cmd) return; if (e.metaKey || e.ctrlKey || e.altKey) return; const printable = e.key.length === 1; - if (e.key === "/" || printable) { + if (e.key === "/" || printable || e.key === "Enter" || e.key === "ArrowUp") { e.preventDefault(); if (currentPiece !== "prompt") $screen.classList.add("overlay"); - $cmd.focus(); + activate(); // auto-activation blips, as in the raster's keyboard:down if (printable && e.key !== "/") setTyped($cmd.value + e.key); + else if (e.key === "ArrowUp") recall(-1); else syncCaret(); } }); -// Tapping anywhere on the prompt screen summons the keyboard (mobile). +// ── Backdrop taps — the press/lift dance of TextInput.act(). A press on the +// backdrop blips and activates on lift; a backdrop tap while active +// deactivates (raising the login curtain back, or dropping the slash-curtain +// to its piece). Buttons and the typed line itself opt out, the way +// backdropTouchOff shields the raster's buttons — clicking typed text +// places the caret, it never drops the line. +let pressedBackdrop = false; $screen.addEventListener("pointerdown", (e) => { - if (e.target.closest("button, a, input")) return; - requestAnimationFrame(() => $cmd.focus()); + const onControl = e.target.closest("button, a, b"); + pressedBackdrop = !onControl && !e.target.closest("input"); + if (!active && !onControl) SND.press(); // the touch blip, before lift +}); +$screen.addEventListener("pointerup", (e) => { + if (e.target.closest("button, a, b")) { pressedBackdrop = false; return; } + const onInput = e.target.closest("input"); + if (!active) { + activate({ silent: true }); // the press already blipped + } else if (pressedBackdrop && !onInput) { + deactivate(); // typed text stays, dimmed — reactivating resumes it + if (currentPiece !== "prompt") { + setTyped(""); + $screen.classList.remove("overlay"); + $ac.focus(); + } + } + pressedBackdrop = false; }); // The stage keeps rendering beneath the prompt screen; if the runtime grabs // focus (its keyboard textarea does on boot), hand typing straight back. window.addEventListener("blur", () => { - if (currentPiece === "prompt" && document.activeElement === $ac) { + if (active && currentPiece === "prompt" && document.activeElement === $ac) { setTimeout(() => $cmd.focus(), 0); } }); +// Keyboard-tab focus (or any stray focus) counts as quiet activation — except +// the browser's own refocus right after a deactivating blur (the raster's +// manualDeactivationTime guard, DOM'd). +$cmd.addEventListener("focus", () => { + if (active) return; + if (performance.now() - deactivatedAt < 150) { $cmd.blur(); return; } + activate({ silent: true }); +}); + // ── MOTD — the same mood feed the raster prompt cycles every 8s. ────────── const MOTD_CYCLE_MS = 8000; let moods = [], moodAt = 0;