Pay for the shortcuts list out of the transcript

The toggle joins the other window controls in the banner, and opening the list now costs the
conversation its rows rather than costing the page more height.
This commit is contained in:
Gmer4Lfe
2026-08-09 13:28:24 -04:00
parent d91fa89310
commit 4980b62dc2
+50 -25
View File
@@ -212,25 +212,20 @@ function vv_ai_chat_assets(): void {
/* Banner. The title takes the same shape a .vv-card h3 does, so an instance dropped into a card
reads as that card's heading rather than as a second one underneath it. */
.vv-ai-head { display:flex; align-items:center; justify-content:space-between; gap:8px;
margin-bottom:8px; }
.vv-ai-head-t { display:flex; align-items:center; gap:5px; font-size:13px; color:#888;
text-transform:uppercase; letter-spacing:0.05em; min-width:0; }
.vv-ai-head { display:flex; align-items:center; gap:6px; margin-bottom:8px; }
/* The title takes the slack, so the two controls group together at the right rather than being
spread apart — with three children, space-between would strand the size button in the middle. */
.vv-ai-head-t { display:flex; align-items:center; gap:5px; flex:1 1 auto; min-width:0;
font-size:13px; color:#888; text-transform:uppercase; letter-spacing:0.05em;
overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
/* Shortcuts strip. Shut it reads as one quiet line; open it is a two-column list that wraps to
one on a narrow card. Nothing here competes with the transcript below it.
Shut, it is about half the height it was. Two things were paying for that: the label inherited
a 1.5 line-height it did not need at 10px, and the block carried a margin-bottom that duplicated
the gap the wrapper already puts between its children. The gap alone spaces it now. */
/* Shortcuts. The toggle lives in the banner with the other window controls; this is only the list
it opens. Shut, the block occupies nothing at all — no line, no margin — because the control
that speaks for it is already on screen above. Open, it is a two-column list that wraps to one
on a narrow card, and the transcript below gives up exactly its height. */
.vv-ai-keys { line-height:1; }
.vv-ai-keys-t { display:flex; align-items:center; gap:5px; background:none; border:none; padding:0;
font-family:inherit; font-size:10px; line-height:1.1; color:#4a4a4a;
cursor:pointer; letter-spacing:0.04em; text-transform:uppercase; }
.vv-ai-keys-t:hover { color:#777; }
.vv-ai-keys-c { display:inline-block; font-size:8px; transition:transform .12s;
transform:rotate(-90deg); }
.vv-ai-keys.open .vv-ai-keys-c { transform:rotate(0deg); }
.vv-ai-keysbtn { font-size:10px !important; line-height:1; padding:4px 10px !important;
letter-spacing:0.04em; flex-shrink:0; }
/* line-height restored: the wrapper drops it to 1 to tighten the collapsed line, and inheriting
that here would stack the open rows on top of each other. */
.vv-ai-keys-b { display:none; grid-template-columns:repeat(auto-fit,minmax(190px,1fr));
@@ -240,8 +235,8 @@ function vv_ai_chat_assets(): void {
.vv-ai-key { display:flex; align-items:baseline; gap:7px; font-size:10px; min-width:0; }
.vv-ai-key-k { display:flex; gap:3px; flex-shrink:0; }
.vv-ai-key-d { color:#5a5a5a; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
.vv-ai-key kbd { font-family:inherit; font-size:9px; color:#8a8a8a; background:#181818;
border:1px solid #2a2a2a; border-radius:3px; padding:1px 5px; line-height:1.5; }
.vv-ai-key kbd { font-family:inherit; font-size:8px; color:#8a8a8a; background:#181818;
border:1px solid #2a2a2a; border-radius:3px; padding:1px 4px; line-height:1.5; }
/* ── Compact form, for a chat living inside a Monitor card ──────────────── */
/* Not a different component — the same markup with the chrome pulled in. The card supplies the
@@ -904,13 +899,32 @@ vv_ai_profiles_script();
let big = false, large = false;
const growBtn = $('grow'), sizeBtn = $('size');
// What the open shortcuts list costs, including the margin that separates it from the
// transcript. Zero while shut. Measured rather than assumed: it is a grid that reflows to one
// column on a narrow card, so its height is a function of the width it was given.
function keysCost() {
const w = $('keys'), b = $('keys-b');
if (!w || !b || !w.classList.contains('open')) return 0;
const m = parseFloat(getComputedStyle(b).marginTop) || 0;
return b.offsetHeight + m;
}
function applyHeights() {
const el = chatEl();
const base = el.dataset.h || '';
const med = el.dataset.hTall || '';
const big2 = el.dataset.hLarge || med;
if (!base || !med) return;
el.style.height = big ? (large ? big2 : med) : base;
const want = big ? (large ? big2 : med) : base;
// Subtracted, not added to. The height the page asked for is the height the whole window
// keeps; the list is taken out of the conversation's share of it. calc() because that height
// may be a viewport share while this cost is only ever pixels.
//
// Floored, because on the smallest placements the list is taller than the transcript it is
// being taken from — the Scheduler collapsed at a tenth of its panel is a couple of rows.
// Leaving a transcript of zero would be worse than briefly outgrowing the budget.
const k = keysCost();
el.style.height = k ? 'max(56px, calc(' + want + ' - ' + k + 'px))' : want;
if (growBtn) {
growBtn.textContent = big ? '⤡' : '⤢';
@@ -961,17 +975,22 @@ vv_ai_profiles_script();
const KEYS_OPEN = 'vv-ai-keys-open';
const keysWrap = $('keys'), keysT = $('keys-t');
if (keysWrap && keysT) {
// applyHeights() after the class, never before: the cost is measured off the rendered list,
// and while it is still display:none that measurement is zero.
const applyKeys = open => {
keysWrap.classList.toggle('open', open);
keysT.setAttribute('aria-expanded', open ? 'true' : 'false');
keysT.classList.toggle('vv-ai-grow-on', open);
applyHeights();
};
applyKeys(localStorage.getItem(KEYS_OPEN) !== '0');
keysT.addEventListener('click', () => {
const open = !keysWrap.classList.contains('open');
localStorage.setItem(KEYS_OPEN, open ? '1' : '0');
applyKeys(open);
// The panel is above the transcript, so opening it takes height the page may have to
// account for — the same reason the size controls announce themselves.
scroll();
// The total is meant to hold, but the floor above can break that on a small placement, so
// the page is told either way rather than being left to find out by clipping.
onResize(big);
});
}
@@ -1272,6 +1291,10 @@ function vv_ai_chat_markup(string $prefix, array $o = []): void {
</span>
<button class="vv-ai-btn ghost vv-ai-size" id="<?= $p ?>-size" type="button"
style="display:none">Medium</button>
<!-- Far right, and there whatever the conversation is doing — unlike the size button beside
it, which has nothing to say while collapsed. -->
<button class="vv-ai-btn ghost vv-ai-keysbtn" id="<?= $p ?>-keys-t" type="button"
aria-expanded="true" title="Keyboard shortcuts">Shortcuts</button>
</div>
<!-- Shortcuts. Open on a browser that has never seen it and remembered from then on, which for
@@ -1279,12 +1302,14 @@ function vv_ai_chat_markup(string $prefix, array $o = []): void {
point. A panel that is closed by default is one nobody discovers; one that cannot be closed
is clutter on every load forever.
It is paid for out of the transcript rather than added underneath the banner: the window
keeps the height it was given and the conversation gives up the rows, so opening this never
pushes the composer down or, on the Scheduler, past the end of the panel. Closing hands the
rows straight back. See applyHeights().
The list is data, so the keys shown and the keys handled come from one place and cannot
drift into describing a shortcut that no longer works. -->
<div class="vv-ai-keys" id="<?= $p ?>-keys">
<button class="vv-ai-keys-t" id="<?= $p ?>-keys-t" type="button" aria-expanded="true">
<span class="vv-ai-keys-c">▾</span> Shortcuts
</button>
<div class="vv-ai-keys-b" id="<?= $p ?>-keys-b">
<?php foreach (vv_ai_chat_keys() as $k): ?>
<div class="vv-ai-key">