usb: land the HID controller the doorway was already calling
78d6743 committed PowerMenu's half and never added the file, so the
singleton in qmldir resolved to nothing and the shell would not load on a
tree that had not been hand-linked.
HidController streams to usb-hid-inject and acknowledges every accepted
report, closes both endpoint fds on the busy edge before usb-signaller
tears the functions down, and refuses non-ASCII on the glass instead of
clearing a field whose first report was never written.
The face reports the agent and host results back to the page, and
deploy.sh stages the tracked face assets before the QML switch so a new
control cannot arrive without the page that implements it.
This commit is contained in:
parent
abdf31087d
commit
32c019875c
8 changed files with 857 additions and 28 deletions
|
|
@ -9,7 +9,12 @@
|
|||
background: transparent; overflow: hidden;
|
||||
-webkit-user-select: none; user-select: none;
|
||||
}
|
||||
#live2d-widget { position: fixed; inset: 0; }
|
||||
* { box-sizing: border-box; }
|
||||
button, input { font: inherit; }
|
||||
#live2d-widget {
|
||||
position: fixed; left: 0; right: 0; top: 0; height: 760px;
|
||||
z-index: 1;
|
||||
}
|
||||
#live2d { width: 100%; height: 100%; }
|
||||
#bubble {
|
||||
position: fixed; left: 8px; right: 8px; top: 12px;
|
||||
|
|
@ -17,13 +22,144 @@
|
|||
background: rgba(20,20,24,0.82); color: #f2f2f2;
|
||||
font: 15px/1.35 system-ui, sans-serif;
|
||||
opacity: 0; transition: opacity .2s ease;
|
||||
z-index: 4;
|
||||
}
|
||||
#bubble.up { opacity: 1; }
|
||||
|
||||
/*
|
||||
* The room viewtop left under her, given a purpose. Trackpad first; the
|
||||
* literal keyboard only replaces it when summoned. The dark sheer gradient
|
||||
* keeps the controls legible without putting an app-shaped slab around her.
|
||||
*/
|
||||
#hands {
|
||||
position: fixed; left: 8px; right: 8px; top: 766px; height: 226px;
|
||||
padding: 9px;
|
||||
border: 1px solid rgba(221, 205, 237, .24);
|
||||
border-radius: 20px;
|
||||
background:
|
||||
radial-gradient(140% 180% at 84% -50%, rgba(187, 142, 214, .25), transparent 58%),
|
||||
linear-gradient(155deg, rgba(30, 25, 38, .92), rgba(9, 9, 14, .86));
|
||||
box-shadow: 0 12px 35px rgba(0, 0, 0, .28), inset 0 1px rgba(255,255,255,.05);
|
||||
color: rgba(255,255,255,.92);
|
||||
font: 13px/1.2 system-ui, sans-serif;
|
||||
opacity: 0;
|
||||
transform: translateY(10px) scale(.985);
|
||||
pointer-events: none;
|
||||
transition: opacity .18s ease, transform .22s ease;
|
||||
z-index: 5;
|
||||
}
|
||||
#hands.up { opacity: 1; transform: none; pointer-events: auto; }
|
||||
#hands button {
|
||||
min-width: 38px; height: 38px; padding: 0 10px;
|
||||
border: 1px solid rgba(255,255,255,.10); border-radius: 13px;
|
||||
background: rgba(255,255,255,.075); color: rgba(255,255,255,.90);
|
||||
touch-action: manipulation;
|
||||
}
|
||||
#hands button:active, #hands button.on {
|
||||
background: rgba(210, 177, 230, .23);
|
||||
transform: scale(.96);
|
||||
}
|
||||
#hands button:disabled { opacity: .42; }
|
||||
.hands-head { height: 26px; display: flex; align-items: center; gap: 6px; padding: 0 3px 5px; }
|
||||
#hands-dot {
|
||||
width: 8px; height: 8px; border-radius: 50%;
|
||||
background: #d09191; box-shadow: 0 0 0 3px rgba(208,145,145,.10);
|
||||
}
|
||||
#hands.ready #hands-dot {
|
||||
background: #9bd1ad; box-shadow: 0 0 10px rgba(155,209,173,.55);
|
||||
}
|
||||
#hands-listening { display: none; color: #e8b7d8; }
|
||||
#hands.listening #hands-listening { display: inline; }
|
||||
#hands-status { flex: 1; overflow: hidden; white-space: nowrap; text-overflow: ellipsis; color: rgba(255,255,255,.68); }
|
||||
.thread-button { width: 30px; min-width: 30px !important; height: 26px !important; padding: 0 !important; border-radius: 10px !important; }
|
||||
|
||||
#agent-form, #host-form { height: 40px; display: flex; gap: 6px; }
|
||||
#agent-input, #host-input {
|
||||
min-width: 0; flex: 1; height: 40px; padding: 0 13px;
|
||||
border: 1px solid rgba(255,255,255,.12); border-radius: 14px;
|
||||
outline: none; color: #fff; background: rgba(0,0,0,.20);
|
||||
-webkit-user-select: text; user-select: text;
|
||||
}
|
||||
#agent-input:focus, #host-input:focus { border-color: rgba(217,180,235,.55); }
|
||||
#agent-input::placeholder, #host-input::placeholder { color: rgba(255,255,255,.43); }
|
||||
#mic.listening { color: #f0b6db; box-shadow: inset 0 0 0 1px rgba(240,182,219,.45); }
|
||||
|
||||
#track-row, #keyboard-pane { height: 92px; margin-top: 6px; }
|
||||
#track-row { display: flex; gap: 6px; }
|
||||
#trackpad, #scrollpad {
|
||||
position: relative; overflow: hidden;
|
||||
border: 1px solid rgba(255,255,255,.09); border-radius: 15px;
|
||||
background:
|
||||
linear-gradient(110deg, rgba(255,255,255,.035), transparent 45%),
|
||||
rgba(0,0,0,.18);
|
||||
touch-action: none;
|
||||
}
|
||||
#trackpad { flex: 1; }
|
||||
#trackpad::after {
|
||||
content: 'trackpad'; position: absolute; left: 12px; bottom: 9px;
|
||||
color: rgba(255,255,255,.27); letter-spacing: .08em; font-size: 11px;
|
||||
}
|
||||
#scrollpad { width: 42px; }
|
||||
#scrollpad::after {
|
||||
content: '↕'; position: absolute; inset: 0; display: grid; place-items: center;
|
||||
color: rgba(255,255,255,.35); font-size: 20px;
|
||||
}
|
||||
#keyboard-pane { display: none; }
|
||||
#keyboard-pane .quick-keys { display: flex; gap: 5px; margin-top: 6px; }
|
||||
#keyboard-pane .quick-keys button { flex: 1; height: 40px; padding: 0 5px; }
|
||||
#hands.keyboard #track-row { display: none; }
|
||||
#hands.keyboard #keyboard-pane { display: block; }
|
||||
|
||||
.hands-actions { height: 40px; display: flex; gap: 5px; margin-top: 6px; }
|
||||
.hands-actions button { flex: 1; height: 40px !important; padding: 0 5px !important; }
|
||||
#cad.holding {
|
||||
background: linear-gradient(90deg, rgba(210,177,230,.30), rgba(255,255,255,.08));
|
||||
transition: background .9s linear;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div id="live2d-widget"><canvas id="live2d"></canvas></div>
|
||||
<div id="bubble"></div>
|
||||
<section id="hands" aria-label="USB Hands">
|
||||
<div class="hands-head">
|
||||
<span id="hands-dot"></span>
|
||||
<span id="hands-status">USB hand asleep</span>
|
||||
<span id="hands-listening">listening</span>
|
||||
<button class="thread-button" id="resume-thread" title="Resume latest conversation">↶</button>
|
||||
<button class="thread-button" id="new-thread" title="Begin a new conversation">+</button>
|
||||
</div>
|
||||
<form id="agent-form">
|
||||
<button type="button" id="mic" aria-label="Start or stop listening">●</button>
|
||||
<input id="agent-input" autocomplete="off" enterkeyhint="send"
|
||||
placeholder="Tell your agent what the other screen needs…">
|
||||
<button type="submit" aria-label="Send to agent">↑</button>
|
||||
</form>
|
||||
<div id="track-row">
|
||||
<div id="trackpad" aria-label="Trackpad"></div>
|
||||
<div id="scrollpad" aria-label="Scroll"></div>
|
||||
</div>
|
||||
<div id="keyboard-pane">
|
||||
<form id="host-form">
|
||||
<input id="host-input" autocomplete="off" enterkeyhint="send"
|
||||
placeholder="Type into the attached host…">
|
||||
<button type="submit" aria-label="Type on host">↑</button>
|
||||
</form>
|
||||
<div class="quick-keys">
|
||||
<button data-key="escape">Esc</button>
|
||||
<button data-key="tab">Tab</button>
|
||||
<button data-key="backspace">⌫</button>
|
||||
<button data-key="enter">Enter</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="hands-actions">
|
||||
<button id="left-click">Left</button>
|
||||
<button id="right-click">Right</button>
|
||||
<button id="keyboard-toggle">⌨</button>
|
||||
<button id="arm-hid">Arm</button>
|
||||
<button id="cad" title="Hold for Ctrl+Alt+Delete">CAD</button>
|
||||
</div>
|
||||
</section>
|
||||
<!--
|
||||
Not decoration, and not optional: `live2d.js` is not only the Cubism
|
||||
runtime. It is the reference's webpack bundle, character layer included, and
|
||||
|
|
@ -57,13 +193,16 @@
|
|||
// idle chatter that is hers comes from the subconscious, not a random line
|
||||
// table (TASK-59 Q2).
|
||||
(function () {
|
||||
var widget = document.getElementById('live2d-widget');
|
||||
var canvas = document.getElementById('live2d');
|
||||
var bubble = document.getElementById('bubble');
|
||||
var hands = document.getElementById('hands');
|
||||
var model = null;
|
||||
|
||||
function fit() {
|
||||
canvas.width = window.innerWidth * window.devicePixelRatio;
|
||||
canvas.height = window.innerHeight * window.devicePixelRatio;
|
||||
var r = widget.getBoundingClientRect();
|
||||
canvas.width = r.width * window.devicePixelRatio;
|
||||
canvas.height = r.height * window.devicePixelRatio;
|
||||
}
|
||||
window.addEventListener('resize', fit);
|
||||
fit();
|
||||
|
|
@ -183,13 +322,183 @@
|
|||
if (hideAt && Date.now() > hideAt) { hideAt = null; window.face.clear(); }
|
||||
}, 500);
|
||||
|
||||
// Press and hold to speak; a short press is still a tap.
|
||||
// ## USB Hands — one conversation wearing a trackpad
|
||||
//
|
||||
// This surface has no keyboard and is not going to get one — Casey,
|
||||
// 2026-08-06: "input is audio only on this interface, the sidepanel is the
|
||||
// text interface." So the whole of talking to her is one gesture, and the
|
||||
// page only reports its edges: the shell owns the recorder, the endpoint
|
||||
// and the transcript, the same way it already owns the conversation.
|
||||
// The page reports gestures and words; HidController owns the persistent
|
||||
// writer and Souveraine owns the conversation. Nothing here can compose a
|
||||
// gadget or grow a second chat transport.
|
||||
function hid(op, fields) {
|
||||
if (!window.souveraine) return;
|
||||
var message = fields || {};
|
||||
message.op = op;
|
||||
window.souveraine.hid(message);
|
||||
}
|
||||
|
||||
var agentPending = false, hostPending = false;
|
||||
window.hands = {
|
||||
state: function (state) {
|
||||
state = state || {};
|
||||
hands.classList.toggle('up', !!state.active);
|
||||
hands.classList.toggle('ready', !!state.ready);
|
||||
hands.classList.toggle('listening', !!state.listening);
|
||||
document.getElementById('mic').classList.toggle('listening', !!state.listening);
|
||||
|
||||
var status = document.getElementById('hands-status');
|
||||
if (state.error) status.textContent = state.error;
|
||||
else if (state.ready) status.textContent = (state.agent || 'agent') + ' · hand awake · ' + state.mode;
|
||||
else if (state.mode === 'hid' || state.mode === 'kvm') status.textContent = 'waking the HID hand…';
|
||||
else status.textContent = (state.agent || 'agent') + ' · arm the USB hand';
|
||||
|
||||
document.getElementById('agent-input').placeholder = state.thinking
|
||||
? (state.agent || 'agent') + ' is thinking…'
|
||||
: 'Tell ' + (state.agent || 'your agent') + ' what the other screen needs…';
|
||||
hands.classList.toggle('thinking', !!state.thinking);
|
||||
document.getElementById('agent-input').disabled = !!state.thinking || agentPending;
|
||||
document.getElementById('resume-thread').classList.toggle('on', state.conversation === 'resumed');
|
||||
document.getElementById('new-thread').classList.toggle('on', state.conversation === 'new');
|
||||
requestAnimationFrame(publishShape);
|
||||
},
|
||||
agentResult: function (result) {
|
||||
result = result || {};
|
||||
agentPending = false;
|
||||
var input = document.getElementById('agent-input');
|
||||
input.disabled = hands.classList.contains('thinking');
|
||||
if (result.accepted) input.value = '';
|
||||
else if (result.reason) document.getElementById('hands-status').textContent = result.reason;
|
||||
},
|
||||
hostResult: function (result) {
|
||||
result = result || {};
|
||||
hostPending = false;
|
||||
var input = document.getElementById('host-input');
|
||||
input.disabled = false;
|
||||
if (result.accepted) input.value = '';
|
||||
else if (result.reason) document.getElementById('hands-status').textContent = result.reason;
|
||||
}
|
||||
};
|
||||
|
||||
document.getElementById('agent-form').addEventListener('submit', function (event) {
|
||||
event.preventDefault();
|
||||
var input = document.getElementById('agent-input');
|
||||
var text = input.value.trim();
|
||||
if (!text || !window.souveraine || agentPending || hands.classList.contains('thinking')) return;
|
||||
agentPending = true;
|
||||
input.disabled = true;
|
||||
window.souveraine.say(text);
|
||||
});
|
||||
|
||||
document.getElementById('mic').addEventListener('click', function () {
|
||||
if (!window.souveraine) return;
|
||||
window.souveraine.talk(hands.classList.contains('listening') ? 'end' : 'start');
|
||||
});
|
||||
document.getElementById('resume-thread').addEventListener('click', function () {
|
||||
if (window.souveraine) window.souveraine.thread('resume');
|
||||
});
|
||||
document.getElementById('new-thread').addEventListener('click', function () {
|
||||
if (window.souveraine) window.souveraine.thread('new');
|
||||
});
|
||||
|
||||
var trackpad = document.getElementById('trackpad');
|
||||
var trackDown = false, trackX = 0, trackY = 0, trackTravel = 0, trackAt = 0;
|
||||
trackpad.addEventListener('pointerdown', function (event) {
|
||||
trackDown = true; trackX = event.clientX; trackY = event.clientY;
|
||||
trackTravel = 0; trackAt = Date.now();
|
||||
try { trackpad.setPointerCapture(event.pointerId); } catch (_) {}
|
||||
});
|
||||
trackpad.addEventListener('pointermove', function (event) {
|
||||
if (!trackDown) return;
|
||||
var dx = event.clientX - trackX, dy = event.clientY - trackY;
|
||||
trackX = event.clientX; trackY = event.clientY;
|
||||
trackTravel += Math.abs(dx) + Math.abs(dy);
|
||||
hid('move', { x: Math.round(dx * 1.45), y: Math.round(dy * 1.45), wheel: 0 });
|
||||
});
|
||||
function endTrack(event) {
|
||||
if (!trackDown) return;
|
||||
trackDown = false;
|
||||
if (trackTravel < 9 && Date.now() - trackAt < 320)
|
||||
hid('click', { button: 'left' });
|
||||
try { trackpad.releasePointerCapture(event.pointerId); } catch (_) {}
|
||||
}
|
||||
trackpad.addEventListener('pointerup', endTrack);
|
||||
trackpad.addEventListener('pointercancel', function () { trackDown = false; });
|
||||
|
||||
var scrollpad = document.getElementById('scrollpad');
|
||||
var scrollDown = false, scrollY = 0, scrollCarry = 0;
|
||||
scrollpad.addEventListener('pointerdown', function (event) {
|
||||
scrollDown = true; scrollY = event.clientY;
|
||||
try { scrollpad.setPointerCapture(event.pointerId); } catch (_) {}
|
||||
});
|
||||
scrollpad.addEventListener('pointermove', function (event) {
|
||||
if (!scrollDown) return;
|
||||
scrollCarry += (event.clientY - scrollY) / 11;
|
||||
scrollY = event.clientY;
|
||||
var steps = scrollCarry < 0 ? Math.ceil(scrollCarry) : Math.floor(scrollCarry);
|
||||
if (steps !== 0) {
|
||||
scrollCarry -= steps;
|
||||
hid('move', { x: 0, y: 0, wheel: steps });
|
||||
}
|
||||
});
|
||||
scrollpad.addEventListener('pointerup', function () { scrollDown = false; scrollCarry = 0; });
|
||||
scrollpad.addEventListener('pointercancel', function () { scrollDown = false; scrollCarry = 0; });
|
||||
|
||||
document.getElementById('left-click').addEventListener('click', function () {
|
||||
hid('click', { button: 'left' });
|
||||
});
|
||||
document.getElementById('right-click').addEventListener('click', function () {
|
||||
hid('click', { button: 'right' });
|
||||
});
|
||||
document.getElementById('arm-hid').addEventListener('click', function () { hid('arm'); });
|
||||
|
||||
var keyboardToggle = document.getElementById('keyboard-toggle');
|
||||
keyboardToggle.addEventListener('click', function () {
|
||||
var showing = !hands.classList.contains('keyboard');
|
||||
hands.classList.toggle('keyboard', showing);
|
||||
keyboardToggle.classList.toggle('on', showing);
|
||||
var input = document.getElementById('host-input');
|
||||
if (showing) input.focus(); else input.blur();
|
||||
});
|
||||
document.getElementById('host-form').addEventListener('submit', function (event) {
|
||||
event.preventDefault();
|
||||
var input = document.getElementById('host-input');
|
||||
if (input.value.length === 0 || hostPending) return;
|
||||
hostPending = true;
|
||||
input.disabled = true;
|
||||
hid('type', { text: input.value });
|
||||
});
|
||||
Array.prototype.forEach.call(document.querySelectorAll('[data-key]'), function (button) {
|
||||
button.addEventListener('click', function () { hid('key', { key: button.dataset.key }); });
|
||||
});
|
||||
|
||||
// Ctrl+Alt+Delete is the one quick key a stray tap must not send. Its hold
|
||||
// is local feedback; only the completed edge crosses to the HID owner.
|
||||
var cad = document.getElementById('cad'), cadTimer = null, cadPointer = null;
|
||||
cad.addEventListener('pointerdown', function (event) {
|
||||
if (cadPointer !== null) return;
|
||||
cadPointer = event.pointerId;
|
||||
try { cad.setPointerCapture(event.pointerId); } catch (_) {}
|
||||
cad.classList.add('holding');
|
||||
cadTimer = setTimeout(function () {
|
||||
cadTimer = null; cad.classList.remove('holding');
|
||||
hid('key', { key: 'delete', modifiers: 'ctrl alt' });
|
||||
}, 900);
|
||||
});
|
||||
['pointerup', 'pointercancel', 'pointerleave', 'lostpointercapture'].forEach(function (name) {
|
||||
cad.addEventListener(name, function (event) {
|
||||
if (cadPointer !== null && event.pointerId !== undefined && event.pointerId !== cadPointer) return;
|
||||
if (cadTimer) { clearTimeout(cadTimer); cadTimer = null; }
|
||||
if (cadPointer !== null) {
|
||||
try { cad.releasePointerCapture(cadPointer); } catch (_) {}
|
||||
}
|
||||
cadPointer = null;
|
||||
cad.classList.remove('holding');
|
||||
});
|
||||
});
|
||||
|
||||
// Press and hold on her remains a voice path; a short press is still a tap.
|
||||
//
|
||||
// The 2026-08-06 shape was audio-only. USB Hands adds an explicit microphone
|
||||
// and a small agent field beneath her, while keeping this gesture for the
|
||||
// ordinary face. Every route still reports only its edges: the shell owns
|
||||
// the recorder, endpoint and transcript.
|
||||
//
|
||||
// The threshold separates the two meanings of touching her rather than
|
||||
// giving the tap a modifier. Below it she plays a motion; above it she
|
||||
|
|
@ -284,7 +593,8 @@
|
|||
+ ' shape_fn=' + (window.souveraine && typeof window.souveraine.shape));
|
||||
return;
|
||||
}
|
||||
var cw = window.innerWidth / GRID_X, ch = window.innerHeight / GRID_Y;
|
||||
var canvasRect = canvas.getBoundingClientRect();
|
||||
var cw = canvasRect.width / GRID_X, ch = canvasRect.height / GRID_Y;
|
||||
try {
|
||||
// Scaled down by the GPU on the way in, so the readback is 24x36 and
|
||||
// not the full panel. Must happen inside a frame: the WebGL canvas has
|
||||
|
|
@ -304,12 +614,18 @@
|
|||
// Runs merged along the row, so a full-width band is one rectangle
|
||||
// instead of 24 — the region is unioned rect by rect on the other side.
|
||||
if (!solid && runStart >= 0) {
|
||||
rects.push([Math.floor(runStart * cw), Math.floor(gy * ch),
|
||||
rects.push([Math.floor(canvasRect.left + runStart * cw),
|
||||
Math.floor(canvasRect.top + gy * ch),
|
||||
Math.ceil((gx - runStart) * cw), Math.ceil(ch)]);
|
||||
runStart = -1;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (hands.classList.contains('up')) {
|
||||
var hr = hands.getBoundingClientRect();
|
||||
rects.push([Math.floor(hr.left), Math.floor(hr.top),
|
||||
Math.ceil(hr.width), Math.ceil(hr.height)]);
|
||||
}
|
||||
// Nothing drawn yet: say nothing. An empty report means "all of it" to the
|
||||
// host, and publishing that on every frame before the rig loads would make
|
||||
// her briefly swallow the screen each time she is summoned.
|
||||
|
|
@ -350,6 +666,7 @@
|
|||
// it takes to reach for something behind her, and it costs one 24x36
|
||||
// readback.
|
||||
setInterval(function () { requestAnimationFrame(publishShape); }, 500);
|
||||
if (window.souveraine) window.souveraine.ready();
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
|
|
|
|||
Loading…
Reference in a new issue