- commit
- d4a1ae8a8ef773be0572ee2395fdc76ce6100cb4
- parent
- 23cb438492bf630ab8332c9905b082096254fcd8
- author
- Dami <dami.etoile@gmail.com>
Binary file.
Binary file.
Binary file.
Binary file.
Binary file.
Binary file.
Binary file.
Binary file.
Binary file.
Binary file.
Binary file.
Binary file.
Binary file.
Binary file.
Binary file.
Binary file.
Binary file.
Binary file.
Binary file.
Binary file.
Binary file.
Binary file.
Binary file.
Binary file.
Binary file.
Binary file.
Binary file.
Binary file.
Binary file.
Binary file.
Binary file.
Binary file.
Binary file.
Binary file.
Binary file.
Binary file.
Binary file.
Binary file.
Binary file.
Binary file.
Binary file.
Binary file.
Binary file.
Binary file.
Binary file.
Binary file.
Binary file.
Binary file.
Binary file.
Binary file.
@@ -10,7 +10,7 @@ export function audioButtonPresentation({ available, muted, state, detail }) {
1010 } else if (normalizedState === 'playing') {1111 glyph = '🔊';1212 } else {13- glyph = '🔉';13+ glyph = 'audio ...';1414 }1515 1616 const action = available@@ -3,7 +3,7 @@ import test from 'node:test';
33 44 import { audioButtonPresentation } from './audio-ui.mjs';55 6-test('audio button uses a loud speaker and mute action while playing', () => {6+test('audio button uses a speaker glyph with a mute action while playing', () => {77 assert.deepEqual(audioButtonPresentation({88 available: true,99 muted: false,@@ -16,7 +16,7 @@ test('audio button uses a loud speaker and mute action while playing', () => {
1616 });1717 });1818 19-test('audio button uses a muted speaker and unmute action while muted', () => {19+test('audio button uses a muted glyph with an unmute action while muted', () => {2020 const presentation = audioButtonPresentation({2121 available: true,2222 muted: true,@@ -27,13 +27,13 @@ test('audio button uses a muted speaker and unmute action while muted', () => {
2727 assert.match(presentation.ariaLabel, /^Unmute audio\. Audio muted\./);2828 });2929 30-test('audio button distinguishes priming from unavailable without visible text', () => {30+test('audio button distinguishes priming from unavailable', () => {3131 assert.equal(audioButtonPresentation({3232 available: true,3333 muted: false,3434 state: 'priming',3535 detail: 'Waiting for bounded Opus prebuffer',36- }).glyph, '🔉');36+ }).glyph, 'audio ...');3737 const unavailable = audioButtonPresentation({3838 available: false,3939 muted: false,@@ -3,12 +3,24 @@
33 <head>44 <meta charset="UTF-8" />55 <meta name="viewport" content="width=device-width, initial-scale=1.0" />6- <title>Sigil Spark</title>6+ <title>Portal</title>77 <link rel="stylesheet" href="./style.css" />88 </head>99 <body>1010 <div class="overlay-screen hidden" id="tap-overlay" style="z-index: 200;">1111 <div class="overlay-content">12+ <div class="flow-steps" aria-hidden="true">13+ <span class="flow-step">01 pin</span>14+ <span class="flow-step active">02 tap</span>15+ <span class="flow-step">03 play</span>16+ </div>17+ <svg class="pixel-key" viewBox="0 0 26 12" width="78" height="36" aria-hidden="true">18+ <rect x="0" y="1" width="8" height="10" fill="var(--terracotta)" />19+ <rect x="3" y="5" width="2" height="2" fill="var(--paper)" />20+ <rect x="8" y="5" width="18" height="2" fill="var(--ochre)" />21+ <rect x="14" y="7" width="2" height="3" fill="var(--moss)" />22+ <rect x="19" y="7" width="2" height="2" fill="var(--moss)" />23+ </svg>1224 <h1 id="tap-overlay-title">tap key</h1>1325 <p id="tap-overlay-desc">Touch the sensor on your key when it blinks.</p>1426 <div class="overlay-status pending" id="tap-status">waiting...</div>@@ -30,7 +42,7 @@
3042 <button class="pin-pad-key" data-controller-focus data-pin-digit="7" onclick="pinPadDigit('7')">7</button>3143 <button class="pin-pad-key" data-controller-focus data-pin-digit="8" onclick="pinPadDigit('8')">8</button>3244 <button class="pin-pad-key" data-controller-focus data-pin-digit="9" onclick="pinPadDigit('9')">9</button>33- <button class="pin-pad-key pin-pad-action" data-controller-focus onclick="pinPadBackspace()" aria-label="Backspace">⌫</button>45+ <button class="pin-pad-key pin-pad-action" data-controller-focus onclick="pinPadBackspace()" aria-label="Backspace">del</button>3446 <button class="pin-pad-key" data-controller-focus data-pin-digit="0" onclick="pinPadDigit('0')">0</button>3547 <button class="pin-pad-key pin-pad-action" data-controller-focus onclick="closePinPad()">done</button>3648 </div>@@ -40,7 +52,12 @@
4052 4153 <div class="overlay-screen hidden" id="intro">4254 <div class="overlay-content">43- <h1>sigil spark</h1>55+ <div class="flow-steps" aria-hidden="true">56+ <span class="flow-step active">01 pin</span>57+ <span class="flow-step">02 tap</span>58+ <span class="flow-step">03 play</span>59+ </div>60+ <h1>portal</h1>4461 <p id="intro-desc">enter the host security-key pin</p>4562 <div class="dev-warning hidden" id="dev-connect-warning" role="alert">4663 DEVELOPMENT DIRECT NODE<br />PASSKEY LOOKUP BYPASSED · NOT CLIENT AUTHORIZATION@@ -58,7 +75,7 @@
5875 </div>5976 6077 <div class="topbar">61- <span class="brand">sigil spark</span>78+ <span class="brand">portal</span>6279 <span class="dev-badge hidden" id="dev-connect-badge" title="Skips passkey identity lookup. This is not client authorization.">dev direct-node · not auth</span>6380 <span class="sep">·</span>6481 <div class="status-line" id="status-line">@@ -88,7 +105,7 @@
88105 <button class="control-toggle" id="control-toggle" data-controller-focus onclick="toggleControl()">89106 view only · input unavailable90107 </button>91- <button class="control-toggle disabled" id="audio-toggle" data-controller-focus onclick="toggleAudioMute()" aria-disabled="true" aria-label="Audio unavailable. Not connected" title="Audio unavailable: not connected">108+ <button type="button" class="control-toggle disabled" id="audio-toggle" data-controller-focus onclick="toggleAudioMute()" aria-disabled="true" aria-label="Audio unavailable. Not connected" title="Audio unavailable: not connected">92109 🔇93110 </button>94111 </div>@@ -109,7 +126,7 @@
109126 </div>110127 <div class="panel-body">111128 <div class="panel-section">112- <h3># security key</h3>129+ <h3><span class="hash" aria-hidden="true">#</span> security key</h3>113130 <div class="info-row"><span class="info-label">status</span><span class="info-value" id="fido-status">—</span></div>114131 <div class="info-row"><span class="info-label">product</span><span class="info-value" id="fido-product">—</span></div>115132 <div class="info-row"><span class="info-label">vid:pid</span><span class="info-value" id="fido-vidpid">—</span></div>@@ -119,7 +136,7 @@
119136 <button class="link-btn panel-action" data-controller-focus onclick="scanFido()">rescan</button>120137 </div>121138 <div class="panel-section" id="stream-section">122- <h3># stream</h3>139+ <h3><span class="hash" aria-hidden="true">#</span> stream</h3>123140 <div class="info-row"><span class="info-label">codec</span><span class="info-value" id="stream-codec">—</span></div>124141 <div class="info-row"><span class="info-label">transport</span><span class="info-value" id="stream-transport">—</span></div>125142 <div class="info-row"><span class="info-label">path</span><span class="info-value" id="stream-path">—</span></div>@@ -36,4 +36,6 @@ test('stream diagnostics remain exclusive to the Info panel', () => {
3636 assert.ok(streamIds.length > 0);3737 for (const id of streamIds) assert.match(panel, new RegExp(`id="${id}"`));3838 assert.match(html, /id="audio-toggle"[^>]*aria-label="Audio unavailable\. Not connected"/);39+ assert.match(html, /type="button"[^>]*id="audio-toggle"/);40+ assert.match(css, /#audio-toggle\s*\{[^}]*min-width:\s*40px;[^}]*min-height:\s*32px;/s);3941 });@@ -1,14 +1,39 @@
1+/* ─────────────────────────────────────────────────────────────2+ portal webview — goq.sh visual language, dark ramp.3+ The app frames a video stream, so it commits to the landing4+ page's dark palette as a single theme.5+ ───────────────────────────────────────────────────────────── */6+17 :root {2- --background: #1a1a1a;3- --foreground: #c4c4c4;4- --muted: #2a2a2a;5- --muted-fg: #808080;6- --accent: #2a2a2a;7- --border: rgba(255,255,255,0.1);8- --border-strong: rgba(255,255,255,0.15);9- --destructive: #ff6568;10- --green: #4ade80;11- --yellow: #fbbf24;8+ /* paper + ink (goq.sh dark ramp) */9+ --paper: #201913;10+ --paper-2: #271f17;11+ --stage: #16110c;12+ --ink: #ece0cd;13+ --ink-muted: #a3927a;14+ --hair: rgba(236, 224, 205, 0.12);15+ --hair-strong: rgba(236, 224, 205, 0.24);16+ --panel-shadow: rgba(0, 0, 0, 0.45);17+18+ /* earth-tone splashes */19+ --terracotta: #cf7048;20+ --ochre: #d99a47;21+ --moss: #8a9155;22+ --clay: #bd6c52;23+ --sand: #cbb488;24+25+ /* semantic aliases (state colors ride the palette) */26+ --background: var(--paper);27+ --foreground: var(--ink);28+ --muted: var(--paper-2);29+ --muted-fg: var(--ink-muted);30+ --accent: var(--terracotta);31+ --border: var(--hair);32+ --border-strong: var(--hair-strong);33+ --destructive: var(--clay);34+ --green: var(--moss);35+ --yellow: var(--ochre);36+1237 --mono: "SFMono-Regular", "Cascadia Mono", "Liberation Mono", ui-monospace, monospace;1338 --sans: -apple-system, BlinkMacSystemFont, "Segoe UI", system-ui, sans-serif;1439 }@@ -32,6 +57,8 @@ body {
3257 -webkit-font-smoothing: antialiased;3358 }3459 60+::selection { background: var(--ochre); color: var(--paper); }61+3562 /* ─── Top bar ─────────────────────────────────────────────────────── */3663 .topbar {3764 display: flex;@@ -44,12 +71,13 @@ body {
4471 .brand {4572 font-family: var(--mono);4673 font-size: 13px;47- font-weight: 500;48- color: var(--foreground);74+ font-weight: 600;75+ letter-spacing: 0.02em;76+ text-transform: lowercase;77+ color: var(--terracotta);4978 }5079 .dev-badge {51- border: 1px solid rgba(251, 191, 36, 0.65);52- border-radius: 3px;80+ border: 1px solid color-mix(in srgb, var(--ochre) 65%, transparent);5381 color: var(--yellow);5482 font-family: var(--mono);5583 font-size: 10px;@@ -63,7 +91,7 @@ body {
6391 margin: -10px 0 20px;6492 border: 1px solid var(--yellow);6593 color: var(--yellow);66- background: rgba(251, 191, 36, 0.08);94+ background: color-mix(in srgb, var(--ochre) 8%, transparent);6795 padding: 10px;6896 font-family: var(--mono);6997 font-size: 11px;@@ -78,10 +106,11 @@ body {
78106 color: var(--muted-fg);79107 display: flex;80108 align-items: center;81- gap: 6px;109+ gap: 7px;82110 }111+/* pixel-square state dot, matching the site's pixel bullets */83112 .status-line .dot {84- width: 6px; height: 6px; border-radius: 50%;113+ width: 7px; height: 7px;85114 background: var(--muted-fg);86115 }87116 .status-line .dot.ok { background: var(--green); }@@ -115,6 +144,7 @@ body {
115144 .link-btn {116145 font-family: var(--mono);117146 font-size: 12px;147+ text-transform: lowercase;118148 color: var(--muted-fg);119149 cursor: pointer;120150 text-decoration: underline;@@ -131,7 +161,7 @@ body {
131161 .link-btn:focus-visible,132162 .link-btn.controller-focus {133163 color: var(--foreground);134- outline: 2px solid var(--green);164+ outline: 2px solid var(--moss);135165 outline-offset: 4px;136166 }137167 @@ -143,7 +173,7 @@ body {
143173 justify-content: center;144174 position: relative;145175 min-height: 0;146- background: #0d0d0d;176+ background: var(--stage);147177 }148178 #frame-canvas {149179 max-width: 100%;@@ -179,6 +209,14 @@ body.native-pointer-control * { cursor: none !important; }
179209 font-size: 13px;180210 padding: 40px;181211 }212+.placeholder::before {213+ content: "";214+ display: inline-block;215+ width: 8px; height: 8px;216+ margin-right: 9px;217+ background: var(--ochre);218+ vertical-align: baseline;219+}182220 .placeholder.hidden { display: none; }183221 184222 /* Session controls */@@ -218,7 +256,7 @@ body.native-pointer-control * { cursor: none !important; }
218256 }219257 .control-toggle:focus-visible,220258 .control-toggle.controller-focus {221- outline: 2px solid var(--green);259+ outline: 2px solid var(--moss);222260 outline-offset: 1px;223261 }224262 .control-toggle.disabled {@@ -231,11 +269,17 @@ body.native-pointer-control * { cursor: none !important; }
231269 text-decoration: none;232270 }233271 #audio-toggle {234- min-width: 32px;235- padding-inline: 6px;272+ display: inline-flex;273+ align-items: center;274+ justify-content: center;275+ min-width: 40px;276+ min-height: 32px;277+ padding: 6px 10px;278+ font-family: system-ui, sans-serif;236279 font-size: 16px;237280 line-height: 1;238- text-decoration: none;281+ white-space: nowrap;282+ touch-action: manipulation;239283 }240284 241285 /* ─── Bottom bar ──────────────────────────────────────────────────── */@@ -264,11 +308,13 @@ body.native-pointer-control * { cursor: none !important; }
264308 .bottombar .prompt { color: var(--foreground); }265309 .bottombar .sep { color: var(--muted-fg); }266310 .bottombar .action {311+ font-family: var(--mono);312+ text-transform: lowercase;267313 color: var(--muted-fg);268314 cursor: pointer;269315 text-decoration: underline;270316 text-decoration-color: transparent;271- transition: color 0.1s, text-decoration-color 0.1s;317+ transition: color 0.1s, text-decoration-color 0.1s, background 0.1s;272318 background: none;273319 border: none;274320 }@@ -276,13 +322,24 @@ body.native-pointer-control * { cursor: none !important; }
276322 color: var(--foreground);277323 text-decoration-color: currentColor;278324 }279-.bottombar .action.primary { color: var(--foreground); }280-.bottombar .action.danger { color: var(--destructive); opacity: 0.7; }325+.bottombar .action.primary {326+ color: var(--foreground);327+ border: 1px solid var(--border-strong);328+ padding: 4px 12px;329+ letter-spacing: 0.04em;330+ text-decoration: none;331+ box-shadow: 2px 2px 0 var(--hair);332+}333+.bottombar .action.primary:hover {334+ background: var(--ink);335+ color: var(--paper);336+}337+.bottombar .action.danger { color: var(--destructive); opacity: 0.8; }281338 .bottombar .action.danger:hover { opacity: 1; }282339 .bottombar .action:focus-visible,283340 .bottombar .action.controller-focus {284341 color: var(--foreground);285- outline: 2px solid var(--green);342+ outline: 2px solid var(--moss);286343 outline-offset: 3px;287344 }288345 .bottombar .hidden { display: none; }@@ -304,9 +361,9 @@ body.native-pointer-control * { cursor: none !important; }
304361 }305362 .pin-input:focus {306363 outline: none;307- border-bottom-color: var(--foreground);364+ border-bottom-color: var(--sand);308365 }309-.pin-input.controller-focus { outline: 2px solid var(--green); outline-offset: 3px; }366+.pin-input.controller-focus { outline: 2px solid var(--moss); outline-offset: 3px; }310367 .pin-input.hidden { display: none; }311368 .pin-label {312369 font-size: 11px;@@ -329,7 +386,7 @@ body.native-pointer-control * { cursor: none !important; }
329386 #control-toggle { max-width: 40vw; }330387 }331388 332-/* ─── Intro / overlay screen ──────────────────────────────────────── */389+/* ─── Intro / overlay screens ─────────────────────────────────────── */333390 .overlay-screen {334391 position: fixed;335392 inset: 0;@@ -341,16 +398,37 @@ body.native-pointer-control * { cursor: none !important; }
341398 }342399 .overlay-screen.hidden { display: none; }343400 .overlay-content {401+ position: relative;344402 text-align: center;345- width: 320px;346- padding: 32px;403+ width: 360px;404+ max-width: calc(100vw - 48px);405+ padding: 36px 32px 32px;347406 border: 1px solid var(--border-strong);348407 background: var(--muted);408+ box-shadow: 12px 12px 0 var(--panel-shadow);409+}410+/* dithered strip along the panel's top edge */411+.overlay-content::before {412+ content: "";413+ position: absolute;414+ top: 0; left: 0; right: 0;415+ height: 6px;416+ background-image:417+ repeating-linear-gradient(90deg,418+ var(--terracotta) 0 6px, transparent 6px 18px),419+ repeating-linear-gradient(90deg,420+ var(--moss) 9px 12px, transparent 12px 30px),421+ repeating-linear-gradient(90deg,422+ var(--ochre) 21px 24px, transparent 24px 42px);423+ opacity: 0.7;424+ pointer-events: none;349425 }350426 .overlay-content h1 {351427 font-family: var(--mono);352428 font-size: 16px;353- font-weight: 500;429+ font-weight: 700;430+ text-transform: lowercase;431+ letter-spacing: -0.01em;354432 color: var(--foreground);355433 margin-bottom: 12px;356434 }@@ -358,6 +436,7 @@ body.native-pointer-control * { cursor: none !important; }
358436 font-size: 26px;359437 letter-spacing: -0.5px;360438 margin-bottom: 8px;439+ color: var(--terracotta);361440 }362441 .overlay-content p {363442 font-size: 12px;@@ -367,10 +446,40 @@ body.native-pointer-control * { cursor: none !important; }
367446 font-family: var(--mono);368447 letter-spacing: 0.02em;369448 }449+/* pixel security-key mark (tap overlay) */450+.pixel-key {451+ display: block;452+ margin: 0 auto 16px;453+ image-rendering: pixelated;454+ image-rendering: crisp-edges;455+}456+/* three-step flow eyebrow: 01 pin · 02 tap · 03 play */457+.flow-steps {458+ display: flex;459+ justify-content: center;460+ gap: 16px;461+ margin-bottom: 20px;462+ font-family: var(--mono);463+ font-size: 10px;464+ font-weight: 500;465+ letter-spacing: 0.12em;466+ text-transform: uppercase;467+ color: var(--muted-fg);468+}469+.flow-step { opacity: 0.55; }470+.flow-step.active {471+ opacity: 1;472+ font-weight: 700;473+ padding-bottom: 3px;474+ border-bottom: 2px solid currentColor;475+}476+#intro .flow-step.active { color: var(--terracotta); }477+#tap-overlay .flow-step.active { color: var(--ochre); }370478 .overlay-actions {371479 display: flex;372480 gap: 16px;373481 justify-content: center;482+ align-items: center;374483 margin-top: 14px;375484 }376485 .shortcut-key {@@ -395,15 +504,16 @@ body.native-pointer-control * { cursor: none !important; }
395504 }396505 .overlay-input:focus {397506 outline: none;398- border-bottom-color: var(--foreground);507+ border-bottom-color: var(--sand);399508 }400509 .overlay-input.controller-focus {401- outline: 2px solid var(--green);510+ outline: 2px solid var(--moss);402511 outline-offset: 4px;403512 }404513 .overlay-btn {405514 font-family: var(--mono);406515 font-size: 13px;516+ text-transform: lowercase;407517 color: var(--muted-fg);408518 cursor: pointer;409519 background: none;@@ -411,20 +521,35 @@ body.native-pointer-control * { cursor: none !important; }
411521 padding: 6px 12px;412522 text-decoration: underline;413523 text-decoration-color: transparent;414- transition: color 0.1s, text-decoration-color 0.1s;524+ transition: color 0.1s, text-decoration-color 0.1s, background 0.1s;415525 }416526 .overlay-btn:hover {417527 color: var(--foreground);418528 text-decoration-color: currentColor;419529 }420-.overlay-btn.primary { color: var(--foreground); }530+.overlay-btn.primary {531+ color: var(--foreground);532+ border: 1px solid var(--border-strong);533+ padding: 7px 20px;534+ letter-spacing: 0.06em;535+ text-decoration: none;536+ box-shadow: 3px 3px 0 var(--hair);537+}538+.overlay-btn.primary:hover {539+ background: var(--ink);540+ color: var(--paper);541+}421542 .overlay-btn:focus-visible,422543 .overlay-btn.controller-focus {423544 color: var(--foreground);424- outline: 2px solid var(--green);545+ outline: 2px solid var(--moss);425546 outline-offset: 2px;426547 text-decoration-color: currentColor;427548 }549+.overlay-btn.primary:focus-visible,550+.overlay-btn.primary.controller-focus {551+ text-decoration: none;552+}428553 .overlay-btn:disabled {429554 opacity: 0.4;430555 cursor: not-allowed;@@ -448,7 +573,7 @@ body.native-pointer-control * { cursor: none !important; }
448573 449574 /* ─── Controller PIN pad ─────────────────────────────────────────── */450575 #pin-pad-overlay { z-index: 160; }451-.pin-pad-content { width: 340px; }576+.pin-pad-content { width: 380px; }452577 .pin-pad-display {453578 min-height: 36px;454579 margin: 8px 0 4px;@@ -459,29 +584,34 @@ body.native-pointer-control * { cursor: none !important; }
459584 }460585 .pin-pad-grid {461586 display: grid;462- grid-template-columns: repeat(3, 64px);587+ grid-template-columns: repeat(3, 72px);463588 justify-content: center;464589 gap: 10px;465590 margin-top: 20px;466591 }467592 .pin-pad-key {468- height: 52px;593+ height: 56px;469594 border: 1px solid var(--border-strong);470- border-radius: 4px;471595 color: var(--foreground);472596 background: var(--background);473597 font-family: var(--mono);474598 font-size: 18px;475599 cursor: pointer;600+ box-shadow: 3px 3px 0 var(--hair);601+ transition: background 0.1s, color 0.1s;602+}603+.pin-pad-key:hover { border-color: var(--sand); }604+.pin-pad-key:active {605+ transform: translate(2px, 2px);606+ box-shadow: 1px 1px 0 var(--hair);476607 }477-.pin-pad-key:hover { border-color: var(--foreground); }478608 .pin-pad-key:focus-visible,479609 .pin-pad-key.controller-focus {480- outline: 3px solid var(--green);610+ outline: 3px solid var(--moss);481611 outline-offset: 2px;482- border-color: var(--green);612+ border-color: var(--moss);483613 }484-.pin-pad-key.pin-pad-action { font-size: 12px; color: var(--muted-fg); }614+.pin-pad-key.pin-pad-action { font-size: 12px; color: var(--muted-fg); text-transform: lowercase; }485615 .pin-pad-clear { display: block; margin: 12px auto 0; }486616 .panel-action { margin-top: 10px; }487617 @@ -489,7 +619,7 @@ body.native-pointer-control * { cursor: none !important; }
489619 .panel-overlay {490620 position: fixed;491621 inset: 0;492- background: rgba(0,0,0,0.4);622+ background: rgba(0, 0, 0, 0.5);493623 z-index: 90;494624 display: none;495625 }@@ -499,7 +629,7 @@ body.native-pointer-control * { cursor: none !important; }
499629 top: 0; right: 0; bottom: 0;500630 width: 300px;501631 background: var(--background);502- border-left: 1px solid var(--border);632+ border-left: 1px solid var(--border-strong);503633 transform: translateX(100%);504634 transition: transform 0.15s;505635 z-index: 91;@@ -517,7 +647,8 @@ body.native-pointer-control * { cursor: none !important; }
517647 .panel-header h2 {518648 font-family: var(--mono);519649 font-size: 13px;520- font-weight: 500;650+ font-weight: 600;651+ text-transform: lowercase;521652 }522653 .panel-body {523654 flex: 1;@@ -530,10 +661,12 @@ body.native-pointer-control * { cursor: none !important; }
530661 .panel-section h3 {531662 font-family: var(--mono);532663 font-size: 11px;533- font-weight: 500;534- color: var(--muted-fg);664+ font-weight: 700;665+ text-transform: lowercase;666+ color: var(--foreground);535667 margin-bottom: 8px;536668 }669+.panel-section h3 .hash { color: var(--ochre); font-weight: 500; }537670 .info-row {538671 display: flex;539672 justify-content: space-between;@@ -558,6 +691,7 @@ body.native-pointer-control * { cursor: none !important; }
558691 .info-value {559692 font-family: var(--mono);560693 font-size: 11px;694+ font-variant-numeric: tabular-nums;561695 color: var(--foreground);562696 }563697 .config-select {@@ -580,5 +714,10 @@ body.native-pointer-control * { cursor: none !important; }
580714 }581715 .config-input:focus {582716 outline: none;583- border-color: var(--foreground);717+ border-color: var(--sand);718+}719+720+@media (prefers-reduced-motion: reduce) {721+ .panel { transition: none; }722+ .pin-pad-key:active { transform: none; }584723 }@@ -3,6 +3,8 @@
33 Vanilla, no dependencies. Ordered (Bayer 8x8) dithering of a44 slowly drifting earth-tone gradient, rendered at low resolution55 and scaled up (CSS image-rendering: pixelated) into chunky pixels.6+ The pointer dents a damped wave field on the same grid, so moving7+ the mouse leaves a rippling wake dithered into the terrain.68 ───────────────────────────────────────────────────────────── */79 (function () {810 "use strict";@@ -133,8 +135,82 @@
133135 dctx.imageSmoothingEnabled = false;134136 palette = readPalette();135137 img = bctx.createImageData(W, H);138+ waveA = new Float32Array(W * H);139+ waveB = new Float32Array(W * H);140+ lastCellX = -1;136141 }137142 143+ /* ── Pointer ripples: a damped wave automaton on the dither grid.144+ The pointer dents the height field; the dent propagates as145+ rings that get dithered into the same ramps as the terrain. */146+ var waveA, waveB; // waveA = current heights, waveB = previous147+ var WAVE_DAMP = 0.975;148+ var WAVE_WARP = 14; // wave gradient displaces the noise-sampling coords (refraction)149+ var WAVE_SHADE = 0.1; // faint crest/trough shading on top of the refraction150+ var lastCellX = -1, lastCellY = -1;151+152+ // Soft 5x5 dent: a wider impulse makes longer-wavelength rings153+ // that survive the busy terrain instead of vanishing into it.154+ function splash(cx, cy, strength) {155+ var x = cx | 0, y = cy | 0;156+ if (x < 2 || y < 2 || x >= W - 2 || y >= H - 2) return;157+ for (var dy = -2; dy <= 2; dy++) {158+ var row = (y + dy) * W + x;159+ for (var dx = -2; dx <= 2; dx++) {160+ var w = 1 - (dx * dx + dy * dy) / 6;161+ if (w > 0) waveA[row + dx] -= strength * w;162+ }163+ }164+ }165+166+ function stepWaves() {167+ var a = waveA, b = waveB;168+ for (var y = 1; y < H - 1; y++) {169+ var row = y * W;170+ for (var x = 1; x < W - 1; x++) {171+ var i = row + x;172+ b[i] = ((a[i - 1] + a[i + 1] + a[i - W] + a[i + W]) * 0.5 - b[i]) * WAVE_DAMP;173+ }174+ }175+ waveA = b;176+ waveB = a;177+ }178+179+ function pointerCell(e) {180+ var r = display.getBoundingClientRect();181+ return [182+ ((e.clientX - r.left) / r.width) * W,183+ ((e.clientY - r.top) / r.height) * H,184+ ];185+ }186+187+ // Listen on the hero (not the canvas) so the trail continues188+ // under the floating panel instead of dying at its edge.189+ var hero = display.parentElement;190+ hero.addEventListener("pointermove", function (e) {191+ if (reduced.matches || !raf) return;192+ var c = pointerCell(e);193+ if (lastCellX >= 0) {194+ var dx = c[0] - lastCellX;195+ var dy = c[1] - lastCellY;196+ var steps = Math.min(48, Math.ceil(Math.max(Math.abs(dx), Math.abs(dy))) || 1);197+ for (var s = 1; s <= steps; s++) {198+ splash(lastCellX + (dx * s) / steps, lastCellY + (dy * s) / steps, 0.4);199+ }200+ } else {201+ splash(c[0], c[1], 0.4);202+ }203+ lastCellX = c[0];204+ lastCellY = c[1];205+ });206+ hero.addEventListener("pointerdown", function (e) {207+ if (reduced.matches || !raf) return;208+ var c = pointerCell(e);209+ splash(c[0], c[1], 2);210+ });211+ hero.addEventListener("pointerleave", function () { lastCellX = -1; });212+ hero.addEventListener("pointercancel", function () { lastCellX = -1; });213+138214 // Per-load random seed: shifts sampling into a different region of139215 // the infinite noise field so the background doesn't start the same140216 // place every visit. (Browser Math.random — fine here.)@@ -182,7 +258,18 @@
182258 var brow = BAYER[y & 7];183259 var brow2 = BAYER[(y + 4) & 7];184260 for (var x = 0; x < W; x++) {185- var v = field(x, y, t);261+ // refract the terrain through the wave: the height-field262+ // gradient bends where this cell samples the noise, so the263+ // marbling itself churns inside the rings.264+ var wi = i >> 2;265+ var wx = x, wy = y;266+ if (x > 0 && x < W - 1 && y > 0 && y < H - 1) {267+ wx += (waveA[wi + 1] - waveA[wi - 1]) * WAVE_WARP;268+ wy += (waveA[wi + W] - waveA[wi - W]) * WAVE_WARP;269+ }270+ var v = field(wx, wy, t) + waveA[wi] * WAVE_SHADE;271+ if (v < 0) v = 0;272+ else if (v > 1) v = 1;186273 // ordered-dither the luminance ramp between neighbouring stops187274 var scaled = v * last;188275 var lo = Math.floor(scaled);@@ -191,7 +278,7 @@
191278 var frac = scaled - lo;192279 var idx = frac > brow[x & 7] ? lo + 1 : lo;193280 // ordered-dither the region to pick which ramp this pixel uses194- var region = regionField(x, y, t);281+ var region = regionField(wx, wy, t);195282 var arr = region > brow2[(x + 4) & 7] ? warm : green;196283 var c = arr[idx];197284 data[i] = c[0];@@ -214,6 +301,8 @@
214301 function loop(now) {215302 if (start === null) start = now;216303 if (now - lastDraw >= FRAME_MS) {304+ stepWaves();305+ stepWaves(); // two sim ticks per drawn frame: rings spread faster217306 render((now - start) / 1000);218307 lastDraw = now;219308 }@@ -5,7 +5,7 @@
55 <meta name="viewport" content="width=device-width, initial-scale=1" />66 <title>goq.sh: one-to-one Steam streaming over iroh</title>77 <meta name="description" content="goq.sh pairs Portal, a native client, with Sigil, a headless Gamescope host, for games over QUIC. One-to-one, low-latency Steam streaming over iroh. Your key is the address." />8- <link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3E%3Crect width='16' height='16' fill='%23ece3d2'/%3E%3Crect x='1' y='4' width='6' height='8' fill='%23b5623b'/%3E%3Crect x='3' y='7' width='2' height='2' fill='%23ece3d2'/%3E%3Crect x='7' y='7' width='8' height='2' fill='%23c98a3a'/%3E%3Crect x='10' y='9' width='2' height='2' fill='%236b7145'/%3E%3Crect x='13' y='9' width='2' height='3' fill='%236b7145'/%3E%3C/svg%3E" />8+ <link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 1024 1024' shape-rendering='crispEdges'%3E%3Crect x='100' y='100' width='824' height='824' rx='185' fill='%23ece3d2'/%3E%3Cg transform='translate(224,356) scale(44)' fill='%232b2119' fill-opacity='0.18'%3E%3Crect width='6' height='8'/%3E%3Crect x='6' y='3' width='8' height='2'/%3E%3Crect x='9' y='5' width='2' height='2'/%3E%3Crect x='12' y='5' width='2' height='3'/%3E%3C/g%3E%3Cg transform='translate(204,336) scale(44)'%3E%3Crect width='6' height='8' fill='%23b5623b'/%3E%3Crect x='2' y='3' width='2' height='2' fill='%23ece3d2'/%3E%3Crect x='6' y='3' width='8' height='2' fill='%23c98a3a'/%3E%3Crect x='9' y='5' width='2' height='2' fill='%236b7145'/%3E%3Crect x='12' y='5' width='2' height='3' fill='%236b7145'/%3E%3C/g%3E%3C/svg%3E" />99 <link rel="stylesheet" href="./styles.css" />1010 </head>1111 <body>