Separate whether the chat is expanded from how big expanded is

One is an action taken constantly and the other a preference set once, so the banner names the
size and the composer keeps the toggle.
This commit is contained in:
Gmer4Lfe
2026-08-09 12:59:30 -04:00
parent 455fb55857
commit c3ed7262cd
4 changed files with 124 additions and 44 deletions
+99 -32
View File
@@ -205,7 +205,14 @@ function vv_ai_chat_assets(): void {
.vv-ai-ctrls select { background:#0a0a0a; border:1px solid #222; color:#8a8a8a; font-size:11px;
padding:4px 7px; border-radius:3px; }
/* Square-ish, so the two icon buttons read as a pair distinct from the worded ones. */
.vv-ai-grow, .vv-ai-histbtn { min-width:30px; text-align:center; }
.vv-ai-histbtn { min-width:30px; text-align:center; }
/* 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; }
/* ── 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
@@ -266,7 +273,8 @@ function vv_ai_chat_assets(): void {
.vv-ai-crow.active .vv-ai-crow-t { color:#9bd; }
.vv-ai-crow-m { font-size:9px; color:#3a3a3a; font-family:monospace; flex-shrink:0; }
.vv-ai-chat { transition:height .14s ease; }
.vv-ai-grow { font-size:13px !important; line-height:1; padding:4px 9px !important; }
.vv-ai-size { font-size:10px !important; line-height:1; padding:4px 10px !important;
letter-spacing:0.04em; flex-shrink:0; }
.vv-ai-grow-on { color:#9bd !important; border-color:#2d4a6a !important; }
.vv-ai-crow-s { font-size:9px; color:#5c7cfa; font-family:monospace; }
.vv-ai-c .vv-ai-crow-s { color:#7a9ae0; }
@@ -860,19 +868,39 @@ vv_ai_profiles_script();
// whose sizes are not knowable when the markup is written can rewrite them later — the
// Scheduler's panel takes its heights as a share of whatever room the panel has, which is a
// number that only exists after layout and changes on every resize. See setHeights().
let big = false;
const growBtn = $('grow');
// Two independent pieces of state, not one three-way. `big` is whether the conversation is
// expanded — an action, taken often. `large` is how much expanded is worth — a setting, chosen
// once. Collapsing and then expanding again returns to the size you picked rather than
// resetting it, which is the whole reason they are separate.
let big = false, large = false;
const growBtn = $('grow'), sizeBtn = $('size');
function applyHeights() {
const el = chatEl();
const base = el.dataset.h || '';
const tall = el.dataset.hTall || '';
if (!base || !tall) return;
el.style.height = big ? tall : base;
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;
if (growBtn) {
growBtn.textContent = big ? '⤡' : '⤢';
growBtn.title = big ? 'Back to the smaller view' : 'Give the conversation more room';
growBtn.classList.toggle('vv-ai-grow-on', big);
}
if (sizeBtn) {
// Nothing to choose between while collapsed — it would name a size that is not on screen
// and does not become so until the button beside it is pressed.
sizeBtn.style.display = big ? '' : 'none';
// Names the size in force, not the one it would switch to. A button labelled with its own
// effect reads as a statement of current state to about half of everyone who sees it, and
// the two readings disagree about what clicking does — so it states, and the title says
// what happens.
sizeBtn.textContent = large ? 'Large' : 'Medium';
sizeBtn.title = large ? 'Expanded is large — click for medium'
: 'Expanded is medium — click for large';
sizeBtn.classList.toggle('vv-ai-grow-on', large);
}
}
if (growBtn) {
// onResize fires only here, on the operator's own toggle — never from setHeights(). A page
@@ -890,6 +918,16 @@ vv_ai_profiles_script();
onResize(big);
});
}
if (sizeBtn) {
// Only meaningful while expanded, and only reachable then, so this always changes the
// height on screen — and therefore always has to be announced like the toggle does.
sizeBtn.addEventListener('click', () => {
large = !large;
applyHeights();
scroll();
onResize(big);
});
}
// Surface any script error into the transcript. Without it a throw anywhere on the page is
// invisible unless the console happens to be open, which is how a silent hang survives a
@@ -939,9 +977,10 @@ vv_ai_profiles_script();
// Heights supplied after the fact, for a placement whose room is a share of a panel rather
// than a constant. Re-applies immediately at whichever of the two states is current, so a
// resize while expanded stays expanded instead of snapping back.
setHeights(base, tall) {
setHeights(base, tallMed, tallLarge) {
const el = chatEl();
el.dataset.h = base; el.dataset.hTall = tall;
el.dataset.h = base; el.dataset.hTall = tallMed;
if (tallLarge) el.dataset.hLarge = tallLarge;
applyHeights();
},
@@ -1065,12 +1104,16 @@ vv_ai_profiles_script();
//
// profile which profile starts active
// compact card-sized chrome, for a chat living inside a card
// height resting transcript height. Defaults per mode rather than being left empty: the
// expand control is standard, and a control with no second height to move to is a
// button that does nothing on the one surface that forgot to pass a number.
// tall the expanded height. Defaults to 2.5x height, which is the point where a long
// answer stops needing a scroll for most questions without the card swallowing the
// page it sits on
// height resting transcript height, the collapsed state. Defaults per mode rather than being
// left empty: the expand control is standard, and a control with no second height to
// move to is a button that does nothing on the surface that forgot to pass a number.
// tall expanded at the Medium setting. Defaults to 2x height.
// tallLarge expanded at the Large setting. Defaults to 3x height.
// Two expanded sizes because ⤢ and the banner's Medium/Large answer different
// questions: whether the conversation is expanded, and how much that is worth. The
// first is an action taken constantly, the second a preference set once.
// title banner text. Defaults to "Assistant"
// icon raw SVG for the banner, page-supplied and emitted unescaped
// empty empty-state text
// scope what this conversation is about — string, or a function re-read at send time for a
// surface whose subject follows the view the operator has open
@@ -1082,24 +1125,48 @@ function vv_ai_chat_markup(string $prefix, array $o = []): void {
// none: see `height` above.
$height = $o['height'] ?? ($compact ? '300px' : '46vh');
$empty = $o['empty'] ?? 'Ask Varaverk about itself.';
// 2.5x by default, computed here so the page can override with a value that suits its layout
// rather than the include guessing at one it cannot see.
$tall = $o['tall'] ?? '';
if ($height !== '' && $tall === '' && preg_match('/^(\d+(?:\.\d+)?)(px|vh|em|rem)$/', $height, $hm)) {
$n = (float)$hm[1] * 2.5;
// vh is a share of the viewport, so 2.5x runs off the bottom of the screen — an expand
// that puts the composer out of reach is worse than no expand. Clamped to something that
// still leaves the page scrollable to its own controls.
if ($hm[2] === 'vh') $n = min($n, 85);
$tall = rtrim(rtrim(number_format($n, 2, '.', ''), '0'), '.') . $hm[2];
}
$style = $height !== '' ? ' style="height:' . htmlspecialchars($height, ENT_QUOTES)
. ';max-height:none;"'
. ' data-h="' . htmlspecialchars($height, ENT_QUOTES) . '"'
. ' data-h-tall="' . htmlspecialchars($tall, ENT_QUOTES) . '"' : '';
// Derived from the resting height, so a page need only state the one number it actually cares
// about. Large is 3x, capped at 85vh — vh is a share of the viewport and an expand that puts
// the composer out of reach is worse than no expand.
//
// Medium is placed between resting and large rather than at a multiple of its own, because a
// fixed multiple collapses under that cap: at 52vh resting, 2x and 3x are both 85vh after
// clamping, and the Medium/Large button would have had two settings that did the same thing.
$sizes = function (string $from): array {
if (!preg_match('/^(\d+(?:\.\d+)?)(px|vh|em|rem)$/', $from, $m)) return ['', ''];
$fmt = fn(float $n) => rtrim(rtrim(number_format($n, 2, '.', ''), '0'), '.') . $m[2];
$base = (float)$m[1];
$lg = $base * 3.0;
if ($m[2] === 'vh') $lg = min($lg, 85.0);
return [$fmt($base + ($lg - $base) * 0.55), $fmt($lg)];
};
[$defMed, $defLarge] = $sizes($height);
$tall = $o['tall'] ?? $defMed;
$large = $o['tallLarge'] ?? $defLarge;
$style = $height !== '' ? ' style="height:' . htmlspecialchars($height, ENT_QUOTES)
. ';max-height:none;"'
. ' data-h="' . htmlspecialchars($height, ENT_QUOTES) . '"'
. ' data-h-tall="' . htmlspecialchars($tall, ENT_QUOTES) . '"'
. ' data-h-large="' . htmlspecialchars($large, ENT_QUOTES) . '"' : '';
?>
<div class="vv-ai-chatwrap<?= $compact ? ' vv-ai-c' : '' ?>" style="display:flex;flex-direction:column;gap:<?= $compact ? '4px' : '12px' ?>;min-width:0;">
<!-- Banner. Names the thing, and carries the setting rather than the action: how big expanded
is, not whether it is expanded. ⤢ below does the expanding, which is a thing you do to the
conversation and belongs with the other things you do to it. This picks what that action
will be worth, which you set once and rarely revisit.
Hidden while collapsed, because at that point it describes nothing on screen. -->
<div class="vv-ai-head">
<span class="vv-ai-head-t">
<?php if (!empty($o['icon'])): ?><span class="vv-ico"><?= $o['icon'] ?></span><?php endif; ?>
<?= htmlspecialchars($o['title'] ?? 'Assistant') ?>
</span>
<button class="vv-ai-btn ghost vv-ai-size" id="<?= $p ?>-size" type="button"
style="display:none">Medium</button>
</div>
<div class="vv-ai-chat" id="<?= $p ?>-chat"<?= $style ?>>
<div class="vv-ai-empty"><?= htmlspecialchars($empty) ?></div>
</div>