diff --git a/assets/chat.js b/assets/chat.js index 650e6b7..2ea2913 100644 --- a/assets/chat.js +++ b/assets/chat.js @@ -714,10 +714,11 @@ // rendered, each answer with the entities it cited. Every access is wrapped, because a // browser with site data blocked throws on the first read and the chat still has to work. var STORE_KEY = "chat"; - // How big the panel is, kept beside the conversation and for the same lifetime: a size is a - // choice about this tab's reading, not a preference to carry to the next visit, and the - // conversation it frames goes when the tab goes. Its own key, because it outlives any one - // conversation: a visitor who sizes the panel, empties it and asks again keeps the size. + // How big the panel is, kept in `localStorage` as `lang` and `theme` are: a size is a choice + // about how this visitor reads the chat on this screen, so it holds in every tab and on the + // next visit, where the conversation it frames goes when its tab goes. Its own key, because it + // is no part of any conversation: a link opened in a new tab finds the panel at the size the + // visitor gave it, with nothing else carried over. var SIZE_KEY = "chat-size"; var MIN_W = 320, MIN_H = 260, STEP = 24; function stored(){ @@ -725,10 +726,10 @@ catch (e) { return null; } } function storedSize(){ - try { var raw = sessionStorage.getItem(SIZE_KEY); var v = raw ? JSON.parse(raw) : null; return v && v.w && v.h ? v : null; } + try { var raw = localStorage.getItem(SIZE_KEY); var v = raw ? JSON.parse(raw) : null; return v && v.w && v.h ? v : null; } catch (e) { return null; } } - function keepSize(w, h){ try { sessionStorage.setItem(SIZE_KEY, JSON.stringify({ w: w, h: h })); } catch (e) {} } + function keepSize(w, h){ try { localStorage.setItem(SIZE_KEY, JSON.stringify({ w: w, h: h })); } catch (e) {} } // Where the visitor was reading, kept with the conversation so the back button returns them // to it and not to the end. A place is a turn and a distance into its bubble, not a pixel @@ -1872,9 +1873,10 @@ var introPick = null; // The question a link carries, waiting: a card the visitor acts on, built from the strings and // never from markup, so whatever the link says is shown as text. Send hands it to send() as if - // typed, Edit puts it in the input to change, and Discard drops it. Under the notice while the - // intro stands, since that is what the visitor reads first; at the end of a conversation the - // tab already holds, since the intro is then out of sight above it. + // typed, Edit puts it in the input to change, and Discard drops it. It stands right after the + // intro's prompt line, where a question the visitor typed would stand, so the hello, the notice + // and what the panel offers read in the order they always do and the question is the last thing + // in the log; at the end of a conversation the tab already holds, for the same reason. function askCard(){ var s = strings(langNow()).ask, card = el("div", "rbchat-ask"), row = el("div", "rbchat-ask-row"); card.setAttribute("role", "group"); card.setAttribute("aria-label", s.label); @@ -1892,9 +1894,10 @@ askEl = card; return card; } + // Wherever the card stands it is the last thing in the log, so the log's end is where it shows. function showAsk(){ - if (!linkAsk || (askEl && askEl.isConnected)) return; - log.appendChild(askCard()); + if (!linkAsk) return; + if (!(askEl && askEl.isConnected)) log.appendChild(askCard()); log.scrollTop = log.scrollHeight; } @@ -1919,11 +1922,11 @@ hello.appendChild(el("span", "rbchat-h2", lines[1])); introEl.appendChild(hello); notice = el("p", "rbchat-notice"); introEl.appendChild(notice); writeNotice(); - if (linkAsk && !messages.length) introEl.appendChild(askCard()); var groups = el("div", "rbchat-groups"); introEl.appendChild(groups); var promptLine = el("p", "rbchat-prompt-line", "› " + s.prompt + " "); // The block cursor waits here while the intro plays, and hands over to the command line. promptLine.appendChild(el("span", "rbchat-cur")); introEl.appendChild(promptLine); + if (linkAsk && !messages.length) introEl.appendChild(askCard()); log.insertBefore(introEl, log.firstChild); if (!play || still()) finishIntro(); facts(function(f){ @@ -1956,6 +1959,8 @@ // A restored conversation has moved past the intro: its menus are spent, and the rows a // number picks stay those of the menu after the last answer. if (messages.length) spendIntro(); else { menuRows = rows.concat(picked).map(function(r){ return r[0]; }); keysLine(); } + // The menus are drawn above a waiting question and would push it out of sight. + if (askEl && askEl.parentNode === introEl) log.scrollTop = log.scrollHeight; if (!introEl.classList.contains("rbchat-still")) playIntro(); }); }); @@ -2080,7 +2085,13 @@ for (var b = 0; b < labels.length; b++) labels[b].textContent = strings(answerLang(turns, langNow())).askNext; } // A language switch redraws the intro in the new language, finished, where the log holds one. - if (introEl && log) { var keep_ = log.scrollTop; introEl.parentNode && introEl.parentNode.removeChild(introEl); introEl = null; intro(false); log.scrollTop = keep_; } + // A waiting question is redrawn with it, and the button that held the focus holds it again: + // a page sets its language after the panel has opened, and Enter still has to send. + if (introEl && log) { + var keep_ = log.scrollTop, held = askEl && askEl.contains(document.activeElement) ? Array.prototype.indexOf.call(askEl.querySelectorAll("button"), document.activeElement) : -1; + introEl.parentNode && introEl.parentNode.removeChild(introEl); introEl = null; intro(false); log.scrollTop = keep_; + if (held >= 0 && askEl) askEl.querySelectorAll("button")[held].focus({ preventScroll: true }); + } // The chips standing under an answer are drawn again from the titles they were picked as, so a // model question reads in the new language and a number key sends what its row now shows. if (qBox && qPicked && qPicked.length) { var was_ = qPicked.slice(); hideQuestions(); offer(was_); } @@ -2111,7 +2122,7 @@ // something to clear: an empty panel shows no control for emptying it. A conversation has no // length at which it must start over, so this is the only door; its reset takes the turns, // the log and the tab's copy together; the panel's size stays, being a choice - // about this tab's reading rather than part of the conversation. + // about how the visitor reads rather than part of the conversation. newBtn = el("button", "rbchat-new"); newBtn.type = "button"; newBtn.hidden = true; // The glyph is an arrow come back round, not a plus: a plus beside the cross read as "add", // and what the button does is start over. The note under it names the action for a pointer @@ -2206,7 +2217,7 @@ // Double-click gives the stylesheet's size back and forgets the stored one, as the stage's // gutter does: otherwise the only way back is to drag until it looks right again. grip.addEventListener("dblclick", function(){ - try { sessionStorage.removeItem(SIZE_KEY); } catch (e) {} + try { localStorage.removeItem(SIZE_KEY); } catch (e) {} unsize(); }); grip.addEventListener("keydown", function(ev){ diff --git a/blocks/theme-boot.js b/blocks/theme-boot.js index 47f3671..caa2165 100644 --- a/blocks/theme-boot.js +++ b/blocks/theme-boot.js @@ -1,4 +1,4 @@ - /* ─── theme boot · v5 · {{variant}} ──────────────────────────────────── + /* ─── theme boot · v6 · {{variant}} ──────────────────────────────────── Set the theme before anything paints. Generated from @robertblust/design — editing it here does nothing, because the next `npm run design` overwrites it. @@ -41,13 +41,14 @@ try { if (window.top !== window && /[?&]embed(&|$)/.test(location.search)) document.documentElement.setAttribute("data-embed", ""); } catch (e) {} - // A conversation the tab keeps open: the keys are chat.js's, `chat` and `chat-size`, read - // the way it reads them. Never in a frame, which takes no chat of its own. + // A conversation the tab keeps open: the keys are chat.js's, `chat` in the tab's storage and + // `chat-size` in the browser's, read the way it reads them. Never in a frame, which takes no + // chat of its own. try { var r = document.documentElement, c = JSON.parse(sessionStorage.getItem("chat") || "null"); if (c && c.open && window.top === window) { r.setAttribute("data-chat-waiting", location.host); - var z = JSON.parse(sessionStorage.getItem("chat-size") || "null"); + var z = JSON.parse(localStorage.getItem("chat-size") || "null"); if (z && z.w && z.h) { r.style.setProperty("--rbchat-kept-w", z.w + "px"); r.style.setProperty("--rbchat-kept-h", z.h + "px"); } } } catch (e) {} diff --git a/docs/design-system/assets.md b/docs/design-system/assets.md index 4d8e232..8c6b635 100644 --- a/docs/design-system/assets.md +++ b/docs/design-system/assets.md @@ -75,7 +75,7 @@ A scrollbar inside the panel is the terminal's too: `--t-dim` on no track, set o A fresh conversation opens on the intro, and its notice is the one line there that is not a menu: it carries a rail in `--t-part` ([Tokens](tokens.md#token-families)) and opens, in the panel's ink, with the sentence that an AI answers and not a person, which the EU AI Act asks a chat to say before the first message; where the message goes and the privacy page follow in the dim of a comment. The sentences are the widget's own in both languages, in `chat.js`. -An address can ask for the chat, so a post can send its reader to the panel or to one answer: `?chat=open` opens the panel, and `?ask=` opens it with a question waiting, with `?chat=open` or without it. The page reads either once and takes it out of the address, so a reload or a shared address does not ask again. Nothing is sent on arrival, as the intro's notice says: the question waits in `.rbchat-ask`, a card under that notice and above what the panel offers to try, or at the end of the log where the tab already holds a conversation, and Send hands it to the chat as if typed, Edit puts it in the input and Discard drops it. On a desk Send takes the focus, so Enter sends; on a touch screen nothing is focused, so no keyboard rises over the page. The question is trimmed and set as text, never as markup, and one that is empty, too long for a message or not decodable opens the panel without a card. +An address can ask for the chat, so a post can send its reader to the panel or to one answer: `?chat=open` opens the panel, and `?ask=` opens it with a question waiting, with `?chat=open` or without it. The page reads either once and takes it out of the address, so a reload or a shared address does not ask again. Nothing is sent on arrival, as the intro's notice says: the question waits in `.rbchat-ask`, a card right after the intro's prompt line, where a typed question would stand, or at the end of the log where the tab already holds a conversation, and Send hands it to the chat as if typed, Edit puts it in the input and Discard drops it. On a desk Send takes the focus, so Enter sends; on a touch screen nothing is focused, so no keyboard rises over the page. The question is trimmed and set as text, never as markup, and one that is empty, too long for a message or not decodable opens the panel without a card. Where the chat service checks its answers, a claim the evidence does not carry is marked once the answer has finished, and only where its probability passes the threshold the service sends: a dotted underline, `--t-part` for a claim carried in part and `--t-bad` for the rest. Hover or keyboard focus shows its note in the panel's own tooltip box, the one under the header's buttons, with a name and one line; a tap does not, and on a phone the line under the answer says how many statements are not fully backed. With no threshold nothing is marked. diff --git a/test/chat-ask.test.mjs b/test/chat-ask.test.mjs index a314b04..66517bc 100644 --- a/test/chat-ask.test.mjs +++ b/test/chat-ask.test.mjs @@ -1,5 +1,5 @@ // A link that asks the chat a question, in Chromium: a page carrying chat.js is loaded with -// ?ask=, the panel opens with the question waiting under the notice, and nothing reaches the +// ?ask=, the panel opens with the question waiting after the prompt line, and nothing reaches the // chat endpoint until the visitor sends it. The pure part, linkQuestion(), is in chat.test.mjs. import { test, before, after } from "node:test"; import assert from "node:assert/strict"; @@ -15,6 +15,12 @@ const asset = (f) => fs.readFileSync(path.join(PKG, "assets", f)); const PAGE = `
A page.
`; +// A page whose model fills both of the intro's menus, as a site's does. +const MODEL = { commit: "ffb11a52dc8a5ff2a46cbbd43ab8be8930797f6f", repo: "companygraph/mental-model", entities: [ + { id: "processes/answering", type: "process", name: "Answering" }, + ...["A", "B", "C"].map((n) => ({ id: "kpis/" + n, type: "kpi", name: n })), + ...["What is an owner?", "How does the chat answer a question?", "Which rules does every instance pass?"].map((n, i) => ({ id: "questions/" + i, type: "question", name: n })) +], edges: [] }; // What each POST to the chat carried, so a test can say nothing was sent, or exactly what was. const posted = []; @@ -33,7 +39,7 @@ before(async () => { }); return; } - const files = { "/": ["text/html", PAGE], "/chat.js": ["text/javascript", asset("chat.js")], "/chat.css": ["text/css", asset("chat.css")] }; + const files = { "/": ["text/html", PAGE], "/full": ["text/html", PAGE.replace(" defer>", ' data-questions="/model.json" defer>')], "/model.json": ["application/json", JSON.stringify(MODEL)], "/chat.js": ["text/javascript", asset("chat.js")], "/chat.css": ["text/css", asset("chat.css")] }; const hit = files[url]; if (!hit) { res.writeHead(404); res.end(); return; } res.writeHead(200, { "content-type": hit[0] }); res.end(hit[1]); @@ -45,7 +51,7 @@ before(async () => { after(async () => { await browser.close(); await new Promise((r) => server.close(r)); }); const Q = "show me the organization of CompanyGraph"; -const link = (q, before = "") => `/${before ? before + "&" : "?"}ask=${encodeURIComponent(q)}`; +const link = (q, before = "", at = "/") => `${at}${before ? before + "&" : "?"}ask=${encodeURIComponent(q)}`; async function arrive(address, options = {}) { posted.length = 0; @@ -63,18 +69,18 @@ const card = (page) => page.evaluate(() => { open: !!panel && !panel.hidden, shown: !!c, question: c ? c.querySelector(".rbchat-ask-q").textContent : null, - order: kids.filter((k) => ["rbchat-notice", "rbchat-ask", "rbchat-groups"].includes(k)), + order: kids.filter((k) => ["rbchat-notice", "rbchat-ask", "rbchat-groups", "rbchat-prompt-line"].includes(k)), focused: document.activeElement && document.activeElement.className, address: location.pathname + location.search + location.hash, }; }); -test("a link with ?ask= opens the panel with the question waiting under the notice, sends nothing, and drops it from the address", async () => { +test("a link with ?ask= opens the panel with the question waiting after the prompt line, sends nothing, and drops it from the address", async () => { const { page, context } = await arrive(link(Q)); await page.waitForSelector(".rbchat-ask"); const s = await card(page); assert.deepEqual([s.open, s.shown, s.question], [true, true, Q]); - assert.deepEqual(s.order, ["rbchat-notice", "rbchat-ask", "rbchat-groups"], "under the notice that says nothing is sent, above what the panel offers"); + assert.deepEqual(s.order, ["rbchat-notice", "rbchat-groups", "rbchat-prompt-line", "rbchat-ask"], "after the prompt line, where a typed question would stand"); assert.equal(s.address, "/"); assert.match(s.focused, /rbchat-ask-go/, "on a desk Send has the focus, so Enter sends"); await page.waitForTimeout(300); @@ -85,6 +91,39 @@ test("a link with ?ask= opens the panel with the question waiting under the noti await context.close(); }); +test("the question stands after the prompt line once the menus are drawn above it, and in view", async () => { + const { page, context } = await arrive(link(Q, "", "/full")); + await page.waitForSelector(".rbchat-intro.rbchat-still .rbchat-row"); + await page.waitForSelector(".rbchat-ask"); + const s = await page.evaluate(() => { + const intro = document.querySelector(".rbchat-intro"), c = document.querySelector(".rbchat-ask").getBoundingClientRect(), l = document.querySelector(".rbchat-log").getBoundingClientRect(); + return { rows: intro.querySelectorAll(".rbchat-row").length, last: intro.lastElementChild.className.split(" ")[0], before: intro.lastElementChild.previousElementSibling.className.split(" ")[0], inView: c.top >= l.top - 1 && c.bottom <= l.bottom + 1 }; + }); + assert.ok(s.rows >= 4, `both menus drawn: ${s.rows}`); + assert.deepEqual([s.before, s.last], ["rbchat-prompt-line", "rbchat-ask"]); + assert.equal(s.inView, true, "the menus drawn above the card pushed it out of sight"); + await context.close(); +}); + +test("a language switch redraws the card and leaves the focus where it was: on Send on a desk, nowhere on a phone", async () => { + const german = async (page) => { + await page.evaluate(() => { document.documentElement.lang = "de"; }); + await page.waitForFunction(() => /^Der Link/.test(document.querySelector(".rbchat-ask-from").textContent)); + return page.evaluate(() => { const a = document.activeElement; return a === document.querySelector(".rbchat-ask-go") ? "send" : a.tagName; }); + }; + const desk = await arrive(link(Q)); + await desk.page.waitForSelector(".rbchat-ask"); + assert.equal(await german(desk.page), "send", "the redrawn card lost the focus, so Enter no longer sends"); + await desk.page.keyboard.press("Enter"); + await desk.page.waitForSelector(".rbchat-assistant"); + assert.deepEqual(posted.map((x) => x.messages), [[{ role: "user", content: Q }]]); + await desk.context.close(); + const phone = await arrive(link(Q), { hasTouch: true, isMobile: true, viewport: { width: 390, height: 844 } }); + await phone.page.waitForSelector(".rbchat-ask"); + assert.equal(await german(phone.page), "BODY"); + await phone.context.close(); +}); + test("the question keeps the address's other parameters, with or without ?chat=open", async () => { for (const [address, rest] of [[link(Q, "?lang=de") + "#x", "/?lang=de#x"], [`/?chat=open&ask=${encodeURIComponent(Q)}&theme=dark`, "/?theme=dark"]]) { const { page, context } = await arrive(address); diff --git a/test/chat-terminal.test.mjs b/test/chat-terminal.test.mjs index 4610856..e40e1b4 100644 --- a/test/chat-terminal.test.mjs +++ b/test/chat-terminal.test.mjs @@ -610,3 +610,25 @@ test("the banner names the core and the model's commit, each linked, and the mod assert.equal(await broken.p.$(".rbchat-versions"), null); await broken.close(); }); + +test("a size given to the panel is the browser's: another tab opens the panel at it, and a double-click on the corner forgets it", async () => { + const context = await browser.newContext({ viewport: { width: 1280, height: 900 } }); + const size = (p) => p.$eval("section.rbchat", (x) => { const r = x.getBoundingClientRect(); return { w: Math.round(r.width), h: Math.round(r.height) }; }); + const kept = (p) => p.evaluate(() => ({ local: JSON.parse(localStorage.getItem("chat-size")), session: sessionStorage.getItem("chat-size") })); + // The family's reset, as every site's page carries it: the panel's size includes its border. + const arrive = async () => { const p = await context.newPage(); await p.goto(base + "/"); await p.waitForSelector(".rbchat-open", { state: "attached" }); await p.addStyleTag({ content: "*{box-sizing:border-box}" }); await open(p); return p; }; + const first = await arrive(); + const stock = await size(first); + await first.focus(".rbchat-grip"); + await first.keyboard.press("ArrowLeft"); await first.keyboard.press("ArrowLeft"); await first.keyboard.press("ArrowDown"); + const sized = await size(first); + assert.deepEqual(sized, { w: stock.w + 48, h: stock.h - 24 }); + assert.deepEqual(await kept(first), { local: sized, session: null }, "the size is kept where every tab reads it, and nowhere else"); + // A second tab has a session of its own and the same local storage. + const second = await arrive(); + assert.deepEqual(await size(second), sized); + await second.dblclick(".rbchat-grip"); + assert.deepEqual(await size(second), stock); + assert.deepEqual(await kept(second), { local: null, session: null }); + await context.close(); +}); diff --git a/test/first-paint.test.mjs b/test/first-paint.test.mjs index f89b80f..e5114c5 100644 --- a/test/first-paint.test.mjs +++ b/test/first-paint.test.mjs @@ -27,12 +27,13 @@ before(async () => { }); after(async () => { await browser.close(); await new Promise((r) => server.close(r)); }); -// A tab whose chat is kept open, as the page before this one left it. +// A tab whose chat is kept open, as the page before this one left it, in a browser that keeps +// the size a visitor once gave the panel. async function tabWithChat(stored, size) { const context = await browser.newContext({ viewport: { width: 1300, height: 900 } }); await context.addInitScript(([stored, size]) => { if (stored) sessionStorage.setItem("chat", JSON.stringify(stored)); - if (size) sessionStorage.setItem("chat-size", JSON.stringify(size)); + if (size) localStorage.setItem("chat-size", JSON.stringify(size)); }, [stored, size]); return { page: await context.newPage(), close: () => context.close() }; } diff --git a/versions.json b/versions.json index f8e249c..36ff290 100644 --- a/versions.json +++ b/versions.json @@ -11,7 +11,7 @@ "fit": "v1", "rests": "v1", "runtime": "v10", - "themeBoot": "v5", + "themeBoot": "v6", "theme": "v5", "navFit": "v3", "processes": "v1",