@gotcos/glasses-server 6.99.29 → 6.99.33

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -128,7 +128,7 @@
128
128
  .mm-try { margin: 8px 0 2px; }
129
129
  .mm-tries { margin: 0; padding: 0; list-style: none; display: grid; gap: 4px; }
130
130
  .mm-tries .mm-link { font-size: 13.5px; overflow-wrap: anywhere; }
131
- .mm-answer { margin: 12px 0 8px; padding: 12px 14px; border-radius: 10px; background: var(--qopen); font-size: 14.5px; line-height: 1.55; overflow-wrap: anywhere; max-height: 60vh; overflow-y: auto; }
131
+ .mm-answer { margin: 12px 0 8px; padding: 12px 14px; border-radius: 10px; background: var(--qopen); font-size: 14.5px; line-height: 1.55; overflow-wrap: anywhere; max-height: 60vh; max-height: 60dvh; overflow-y: auto; }
132
132
  .mm-answer h4 { margin: 10px 0 4px; font: 600 14.5px/1.3 var(--body); }
133
133
  .mm-answer p { margin: 0 0 8px; }
134
134
  .mm-answer ul { margin: 0 0 8px; padding-left: 1.2em; }
@@ -109,19 +109,14 @@
109
109
  // Copy stays in this browser: the same copy the rest of Control Web uses (plain-http fallback included).
110
110
  if (op === 'copy') return W.copyText(String(a.text || '')).then(function () { return {}; });
111
111
  var limit = READ_LIMIT[op] != null ? READ_LIMIT[op] : (isRead(op, a) ? 45000 : 0);
112
- return W.api('/api/web-bridge/memories', { method: 'POST', body: { id: ++seq, op: op, args: a }, timeoutMs: limit }).then(null, plainNetError).then(function (r) {
112
+ return W.api('/api/web-bridge/memories', { method: 'POST', body: { id: ++seq, op: op, args: a }, timeoutMs: limit }).then(function (r) {
113
113
  if (r && r.ok) return r.details || {};
114
114
  var raw = (r && r.message) || 'Your Mac did not answer that.';
115
115
  var err = new Error(PLAIN[raw] || raw); err.code = raw; throw err;
116
116
  });
117
117
  }
118
- // No HTTP status means the request never reached COS: the browser's own words ("Failed to fetch", "Load failed") say
119
- // nothing to a person, so say what happened and what to do. A timeout already says it in plain words (W.api).
120
- var UNREACHABLE = 'Could not reach COS on your Mac. Check that the Mac is awake and on Tailscale, then try again.';
121
- function plainNetError(err) {
122
- if (err && !err.status && err.code !== 'timeout') { var e = new Error(UNREACHABLE); e.code = 'unreachable'; throw e; }
123
- throw err;
124
- }
118
+ // A request that never reached COS is said in plain words by W.api itself (app.js, 6.99.30: code 'unreachable'),
119
+ // as a timeout is: nothing to translate here.
125
120
  function codeOf(e) { return String((e && (e.code || e.message)) || ''); }
126
121
 
127
122
  // ── state ────────────────────────────────────────────────
@@ -1,4 +1,4 @@
1
- /* COS Control Web: session questions and approvals (panes/questions.js), server 6.99.12. */
1
+ /* COS Control Web: session questions and approvals (panes/questions.js), from server 6.99.12. */
2
2
  .sq-host { display: grid; gap: 12px; margin: 0 0 16px; }
3
3
  .sq-host[hidden] { display: none; }
4
4
  .sq-card { background: var(--panel); border: 1px solid color-mix(in srgb, var(--hot) 45%, var(--panel-border)); border-radius: 16px; padding: 14px 16px 12px; min-width: 0; }
@@ -52,11 +52,27 @@
52
52
  .sq-foot { position: sticky; bottom: var(--safe-bottom, 0px); z-index: 2; margin: 12px -16px -12px; padding: 10px 16px 12px; background: var(--panel);
53
53
  border-top: 1px solid var(--row-border); border-radius: 0 0 16px 16px; }
54
54
  .sq-foot .sq-acts { margin-top: 0; }
55
- /* QA 6.99.27: on the page, the footer rides above the on-screen keyboard (questions.js sets --sq-kb from
56
- visualViewport). In Today's overlay the layer already follows the visible viewport, so the footer sits at the
57
- bottom of the sheet with no gap. */
55
+ /* QA 6.99.27: on the page, the footer rides above whatever covers the bottom (questions.js sets --sq-kb from app.js
56
+ viewportFit: a keyboard, its strip, a browser's bottom bar). In Today's sheet the layer is itself the visible part of
57
+ the page (today-actions.js applyFit), so the footer sits at the bottom of the sheet with no gap. */
58
58
  .sq-host .sq-foot { bottom: max(var(--safe-bottom, 0px), var(--sq-kb, 0px)); }
59
59
  .ta-q .sq-foot { bottom: 0; }
60
+ /* 6.99.30 (canary): with a keyboard up on the page (questions.js sets sq-kb-up) the footer scrolls with the card, so
61
+ the options it would cover stay readable. */
62
+ .sq-host.sq-kb-up .sq-foot { position: static; }
63
+ /* The Mac not answering: Submit, Allow and Deny wait, and say why (questions.js offline). */
64
+ .sq-offline { margin: 8px 0 0; font-size: 13px; color: var(--sub); }
65
+ /* 6.99.30 (canary): folded, a waiting question is one line over the pane (it filled the first screen of every pane);
66
+ a tap opens the cards. From 600 px the cards show as before and the line does not. */
67
+ .sq-banner { display: none; }
68
+ @media (max-width: 599px) {
69
+ .sq-host .sq-banner { display: flex; align-items: center; gap: 10px; width: 100%; min-height: 44px; padding: 10px 14px; border-radius: 14px;
70
+ background: var(--panel); border: 1px solid color-mix(in srgb, var(--hot) 45%, var(--panel-border)); color: var(--ink); font: 600 15px/1.3 var(--body); text-align: left; }
71
+ .sq-host .sq-banner-text { flex: 1; min-width: 0; }
72
+ .sq-host .sq-banner-act { flex: none; color: var(--accent); }
73
+ .sq-host .sq-cards:not(.open) { display: none; }
74
+ }
75
+ .sq-cards { display: grid; gap: 12px; }
60
76
  .sq-progress { margin: 0 0 8px; font: 500 13.5px/1.35 var(--body); color: var(--sub); }
61
77
  .sq-progress[data-alert] { color: var(--hot); font-weight: 600; }
62
78
  .sq-keys { display: none; margin-top: 10px; }
@@ -50,6 +50,7 @@
50
50
  lastPollAt: 0 // when the last poll went out (Date.now)
51
51
  };
52
52
  var DEFAULT_POLL_MS = 10000;
53
+ var POLL_TIMEOUT_MS = 15000;
53
54
  // 6.99.27 (Miles 11/07 09:29, "a big delay to see the questions on the homepage"): while this tab is visible on
54
55
  // Today it reads every 3 s, whatever the server's idle pace (10 s). Before, a question parked just after an idle read
55
56
  // waited up to 10 s plus the round trip for the next one. Hidden, the page reads nothing (visibilitychange below).
@@ -141,18 +142,37 @@
141
142
  Q.inflight = true; Q.again = false; Q.lastPollAt = Date.now();
142
143
  capForPoll().then(function (cap) {
143
144
  if (!usable(cap)) { Q.again = false; landed(); clearCards(); schedule(60000); return; }
144
- return fetch(pollUrl(), { headers: { 'X-COS-Web': '1' }, credentials: 'same-origin', cache: 'no-store' }).then(function (res) {
145
- if (res.status === 401) { Q.again = false; landed(); Q.stopped = true; clearCards(); return; }
146
- if (!res.ok) { schedule(DEFAULT_POLL_MS); landed(); return; }
145
+ // 6.99.32 (QA 6.99.30 canary W1): a Mac that hangs (asleep, a stale Tailscale route) never answered and never
146
+ // refused, so this read stayed in flight forever: no more polls, and the Mac never marked down. It gives up after
147
+ // POLL_TIMEOUT_MS, says the Mac is not answering, and polls again. The limit covers the body too (QA 6.99.32
148
+ // Skeptic W-1): a Mac that sleeps between the headers and the body otherwise held the poll for good.
149
+ var ctrl = typeof AbortController === 'function' ? new AbortController() : null;
150
+ var limit = ctrl ? setTimeout(function () { ctrl.abort(); }, POLL_TIMEOUT_MS) : null;
151
+ return fetch(pollUrl(), { headers: { 'X-COS-Web': '1' }, credentials: 'same-origin', cache: 'no-store', signal: ctrl ? ctrl.signal : undefined }).then(function (res) {
152
+ // QA 6.99.30 (Skeptic): this 3 s read is what Sessions and Meetings pages keep running, so it tells app.js the Mac
153
+ // answered (any status) or did not (below); otherwise a "down" mark stranded Submit, Allow and Deny for minutes.
154
+ if (W.noteServer) W.noteServer(true);
155
+ if (res.status === 401) { clearTimeout(limit); Q.again = false; landed(); Q.stopped = true; clearCards(); return; }
156
+ if (!res.ok) { clearTimeout(limit); schedule(DEFAULT_POLL_MS); landed(); return; }
147
157
  return res.json().then(function (d) {
158
+ clearTimeout(limit);
148
159
  if (!d || d.enabled === false) { Q.again = false; landed(); clearCards(); schedule(60000); return; }
149
160
  // Painted as soon as the read lands: take() draws the cards and repaints Today's Needs you and the pet.
150
161
  take(Array.isArray(d.items) ? d.items : [], Array.isArray(d.settled) ? d.settled : []);
151
162
  var every = Number(d.pollIntervalMs) > 0 ? Number(d.pollIntervalMs) : DEFAULT_POLL_MS;
152
163
  schedule(nextWait(every));
153
164
  landed();
165
+ }, function () {
166
+ // The body never finished (cut off, or the limit fired mid-body). The Mac did answer the headers, so it is not
167
+ // marked down; the poll is freed and goes again.
168
+ clearTimeout(limit); schedule(DEFAULT_POLL_MS); landed();
154
169
  });
155
- }, function () { schedule(DEFAULT_POLL_MS); landed(); });
170
+ }, function () {
171
+ // No answer at all: refused, unreachable, or the limit fired before the headers (the only abort this read has).
172
+ clearTimeout(limit);
173
+ if (W.noteServer) W.noteServer(false);
174
+ schedule(DEFAULT_POLL_MS); landed();
175
+ });
156
176
  }).catch(function () { schedule(DEFAULT_POLL_MS); landed(); });
157
177
  }
158
178
  // Read now, unless a read went out under NUDGE_GAP_MS ago (then at the end of that gap) or one is on the wire (then
@@ -190,8 +210,9 @@
190
210
  var ids = {}; pending.forEach(function (i) { ids[i.id] = 1; });
191
211
  Object.keys(Q.picks).forEach(function (id) { if (!ids[id]) { delete Q.picks[id]; delete Q.armed[id]; delete Q.notes[id]; delete Q.answerIds[id]; delete Q.missing[id]; } });
192
212
  pending.forEach(function (i) {
193
- if (!Q.picks[i.id]) Q.picks[i.id] = (i.questions || []).map(function () { return { labels: [], other: '' }; });
213
+ if (!Q.picks[i.id]) Q.picks[i.id] = keptPicks(i) || (i.questions || []).map(function () { return { labels: [], other: '' }; });
194
214
  });
215
+ forgetPicks(ids);
195
216
  Q.items = pending;
196
217
  // A card that is already drawn is never redrawn by a poll: a half-typed Other answer stays as it is.
197
218
  if (before !== after) { render(); changed(); }
@@ -295,8 +316,9 @@
295
316
  // always answers: it never greys out for a missing answer, it goes to it.
296
317
  '<div class="sq-foot" data-sq-foot="' + esc(item.id) + '">' +
297
318
  '<p class="sq-progress" data-sq-progress="' + esc(item.id) + '" role="status"' + (Q.missing[item.id] != null && !complete(item) ? ' data-alert="1"' : '') + '>' + esc(progressText(item)) + '</p>' +
298
- '<div class="sq-acts"><button type="button" class="cx-btn primary sq-send" data-sq="send" data-sq-id="' + esc(item.id) + '"' + (sending ? ' disabled' : '') + '>' + sendText(item.id) + '</button>' +
299
- '<button type="button" class="link-btn sq-leave" data-sq="leave" data-sq-id="' + esc(item.id) + '"' + (sending ? ' disabled' : '') + '>Leave for the Mac</button></div>' +
319
+ '<div class="sq-acts"><button type="button" class="cx-btn primary sq-send" data-sq="send" data-sq-id="' + esc(item.id) + '"' + (sending || offline() ? ' disabled' : '') + '>' + sendText(item.id) + '</button>' +
320
+ '<button type="button" class="link-btn sq-leave" data-sq="leave" data-sq-id="' + esc(item.id) + '"' + (sending || offline() ? ' disabled' : '') + '>Leave for the Mac</button></div>' +
321
+ (offline() ? '<p class="sq-offline" role="status">' + esc(OFFLINE_LINE) + '</p>' : '') +
300
322
  noteHtml(item) + '</div></article>';
301
323
  }
302
324
  function approvalCard(item) {
@@ -307,9 +329,10 @@
307
329
  '<header class="sq-head"><h2 class="sq-title">' + esc(engine(item)) + ' wants to use ' + esc(a.tool || item.tool || 'a tool') + '</h2><span class="sq-secs" data-sq-secs="' + esc(item.id) + '">' + esc(timeLeft(secondsLeft(item))) + '</span></header>' +
308
330
  '<pre class="sq-command">' + esc(a.summary || 'No details were sent with this request.') + '</pre>' +
309
331
  (a.detail && a.detail !== a.summary ? '<details class="sq-details"><summary>Full command</summary><pre class="sq-command">' + esc(a.detail) + '</pre></details>' : '') +
310
- '<div class="sq-acts"><button type="button" class="cx-btn primary" data-sq="allow" data-sq-id="' + esc(item.id) + '"' + (sending ? ' disabled' : '') + '>' + (armed === 'allow' ? 'Tap again to allow' : 'Allow once') + '</button>' +
311
- '<button type="button" class="cx-btn sq-deny" data-sq="deny" data-sq-id="' + esc(item.id) + '"' + (sending ? ' disabled' : '') + '>' + (armed === 'deny' ? 'Tap again to deny' : 'Deny') + '</button>' +
312
- '<button type="button" class="link-btn sq-leave" data-sq="leave" data-sq-id="' + esc(item.id) + '"' + (sending ? ' disabled' : '') + '>Leave for the Mac</button></div>' +
332
+ '<div class="sq-acts"><button type="button" class="cx-btn primary" data-sq="allow" data-sq-id="' + esc(item.id) + '"' + (sending || offline() ? ' disabled' : '') + '>' + (armed === 'allow' ? 'Tap again to allow' : 'Allow once') + '</button>' +
333
+ '<button type="button" class="cx-btn sq-deny" data-sq="deny" data-sq-id="' + esc(item.id) + '"' + (sending || offline() ? ' disabled' : '') + '>' + (armed === 'deny' ? 'Tap again to deny' : 'Deny') + '</button>' +
334
+ '<button type="button" class="link-btn sq-leave" data-sq="leave" data-sq-id="' + esc(item.id) + '"' + (sending || offline() ? ' disabled' : '') + '>Leave for the Mac</button></div>' +
335
+ (offline() ? '<p class="sq-offline" role="status">' + esc(OFFLINE_LINE) + '</p>' : '') +
313
336
  noteHtml(item) + '</article>';
314
337
  }
315
338
  // A question or an approval the Mac's own prompt waits on: what it asks when this page saw it held, and where to answer.
@@ -356,7 +379,11 @@
356
379
  var active = document.activeElement;
357
380
  var focus = active && active.hasAttribute && active.hasAttribute('data-sq-other') && m.el.contains && m.el.contains(active) ? { id: active.getAttribute('data-sq-id'), q: active.getAttribute('data-q'), start: active.selectionStart, end: active.selectionEnd } : null;
358
381
  var list = itemsFor(m.sessionId), mac = atMacFor(m.sessionId);
359
- m.el.innerHTML = list.map(function (i) { return i.kind === 'approval' ? approvalCard(i) : questionCard(i, m.scope); }).join('') + mac.map(macCard).join('');
382
+ var cards = list.map(function (i) { return i.kind === 'approval' ? approvalCard(i) : questionCard(i, m.scope); }).join('') + mac.map(macCard).join('');
383
+ // The page's own host (over every pane): folded it is one line until tapped (questions.css .sq-banner, 6.99.30).
384
+ if (m.el === host && list.length) cards = bannerHtml(list) + '<div class="sq-cards' + (Q.bannerOpen ? ' open' : '') + '" id="sq-cards">' + cards + '</div>';
385
+ else if (m.el === host) Q.bannerOpen = false;
386
+ m.el.innerHTML = cards;
360
387
  m.el.hidden = !list.length && !mac.length;
361
388
  if (focus) {
362
389
  var box = m.el.querySelector('[data-sq-other][data-sq-id="' + focus.id + '"][data-q="' + focus.q + '"]');
@@ -364,6 +391,17 @@
364
391
  }
365
392
  if (m.onChange) { try { m.onChange(list.length, mac.length); } catch (_e) {} }
366
393
  }
394
+ // 6.99.30 (canary): "1 question for you. Answer", one line over the pane on a folded phone; a tap opens the cards.
395
+ function bannerHtml(list) {
396
+ var qs = list.filter(function (i) { return i.kind !== 'approval'; }).length, n = list.length;
397
+ var noun = qs === n ? (n === 1 ? 'question' : 'questions') : qs === 0 ? (n === 1 ? 'approval' : 'approvals') : (n === 1 ? 'request' : 'requests');
398
+ return '<button type="button" class="sq-banner" data-sq="banner" aria-expanded="' + (Q.bannerOpen ? 'true' : 'false') + '" aria-controls="sq-cards">' +
399
+ '<span class="sq-banner-text">' + esc(n + ' ' + noun + ' for you.') + '</span><span class="sq-banner-act">' + (Q.bannerOpen ? 'Hide' : 'Answer') + '</span></button>';
400
+ }
401
+ // 6.99.30 (canary): with the Mac not answering (app.js noteServer), Submit, Allow and Deny wait and say why, instead of
402
+ // offering a tap that cannot land.
403
+ function offline() { return !!(W.serverUp && W.serverUp() === false); }
404
+ var OFFLINE_LINE = 'Your Mac is not answering. You can answer here once it is back.';
367
405
  function render() {
368
406
  hostsOf().forEach(drawInto);
369
407
  clearInterval(Q.tick);
@@ -384,7 +422,7 @@
384
422
  }
385
423
  function patchOne(card, id, item) {
386
424
  var send = card.querySelector('[data-sq="send"]');
387
- if (send) { send.disabled = !!Q.sending[id]; send.textContent = sendText(id); }
425
+ if (send) { send.disabled = !!Q.sending[id] || offline(); send.textContent = sendText(id); }
388
426
  var progress = card.querySelector('[data-sq-progress]');
389
427
  if (progress && item.kind === 'question') {
390
428
  progress.textContent = progressText(item);
@@ -487,6 +525,7 @@
487
525
  function onClick(e) {
488
526
  var b = e.target.closest('[data-sq]');
489
527
  if (!b || b.disabled) return;
528
+ if (b.getAttribute('data-sq') === 'banner') { Q.bannerOpen = !Q.bannerOpen; drawInto(hostsOf()[0]); return; }
490
529
  var id = b.getAttribute('data-sq-id'), what = b.getAttribute('data-sq'), item = itemOf(id);
491
530
  if (!item) return;
492
531
  var card = b.closest ? b.closest('.sq-card') : null;
@@ -551,9 +590,36 @@
551
590
  // A change after Submit was armed takes the arm back: review, then submit.
552
591
  Q.armed[id] = '';
553
592
  if (Q.missing[id] != null) delete Q.missing[id];
593
+ keepPicks(id);
554
594
  patchCard(id);
555
595
  return true;
556
596
  }
597
+ // 6.99.30 (QA): a reload mid-answer keeps the picks and the typed Other words, per question id, for this tab only
598
+ // (sessionStorage). Restored only onto the same question (the same number of parts, and labels it still offers);
599
+ // dropped once the question is answered or gone.
600
+ var PICKS_KEY = 'cos.web.questions.picks';
601
+ function picksStore() { try { var v = JSON.parse(window.sessionStorage.getItem(PICKS_KEY) || '{}'); return v && typeof v === 'object' ? v : {}; } catch (_e) { return {}; } }
602
+ function writePicks(all) { try { window.sessionStorage.setItem(PICKS_KEY, JSON.stringify(all)); } catch (_e) {} }
603
+ function keepPicks(id) {
604
+ var all = picksStore(), p = Q.picks[id];
605
+ if (!p) return;
606
+ all[id] = p.map(function (x) { return { labels: x.labels.slice(0, 20), other: String(x.other || '').slice(0, 2000) }; });
607
+ writePicks(all);
608
+ }
609
+ function keptPicks(item) {
610
+ var kept = picksStore()[item.id], qs = item.questions || [];
611
+ if (!Array.isArray(kept) || kept.length !== qs.length) return null;
612
+ return qs.map(function (q, qi) {
613
+ var k = kept[qi] || {}, labels = (q.options || []).map(function (o) { return o.label; });
614
+ return { labels: (Array.isArray(k.labels) ? k.labels : []).filter(function (l) { return labels.indexOf(l) >= 0; }), other: typeof k.other === 'string' ? k.other : '' };
615
+ });
616
+ }
617
+ function forgetPicks(live) {
618
+ var all = picksStore(), gone = Object.keys(all).filter(function (id) { return !live[id]; });
619
+ if (!gone.length) return;
620
+ gone.forEach(function (id) { delete all[id]; });
621
+ writePicks(all);
622
+ }
557
623
  // Keys 1 to 9 while a question has focus, as in the terminal: the option with that number, or the last number to type
558
624
  // your own. Never while typing in Other, and never with a modifier held.
559
625
  function onKey(e) {
@@ -584,6 +650,10 @@
584
650
  var id = t.getAttribute('data-sq-id'), item = itemOf(id); if (!item) return;
585
651
  var qi = Number(t.getAttribute('data-q')), q = item.questions[qi], p = Q.picks[id][qi];
586
652
  p.other = t.value;
653
+ // 6.99.30 (QA): the same card drawn on Today and in the sheet keeps one answer: the words go into every copy.
654
+ hostsOf().forEach(function (m) {
655
+ m.el.querySelectorAll('[data-sq-other][data-sq-id="' + id + '"][data-q="' + qi + '"]').forEach(function (box) { if (box !== t) box.value = t.value; });
656
+ });
587
657
  // Single choice: typing your own answer clears the picked option, as on the phone.
588
658
  if (!q.multiSelect && t.value.trim() && p.labels.length) {
589
659
  p.labels = [];
@@ -591,6 +661,7 @@
591
661
  }
592
662
  Q.armed[id] = '';
593
663
  if (Q.missing[id] != null) delete Q.missing[id];
664
+ keepPicks(id);
594
665
  patchCard(id);
595
666
  }
596
667
  function listen(el) { el.addEventListener('click', onClick); el.addEventListener('change', onChange); el.addEventListener('input', onInput); el.addEventListener('keydown', onKey); }
@@ -604,20 +675,30 @@
604
675
  return { count: function () { return itemsFor(m.sessionId).length; }, unmount: function () { mounts = mounts.filter(function (x) { return x !== m; }); } };
605
676
  }
606
677
 
607
- // QA 6.99.27 (User 2): Today's own card (#sq-host) has a sticky footer, and iOS puts the keyboard over the layout
608
- // viewport, so with Other focused the footer sat under it. The keyboard's height (what of the layout viewport the
609
- // visible one leaves at the bottom) goes on the host as --sq-kb, and questions.css lifts the footer by it. Today's
610
- // overlay needs none of this: its layer already follows the visible viewport (today-actions.js fit).
678
+ // QA 6.99.27 (User 2): Today's own card (#sq-host) has a sticky footer, and the keyboard covers the bottom of the
679
+ // layout viewport, so with Other focused the footer sat under it. What covers the bottom goes on the host as --sq-kb
680
+ // and questions.css lifts the footer by it. 6.99.30: read the one way Control Web reads the visible viewport (app.js
681
+ // COSWeb.viewportFit, which Today's sheet uses too): the layout viewport under the visible box, in layout px, so a
682
+ // pinch zoom alone lifts nothing and a page the browser panned to the field lifts by what is really under it.
683
+ // The card in Today's sheet (.ta-q) needs none of this: the sheet itself is the visible box (today-actions.js fit).
611
684
  function keyboardInset() {
612
- var vv = window.visualViewport;
613
- if (!vv) return 0;
614
- var inset = Math.round((window.innerHeight || 0) - (vv.height + vv.offsetTop));
615
- return inset > 0 ? inset : 0;
685
+ var v = W.viewportFit ? W.viewportFit() : null;
686
+ return v && v.vv ? v.below : 0;
616
687
  }
617
688
  function fitKeyboard() {
618
- if (host.style && host.style.setProperty) host.style.setProperty('--sq-kb', keyboardInset() + 'px');
689
+ var inset = keyboardInset();
690
+ if (host.style && host.style.setProperty) host.style.setProperty('--sq-kb', inset + 'px');
691
+ // A keyboard up (120 px or more, or any strip under a field of the card): the footer stops sticking (questions.css).
692
+ var v = W.viewportFit ? W.viewportFit() : null, a = document.activeElement;
693
+ var typing = !!(a && host.contains && host.contains(a) && a.hasAttribute && a.hasAttribute('data-sq-other'));
694
+ if (host.classList) host.classList.toggle('sq-kb-up', !!(v && v.vv && (v.inset >= 120 || (typing && v.inset > 0))));
619
695
  }
620
696
  if (window.visualViewport) { window.visualViewport.addEventListener('resize', fitKeyboard); window.visualViewport.addEventListener('scroll', fitKeyboard); }
697
+ window.addEventListener('resize', fitKeyboard); window.addEventListener('orientationchange', fitKeyboard);
698
+ window.addEventListener('pageshow', fitKeyboard);
699
+ host.addEventListener('focusin', fitKeyboard); host.addEventListener('focusout', function () { setTimeout(fitKeyboard, 0); });
700
+ // The Mac going away or coming back redraws the cards at once (their buttons wait while it is away).
701
+ if (W.onServer) W.onServer(function () { render(); });
621
702
 
622
703
  // Hidden: no reads at all (a phone in a pocket spends nothing). Visible again: one read at once (within the 1 s gap).
623
704
  document.addEventListener('visibilitychange', function () { if (document.hidden) stop(); else if (signedIn()) start(); });
@@ -7,11 +7,18 @@
7
7
  --pv-claude: rgb(199, 115, 56);
8
8
  --pv-codex: rgb(26, 140, 122);
9
9
  --pv-cursor: rgb(107, 97, 219);
10
+ /* 6.99.30 (design QA, light mode): the provider badges' 9 px words, about 25% darker than the glyph colour, so they
11
+ read at 4.5:1 or better on their tint. Dark mode keeps the glyph colour (below). */
12
+ --pv-claude-ink: rgb(143, 83, 40);
13
+ --pv-codex-ink: rgb(20, 105, 92);
14
+ --pv-cursor-ink: rgb(80, 73, 164);
10
15
  --run-green: rgb(56, 145, 99);
11
16
  }
12
- :root[data-theme='dark'] .sx { --pv-claude: rgb(226, 146, 92); --pv-codex: rgb(72, 190, 168); --pv-cursor: rgb(158, 150, 245); --run-green: rgb(108, 207, 143); }
17
+ :root[data-theme='dark'] .sx { --pv-claude: rgb(226, 146, 92); --pv-codex: rgb(72, 190, 168); --pv-cursor: rgb(158, 150, 245); --run-green: rgb(108, 207, 143);
18
+ --pv-claude-ink: var(--pv-claude); --pv-codex-ink: var(--pv-codex); --pv-cursor-ink: var(--pv-cursor); }
13
19
  @media (prefers-color-scheme: dark) {
14
- :root:not([data-theme='light']) .sx { --pv-claude: rgb(226, 146, 92); --pv-codex: rgb(72, 190, 168); --pv-cursor: rgb(158, 150, 245); --run-green: rgb(108, 207, 143); }
20
+ :root:not([data-theme='light']) .sx { --pv-claude: rgb(226, 146, 92); --pv-codex: rgb(72, 190, 168); --pv-cursor: rgb(158, 150, 245); --run-green: rgb(108, 207, 143);
21
+ --pv-claude-ink: var(--pv-claude); --pv-codex-ink: var(--pv-codex); --pv-cursor-ink: var(--pv-cursor); }
15
22
  }
16
23
 
17
24
  /* ── header, views, clock ─────────────────────────────── */
@@ -41,7 +48,7 @@
41
48
  @media (max-width: 599px) {
42
49
  .sx-search { order: 0; flex: 1 1 auto; }
43
50
  .sx-search .cx-clear { min-height: 40px; padding: 0 2px; }
44
- .sx-select { min-height: 40px; height: 40px; font-size: 16px; background-position: calc(100% - 16px) 17px, calc(100% - 11px) 17px; }
51
+ .sx-select { background-position: calc(100% - 16px) 19px, calc(100% - 11px) 19px; }
45
52
  #sessions-recency { max-width: 128px; }
46
53
  .sx-ufilters .sx-select { flex: 1 1 150px; max-width: none; }
47
54
  .sx-utable .hp { display: none; }
@@ -62,12 +69,14 @@
62
69
  .sx-title { display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; overflow-wrap: anywhere; font: 600 15px/1.3 var(--body); color: var(--ink); }
63
70
  .sx-snippet { display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; overflow-wrap: anywhere; margin-top: 3px; font-size: 12.5px; line-height: 1.4; color: var(--sub); }
64
71
  .sx-meta { display: flex; flex-wrap: wrap; align-items: center; gap: 4px 8px; margin-top: 5px; min-width: 0; }
65
- .sx-badge { display: inline-flex; align-items: center; height: 17px; padding: 0 6px; border-radius: 9px; font: 700 9px/1 var(--mono); letter-spacing: 0.06em; color: var(--pv-claude); background: color-mix(in srgb, var(--pv-claude) 14%, transparent); white-space: nowrap; }
66
- .sx-badge.codex { color: var(--pv-codex); background: color-mix(in srgb, var(--pv-codex) 14%, transparent); }
67
- .sx-badge.cursor { color: var(--pv-cursor); background: color-mix(in srgb, var(--pv-cursor) 14%, transparent); }
72
+ .sx-badge { display: inline-flex; align-items: center; height: 17px; padding: 0 6px; border-radius: 9px; font: 700 9px/1 var(--mono); letter-spacing: 0.06em; color: var(--pv-claude-ink, var(--pv-claude)); background: color-mix(in srgb, var(--pv-claude) 14%, transparent); white-space: nowrap; }
73
+ .sx-badge.codex { color: var(--pv-codex-ink, var(--pv-codex)); background: color-mix(in srgb, var(--pv-codex) 14%, transparent); }
74
+ .sx-badge.cursor { color: var(--pv-cursor-ink, var(--pv-cursor)); background: color-mix(in srgb, var(--pv-cursor) 14%, transparent); }
68
75
  .sx-pinned { display: inline-flex; align-items: center; height: 17px; padding: 0 6px; border-radius: 9px; font: 700 8.5px/1 var(--mono); letter-spacing: 0.06em; color: var(--sub); background: color-mix(in srgb, var(--ink) 8%, transparent); }
69
76
  .sx-chip { font: 600 11.5px/1.3 var(--body); color: var(--sub); }
70
77
  .sx-chip.waiting { color: var(--amber); }
78
+ /* 6.99.30: a queued message the server refused or let expire (app.js notSentFor). */
79
+ .sx-chip.refused { color: var(--hot); }
71
80
  .sx-chip.running { color: var(--run-green); }
72
81
  .sx-when, .sx-ws { font-size: 11.5px; line-height: 1.3; color: var(--sub); }
73
82
  .sx-ws { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; max-width: 100%; }
@@ -116,7 +125,9 @@
116
125
  .sx-act-tool { flex: none; font: 400 11px/16px var(--mono); color: var(--sub); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
117
126
  .sx-act-prose { flex: none; margin-top: 2px; font-size: 13px; line-height: 16px; color: var(--ink); white-space: pre-wrap; overflow-wrap: anywhere; }
118
127
  .sx-act-fallback { height: calc(var(--act-state) + var(--act-prompt) + var(--act-tail) + 8px); margin: 0; padding-top: 6px; font-size: 12.5px; line-height: 1.4; color: var(--sub); overflow: hidden; box-sizing: border-box; }
119
- @media (max-width: 599px) { .sx-activity { --act-state: 38px; } .sx-act-stop.cx-btn { height: 36px; } }
128
+ /* 6.99.32 (QA Completionist W4): below 600 px every .cx-btn is at least 44 px (app.css), so the row that holds Stop is 46 px;
129
+ at 38 it clipped Stop's border top and bottom. The fallback's height follows the variable. */
130
+ @media (max-width: 599px) { .sx-activity { --act-state: 46px; } }
120
131
  .sx-note { font-size: 12px; color: var(--sub); margin: 0 2px 12px; }
121
132
 
122
133
  /* YOU and ASSISTANT cards (Control: card fill, a tinted hairline; sessions tint for you, green for the reply). */
@@ -221,8 +221,7 @@
221
221
  #pane-speakers .sp-mrow { grid-template-columns: 32px minmax(0, 1fr) 12px; }
222
222
  #pane-speakers .sp-tools { width: 100%; }
223
223
  #pane-speakers .sp-search { max-width: none; flex-basis: 100%; }
224
- #pane-speakers .sp-search .field { min-height: 40px; font-size: 16px; }
225
- .sp-select select.field { min-height: 38px; font-size: 14px; background-position: calc(100% - 16px) 16px, calc(100% - 11px) 16px; }
224
+ .sp-select select.field { background-position: calc(100% - 16px) 19px, calc(100% - 11px) 19px; }
226
225
  #pane-speakers .sp-text { min-height: 36px; font-size: 14.5px; }
227
226
  #pane-speakers .sp-field { min-height: 40px; font-size: 16px; width: 100%; }
228
227
  #pane-speakers .cx-switch { gap: 12px; flex-wrap: wrap; }
@@ -242,11 +241,10 @@
242
241
  .sp-pplay { width: 36px; height: 36px; }
243
242
  .sp-pplay-space { width: 36px; }
244
243
  .sp-opt { min-height: 40px; font-size: 15px; }
245
- .sp-vacts .cx-btn { height: 36px; font-size: 14px; }
244
+ .sp-vacts .cx-btn { font-size: 14px; }
246
245
  .sp-leg { min-height: 30px; }
247
246
  .sp-racts { justify-content: flex-start; }
248
- .sp-rhead .cx-btn { height: 36px; }
249
- .sp-policy .field { min-height: 38px; font-size: 14px; background-position: calc(100% - 16px) 16px, calc(100% - 11px) 16px; }
247
+ .sp-policy .field { background-position: calc(100% - 16px) 19px, calc(100% - 11px) 19px; }
250
248
  }
251
249
  @media (min-width: 600px) {
252
250
  .sp-pills-in { display: none !important; }
@@ -107,7 +107,8 @@
107
107
  var off = new Error(OFFLINE); off.code = 'offline'; throw off;
108
108
  });
109
109
  }
110
- var OFFLINE = 'Could not reach your Mac. Check Tailscale on this phone and that the Mac is awake.';
110
+ // The one sentence Control Web says for a request that never reached the Mac (app.js COSWeb.unreachableCopy, 6.99.30).
111
+ var OFFLINE = W.unreachableCopy || 'Could not reach COS on your Mac. Check that it is awake and on Tailscale, then try again.';
111
112
  // The server answered, but not with JSON (a proxy page, a crash): say that, never that it stopped.
112
113
  function unreadable(r) { return new Error('The server answered ' + r.status + ' without a readable reply.'); }
113
114
  // A 401 hands the page to the shell's sign-in and stops every timer here, so nothing polls a signed-out session.
@@ -264,7 +265,9 @@
264
265
  namedVoices: vr ? int(vr.namedVoices) || 0 : null, humanTouched: vr ? vr.humanTouched === true : null
265
266
  };
266
267
  }
267
- function dateLine(m) { return [m.date, m.time ? clock12(m.time) : '', m.duration].filter(Boolean).join(' · '); }
268
+ // A row's day in plain words ("Today", "Nov 7"), never the ISO date (6.99.30).
269
+ function day(v) { return W.plainDay ? W.plainDay(v) : v; }
270
+ function dateLine(m) { return [day(m.date), m.time ? clock12(m.time) : '', m.duration].filter(Boolean).join(' · '); }
268
271
  function countsSummary(m) {
269
272
  var parts = [];
270
273
  if (m.topicCount > 0) parts.push(m.topicCount + ' topic' + s(m.topicCount));
@@ -579,7 +582,7 @@
579
582
  var message = str(r.body.message) || str(r.body.error) || (r.status === 200 ? 'Naming receipt ready' : 'Naming refused (' + r.status + ')');
580
583
  return { message: message, receipt: parseReceipt(r.body, r.status) };
581
584
  }, function (err) {
582
- if (err && err.code === 'offline') throw new Error(OFFLINE + ' Then refresh to review any interrupted naming before retrying.');
585
+ if (err && err.code === 'offline') throw new Error('Could not reach COS on your Mac. Check that it is awake and on Tailscale, then refresh to review any interrupted naming before retrying.');
583
586
  throw err;
584
587
  });
585
588
  }
@@ -2049,7 +2052,7 @@
2049
2052
  out += '<div class="cx-list sp-list">' + p.appearances.map(function (a) {
2050
2053
  return '<button type="button" class="cx-row sp-arow" data-sp="open-appearance" data-sid="' + esc(a.sessionId) + '">' +
2051
2054
  '<span class="cx-row-glyph' + (a.needsReview ? ' sp-amber' : '') + '">' + icon('speakers') + '</span><span class="cx-row-body">' +
2052
- '<span class="cx-row-title strong sp-one">' + esc(a.title) + '</span><span class="sp-mono">' + esc(a.date + ' · ' + a.segments + ' segments · ' + formatDuration(a.speakingMs)) + '</span></span>' +
2055
+ '<span class="cx-row-title strong sp-one">' + esc(a.title) + '</span><span class="sp-mono">' + esc([day(a.date), a.segments + ' segments', formatDuration(a.speakingMs)].filter(Boolean).join(' · ')) + '</span></span>' +
2053
2056
  '<span class="sp-match"><b>' + esc(a.observedMatch == null ? '–' : pct(a.observedMatch)) + '</b><small>' + (a.confirmedByHuman ? 'human confirmed' : a.needsReview ? 'review' : 'observed match') + '</small></span>' +
2054
2057
  '<span class="chev"></span></button>';
2055
2058
  }).join('') + '</div>';
@@ -118,14 +118,15 @@
118
118
  '<span class="cx-row-glyph"><svg class="icon"><use href="#i-threads"/></svg></span><span class="cx-row-body">' +
119
119
  '<span class="cx-row-title strong">' + esc(t.name || '(untitled)') + '</span>' +
120
120
  (subtitle(t) ? '<span class="cx-row-sub">' + esc(subtitle(t)) + '</span>' : '') +
121
- '<span class="cx-id">' + esc(t.id) + '</span></span><span class="chev"></span></button>';
121
+ // 6.99.30 (design QA): no raw id ("t1") on the row; the thread's own page still shows it, to copy.
122
+ '</span><span class="chev"></span></button>';
122
123
  }
123
124
  function hitRow(h) {
124
125
  return '<button type="button" class="cx-row" data-th="open" data-id="' + esc(h.id) + '">' +
125
126
  '<span class="cx-row-glyph"><svg class="icon"><use href="#i-threads"/></svg></span><span class="cx-row-body">' +
126
127
  '<span class="cx-row-title strong">' + esc(h.title || h.name || '(untitled)') + '</span>' +
127
128
  (h.snippet ? '<span class="cx-row-sub">' + esc(h.snippet) + '</span>' : '') +
128
- '<span class="cx-meta" style="margin-top:4px"><span class="cx-id" style="margin:0">' + esc(h.id) + '</span><span class="cx-cap">' + esc(matchLabel(h.match)) + '</span></span>' +
129
+ '<span class="cx-meta" style="margin-top:4px"><span class="cx-cap">' + esc(matchLabel(h.match)) + '</span></span>' +
129
130
  '</span><span class="chev"></span></button>';
130
131
  }
131
132
  function list() {
@@ -12,28 +12,35 @@
12
12
 
13
13
  /* The page behind does not scroll. Only the root: hidden on body too would make body a scroll container and unstick the rail. */
14
14
  html.ta-open { overflow: hidden; }
15
+ /* 6.99.30 (design QA): Today's own question card is drawn in the sheet; the one on the page under it waits. */
16
+ html.ta-open #sq-host { display: none; }
15
17
 
16
18
  .ta-layer {
17
- /* 6.99.29 (Miles's unfolded Duo, IMG_0503, 11/07 12:48): with the keyboard closed the layer is the whole fixed
18
- viewport, down to the physical bottom edge, whatever visualViewport reports. Sized from visualViewport.height it
19
- ended about 106 px short and Today showed below the backdrop and the sheet. The home indicator is cleared once, by
20
- the padding inside the sheet (.ta-foot, or .ta-body when there is no reply box), never by the layer or the sheet. */
21
- position: fixed; inset: 0; z-index: 45;
19
+ /* 6.99.30: the layer is the VISIBLE part of the page, keyboard up or down, in any browser: top and height from
20
+ visualViewport (today-actions.js applyFit, app.js viewportFit), the dynamic viewport where there is none. Whatever
21
+ covers the bottom (a keyboard, its strip, Comet's or any browser's bottom bar) stays below the reply box and Send.
22
+ Only the backdrop and the sheet's colour reach on down to the physical bottom (::before and .ta-sheet::after,
23
+ --ta-below plus the bleed), so no band of Today shows (IMG_0503). The home indicator is cleared once, by the padding
24
+ inside the sheet (.ta-foot, or the sheet when there is no reply box), never by the layer or the sheet. */
25
+ position: fixed; left: 0; right: 0; z-index: 45;
26
+ top: var(--ta-vt, 0px); height: var(--ta-vh, 100vh);
22
27
  --ta-bleed: 120px;
23
28
  --run-green: rgb(56, 145, 99);
24
29
  }
25
- /* The keyboard is up (today-actions.js applyFit): the layer is the visible viewport, so it never covers the reply box. */
26
- .ta-layer.ta-kb { top: var(--ta-vt, 0px); height: var(--ta-vh, 100dvh); }
30
+ /* Before the first reading (or with script failing), the dynamic viewport where the browser has it. A var() fallback
31
+ the browser cannot parse would void the whole height, so the dvh one sits behind @supports. */
32
+ @supports (height: 100dvh) { .ta-layer { height: var(--ta-vh, 100dvh); } }
27
33
  :root[data-theme='dark'] .ta-layer { --run-green: rgb(108, 207, 143); }
28
34
  @media (prefers-color-scheme: dark) { :root:not([data-theme='light']) .ta-layer { --run-green: rgb(108, 207, 143); } }
29
35
  .ta-layer[hidden] { display: none; }
30
36
  .ta-scrim { position: absolute; inset: 0; background: rgba(23, 18, 14, 0.45); touch-action: none; animation: fade 160ms ease; }
31
- /* Past the bottom edge, in case Safari's fixed viewport ever stops short of it: the dim and the sheet run on below the
32
- screen, so no band of Today can show under them. Off with the keyboard up (the keyboard covers that edge).
37
+ /* Below the visible part, down to the physical bottom and past it: whatever lies there (a browser's bar the page shows
38
+ through, a keyboard, a fixed viewport that stops short) shows the dim and the sheet's own colour, never Today.
39
+ --ta-below is the layout viewport under the layer; the bleed covers a bottom edge reported short.
33
40
  QA 6.99.29: the dim's bleed is the LAYER's own pseudo-element, never the scrim, so it is visual only: a tap on that
34
41
  band lands on the layer (no data-ta, so nothing happens) instead of closing the overlay or reaching Today below. */
35
- .ta-layer:not(.ta-kb)::before { content: ''; position: absolute; left: 0; right: 0; top: 100%; height: var(--ta-bleed); background: rgba(23, 18, 14, 0.45); touch-action: none; animation: fade 160ms ease; }
36
- .ta-layer:not(.ta-kb) .ta-sheet::after { content: ''; position: absolute; left: 0; right: 0; top: 100%; height: var(--ta-bleed); background: var(--page); pointer-events: none; }
42
+ .ta-layer::before { content: ''; position: absolute; left: 0; right: 0; top: 100%; height: calc(var(--ta-below, 0px) + var(--ta-bleed)); background: rgba(23, 18, 14, 0.45); touch-action: none; animation: fade 160ms ease; }
43
+ .ta-layer .ta-sheet::after { content: ''; position: absolute; left: 0; right: 0; top: 100%; height: calc(var(--ta-below, 0px) + var(--ta-bleed)); background: var(--page); pointer-events: none; }
37
44
 
38
45
  .ta-sheet {
39
46
  position: absolute; left: 0; right: 0; bottom: 0;
@@ -62,9 +69,9 @@ html.ta-open { overflow: hidden; }
62
69
  .ta-foot .status-line:empty { display: none; }
63
70
  .ta-foot .sx-box, .ta-foot .sx-park { margin: 0 0 10px; }
64
71
  /* No reply box (the session list, a question answered in its card): the sheet itself keeps the home indicator's strip,
65
- so the end of the list and the question card's footer stop above it and nothing scrolls under it. With the keyboard
66
- up the keyboard covers that strip. */
67
- .ta-layer:not(.ta-kb) .ta-sheet:has(> .ta-foot:empty), .ta-layer:not(.ta-kb) .ta-sheet:has(> .ta-foot[hidden]) { padding-bottom: var(--safe-bottom); }
72
+ so the end of the list and the question card's footer stop above it and nothing scrolls under it. With a keyboard or
73
+ its strip up, that covers the strip. */
74
+ .ta-layer:not(.ta-kb):not(.ta-kb-strip) .ta-sheet:has(> .ta-foot:empty), .ta-layer:not(.ta-kb):not(.ta-kb-strip) .ta-sheet:has(> .ta-foot[hidden]) { padding-bottom: var(--safe-bottom); }
68
75
 
69
76
  .ta-sub { display: flex; flex-wrap: wrap; align-items: center; gap: 4px 8px; margin: 0 0 8px; color: var(--sub); font-size: 14.5px; line-height: 1.4; }
70
77
  .ta-note { margin: 6px 0 0; color: var(--hot); font-size: 13.5px; line-height: 1.4; }
@@ -126,9 +133,9 @@ html.ta-open { overflow: hidden; }
126
133
  .ta-foot { padding-bottom: calc(var(--safe-bottom) + 14px); }
127
134
  }
128
135
  /* 6.99.29 (Miles's unfolded Duo, 11/07): the on-screen keyboard is up, at any width (today-actions.js applyFit marks the
129
- layer ta-kb when the visible viewport is 120 px or more shorter than the layout one). The sheet fills what is visible,
130
- the header folds to one compact row, the reply box stays pinned at the bottom just above the keyboard, and the
131
- conversation takes the rest and scrolls. The keyboard covers the home indicator, so the foot drops that inset. */
136
+ layer ta-kb when 120 px or more of the page is covered at the bottom). The sheet fills what is visible, the header
137
+ folds to one compact row, the reply box stays pinned at the bottom just above the keyboard, and the conversation
138
+ takes the rest and scrolls. The keyboard covers the home indicator, so the foot drops that inset. */
132
139
  .ta-layer.ta-kb .ta-sheet { top: 0; bottom: 0; max-height: none; overflow: hidden; }
133
140
  .ta-layer.ta-kb .ta-grip { display: none; }
134
141
  .ta-layer.ta-kb .ta-head { align-items: center; padding-top: calc(var(--safe-top) + 6px); padding-bottom: 4px; }
@@ -139,6 +146,9 @@ html.ta-open { overflow: hidden; }
139
146
  .ta-layer.ta-kb .ta-body { padding-bottom: 8px; }
140
147
  .ta-layer.ta-kb .ta-foot { padding-top: 8px; padding-bottom: 8px; }
141
148
  .ta-layer.ta-kb .ta-foot .composer textarea { max-height: 96px; }
149
+ /* 6.99.30: a hardware keyboard's strip with a field focused (ta-kb-strip): the header stays, the strip covers the home
150
+ indicator, so the reply box drops that inset. The layer already ends above the strip. */
151
+ .ta-layer.ta-kb-strip .ta-foot { padding-bottom: 8px; }
142
152
  /* Under about three lines left for the conversation: the "Working right now" note waits until the keyboard closes. */
143
153
  .ta-layer.ta-kb-tight .ta-foot .sx-park { display: none; }
144
154
  @keyframes ta-rise { from { transform: translateY(32px); opacity: 0.6; } }
@@ -160,8 +170,18 @@ html.ta-open { overflow: hidden; }
160
170
  /* Files with a reply or a note (11/07): the COS chat's own chips and clip (cos-chat.css .cc-chip, .cc-attach), placed in
161
171
  the reply box. The chips take their own row above the words; the clip sits beside Send. */
162
172
  .ta-composer { flex-wrap: wrap; }
163
- .ta-composer .ta-files { flex: 1 1 100%; padding: 2px 0 4px; }
173
+ /* 6.99.30 (canary BLOCKER): five files pushed the reply box and Send off the sheet at every size (the foot never
174
+ shrinks and the keyboard's sheet clips). The chips scroll inside a capped box, so the words and Send always fit: about
175
+ two chip rows with room, one with the keyboard up. */
176
+ .ta-composer .ta-files { flex: 1 1 100%; padding: 2px 0 4px; max-height: min(30vh, 132px); overflow-y: auto; overscroll-behavior: contain; }
177
+ .ta-layer.ta-kb .ta-composer .ta-files { max-height: 60px; }
164
178
  .ta-composer .ta-files.hidden { display: none; }
165
179
  .ta-clip:empty { display: none; }
166
180
  .ta-clip .cc-attach { height: 38px; width: 42px; border-radius: var(--radius-sm); }
181
+ /* 6.99.30 (canary): with the keyboard up a sticky question footer covered all but part of one option; it scrolls with
182
+ the card instead while typing. */
183
+ .ta-layer.ta-kb .ta-q .sq-foot, .ta-layer.ta-kb-strip .ta-q .sq-foot { position: static; }
184
+ /* The "Not sent" line for a refused queued message (today-actions.js loadNotSent). */
185
+ .ta-notsent:empty { display: none; }
186
+ .ta-notsent { margin: 4px 0 8px; }
167
187
  .ta-composer.dragging { outline: 2px dashed var(--accent); outline-offset: 4px; }