@gotcos/glasses-server 6.99.20 → 6.99.25

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.
Files changed (33) hide show
  1. package/CHANGELOG.md +91 -0
  2. package/docs/work-orchestrator.md +20 -0
  3. package/package.json +1 -1
  4. package/server/index.ts +2 -1
  5. package/server/lib/web-control-static.ts +226 -0
  6. package/server/lib/web-memories-bridge.ts +9 -8
  7. package/server/lib/web-pet-choice.ts +225 -0
  8. package/server/lib/web-pet.ts +226 -0
  9. package/server/lib/work-lens-rounds.ts +29 -4
  10. package/server/routes/web-control.ts +110 -7
  11. package/server/routes/work-lens-rounds.ts +19 -5
  12. package/server/web-control/app.css +24 -5
  13. package/server/web-control/app.js +646 -108
  14. package/server/web-control/control.css +1 -1
  15. package/server/web-control/index.html +27 -13
  16. package/server/web-control/memories/memory-workspace.js +1 -1
  17. package/server/web-control/panes/cos-chat.css +4 -0
  18. package/server/web-control/panes/cos-chat.js +26 -4
  19. package/server/web-control/panes/meetings.css +15 -2
  20. package/server/web-control/panes/memories.css +291 -0
  21. package/server/web-control/panes/memories.js +2208 -0
  22. package/server/web-control/panes/questions.js +59 -21
  23. package/server/web-control/panes/settings.js +102 -4
  24. package/server/web-control/panes/today-actions.css +109 -0
  25. package/server/web-control/panes/today-actions.js +972 -0
  26. package/server/web-control/panes/work.css +54 -0
  27. package/server/web-control/panes/work.js +1037 -18
  28. package/server/web-control/shell.js +6 -2
  29. package/server/web-control/theme.js +0 -2
  30. package/server/web-control/memories/memories-app.js +0 -1561
  31. package/server/web-control/memories/memories-theme.css +0 -73
  32. package/server/web-control/memories/memories.html +0 -72
  33. package/server/web-control/memories/memory-stewardship.js +0 -63
@@ -28,6 +28,7 @@
28
28
  items: [], // pending items, oldest first
29
29
  picks: {}, // id -> [{ labels: [], other: '' }] per question
30
30
  armed: {}, // id -> 'send' | 'allow' | 'deny'
31
+ armedAt: {}, // id -> when it was armed (COSWeb.confirmReady)
31
32
  sending: {}, // id -> clientAnswerId while a POST is out
32
33
  notes: {}, // id -> one line under the card
33
34
  answerIds: {}, // id -> the clientAnswerId this page answers under (a retry replays it)
@@ -115,13 +116,13 @@
115
116
  var text = Q.notes[item.id] || '';
116
117
  return '<p class="sq-note' + (text ? '' : ' hidden') + '" data-sq-note="' + esc(item.id) + '" role="status">' + esc(text) + '</p>';
117
118
  }
118
- function questionCard(item) {
119
+ function questionCard(item, scope) {
119
120
  var picks = Q.picks[item.id] || [];
120
121
  var sending = !!Q.sending[item.id];
121
122
  var blocks = (item.questions || []).map(function (q, qi) {
122
123
  var p = picks[qi] || { labels: [], other: '' };
123
124
  var type = q.multiSelect ? 'checkbox' : 'radio';
124
- var name = 'sq-' + item.id + '-' + qi;
125
+ var name = (scope || 'sq') + '-' + item.id + '-' + qi;
125
126
  return '<fieldset class="sq-q"><legend class="sq-q-text">' + esc(q.question) + (q.header ? ' <span class="sq-q-tag">' + esc(q.header) + '</span>' : '') + '</legend>' +
126
127
  (q.multiSelect ? '<p class="sq-fine">Pick any that apply.</p>' : '') +
127
128
  '<div class="sq-options">' + q.options.map(function (o, oi) {
@@ -153,26 +154,48 @@
153
154
  '<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>' +
154
155
  noteHtml(item) + '</article>';
155
156
  }
156
- function render() {
157
+ // Other places can show the cards for one session (Today's overlay, panes/today-actions.js): the same items, picks,
158
+ // answer ids and posts, drawn into their own element. A mount is dropped when its element leaves the page.
159
+ var mounts = [];
160
+ function hostsOf() {
161
+ mounts = mounts.filter(function (m) { return m.el.isConnected !== false; });
162
+ return [{ el: host, sessionId: null, scope: 'sq' }].concat(mounts);
163
+ }
164
+ function itemsFor(sessionId) {
165
+ var want = String(sessionId || '').toLowerCase();
166
+ return Q.items.filter(function (i) { return !want || String(i.sessionId || '').toLowerCase() === want; });
167
+ }
168
+ function drawInto(m) {
157
169
  var active = document.activeElement;
158
- var focus = active && active.hasAttribute && active.hasAttribute('data-sq-other') ? { id: active.getAttribute('data-sq-id'), q: active.getAttribute('data-q'), start: active.selectionStart, end: active.selectionEnd } : null;
159
- host.innerHTML = Q.items.map(function (i) { return i.kind === 'approval' ? approvalCard(i) : questionCard(i); }).join('');
160
- host.hidden = !Q.items.length;
170
+ 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;
171
+ var list = itemsFor(m.sessionId);
172
+ m.el.innerHTML = list.map(function (i) { return i.kind === 'approval' ? approvalCard(i) : questionCard(i, m.scope); }).join('');
173
+ m.el.hidden = !list.length;
161
174
  if (focus) {
162
- var box = host.querySelector('[data-sq-other][data-sq-id="' + focus.id + '"][data-q="' + focus.q + '"]');
175
+ var box = m.el.querySelector('[data-sq-other][data-sq-id="' + focus.id + '"][data-q="' + focus.q + '"]');
163
176
  if (box) { box.focus({ preventScroll: true }); try { box.setSelectionRange(focus.start, focus.end); } catch (_e) {} }
164
177
  }
178
+ if (m.onChange) { try { m.onChange(list.length); } catch (_e) {} }
179
+ }
180
+ function render() {
181
+ hostsOf().forEach(drawInto);
165
182
  clearInterval(Q.tick);
166
183
  if (Q.items.length) Q.tick = setInterval(tickSeconds, 1000);
167
184
  }
168
185
  function itemOf(id) { for (var i = 0; i < Q.items.length; i++) if (Q.items[i].id === id) return Q.items[i]; return null; }
169
186
  // The seconds move without a redraw (a redraw would drop a half-typed answer).
170
187
  function tickSeconds() {
171
- host.querySelectorAll('[data-sq-secs]').forEach(function (el) { var it = itemOf(el.getAttribute('data-sq-secs')); if (it) el.textContent = timeLeft(secondsLeft(it)); });
188
+ hostsOf().forEach(function (m) {
189
+ m.el.querySelectorAll('[data-sq-secs]').forEach(function (el) { var it = itemOf(el.getAttribute('data-sq-secs')); if (it) el.textContent = timeLeft(secondsLeft(it)); });
190
+ });
172
191
  }
173
192
  function patchCard(id) {
174
- var item = itemOf(id), card = host.querySelector('[data-sq-card="' + id + '"]');
175
- if (!item || !card) return render();
193
+ var item = itemOf(id), cards = [];
194
+ hostsOf().forEach(function (m) { var c = m.el.querySelector('[data-sq-card="' + id + '"]'); if (c) cards.push(c); });
195
+ if (!item || !cards.length) return render();
196
+ cards.forEach(function (card) { patchOne(card, id, item); });
197
+ }
198
+ function patchOne(card, id, item) {
176
199
  var send = card.querySelector('[data-sq="send"]');
177
200
  if (send) { send.disabled = !!Q.sending[id] || !complete(item); send.textContent = Q.sending[id] ? 'Sending…' : Q.armed[id] === 'send' ? 'Tap again to send' : 'Send'; }
178
201
  var allow = card.querySelector('[data-sq="allow"]'); if (allow) allow.textContent = Q.armed[id] === 'allow' ? 'Tap again to allow' : 'Allow once';
@@ -219,7 +242,7 @@
219
242
  });
220
243
  post(item.id, { answers: answers }, 'Sent to ' + engine(item) + '.');
221
244
  }
222
- host.addEventListener('click', function (e) {
245
+ function onClick(e) {
223
246
  var b = e.target.closest('[data-sq]');
224
247
  if (!b || b.disabled) return;
225
248
  var id = b.getAttribute('data-sq-id'), what = b.getAttribute('data-sq'), item = itemOf(id);
@@ -237,23 +260,28 @@
237
260
  });
238
261
  return;
239
262
  }
240
- // Tap, then tap again: the first tap only arms the button.
241
- if (Q.armed[id] !== what) { Q.armed[id] = what; patchCard(id); return; }
263
+ // Tap, then tap again: the first tap only arms the button, and the second counts only once the first has been on
264
+ // screen a moment (COSWeb.confirmReady, the rule Today's overlay uses), so a double tap never allows or sends.
265
+ if (Q.armed[id] !== what) { Q.armed[id] = what; Q.armedAt[id] = Date.now(); patchCard(id); return; }
266
+ if (W.confirmReady && !W.confirmReady(Q.armedAt[id])) return;
242
267
  if (what === 'send') return sendAnswers(item);
243
268
  if (what === 'allow' || what === 'deny') return post(id, { decision: what }, what === 'allow' ? 'Allowed once.' : 'Denied.');
244
- });
245
- host.addEventListener('change', function (e) {
269
+ }
270
+ function onChange(e) {
246
271
  var t = e.target;
247
272
  if (!t.hasAttribute('data-sq-pick')) return;
248
273
  var id = t.getAttribute('data-sq-id'), item = itemOf(id); if (!item) return;
249
274
  var qi = Number(t.getAttribute('data-q')), q = item.questions[qi], label = q.options[Number(t.getAttribute('data-o'))].label;
250
275
  var p = Q.picks[id][qi];
251
276
  if (q.multiSelect) p.labels = t.checked ? p.labels.concat([label]) : p.labels.filter(function (l) { return l !== label; });
252
- else { p.labels = [label]; p.other = ''; var box = host.querySelector('[data-sq-other][data-sq-id="' + id + '"][data-q="' + qi + '"]'); if (box) box.value = ''; }
277
+ else {
278
+ p.labels = [label]; p.other = '';
279
+ hostsOf().forEach(function (m) { var box = m.el.querySelector('[data-sq-other][data-sq-id="' + id + '"][data-q="' + qi + '"]'); if (box) box.value = ''; });
280
+ }
253
281
  Q.armed[id] = '';
254
282
  patchCard(id);
255
- });
256
- host.addEventListener('input', function (e) {
283
+ }
284
+ function onInput(e) {
257
285
  var t = e.target;
258
286
  if (!t.hasAttribute('data-sq-other')) return;
259
287
  var id = t.getAttribute('data-sq-id'), item = itemOf(id); if (!item) return;
@@ -262,14 +290,24 @@
262
290
  // Single choice: typing your own answer clears the picked option, as on the phone.
263
291
  if (!q.multiSelect && t.value.trim() && p.labels.length) {
264
292
  p.labels = [];
265
- host.querySelectorAll('[data-sq-pick][data-sq-id="' + id + '"][data-q="' + qi + '"]').forEach(function (r) { r.checked = false; });
293
+ hostsOf().forEach(function (m) { m.el.querySelectorAll('[data-sq-pick][data-sq-id="' + id + '"][data-q="' + qi + '"]').forEach(function (r) { r.checked = false; }); });
266
294
  }
267
295
  Q.armed[id] = '';
268
296
  patchCard(id);
269
- });
297
+ }
298
+ function listen(el) { el.addEventListener('click', onClick); el.addEventListener('change', onChange); el.addEventListener('input', onInput); }
299
+ listen(host);
300
+ // Draw this session's cards into `el` and keep them in step; `onChange(count)` hears each redraw.
301
+ function mount(el, sessionId, onChange) {
302
+ if (!el) return null;
303
+ var m = { el: el, sessionId: String(sessionId || '').toLowerCase(), scope: 'sqm' + (mounts.length + 1), onChange: onChange || null };
304
+ mounts.push(m); listen(el); drawInto(m);
305
+ if (!Q.stopped) { clearTimeout(Q.timer); Q.timer = setTimeout(poll, 0); }
306
+ return { count: function () { return itemsFor(m.sessionId).length; }, unmount: function () { mounts = mounts.filter(function (x) { return x !== m; }); } };
307
+ }
270
308
 
271
309
  document.addEventListener('visibilitychange', function () { if (document.hidden) stop(); else if (signedIn()) start(); });
272
310
  W.onShell(function () { start(); });
273
311
 
274
- W.sessionQuestions = { _state: Q, _take: take, _render: render, poll: poll };
312
+ W.sessionQuestions = { _state: Q, _take: take, _render: render, poll: poll, mount: mount, itemsFor: itemsFor };
275
313
  })();
@@ -24,12 +24,67 @@
24
24
  // 6.99.11 (Drop it P2): shown only when the server has the P2 settings and Drop it is on.
25
25
  { id: 'reach', title: 'COS reaching out', summary: 'What COS tells you about on its own, when it may interrupt, and your reminders.', mac: [], p2: true },
26
26
  { id: 'privacy', title: 'Privacy and permissions', summary: 'What COS can use on this Mac. Each permission turns on one feature.', mac: ['Permissions'] },
27
- { id: 'pet', title: 'Pet', summary: 'The session pet: its character, size and motion.', mac: ['Session pet', 'Characters'] },
27
+ { id: 'pet', title: 'Pet', summary: 'Which pet this browser shows on Today, and whether it shows. Size, motion and the Mac\u2019s own pet are set in COS Control.', mac: ['Session pet size and motion', 'Characters on the Mac'] },
28
28
  { id: 'system', title: 'System and updates', summary: 'The COS server on this Mac, app updates, and checks when something is off.', mac: ['App updates', 'Troubleshooting'] }
29
29
  ];
30
30
  var DAYS = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];
31
31
 
32
32
  function soft(p) { return p.then(function (d) { return d; }, function () { return null; }); }
33
+ // ── Pet (this browser) ──────────────────────────────────
34
+ // Which pet Today shows here and whether it shows at all. Kept per browser (COSWeb.petPrefs), like the theme.
35
+ // The Mac's own pet is changed in COS Control: the app does not watch its pet folder, so a change from here
36
+ // would leave the Mac showing the old pet. Catalog: GET /api/web-pet/catalog (lib/web-pet-choice.ts).
37
+ var PET = { catalog: null, state: 'idle', q: '' };
38
+ function loadPetCatalog() {
39
+ if (PET.state === 'loading' || PET.state === 'ready') return;
40
+ PET.state = 'loading';
41
+ api('/api/web-pet/catalog').then(function (c) { PET.catalog = c; PET.state = 'ready'; render(); },
42
+ function () { PET.state = 'error'; render(); });
43
+ }
44
+ function petChoiceRow(pet, name, desc, preview, current) {
45
+ var on = current === pet;
46
+ return '<button type="button" class="cx-set-row cx-set-choice pet-choice" data-pet="' + esc(pet) + '" aria-pressed="' + on + '">' +
47
+ (preview ? '<img class="pet-thumb" alt="" loading="lazy" src="' + esc(preview) + '">' : '<span class="pet-thumb"></span>') +
48
+ '<span class="cx-set-text"><span class="cx-set-name">' + esc(name) + '</span>' + (desc ? '<span class="cx-set-desc">' + esc(desc) + '</span>' : '') + '</span>' +
49
+ '<span class="value">' + (on ? '<span class="check">✓</span>' : '') + '</span></button>';
50
+ }
51
+ function petBody() {
52
+ var prefs = W.petPrefs ? W.petPrefs() : { pet: 'mac', show: true };
53
+ var out = group('On this browser', '',
54
+ row('Show the pet on Today', 'Only here. The pet on your Mac stays as it is.', toggle(prefs.show, 'data-set="pet-show"', 'Show the pet on Today')));
55
+ if (PET.state === 'idle') loadPetCatalog();
56
+ if (PET.state !== 'ready') {
57
+ return out + group('Character', '', PET.state === 'error'
58
+ ? '<p class="cx-set-row">Could not load the pets. <button type="button" class="cx-btn quiet" data-set="pet-retry">Try again</button></p>'
59
+ : W.skeletonRows(3));
60
+ }
61
+ var c = PET.catalog || {}, shipped = c.shipped || [], gallery = c.gallery || [];
62
+ out += group('Character', 'The pet this browser shows on Today. It does not change the pet on your Mac.',
63
+ petChoiceRow('mac', 'Same as my Mac', c.mac && c.mac.available ? 'Follows the pet COS Control shows.' : 'Your Mac has no pet set.', c.mac && c.mac.preview, prefs.pet) +
64
+ shipped.map(function (p) { return petChoiceRow(p.pet, p.name, p.summary || (p.animated ? 'Animated.' : ''), p.preview, prefs.pet); }).join(''));
65
+ if (gallery.length) {
66
+ var picked = gallery.filter(function (p) { return p.pet === prefs.pet; })[0];
67
+ out += '<div class="cx-set-group"><h3 class="cx-set-title">Gallery</h3><p class="cx-set-blurb">' + gallery.length +
68
+ ' pets from the OpenPets gallery COS Control uses. Each shows as one still picture, as it does on the Mac.' + (picked ? ' In use: ' + esc(picked.name) + '.' : '') + '</p>' +
69
+ '<input type="search" class="pet-search" id="pet-search" placeholder="Search the gallery" aria-label="Search the gallery" value="' + esc(PET.q) + '">' +
70
+ '<div class="pet-grid" id="pet-grid">' + gallery.map(function (p) {
71
+ var on = p.pet === prefs.pet, words = (p.name + ' ' + p.category + ' ' + p.description).toLowerCase();
72
+ return '<button type="button" class="pet-tile" data-pet="' + esc(p.pet) + '" data-words="' + esc(words) + '" aria-pressed="' + on + '" title="' + esc(p.description || p.name) + '">' +
73
+ '<img alt="" loading="lazy" src="' + esc(p.preview) + '"><span>' + esc(p.name) + '</span></button>';
74
+ }).join('') + '</div><p class="cx-set-blurb hidden" id="pet-none">No pets match that search.</p></div>';
75
+ }
76
+ return out;
77
+ }
78
+ function filterPets() {
79
+ var q = PET.q.trim().toLowerCase(), grid = $('pet-grid'), shown = 0;
80
+ if (!grid) return;
81
+ grid.querySelectorAll('.pet-tile').forEach(function (t) {
82
+ var hit = !q || (t.getAttribute('data-words') || '').indexOf(q) >= 0;
83
+ t.classList.toggle('hidden', !hit); if (hit) shown++;
84
+ });
85
+ var none = $('pet-none'); if (none) none.classList.toggle('hidden', shown > 0);
86
+ }
87
+
33
88
  function load(force) {
34
89
  if (S.state === 'loading' || (S.data && !force)) return;
35
90
  S.state = 'loading';
@@ -67,7 +122,7 @@
67
122
  return '<section class="cx-set-cat"><h2 class="cx-col-title">This browser</h2>' +
68
123
  group('Appearance', '', themeBtn('system', 'Match this device') + themeBtn('light', 'Light') + themeBtn('dark', 'Dark')) +
69
124
  group('Sign-in', '',
70
- '<button type="button" class="cx-set-row" id="device-code-btn"><span class="cx-set-text"><span class="cx-set-name">Sign in another device</span><span class="cx-set-desc">Shows a QR for the phone camera, and a code to type.</span></span><span class="chev"></span></button>' +
125
+ '<button type="button" class="cx-set-row" id="device-code-btn"><span class="cx-set-text"><span class="cx-set-name">Use COS on another phone or browser</span><span class="cx-set-desc">A QR for your iPhone Camera, and a code to type. This is not glasses pairing; pair glasses from COS Control on your Mac.</span></span><span class="chev"></span></button>' +
71
126
  '<div id="device-code-panel" class="device-code hidden" aria-live="polite"></div>' +
72
127
  '<button type="button" class="cx-set-row" id="copy-link"><span class="cx-set-text"><span class="cx-set-name">Copy link for your phone</span><span class="cx-set-desc" id="tailnet-url">' + esc(W.state.tailnetUrl || 'Checking Tailscale') + '</span></span></button>' +
73
128
  '<button type="button" class="cx-set-row" id="sign-out"><span class="cx-set-text"><span class="cx-set-name">Sign out of this browser</span></span></button>' +
@@ -154,6 +209,7 @@
154
209
  var d = S.data || {};
155
210
  var out = '';
156
211
  if (cat.id === 'reach') return reachBody();
212
+ if (cat.id === 'pet') return petBody() + onMac(cat.mac);
157
213
  if (cat.id === 'ai' && d.dp) {
158
214
  var p = d.dp.provider || '';
159
215
  var name = function (x) { return x === 'subq' ? 'SubQ' : x === 'jev' ? 'Jev' : x || 'Not set'; };
@@ -226,9 +282,10 @@
226
282
  browserGroup() +
227
283
  (S.state !== 'ready' ? '<div class="cx-list" style="margin-top:18px">' + W.skeletonRows(4) + '</div>' :
228
284
  CATEGORIES.filter(function (cat) { return !cat.p2 || p2On(); }).map(function (cat) {
229
- return '<section class="cx-set-cat"><h2 class="cx-col-title">' + esc(cat.title) + '</h2><p class="cx-set-blurb">' + esc(cat.summary) + '</p>' + categoryBody(cat) + '</section>';
285
+ return '<section class="cx-set-cat" id="set-cat-' + cat.id + '"><h2 class="cx-col-title">' + esc(cat.title) + '</h2><p class="cx-set-blurb">' + esc(cat.summary) + '</p>' + categoryBody(cat) + '</section>';
230
286
  }).join('')) +
231
287
  '<p class="status-line" id="settings-status"></p></div>';
288
+ if (PET.q) filterPets();
232
289
  }
233
290
 
234
291
  function say(text, isError) { var el = $('settings-status'); if (el) { el.textContent = text; el.classList.toggle('error', !!isError); } }
@@ -236,8 +293,26 @@
236
293
  pane.addEventListener('click', function (e) {
237
294
  var go = e.target.closest('[data-go]');
238
295
  if (go) { var parts = go.getAttribute('data-go').split('/'); return W.go(parts[0], parts.slice(1)); }
296
+ var pick = e.target.closest('[data-pet]');
297
+ if (pick && W.setPetPrefs) {
298
+ W.setPetPrefs({ pet: pick.getAttribute('data-pet') });
299
+ pane.querySelectorAll('[data-pet]').forEach(function (b) {
300
+ var on = b === pick; b.setAttribute('aria-pressed', String(on));
301
+ var v = b.querySelector('.value'); if (v) v.innerHTML = on ? '<span class="check">✓</span>' : '';
302
+ });
303
+ W.toast('Pet changed. Today shows it here.');
304
+ return;
305
+ }
239
306
  var t = e.target.closest('[data-set]');
240
307
  if (!t || t.disabled) return;
308
+ if (t.getAttribute('data-set') === 'pet-show') {
309
+ var shown = t.getAttribute('aria-checked') === 'true';
310
+ t.setAttribute('aria-checked', String(!shown));
311
+ if (W.setPetPrefs) W.setPetPrefs({ show: !shown });
312
+ W.toast(!shown ? 'Pet on' : 'Pet off');
313
+ return;
314
+ }
315
+ if (t.getAttribute('data-set') === 'pet-retry') { PET.state = 'idle'; return render(); }
241
316
  var act = t.getAttribute('data-set');
242
317
  if (act === 'refresh') { S.data = null; S.state = 'idle'; load(true); return render(); }
243
318
  if (act === 'provider') {
@@ -273,6 +348,15 @@
273
348
  }
274
349
  });
275
350
 
351
+ // A gallery picture that will not load shows the pet's name alone, never a broken image.
352
+ pane.addEventListener('error', function (e) {
353
+ var img = e.target;
354
+ if (img && img.tagName === 'IMG' && img.closest && img.closest('.pet-tile, .pet-choice')) img.classList.add('hidden');
355
+ }, true);
356
+ pane.addEventListener('input', function (e) {
357
+ if (e.target && e.target.id === 'pet-search') { PET.q = e.target.value; filterPets(); }
358
+ });
359
+
276
360
  // Number, time, day and steer fields save when they change (a number is clamped by the server, then shown as saved).
277
361
  pane.addEventListener('change', function (e) {
278
362
  var f = e.target && e.target.closest ? e.target.closest('[data-p2]') : null;
@@ -287,6 +371,20 @@
287
371
  });
288
372
 
289
373
  W.registerPane('settings', {
290
- load: function () { load(false); render(); }
374
+ load: function (args) {
375
+ load(false); render();
376
+ // "Change pet" on Today opens Settings at the Pet section.
377
+ if (args && args[0] === 'pet') {
378
+ var tries = 0;
379
+ (function scroll() {
380
+ var el = document.getElementById('set-cat-pet');
381
+ // Land on the pet in use once the catalog has drawn; until then, the Pet section's top.
382
+ var chosen = el && el.querySelector('[data-pet][aria-pressed="true"]');
383
+ if (chosen) { chosen.scrollIntoView({ block: 'center' }); return; }
384
+ if (el && tries === 0) el.scrollIntoView({ block: 'start' });
385
+ if (++tries < 40) setTimeout(scroll, 150);
386
+ })();
387
+ }
388
+ }
291
389
  });
292
390
  })();
@@ -0,0 +1,109 @@
1
+ /* COS Control Web: Today as a place to act (panes/today-actions.js). A bottom sheet on the folded phone, a side sheet
2
+ from 600 px. Built from the page's own tokens and pieces: the composer, .btn, .link-btn, .cx-btn, the reader's
3
+ activity rows (.sx-act-*) and boxes (.sx-box), and the question cards (questions.css). */
4
+
5
+ /* The Today rows that open it: whole-row buttons. */
6
+ .need-open { display: block; width: 100%; text-align: left; color: inherit; }
7
+ .need-open .need-top { display: flex; }
8
+ .need-open .need-foot { display: flex; }
9
+ .intake-row { width: 100%; text-align: left; color: inherit; align-items: center; min-height: 40px; }
10
+ .intake-row::after { content: ''; width: 7px; height: 7px; border-right: 2px solid var(--grey); border-bottom: 2px solid var(--grey); transform: rotate(-45deg); flex: none; opacity: 0.6; margin-left: 2px; }
11
+ .intake-row .intake-meeting { flex: 1; }
12
+
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
+ html.ta-open { overflow: hidden; }
15
+
16
+ .ta-layer {
17
+ position: fixed; left: 0; right: 0; z-index: 45;
18
+ /* The visible viewport (app sets these from visualViewport): the keyboard never covers the reply box. */
19
+ top: var(--ta-vt, 0px); height: var(--ta-vh, 100dvh);
20
+ --run-green: rgb(56, 145, 99);
21
+ }
22
+ :root[data-theme='dark'] .ta-layer { --run-green: rgb(108, 207, 143); }
23
+ @media (prefers-color-scheme: dark) { :root:not([data-theme='light']) .ta-layer { --run-green: rgb(108, 207, 143); } }
24
+ .ta-layer[hidden] { display: none; }
25
+ .ta-scrim { position: absolute; inset: 0; background: rgba(23, 18, 14, 0.45); touch-action: none; animation: fade 160ms ease; }
26
+
27
+ .ta-sheet {
28
+ position: absolute; left: 0; right: 0; bottom: 0;
29
+ /* A strip of Today stays in view above it: context, and somewhere to tap outside. */
30
+ max-height: calc(100% - var(--safe-top) - 56px);
31
+ display: flex; flex-direction: column;
32
+ background: var(--page); color: var(--ink);
33
+ border-radius: 20px 20px 0 0; box-shadow: 0 -12px 40px rgba(0, 0, 0, 0.25);
34
+ padding-left: var(--safe-left); padding-right: var(--safe-right);
35
+ outline: none;
36
+ animation: ta-rise 220ms cubic-bezier(0.2, 0.8, 0.2, 1);
37
+ }
38
+ .ta-grip { flex: none; width: 36px; height: 5px; border-radius: 3px; background: var(--panel-border); margin: 8px auto 0; }
39
+ .ta-head { flex: none; display: flex; align-items: flex-start; gap: 10px; padding: 6px var(--gutter) 4px; }
40
+ .ta-title { flex: 1; min-width: 0; margin: 8px 0 0; font: 500 22px/1.2 var(--display); letter-spacing: -0.005em; overflow-wrap: anywhere; }
41
+ .ta-x { flex: none; display: grid; place-items: center; width: 44px; height: 44px; margin-right: -8px; border-radius: 12px; color: var(--ink); }
42
+ .ta-x .icon { width: 20px; height: 20px; stroke-width: 1.8; }
43
+ .ta-body { flex: 1 1 auto; min-height: 0; overflow: auto; overscroll-behavior: contain; -webkit-overflow-scrolling: touch; padding: 2px var(--gutter) 16px; }
44
+ .ta-foot { flex: none; padding: 10px var(--gutter) calc(var(--safe-bottom) + 10px); border-top: 1px solid var(--row-border); background: var(--page); }
45
+ .ta-foot:empty { display: none; }
46
+ .ta-foot .composer { margin-top: 0; position: static; }
47
+ .ta-foot .status-line { margin: 6px 4px 0; min-height: 0; }
48
+ .ta-foot .status-line:empty { display: none; }
49
+ .ta-foot .sx-box, .ta-foot .sx-park { margin: 0 0 10px; }
50
+
51
+ .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; }
52
+ .ta-note { margin: 6px 0 0; color: var(--hot); font-size: 13.5px; line-height: 1.4; }
53
+ .ta-card-line { margin: 0 0 8px; font-size: 14px; color: var(--ink); overflow-wrap: anywhere; }
54
+ .ta-quote { margin: 8px 0 10px; padding-left: 12px; border-left: 2px solid var(--btn-border); font: italic 400 16px/1.42 var(--display); color: var(--ink); overflow-wrap: anywhere; }
55
+ .ta-ask-line { margin: 8px 0 4px; font-size: 14.5px; font-weight: 600; color: var(--hot); }
56
+ .ta-links { display: flex; flex-wrap: wrap; gap: 0 18px; }
57
+ .ta-q { display: grid; gap: 12px; margin: 8px 0 12px; }
58
+ .ta-q[hidden] { display: none; }
59
+ .ta-activity { margin: 8px 0 4px; }
60
+ .ta-activity .sx-act-state { margin-top: 0; }
61
+ .ta-act-prompt { margin: 2px 0 0; font-size: 13.5px; line-height: 1.4; color: var(--ink); overflow-wrap: anywhere; }
62
+ .ta-act-tail { margin-top: 6px; display: flex; flex-direction: column; }
63
+ .ta-last { margin-top: 14px; padding: 12px 14px; border-radius: 14px; background: var(--panel); border: 1px solid var(--panel-border); font-size: 15px; line-height: 1.5; }
64
+ .ta-last-head { margin: 0 0 6px; font-size: 13px; font-weight: 600; color: var(--sub); }
65
+ .ta-last-text.clamped { max-height: 18em; overflow: hidden; -webkit-mask-image: linear-gradient(#000 72%, transparent); mask-image: linear-gradient(#000 72%, transparent); }
66
+
67
+ /* From today's meetings */
68
+ .ta-list { margin-top: 2px; }
69
+ .ta-group { margin: 18px 0 0; font: 600 15px/1.3 var(--body); color: var(--ink); overflow-wrap: anywhere; }
70
+ .ta-sugg { padding: 12px 0 10px; border-top: 1px solid var(--row-border); }
71
+ .ta-group + .ta-sugg { border-top: 0; padding-top: 8px; }
72
+ .ta-sugg-text { margin: 0; font-size: 15px; font-weight: 600; line-height: 1.35; overflow-wrap: anywhere; }
73
+ .ta-sugg-src { margin: 4px 0 0; font-size: 13.5px; line-height: 1.4; color: var(--sub); overflow-wrap: anywhere; }
74
+ .ta-acts { display: flex; flex-wrap: wrap; align-items: center; gap: 2px 18px; margin-top: 6px; }
75
+ .ta-acts .btn.small { min-height: 44px; padding: 0 16px; }
76
+ .ta-acts .link-btn { font-size: 14.5px; }
77
+ .link-btn.quiet { color: var(--sub); font-weight: 500; }
78
+ .ta-wait { display: inline-flex; align-items: center; min-height: 44px; font-size: 14px; color: var(--sub); }
79
+ .ta-done { font-size: 14.5px; font-weight: 600; color: var(--ink); }
80
+ .ta-sugg.settled { display: flex; flex-wrap: wrap; align-items: center; gap: 0 16px; }
81
+ .ta-sugg.settled > p { flex-basis: 100%; }
82
+ .ta-sugg.settled .ta-sugg-text { color: var(--sub); font-weight: 500; }
83
+
84
+ /* Unfolded Duo, tablets, desktop: a sheet at the right edge, Today still in view beside it. */
85
+ @media (min-width: 600px) {
86
+ .ta-sheet {
87
+ left: auto; top: 0; width: clamp(360px, 60vw, 480px); max-height: none;
88
+ border-radius: 20px 0 0 20px; box-shadow: -16px 0 48px rgba(0, 0, 0, 0.22);
89
+ animation-name: ta-slide;
90
+ }
91
+ .ta-grip { display: none; }
92
+ .ta-head { padding-top: calc(var(--safe-top) + 14px); }
93
+ .ta-foot { padding-bottom: calc(var(--safe-bottom) + 14px); }
94
+ }
95
+ @keyframes ta-rise { from { transform: translateY(32px); opacity: 0.6; } }
96
+ @keyframes ta-slide { from { transform: translateX(32px); opacity: 0.6; } }
97
+ /* Dismiss armed: the second tap removes it (no Undo on this server). */
98
+ .link-btn.quiet.armed { color: var(--hot); font-weight: 600; }
99
+ /* Needs you: "Show N more", "can wait until tomorrow" and a source that could not be read (app.js renderNeeds). */
100
+ .need-more { display: block; width: 100%; text-align: left; padding: 12px 16px; min-height: 44px; border-top: 1px solid var(--row-border); color: var(--accent); font-weight: 600; font-size: 15px; }
101
+ .need-more.need-failed { color: var(--hot); font-weight: 500; }
102
+ /* Next up: Prep me and Open meeting (app.js renderNext). */
103
+ .next-actions { display: flex; flex-wrap: wrap; align-items: center; gap: 4px 18px; padding: 0 16px 14px; }
104
+ .next-actions:empty { display: none; }
105
+ .next-actions .btn.small { min-height: 44px; }
106
+ /* The Ask sheet's File to Work inbox result, on its own row (app.js fileNoteHtml). */
107
+ .nav-row .note.file-error { color: var(--hot); white-space: normal; }
108
+ /* Running card when the session list could not be read (app.js renderRunning). */
109
+ .run-more.run-failed { color: var(--run-open); }