['Enter'], 'what' => 'Ask', 'id' => 'send'],
['keys' => ['Ctrl', 'Enter'], 'what' => 'New line', 'id' => 'nl'],
['keys' => ['↑'], 'what' => 'Your last question, to edit', 'id' => 'recall'],
['keys' => ['/'], 'what' => 'Command — /help lists them', 'id' => 'slash'],
['keys' => ['Esc'], 'what' => 'Close a menu, or clear the box', 'id' => 'esc'],
['keys' => ['Alt', 'N'], 'what' => 'New conversation', 'id' => 'new'],
['keys' => ['Alt', 'E'], 'what' => 'Expand or collapse', 'id' => 'grow'],
['keys' => ['Alt', 'S'], 'what' => 'Medium or large', 'id' => 'size'],
['keys' => ['Alt', 'H'], 'what' => 'Saved conversations', 'id' => 'hist'],
];
}
// One instance's markup. The prefix composes every id, so a page may render more than one.
//
// Every placement renders the same thing. What a page chooses is how much room it gets — width is
// the container's business, the two heights are this function's — and which extra controls belong
// to that surface. Everything else is fixed on purpose: the control row, its order, what the
// buttons do. A chat that rearranges itself per tab is three components wearing one name.
//
// profile which profile starts active
// compact card-sized chrome, for a chat living inside a card
// 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
// controls extra markup dropped in at the head of the action group
function vv_ai_chat_markup(string $prefix, array $o = []): void {
$p = htmlspecialchars($prefix, ENT_QUOTES);
$compact = !empty($o['compact']);
// A card-sized chat and a full-page one want very different resting heights, and neither wants
// none: see `height` above.
$height = $o['height'] ?? ($compact ? '300px' : '46vh');
$empty = $o['empty'] ?? 'Ask Varaverk about itself.';
// 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) . '"' : '';
?>