@gotcos/glasses-server 7.0.29 → 7.0.30
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.
- package/CHANGELOG.md +30 -0
- package/docs/web-key-verifier-contract.md +33 -7
- package/package.json +1 -1
- package/server/lib/provider-failure-text.ts +4 -1
- package/server/lib/web-key-authority.ts +1 -1
- package/server/lib/web-key-registry.ts +1 -1
- package/server/lib/web-key-signer-tls.ts +17 -7
- package/server/web-control/.manifest.json +15 -15
- package/server/web-control/app.css +6 -4
- package/server/web-control/app.js +162 -16
- package/server/web-control/control.css +32 -2
- package/server/web-control/index.html +2 -0
- package/server/web-control/panes/cos-chat.css +5 -1
- package/server/web-control/panes/cos-chat.js +9 -3
- package/server/web-control/panes/meetings.css +4 -0
- package/server/web-control/panes/meetings.js +25 -6
- package/server/web-control/panes/memories.js +2 -2
- package/server/web-control/panes/messages.js +7 -2
- package/server/web-control/panes/settings.js +1 -1
- package/server/web-control/panes/speakers.js +28 -3
- package/server/web-control/panes/today-actions.js +2 -2
- package/server/web-control/panes/work.js +1 -1
- package/server/web-control/shell.js +2 -1
- package/server/web-signer/.manifest.json +2 -2
- package/server/web-signer/signer-proof.js +18 -3
- package/server/web-signer/signer.js +4 -2
- package/shared/web-turn-envelope.ts +33 -8
package/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,33 @@
|
|
|
1
|
+
## 7.0.30 - Control Web: Back goes the way you came, a phone shows how you got here, a message shows who said what, Reply stays at the bottom, Meetings opens on today, and clearer copy buttons
|
|
2
|
+
|
|
3
|
+
2026-11-12. 7.0.29 plus eight Control Web changes from a tester's phone feedback, one clearer sign-in error, and phone-approval changes that stay off unless switched on.
|
|
4
|
+
|
|
5
|
+
- **Back goes the way you came.** Back now returns to the page you came from, not a fixed parent. Open a meeting from Speakers, and Back takes you to Speakers, not to Meetings. It is the browser's own Back, so the two always agree, and it adds nothing to the browser's history. Opened straight from a link or after a reload, Back goes one level up as before. Back names where it goes ("Speakers", "Meetings"). On screens 600 px and wider, the navigation bar's Back keeps the word "Back" and says where it goes in its name and tooltip ("Back to Speakers").
|
|
6
|
+
- **Back stays in reach on a tall phone.** On a page you are reading (a session, a message, a meeting), the top bar with Back no longer slides away as you scroll down.
|
|
7
|
+
- **Reply stays at the bottom of a message.** Opening a message from Messages, the Reply box now sits at the bottom of the screen while you read, and above the keyboard while you type, as the COS chat already did.
|
|
8
|
+
- **Speakers: the copy buttons say they copy.** "Summary" and "Full (N KB)" now read "Copy summary" and "Copy full (N KB)". The one you tap says "Copied" for a moment, and a screen reader hears what was copied.
|
|
9
|
+
- **Meetings opens on today.** The calendar opens on today, marked with a ring, even when today has no meetings. Then the list says "No meetings today." with one tap to the latest earlier day this month that has meetings (or to last month when this month has none).
|
|
10
|
+
- **Settings says "your phone's camera"**, not "your iPhone Camera". The page is not only for iPhones.
|
|
11
|
+
- **A message shows who said what by side.** Your words sit on the right in the COS chat's gold bubble, with "You" under it; COS's answer sits on the left beside its mark. The two cards marked YOU and COS are gone. The bubble's edge is darker in light mode, so it reads against the page (3.48 to 1; it was 1.52 to 1). A screen reader hears "You said" and "COS said". The same applies to a chat opened from the archive.
|
|
12
|
+
- **A phone shows how you got here.** On a page you opened (a meeting, a message, a session), one line under the top bar names the steps that led there, for example "Today / Speakers / Naming voices / Meeting". Each earlier step is a link that goes back to it, the same way Back does; the page you are on is last and is not a link. The line never wraps: a long path drops steps from the middle and shows "…" in their place. There is no line on a pane's list, on a page opened from a link, or after a reload. Screens 600 px and wider are unchanged; they keep the navigation bar.
|
|
13
|
+
- Tested in a browser (WebKit) at a tall iPhone size, the folded Duo with its side rail, and the open Duo, in light and dark. Before this build the same checks fail. Not yet tested on a real phone; Android was not tested.
|
|
14
|
+
|
|
15
|
+
Server:
|
|
16
|
+
|
|
17
|
+
- **A sign-in that ran out says so.** When Claude Code reports "Failed to authenticate: OAuth session expired and could not be refreshed", COS now names it as a sign-in problem, not "provider process exited". The fork check and the provider check read the same words, so they say so too.
|
|
18
|
+
- **Phone approvals, off by default, unchanged for everyone until switched on:**
|
|
19
|
+
- A phone can sign every action this build supports except pushing main, which stays typed at the desk (Miles, 2026-11-12 11:11).
|
|
20
|
+
- The phone names what each approval includes, from one fixed line per channel (for example, the push that a publish makes).
|
|
21
|
+
- The phone-approval signer refuses a stored key or folder that other users can read (stored_readable). It runs only when the phone-approval switch is on.
|
|
22
|
+
- The web-key mutation gate's two dead targets now match. The script ships in the npm pack, so pack bytes change; server behaviour does not.
|
|
23
|
+
|
|
24
|
+
**Default-on for every install:** yes, all eight, with no switch, at every screen size (the how-you-got-here line below 600 px only). The sign-in line is on for everyone. The phone-approval items do nothing while the switch is off, which is the default everywhere.
|
|
25
|
+
|
|
26
|
+
### If you roll back
|
|
27
|
+
|
|
28
|
+
- To 7.0.29: Back goes to a fixed parent again, the top bar tucks away on a reader, the message reader's Reply is at the end of the page, the copy buttons say Summary and Full, Meetings opens on today only when today has meetings, Settings says iPhone, a message shows the YOU and COS cards, the how-you-got-here line is gone, and an expired Claude sign-in reads "provider process exited" again. Nothing is stored.
|
|
29
|
+
- Further back: as in 7.0.29 below.
|
|
30
|
+
|
|
1
31
|
## 7.0.29 - Control Web: on the folded Duo, the panes sit in a side rail at the right edge, and Back is at thumb height
|
|
2
32
|
|
|
3
33
|
2026-11-12. 7.0.28 plus Control Web changes: a side rail for the folded Duo only, and small Sessions list changes for every screen.
|
|
@@ -84,8 +84,9 @@ bumps the epoch.
|
|
|
84
84
|
- `WEB_ANCHOR_MATURITY_MS = 86_400_000` (24 h). An anchor younger than that vouches nothing
|
|
85
85
|
(`anchor_too_new`).
|
|
86
86
|
- `setAt` is file-backed, so a same-user process can backdate it. The 24 h rule binds the server's
|
|
87
|
-
own route
|
|
88
|
-
|
|
87
|
+
own route, and the verifier refuses when the pin file is missing, uncommitted, or its `spkiSha256`
|
|
88
|
+
differs from `anchor.spkiSha256` in the answer. Accepted limit (Miles, 2026-11-12): a process running
|
|
89
|
+
as the owner on this Mac can forge the whole chain, pin included.
|
|
89
90
|
|
|
90
91
|
## 3. Enrollment (anchor-signed approval)
|
|
91
92
|
|
|
@@ -176,7 +177,7 @@ footer as received (CRLF to LF only) and needs no trimming rule of its own.
|
|
|
176
177
|
|
|
177
178
|
| Field | Pattern |
|
|
178
179
|
| --- | --- |
|
|
179
|
-
| action | `install` \| `publish`
|
|
180
|
+
| action | `install` \| `publish` |
|
|
180
181
|
| artifact | `^[a-z0-9@][a-z0-9@._/-]{0,127}$` |
|
|
181
182
|
| version | `^[0-9]{1,5}\.[0-9]{1,5}\.[0-9]{1,6}(-[0-9A-Za-z.-]{1,32})?$` |
|
|
182
183
|
| digest | `^sha256:[0-9a-f]{64}$` or `^sha512:[0-9a-f]{128}$` or `^sha512-[A-Za-z0-9+/]{86}==$` (npm SRI) |
|
|
@@ -187,9 +188,34 @@ footer as received (CRLF to LF only) and needs no trimming rule of its own.
|
|
|
187
188
|
| --- | --- |
|
|
188
189
|
| install | `mac-local` |
|
|
189
190
|
| publish | `npm-next`, `npm-latest`, `control-appcast`, `hub-alias`, `gotcos-site`, `control-downloads`, `release-docs` |
|
|
190
|
-
| push-main | `git-main` |
|
|
191
191
|
|
|
192
|
-
|
|
192
|
+
**Pushing main is never signable by phone** (Miles, typed 2026-11-12 11:11: "allow for all action but the push to
|
|
193
|
+
main. That way we can resolve when at the desk"). It stays typed at the desk. The `push-main` action and its `git-main`
|
|
194
|
+
channel are not expressible: a scope naming them is refused at the grammar (`scope_not_allowed`), and the packet route
|
|
195
|
+
refuses to create one (`action_not_allowed`). Pushing a feature branch and adding a versioned Hub file have no
|
|
196
|
+
`approve` form. `install` and `publish` are accepted only through exact scoped proof (`WEB_KEY_ENABLED_APPROVE_ACTIONS`).
|
|
197
|
+
A message scope never authorizes either action. The phone's signer page keeps its own copy of this table
|
|
198
|
+
(`server/web-signer/signer-proof.js`); a test fails if the two differ.
|
|
199
|
+
|
|
200
|
+
**What each channel's approval includes** (Miles, 2026-11-12: "Publish includes its own push"). A publish approval
|
|
201
|
+
covers the push that the publish consists of, and nothing else. Pushing the code `main` of any repo (the server, the
|
|
202
|
+
COS repo) stays typed at the desk. The phone shows the line below for the signed channel, taken from the channel alone
|
|
203
|
+
(`APPROVE_CHANNEL_EFFECT` in `shared/web-turn-envelope.ts`, copied as `CHANNEL_EFFECT` in the signer page and pinned to
|
|
204
|
+
it by test), never from text the requester sent. A channel not in the table shows nothing and is refused.
|
|
205
|
+
|
|
206
|
+
| Channel | The push or upload it includes | Line the phone shows |
|
|
207
|
+
| --- | --- | --- |
|
|
208
|
+
| `mac-local` | none | Installs on this Mac. No git push. |
|
|
209
|
+
| `npm-next` | none (npm registry only, from the tested package) | Publishes to npm under the next tag, from the tested package. No git push. |
|
|
210
|
+
| `npm-latest` | none (npm registry only, from the tested package) | Publishes to npm under the latest tag, from the tested package. No git push. |
|
|
211
|
+
| `control-appcast` | `ukaoma/cos-starter` `main` (`control/appcast.json`, served by GitHub Pages) | Pushes ukaoma/cos-starter main (control/appcast.json): the COS Control update feed on gotcos.com. |
|
|
212
|
+
| `hub-alias` | `ukaoma/evenhub-g2-cos` `main` (the alias file) | Pushes ukaoma/evenhub-g2-cos main: moves the Even Hub alias to this package. |
|
|
213
|
+
| `gotcos-site` | `ukaoma/cos-starter` `main` (GitHub Pages) | Pushes ukaoma/cos-starter main: republishes www.gotcos.com. |
|
|
214
|
+
| `control-downloads` | a GitHub Release on `ukaoma/cos-starter` (tag `control-v<version>`, the ZIP and its `.sha256`); no branch push. The site's links move only with `gotcos-site` or `control-appcast`, each its own approval | Creates a GitHub Release on ukaoma/cos-starter (a tag, no branch push) with the ZIP and its SHA-256 file. |
|
|
215
|
+
| `release-docs` | `ukaoma/cos-starter` `main` (`docs/`, GitHub Pages) | Pushes ukaoma/cos-starter main (docs/): republishes the public COS docs. |
|
|
216
|
+
|
|
217
|
+
The npm release flow as practised also commits the release files to the server repo and pushes its `main`
|
|
218
|
+
(cos-glasses skill, step 5). That push is NOT part of an npm approval: it is a code `main`, typed at the desk.
|
|
193
219
|
|
|
194
220
|
### Handoff from Control Web (3143) to the signer (3144), Phase 3
|
|
195
221
|
|
|
@@ -324,8 +350,8 @@ mirror) and refuses on difference; `approval.epoch` and `envelope.epoch` must eq
|
|
|
324
350
|
`credentials`, `payment-details`, `moving-money`, `deleting-data`, `sending-messages`,
|
|
325
351
|
`security-or-permission-settings`, `harness-permission-prompts`, `authority-rule-changes`.
|
|
326
352
|
For `message` scope it also includes `install`, `publish`, `push-main`, `push-branch`, `hub-file`.
|
|
327
|
-
For `approve` scope it also includes every action other than the signed one,
|
|
328
|
-
`other-artifact-version-digest-or-channel`.
|
|
353
|
+
For `approve` scope it also includes every action other than the signed one (`push-main` always, though it is not
|
|
354
|
+
signable), and `other-artifact-version-digest-or-channel`.
|
|
329
355
|
|
|
330
356
|
**Attachments:** until `shared/turn-files-block.ts` exists, the verifier refuses any non-empty
|
|
331
357
|
`envelope.attachments`.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@gotcos/glasses-server",
|
|
3
|
-
"version": "7.0.
|
|
3
|
+
"version": "7.0.30",
|
|
4
4
|
"description": "COS Glasses: self-hosted AI heads-up-display server for Even G2 smart glasses, powered by Claude Code, Codex, Cursor Agent CLI, or local Ollama",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -6,7 +6,10 @@
|
|
|
6
6
|
* process machinery. Leaf module: no imports.
|
|
7
7
|
*/
|
|
8
8
|
export const PROVIDER_QUOTA_RE = /usage[ _]limit|hit your (?:usage |session )?limit|session limit|rate.?limit|too many requests|\b429\b|\bquota\b|resets? (?:at|in) |over capacity|overloaded|\b529\b|plan limit/i
|
|
9
|
-
export const PROVIDER_AUTH_RE = /not logged in|please (?:log ?in|sign in)|invalid api key|authentication|unauthori[sz]ed|\b401\b|\b403\b|login required|token (?:has )?expired|no credentials/i
|
|
9
|
+
export const PROVIDER_AUTH_RE = /not logged in|failed to authenticate|please (?:log ?in|sign in)|invalid api key|authentication|unauthori[sz]ed|\b401\b|\b403\b|login required|token (?:has )?expired|no credentials/i
|
|
10
|
+
// "Failed to authenticate" (7.0.30): claude -p says "Failed to authenticate: OAuth session expired and could not
|
|
11
|
+
// be refreshed" for an expired sign-in, which matched none of the words above, so the reason fell back to
|
|
12
|
+
// "provider process exited N". The four messages Claude Code documents are pinned in provider-failure-text.test.ts.
|
|
10
13
|
export const PROVIDER_OVERFLOW_RE = /prompt is too long|context window|too many tokens|exceeds the (?:model|context)/i
|
|
11
14
|
|
|
12
15
|
/** Codes a provider bridge raises when it has nothing better to say. Only these are upgraded. */
|
|
@@ -35,7 +35,7 @@ import {
|
|
|
35
35
|
|
|
36
36
|
export const WEB_ENVELOPE_HEADER = 'x-cos-web-envelope'
|
|
37
37
|
export const WEB_SIGNATURE_HEADER = 'x-cos-web-sig'
|
|
38
|
-
/** Exact scoped install/publish only. Push-main
|
|
38
|
+
/** Exact scoped install/publish only. Push-main is not expressible at all (APPROVE_ACTIONS, Miles 2026-11-12). */
|
|
39
39
|
export const WEB_KEY_ENABLED_APPROVE_ACTIONS: ReadonlySet<string> = new Set(['install', 'publish'])
|
|
40
40
|
|
|
41
41
|
/** The served-asset integrity state the authority reads (review W1). Null: integrity is not running. */
|
|
@@ -779,7 +779,7 @@ export class WebKeyPackets {
|
|
|
779
779
|
* session that created its packet.
|
|
780
780
|
*/
|
|
781
781
|
create(input: Record<string, unknown>, creator: { provider: unknown; sessionId: unknown } | null): { ok: true; packet: WebApprovalPacket; packetSha256: string } | { ok: false; reason: 'action_not_allowed' | 'invalid_packet' | 'packet_cap_reached' | 'session_from_context_only' | 'creator_unknown' } {
|
|
782
|
-
// The closed action list: install
|
|
782
|
+
// The closed action list: install and publish (push-main is never signable by phone, 2026-11-12). Every never-list action is refused here.
|
|
783
783
|
if (typeof input.action !== 'string' || !APPROVE_ACTIONS.has(input.action)) return { ok: false, reason: 'action_not_allowed' }
|
|
784
784
|
if ('provider' in input || 'sessionId' in input) return { ok: false, reason: 'session_from_context_only' }
|
|
785
785
|
// Strict patterns plus the action-to-channel table (shared/web-turn-envelope.ts).
|
|
@@ -178,6 +178,7 @@ export const SIGNER_TLS_REASONS = Object.freeze([
|
|
|
178
178
|
'stored_owner',
|
|
179
179
|
'stored_writable',
|
|
180
180
|
'stored_too_large',
|
|
181
|
+
'stored_readable',
|
|
181
182
|
] as const)
|
|
182
183
|
|
|
183
184
|
export type SignerTlsReason = typeof SIGNER_TLS_REASONS[number]
|
|
@@ -377,13 +378,22 @@ function firstLine(s: string): string {
|
|
|
377
378
|
return (s.split('\n').find(l => l.trim()) ?? '').replace(/[^\x20-\x7e]/g, '').slice(0, 160)
|
|
378
379
|
}
|
|
379
380
|
|
|
380
|
-
type StoredRefusal = 'stored_symlink' | 'stored_not_file' | 'stored_owner' | 'stored_writable' | 'stored_too_large'
|
|
381
|
+
type StoredRefusal = 'stored_symlink' | 'stored_not_file' | 'stored_owner' | 'stored_writable' | 'stored_too_large' | 'stored_readable'
|
|
381
382
|
type StoredRead = { kind: 'none' } | { kind: 'refused'; reason: StoredRefusal } | { kind: 'ok'; text: string }
|
|
382
383
|
|
|
383
|
-
/**
|
|
384
|
-
|
|
384
|
+
/**
|
|
385
|
+
* SR-08: the owner and mode rules shared by the folder and each file.
|
|
386
|
+
*
|
|
387
|
+
* `secret` (security review 2026-11-12, finding 5): the folder and the private key may carry no group or other
|
|
388
|
+
* bits at all, read and search included, so a key another user can read is never served. The certificate is
|
|
389
|
+
* public and the fetch state holds counts only, so there only write bits are refused. This server has written
|
|
390
|
+
* the folder 0700, the key 0600, the state 0600 and the certificate 0644 since the store was added (d4f3ee6,
|
|
391
|
+
* 2026-11-09), so no pair it wrote is refused by this rule.
|
|
392
|
+
*/
|
|
393
|
+
function ownerOrModeRefusal(uid: number, mode: number, ownerUid: number | null, secret = false): StoredRefusal | null {
|
|
385
394
|
if (ownerUid !== null && uid !== ownerUid) return 'stored_owner'
|
|
386
395
|
if ((mode & 0o022) !== 0) return 'stored_writable'
|
|
396
|
+
if (secret && (mode & 0o077) !== 0) return 'stored_readable'
|
|
387
397
|
return null
|
|
388
398
|
}
|
|
389
399
|
|
|
@@ -396,7 +406,7 @@ function storedDirRefusal(dir: string, ownerUid: number | null): 'none' | Stored
|
|
|
396
406
|
if (!st) return 'none'
|
|
397
407
|
if (st.isSymbolicLink()) return 'stored_symlink'
|
|
398
408
|
if (!st.isDirectory()) return 'stored_not_file'
|
|
399
|
-
return ownerOrModeRefusal(st.uid, st.mode, ownerUid)
|
|
409
|
+
return ownerOrModeRefusal(st.uid, st.mode, ownerUid, true)
|
|
400
410
|
}
|
|
401
411
|
|
|
402
412
|
/**
|
|
@@ -404,7 +414,7 @@ function storedDirRefusal(dir: string, ownerUid: number | null): 'none' | Stored
|
|
|
404
414
|
* (no swap between the check and the read): a regular file, owned by this server's user, not writable by
|
|
405
415
|
* group or others, at most SIGNER_TLS_STORED_MAX_BYTES (read with that cap, never the whole file).
|
|
406
416
|
*/
|
|
407
|
-
function readStoredFile(path: string, ownerUid: number | null): StoredRead {
|
|
417
|
+
function readStoredFile(path: string, ownerUid: number | null, secret = false): StoredRead {
|
|
408
418
|
const pre = lstatSync(path, { throwIfNoEntry: false })
|
|
409
419
|
if (!pre) return { kind: 'none' }
|
|
410
420
|
if (pre.isSymbolicLink()) return { kind: 'refused', reason: 'stored_symlink' }
|
|
@@ -420,7 +430,7 @@ function readStoredFile(path: string, ownerUid: number | null): StoredRead {
|
|
|
420
430
|
try {
|
|
421
431
|
const st = fstatSync(fd)
|
|
422
432
|
if (!st.isFile()) return { kind: 'refused', reason: 'stored_not_file' }
|
|
423
|
-
const refusal = ownerOrModeRefusal(st.uid, st.mode, ownerUid)
|
|
433
|
+
const refusal = ownerOrModeRefusal(st.uid, st.mode, ownerUid, secret)
|
|
424
434
|
if (refusal) return { kind: 'refused', reason: refusal }
|
|
425
435
|
if (st.size > SIGNER_TLS_STORED_MAX_BYTES) return { kind: 'refused', reason: 'stored_too_large' }
|
|
426
436
|
const buf = Buffer.alloc(SIGNER_TLS_STORED_MAX_BYTES + 1)
|
|
@@ -761,7 +771,7 @@ export class SignerTls {
|
|
|
761
771
|
if (dir === 'none') { this.storedRefused = null; return }
|
|
762
772
|
if (dir !== null) { this.storedRefused = dir; return }
|
|
763
773
|
const cert = readStoredFile(join(this.deps.dir, SIGNER_TLS_CERT_FILE), this.ownerUid)
|
|
764
|
-
const key = readStoredFile(join(this.deps.dir, SIGNER_TLS_KEY_FILE), this.ownerUid)
|
|
774
|
+
const key = readStoredFile(join(this.deps.dir, SIGNER_TLS_KEY_FILE), this.ownerUid, true)
|
|
765
775
|
if (cert.kind === 'none' || key.kind === 'none') { this.storedRefused = null; return }
|
|
766
776
|
if (cert.kind === 'refused') { this.storedRefused = cert.reason; return }
|
|
767
777
|
if (key.kind === 'refused') { this.storedRefused = key.reason; return }
|
|
@@ -2,42 +2,42 @@
|
|
|
2
2
|
"v": 1,
|
|
3
3
|
"algorithm": "sha256",
|
|
4
4
|
"files": {
|
|
5
|
-
"/app.css": "
|
|
6
|
-
"/app.js": "
|
|
5
|
+
"/app.css": "bc5e29be841629261889aebfd439a3b126a50041c4e079c9866eac93a3552fe1",
|
|
6
|
+
"/app.js": "eb9e4b98ddf4982644c57ce7de11fb9a606e245b5e2169bb25adbe825c05773d",
|
|
7
7
|
"/apple-touch-icon.png": "04ec7eca0bf6f53d89900d146ac32d4826759d6258bcdbd1ccdcc517f26f532c",
|
|
8
|
-
"/control.css": "
|
|
8
|
+
"/control.css": "f5b7a2e0f800cc07072581b7e817fcd2b54bdb1d91f4662a3cdc9d80a7bc32ff",
|
|
9
9
|
"/fonts/DMSans.ttf": "8cd08d97e89c24d0aa92edd2f0f4c8ee6195eee9b7c9f154865a58b02f0c1c0d",
|
|
10
10
|
"/fonts/Fraunces.ttf": "177ff6c0f14e5550a3c624247cd1189611d4eb65d000b14944c63d967958abbb",
|
|
11
11
|
"/fonts/JetBrainsMono.ttf": "48715a42ec242c21e9f02692891e147d022299a52e48d5e413e1a942193ffeda",
|
|
12
|
-
"/index.html": "
|
|
12
|
+
"/index.html": "c3a88479e5b9efa323129c5a425dada70dc7f0f27a0c787c1bc225c661f20a7b",
|
|
13
13
|
"/mark.svg": "8e380d33d393027df192eb1afa08f15a380c2070c72d68f27311b49d9e94ca18",
|
|
14
14
|
"/memories/d3.min.js": "f2094bbf6141b359722c4fe454eb6c4b0f0e42cc10cc7af921fc158fceb86539",
|
|
15
15
|
"/memories/graph-explorer.css": "ec128de59811f45c71b6bededd737989309205e903951eb8211ade7664f87486",
|
|
16
16
|
"/memories/graph-explorer.js": "98bde3df25f42022b098d047d2c8e7a22b972c1d817a18a24a0ab075d77871d3",
|
|
17
17
|
"/memories/memory-workspace.js": "013f6c8c72c3474768f8c374edccde0e3656fc243e56528e258c3c29acca64f5",
|
|
18
18
|
"/memory-review.js": "f27fe519f7ad7eccc3b44124fae724aa51e2eb3cb060d2f1795600e0e41b074f",
|
|
19
|
-
"/panes/cos-chat.css": "
|
|
20
|
-
"/panes/cos-chat.js": "
|
|
19
|
+
"/panes/cos-chat.css": "079af47855641bf21aae75c34727f7c7be4357e0dde4ed78414d0e60776b71d4",
|
|
20
|
+
"/panes/cos-chat.js": "b94fca907500be21f777c5d5fa23b7415be48dcbd41d9fa30a4295ed1e3415d3",
|
|
21
21
|
"/panes/dictation.js": "e9434b353a1a3ab3f21d4cef5b2d5f927a733f6af5f58285fda6a78472761f91",
|
|
22
|
-
"/panes/meetings.css": "
|
|
23
|
-
"/panes/meetings.js": "
|
|
22
|
+
"/panes/meetings.css": "635470e4d5f5665b046eab7382734c9772df569069c9b1eb10cfde6151014bfa",
|
|
23
|
+
"/panes/meetings.js": "e057f4dae697138111a4c07e8ec6c2a0502b5d93023cf491460bc7bf20172c85",
|
|
24
24
|
"/panes/memories.css": "36731d6fd21ad1516dfeccf34fe5b2d3d267d21e40b47704055a0685334b579e",
|
|
25
|
-
"/panes/memories.js": "
|
|
26
|
-
"/panes/messages.js": "
|
|
25
|
+
"/panes/memories.js": "66341dd5126c40275979d1c40f8db72e1f8229ac9634a4bb72b70140db277f2a",
|
|
26
|
+
"/panes/messages.js": "7262ad82b6337a610b11ffff0677663e9317d1fe537f9e4161ae3af1ddef35ef",
|
|
27
27
|
"/panes/preview.js": "79f1aecb27f48d584ed4cf37c7f4f423f0f9b45dd6c2fb24a9d88c72625d8680",
|
|
28
28
|
"/panes/questions.css": "5c5a9b32ce32386b508ce326b17d40ecacbb0f2e049df7f4fe2a0009bd3b7908",
|
|
29
29
|
"/panes/questions.js": "228e8cd0a9651a290cbb380db487af677a2fc7e93a64b1e33c9411cd8b013478",
|
|
30
30
|
"/panes/reader-media.js": "4cf892a855aa2d0dce6978e69296cd49b8036d8fc62f12773b3fcadc210b2ef0",
|
|
31
31
|
"/panes/sessions.css": "654e0509ab2b699b9ea92b6d9e2f7522146df1f0b6355915bef03caaf63addc4",
|
|
32
|
-
"/panes/settings.js": "
|
|
32
|
+
"/panes/settings.js": "315458c5911f31592853800418dde6d87e9e61bbe96f90c77450c94f87d01951",
|
|
33
33
|
"/panes/speakers.css": "49e70dae4266151b14b0057f2e43de169de225b8f3ac30343c81cf532735a487",
|
|
34
|
-
"/panes/speakers.js": "
|
|
34
|
+
"/panes/speakers.js": "e4fbcc147c44f8bf798e0694e0c8b152d5cde82dc46823641c8e8d37714b8fba",
|
|
35
35
|
"/panes/threads.js": "45fd7b56480be53225e91193cfa4c41a4ee7167890f10560e439bc7d2461747f",
|
|
36
36
|
"/panes/today-actions.css": "c82f95b2aaf09802b15d47b24eccdb5c974afcab38ff5f861b3a8d8630b60869",
|
|
37
|
-
"/panes/today-actions.js": "
|
|
37
|
+
"/panes/today-actions.js": "c767b62b21a9f1debffd6e4ff4558891222b573535de1d78849b602030bb09bd",
|
|
38
38
|
"/panes/work.css": "da50355dac690f712d1f6331f70ee5eadbf5982d37508648ae7786afc99ac7b1",
|
|
39
|
-
"/panes/work.js": "
|
|
40
|
-
"/shell.js": "
|
|
39
|
+
"/panes/work.js": "913c56084a6df463cc83358ce360da30d529466f1c583683fd3a4fd5eafe2d30",
|
|
40
|
+
"/shell.js": "cc7d0d771712607d4ba49eb1cfc83c50c7fcae2203f1c386778fee43d1803a81",
|
|
41
41
|
"/theme.js": "e3e6422eaac43979d03149da114f5b83757b7361dd7b309cdf76be36a596044b",
|
|
42
42
|
"/vendor/qrcode-encoder.js": "61d87cc7445cc66b862cff913de4571634746ea62bcd50d0f592be14f6d1ce46"
|
|
43
43
|
}
|
|
@@ -206,8 +206,9 @@ select.field {
|
|
|
206
206
|
|
|
207
207
|
/* No bottom bar, on purpose. On a phone the browser owns the bottom edge (Safari's floating
|
|
208
208
|
address pill, Comet's and Chrome's own bars), and iOS 26 WebKit stops drawing the page under
|
|
209
|
-
its bar while any fixed element sits within ~60px of it. Navigation lives in the top bar
|
|
210
|
-
|
|
209
|
+
its bar while any fixed element sits within ~60px of it. Navigation lives in the top bar (phones),
|
|
210
|
+
the side rail at the right edge (a short screen under 600 px: the folded Duo, html.short-screen),
|
|
211
|
+
or the left rail (600 px and up). */
|
|
211
212
|
.badge-dot { position: absolute; width: 7px; height: 7px; border-radius: 50%; background: var(--hot); }
|
|
212
213
|
|
|
213
214
|
.rail { display: none; }
|
|
@@ -702,8 +703,9 @@ html { touch-action: manipulation; }
|
|
|
702
703
|
html.short-screen .rail-back:active { background: var(--rail-border); }
|
|
703
704
|
/* The focus ring sits inside the button: the rail clips anything past its edge. */
|
|
704
705
|
html.short-screen .rail :focus-visible { outline: 2px solid var(--accent); outline-offset: -4px; border-radius: 12px; }
|
|
705
|
-
/* One way to switch panes on a screen: the rail.
|
|
706
|
-
(app.js makes the button inert)
|
|
706
|
+
/* One way to switch panes on a screen: the rail. On a list the top bar keeps the pane's name, without the caret and
|
|
707
|
+
its menu (app.js makes the button inert); on a reader its Back moves to the rail and the left of the top bar is
|
|
708
|
+
empty. */
|
|
707
709
|
html.short-screen .section-btn .caret, html.short-screen #topbar-back { display: none !important; }
|
|
708
710
|
html.short-screen .section-btn { cursor: default; }
|
|
709
711
|
}
|
|
@@ -573,6 +573,133 @@
|
|
|
573
573
|
var hash = '#/' + [pane].concat(args || []).map(encodeURIComponent).join('/');
|
|
574
574
|
if (location.hash === hash) route(); else location.hash = hash;
|
|
575
575
|
}
|
|
576
|
+
// ── Back by path (7.0.30, item 1: Anand on the 11-12 HC call, Miles's pick) ──
|
|
577
|
+
// Back goes where the person came from. Each address this page makes is marked in its own history entry
|
|
578
|
+
// (history.state.cosNav): this page load's id, the entry's position, and the address it came from. Back is then the
|
|
579
|
+
// browser's own Back (history.back), so the system Back and ours agree and Back adds no entry. With no mark (a deep
|
|
580
|
+
// link, a reload, the first page, or an entry an overlay took over), Back keeps the parent chain it always had, and it
|
|
581
|
+
// REPLACES the entry rather than adding one, so the two can never bounce between each other. The image viewer and
|
|
582
|
+
// Today's sheet push entries of their own (cosImageViewer, cosTodayOverlay): those are never marked, never stepped into,
|
|
583
|
+
// and an address made while one of them still sat behind gets no mark, because the entry behind it is not an address
|
|
584
|
+
// (its position is not the last address's position plus one).
|
|
585
|
+
var NAV = { doc: Math.random().toString(36).slice(2, 10), last: null, replace: null };
|
|
586
|
+
function navState() { try { return history.state; } catch (_e) { return null; } }
|
|
587
|
+
function navOverlay(st) { return !!(st && (st.cosImageViewer || st.cosTodayOverlay)); }
|
|
588
|
+
function navStamp() {
|
|
589
|
+
if (typeof history === 'undefined' || !history || typeof location === 'undefined') return; // a page without them: the parent chain
|
|
590
|
+
var hash = location.hash || '', st = navState(), pos;
|
|
591
|
+
if (navOverlay(st)) { NAV.last = null; NAV.replace = null; return; }
|
|
592
|
+
if (st && st.cosNav && st.cosNav.doc === NAV.doc) { NAV.last = { hash: hash, pos: st.cosNav.pos, back: st.cosNav.back, trail: st.cosNav.trail || null, label: st.cosNav.label || '' }; NAV.replace = null; return; }
|
|
593
|
+
if (st && st.cosNav) {
|
|
594
|
+
// Marked by an earlier load of this page (a reload): what lies behind it belongs to that load, so no path.
|
|
595
|
+
pos = st.cosNav.pos;
|
|
596
|
+
} else if (st) { NAV.last = null; NAV.replace = null; return; } // someone else's state: leave it alone
|
|
597
|
+
else pos = NAV.replace ? NAV.replace.pos : history.length - 1;
|
|
598
|
+
var back = null;
|
|
599
|
+
if (!st && !NAV.replace && NAV.last && typeof NAV.last.pos === 'number' && pos === NAV.last.pos + 1) back = NAV.last.hash;
|
|
600
|
+
NAV.replace = null;
|
|
601
|
+
// 7.0.30, Miles's pick B: the steps before this one ride in the same mark, the last of them being where Back goes.
|
|
602
|
+
var trail = back ? (NAV.last.trail || []).concat([{ h: NAV.last.hash, t: NAV.last.label || '' }]).slice(-NAV_TRAIL_MAX) : null;
|
|
603
|
+
var mark = { doc: NAV.doc, pos: pos, back: back };
|
|
604
|
+
if (trail) mark.trail = trail;
|
|
605
|
+
try { history.replaceState(Object.assign({}, st || {}, { cosNav: mark }), ''); } catch (_e) { /* no history API: the parent chain */ }
|
|
606
|
+
NAV.last = { hash: hash, pos: pos, back: back, trail: trail, label: '' };
|
|
607
|
+
}
|
|
608
|
+
var NAV_TRAIL_MAX = 12;
|
|
609
|
+
// The page's own name, kept in its mark, so the next page's trail can name it. setCrumb calls this.
|
|
610
|
+
function navLabel(text) {
|
|
611
|
+
var st = navState();
|
|
612
|
+
if (navOverlay(st) || !st || !st.cosNav || st.cosNav.doc !== NAV.doc || (st.cosNav.label || '') === text) return;
|
|
613
|
+
try { history.replaceState(Object.assign({}, st, { cosNav: Object.assign({}, st.cosNav, { label: text }) }), ''); } catch (_e) { return; }
|
|
614
|
+
if (NAV.last && NAV.last.pos === st.cosNav.pos) NAV.last.label = text;
|
|
615
|
+
}
|
|
616
|
+
// The way he came, oldest first, from this entry's mark: [{ label, hash, back }], back being how many steps back that
|
|
617
|
+
// one is. Null when Back has no path (a deep link, a reload, the first page, an overlay).
|
|
618
|
+
function navTrail() {
|
|
619
|
+
var st = navState();
|
|
620
|
+
if (navOverlay(st) || !st || !st.cosNav || !st.cosNav.back || !st.cosNav.trail || !st.cosNav.trail.length) return null;
|
|
621
|
+
var t = st.cosNav.trail;
|
|
622
|
+
return t.map(function (s, i) { return { label: trailName(s.h, s.t), hash: s.h, back: t.length - i }; });
|
|
623
|
+
}
|
|
624
|
+
// A step is named by what kind of page it is, not its title (the title is the heading under the row): "Meeting",
|
|
625
|
+
// "Session", "Naming voices". A list is its pane. A message, a day and a chat keep their own short name.
|
|
626
|
+
function trailName(hash, crumb) {
|
|
627
|
+
var a = String(hash || '').replace(/^#\/?/, '').split('/').filter(function (x) { return x !== ''; }), p = ROOT_PANES.indexOf(a[0]) >= 0 ? a[0] : 'today';
|
|
628
|
+
if (a.length < 2 || isTabRoute(a.slice(1))) return TITLES[p] || 'Today';
|
|
629
|
+
if (p === 'meetings') return 'Meeting';
|
|
630
|
+
if (p === 'sessions') return 'Session';
|
|
631
|
+
if (p === 'speakers' && a[1] === 'review') return 'Naming voices';
|
|
632
|
+
return crumb || TITLES[p] || 'Today';
|
|
633
|
+
}
|
|
634
|
+
// One line that never wraps. When it is too wide, steps leave from the middle (the one after the first, then the next),
|
|
635
|
+
// never the first, the one before this page, or this page; "…" stands where they were. widths: each step's, then this
|
|
636
|
+
// page's; more: the width "…" adds. Returns the indexes to leave out.
|
|
637
|
+
function trailCut(widths, more, avail) {
|
|
638
|
+
var n = widths.length - 1, total = widths.reduce(function (a, b) { return a + b; }, 0), cut = [];
|
|
639
|
+
if (total <= avail) return cut;
|
|
640
|
+
total += more;
|
|
641
|
+
for (var i = 1; i < n - 1 && total > avail; i++) { cut.push(i); total -= widths[i]; }
|
|
642
|
+
return cut;
|
|
643
|
+
}
|
|
644
|
+
// The row under the top bar, on a phone (below 600 px; wider screens have the navigation bar's crumbs). Only on a page
|
|
645
|
+
// you opened (a reader, a meeting, a session), and only when there is a path.
|
|
646
|
+
function paintTrail(r) {
|
|
647
|
+
var el = typeof document !== 'undefined' && document.getElementById('nav-trail');
|
|
648
|
+
if (!el) return;
|
|
649
|
+
r = r || parseRoute();
|
|
650
|
+
var steps = r.args.length && !isTabRoute(r.args) ? navTrail() : null;
|
|
651
|
+
if (!steps) { el.hidden = true; el.innerHTML = ''; return; }
|
|
652
|
+
var here = trailName(location.hash, crumbDetail);
|
|
653
|
+
el.innerHTML = '<ol>' + steps.map(function (s, i) {
|
|
654
|
+
return '<li class="trail-step"><a href="' + esc(s.hash) + '" data-trail-back="' + s.back + '">' + esc(s.label) + '</a></li>' +
|
|
655
|
+
(i === 0 ? '<li class="trail-more" aria-hidden="true" hidden>…</li>' : '');
|
|
656
|
+
}).join('') + '<li class="trail-here" aria-current="page"><span>' + esc(here) + '</span></li></ol>';
|
|
657
|
+
el.hidden = false;
|
|
658
|
+
var items = Array.prototype.slice.call(el.querySelectorAll('.trail-step,.trail-here')), more = el.querySelector('.trail-more');
|
|
659
|
+
if (!more || !el.clientWidth) return;
|
|
660
|
+
el.classList.add('trail-fit'); // each step at its full width, to measure
|
|
661
|
+
more.hidden = false;
|
|
662
|
+
var moreW = more.offsetWidth, widths = items.map(function (li) { return li.offsetWidth; });
|
|
663
|
+
more.hidden = true;
|
|
664
|
+
el.classList.remove('trail-fit');
|
|
665
|
+
var cut = trailCut(widths, moreW, el.clientWidth);
|
|
666
|
+
// A left-out step stays for a screen reader but takes no keyboard focus (Q1 L1): it is not on screen.
|
|
667
|
+
cut.forEach(function (i) { items[i].classList.add('trail-cut'); var a = items[i].querySelector('a'); if (a) a.setAttribute('tabindex', '-1'); });
|
|
668
|
+
more.hidden = !cut.length;
|
|
669
|
+
}
|
|
670
|
+
// Where Back goes from here: { pane, path } (path true: the way he came; false: the parent chain).
|
|
671
|
+
function navBackTo() {
|
|
672
|
+
var st = navState();
|
|
673
|
+
// navStamp has run on this entry (every landing routes), so its mark is this load's: an earlier load's was re-marked
|
|
674
|
+
// with no path there.
|
|
675
|
+
if (!navOverlay(st) && st && st.cosNav && st.cosNav.back) {
|
|
676
|
+
var p = String(st.cosNav.back).replace(/^#\/?/, '').split('/')[0];
|
|
677
|
+
return { pane: ROOT_PANES.indexOf(p) >= 0 ? p : 'today', path: true };
|
|
678
|
+
}
|
|
679
|
+
var r = parseRoute(), up = PANES[r.pane] && PANES[r.pane].up ? PANES[r.pane].up(r.args) : null;
|
|
680
|
+
return { pane: up || (r.args.length && !isTabRoute(r.args)) ? r.pane : 'today', path: false };
|
|
681
|
+
}
|
|
682
|
+
// The one way this page replaces an address (W.replace for the panes; Q1 7.0.30 H1). It tells navStamp the entry was
|
|
683
|
+
// replaced, never pushed, so a replaced entry never gets a path: a pane switching a tab with location.replace after a
|
|
684
|
+
// Back (a forward entry left) was stamped as a push at history.length - 1, and Back then called history.back() from
|
|
685
|
+
// the first entry, out of the app. Its position is this entry's mark, or one above the last mark when an overlay's
|
|
686
|
+
// entry (Today's sheet, the image viewer) is the one replaced. Returns false, doing nothing, when the address is the
|
|
687
|
+
// same (location.replace would not route, and the flag would wrongly mark the next address).
|
|
688
|
+
function navReplace(hash) {
|
|
689
|
+
if (location.hash === hash) return false;
|
|
690
|
+
var st = navState(), pos;
|
|
691
|
+
if (navOverlay(st)) pos = NAV.last && typeof NAV.last.pos === 'number' ? NAV.last.pos + 1 : history.length - 1;
|
|
692
|
+
else pos = st && st.cosNav && typeof st.cosNav.pos === 'number' ? st.cosNav.pos : history.length - 1;
|
|
693
|
+
NAV.replace = { pos: pos };
|
|
694
|
+
try { location.replace(location.pathname + location.search + hash); } catch (_e) { NAV.replace = null; location.hash = hash; }
|
|
695
|
+
return true;
|
|
696
|
+
}
|
|
697
|
+
// The parent chain, replacing this entry (never adding one).
|
|
698
|
+
function goReplace(pane, args) {
|
|
699
|
+
var hash = '#/' + [pane].concat(args || []).map(encodeURIComponent).join('/');
|
|
700
|
+
if (!navReplace(hash)) route();
|
|
701
|
+
}
|
|
702
|
+
// ── end back by path
|
|
576
703
|
// 7.0.2 (A8): a phone is under 600 px wide, or a short touch screen at any width (the unfolded Duo is 951x539 in
|
|
577
704
|
// Comet): height is its scarce axis, so it gets the phone Ask sheet and the 44 px tap floor (app.css), as folded does.
|
|
578
705
|
var SHORT_TOUCH_MQ = '(pointer: coarse) and (max-height: 720px)';
|
|
@@ -639,6 +766,7 @@
|
|
|
639
766
|
return !!(e.metaKey || e.ctrlKey || keyboardFirst());
|
|
640
767
|
}
|
|
641
768
|
function route(forceChanged) {
|
|
769
|
+
if (typeof navStamp === 'function') navStamp();
|
|
642
770
|
var r = parseRoute();
|
|
643
771
|
var changedPane = forceChanged === true || r.pane !== state.pane || !document.querySelector('#pane-' + r.pane + ':not(.hidden)');
|
|
644
772
|
if (state.pane !== r.pane && PANES[state.pane] && PANES[state.pane].leave) { try { PANES[state.pane].leave(); } catch (err) { console.error(err); } }
|
|
@@ -664,8 +792,18 @@
|
|
|
664
792
|
var railBack = $('rail-back');
|
|
665
793
|
if (railBack) railBack.hidden = !reading;
|
|
666
794
|
$('section-btn').classList.toggle('hidden', reading);
|
|
667
|
-
|
|
795
|
+
// 7.0.30: Back names where it goes, the way he came or the parent (navBackTo).
|
|
796
|
+
var backTo = typeof navBackTo === 'function' ? navBackTo() : { pane: r.pane };
|
|
797
|
+
$('topbar-back-label').textContent = TITLES[backTo.pane] || 'Back';
|
|
798
|
+
var topBack = $('topbar-back');
|
|
799
|
+
if (topBack && topBack.setAttribute) topBack.setAttribute('aria-label', 'Back to ' + (TITLES[backTo.pane] || 'the list'));
|
|
800
|
+
if (railBack && railBack.setAttribute) railBack.setAttribute('aria-label', 'Back to ' + (TITLES[backTo.pane] || 'the list'));
|
|
801
|
+
// From 600 px Back is the navigation bar's (shell.js): its word stays "Back", as on the Mac's bar, and its name and
|
|
802
|
+
// tooltip say where it goes, as the rail's do (7.0.30 counted run, items_check B1 and B2 at the open Duo).
|
|
803
|
+
var navBack = $('nav-back');
|
|
804
|
+
if (navBack && navBack.setAttribute) { navBack.setAttribute('aria-label', 'Back to ' + (TITLES[backTo.pane] || 'the list')); navBack.setAttribute('title', 'Back to ' + (TITLES[backTo.pane] || 'the list')); }
|
|
668
805
|
$('section-name').textContent = TITLES[r.pane] || 'Today';
|
|
806
|
+
if (typeof paintTrail === 'function') paintTrail(r);
|
|
669
807
|
closeMenu();
|
|
670
808
|
if (changedPane) window.scrollTo(0, 0);
|
|
671
809
|
$('topbar').classList.remove('tucked');
|
|
@@ -701,26 +839,32 @@
|
|
|
701
839
|
runHooks(routeHooks, r);
|
|
702
840
|
}
|
|
703
841
|
function runHooks(list, arg) { list.forEach(function (fn) { try { fn(arg); } catch (err) { console.error(err); } }); }
|
|
704
|
-
// Back
|
|
705
|
-
// nested reader (an archive chat goes back to its day,
|
|
842
|
+
// Back (7.0.30): the way he came, when this page made the entry behind (navBackTo). Otherwise one step up, the way
|
|
843
|
+
// Control's Back does: a pane module may name the step above a nested reader (an archive chat goes back to its day,
|
|
844
|
+
// not to the list), replacing this entry.
|
|
706
845
|
function goBack() {
|
|
846
|
+
if (typeof navBackTo === 'function' && navBackTo().path) { history.back(); return; }
|
|
707
847
|
var r = parseRoute();
|
|
708
848
|
var up = PANES[r.pane] && PANES[r.pane].up ? PANES[r.pane].up(r.args) : null;
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
else
|
|
849
|
+
var step = typeof goReplace === 'function' ? goReplace : go;
|
|
850
|
+
if (up) step(r.pane, up);
|
|
851
|
+
else if (r.args.length && !isTabRoute(r.args)) step(r.pane);
|
|
852
|
+
else step('today');
|
|
712
853
|
}
|
|
713
854
|
// '#/<pane>/t/<tab>' is the pane's list on one of its tabs, not a reader: the phone keeps the section switcher and Back
|
|
714
855
|
// leaves the pane. Memories keeps its tab in the address this way, so a reload or a new browser lands on the same tab.
|
|
715
856
|
function isTabRoute(args) { return args.length === 2 && args[0] === 't'; }
|
|
716
|
-
// On
|
|
717
|
-
//
|
|
857
|
+
// On a phone every vertical pixel counts: the top bar slides away while you scroll down a list and comes back the
|
|
858
|
+
// moment you scroll up. 7.0.30 (item 1): never on a reader, where the top bar holds Back, so Back stays in reach on a
|
|
859
|
+
// tall phone however far down he reads.
|
|
718
860
|
var lastY = 0;
|
|
719
861
|
function onScroll() {
|
|
720
862
|
var y = Math.max(0, window.scrollY || 0);
|
|
721
|
-
|
|
863
|
+
// On a reader the bar stays (its Back is there). The folded Duo's Back is on the side rail (the bar's is hidden by CSS,
|
|
864
|
+
// not the class), so there the bar tucks as before (Q1 7.0.30 M1).
|
|
865
|
+
var bar = $('topbar'), back = $('topbar-back'), onReader = !!(back && !back.classList.contains('hidden')) && !(typeof sideRail === 'function' && sideRail());
|
|
722
866
|
bar.classList.toggle('scrolled', y > 8);
|
|
723
|
-
if (y < 60 || y < lastY - 4) bar.classList.remove('tucked');
|
|
867
|
+
if (y < 60 || y < lastY - 4 || onReader) bar.classList.remove('tucked');
|
|
724
868
|
else if (y > lastY + 4 && isPhone()) bar.classList.add('tucked');
|
|
725
869
|
lastY = y;
|
|
726
870
|
}
|
|
@@ -760,7 +904,7 @@
|
|
|
760
904
|
// 7.0.23 regression (lapse_check, 08:2x): a frame class left on keeps html and body at one screen with overflow
|
|
761
905
|
// hidden, so the gate could not scroll and Connect sat below a keyboard-height screen. The gate is a normal page;
|
|
762
906
|
// showShell's route() puts the frame back (sxFrameSync) when the shell returns.
|
|
763
|
-
document.documentElement.classList.remove('sx-frame'
|
|
907
|
+
document.documentElement.classList.remove('sx-frame');
|
|
764
908
|
// A later 401 from a pane still polling (W.api passes no message) must not blank what the gate already says:
|
|
765
909
|
// the reason it opened, or "That code did not match" while the person retypes.
|
|
766
910
|
if (!message && alreadyOpen) return;
|
|
@@ -2588,7 +2732,7 @@
|
|
|
2588
2732
|
var dest = session ? ['sessions', s[0], s.slice(1).join('|')] : kind === 'pet' ? ['sessions'] : ['work'].concat(kind === 'need' ? [] : ['sort']);
|
|
2589
2733
|
console.error('[today] the overlay did not open (' + kind + '); opening ' + dest.join('/') + ' instead.', err);
|
|
2590
2734
|
toast(session ? 'Could not open that here. Opening the session.' : kind === 'pet' ? 'Could not open that here. Opening Sessions.' : 'Could not open that here. Opening it in Work.');
|
|
2591
|
-
if (history.state && history.state.cosTodayOverlay)
|
|
2735
|
+
if (history.state && history.state.cosTodayOverlay) navReplace('#/' + dest.map(encodeURIComponent).join('/'));
|
|
2592
2736
|
else go(dest[0], dest.slice(1));
|
|
2593
2737
|
});
|
|
2594
2738
|
}
|
|
@@ -5591,7 +5735,7 @@
|
|
|
5591
5735
|
// estimates into them, and its rows keep Control's category titles.
|
|
5592
5736
|
function loadUsage() {
|
|
5593
5737
|
SX.view = 'usage'; sxSave();
|
|
5594
|
-
|
|
5738
|
+
navReplace('#/sessions');
|
|
5595
5739
|
}
|
|
5596
5740
|
function renderUsageView(force) {
|
|
5597
5741
|
var box = $('sessions-usage'); if (!box) return;
|
|
@@ -7366,11 +7510,13 @@
|
|
|
7366
7510
|
|
|
7367
7511
|
// ── events (one delegated listener) ─────────────────────
|
|
7368
7512
|
document.addEventListener('click', function (e) {
|
|
7369
|
-
var t = e.target.closest('[data-today-next],[data-next-prep],[data-next-prep-cancel],[data-next-prep-remove],[data-today-needs],[data-today],[data-goto],[data-pane],[data-session],[data-meeting],[data-speakers],[data-action],[data-open-ask],[data-theme-choice],#topbar-back,#rail-back,#section-btn,#sign-out,#copy-link,#ask-close,#auth-switch,#auth-token-show,#auth-retry,#device-code-btn');
|
|
7513
|
+
var t = e.target.closest('[data-today-next],[data-next-prep],[data-next-prep-cancel],[data-next-prep-remove],[data-today-needs],[data-today],[data-goto],[data-pane],[data-session],[data-meeting],[data-speakers],[data-action],[data-open-ask],[data-theme-choice],[data-trail-back],#topbar-back,#rail-back,#section-btn,#sign-out,#copy-link,#ask-close,#auth-switch,#auth-token-show,#auth-retry,#device-code-btn');
|
|
7370
7514
|
if (!t) { if (e.target.id === 'ask-sheet') closeAsk(); if (e.target.id === 'section-menu') closeMenu(); return; }
|
|
7371
7515
|
var inSheet = !!t.closest('#ask-sheet');
|
|
7372
7516
|
if (t.id === 'topbar-back') return goBack();
|
|
7373
7517
|
if (t.id === 'rail-back') return goBack();
|
|
7518
|
+
// 7.0.30 (pick B): a trail step is the browser's own Back, that many steps.
|
|
7519
|
+
if (t.hasAttribute('data-trail-back')) { e.preventDefault(); history.go(-Number(t.getAttribute('data-trail-back'))); return; }
|
|
7374
7520
|
if (t.id === 'section-btn') return openMenu();
|
|
7375
7521
|
if (t.id === 'auth-retry') { t.disabled = true; t.textContent = 'Checking'; bootStatus().then(function () { t.disabled = false; t.textContent = 'Try again'; }); return; }
|
|
7376
7522
|
if (t.id === 'auth-switch') {
|
|
@@ -7786,7 +7932,7 @@
|
|
|
7786
7932
|
plural: plural, countWord: countWord, dayWord: dayWord, plainDay: plainDay, providerLabel: providerLabel, domainLabel: domainLabel,
|
|
7787
7933
|
skeletonRows: skeletonRows, emptyBox: emptyBox, setStatus: setStatus, autoGrow: autoGrow,
|
|
7788
7934
|
readStore: readStore, writeStore: writeStore, copyText: copyText, isPhone: isPhone,
|
|
7789
|
-
state: state, titles: TITLES, go: go, parseRoute: parseRoute, goBack: goBack,
|
|
7935
|
+
state: state, titles: TITLES, go: go, replace: navReplace, parseRoute: parseRoute, goBack: goBack,
|
|
7790
7936
|
registerPane: function (name, def) { PANES[name] = def; },
|
|
7791
7937
|
sessionList: sessionList,
|
|
7792
7938
|
petPrefs: petPrefs,
|
|
@@ -7796,7 +7942,7 @@
|
|
|
7796
7942
|
onShell: function (fn) { shellHooks.push(fn); },
|
|
7797
7943
|
// The detail title after "COS Control > Section" (Control's breadcrumb). Pane modules set it
|
|
7798
7944
|
// once a detail has loaded; route() clears it on every navigation.
|
|
7799
|
-
setCrumb: function (text) { crumbDetail = String(text || ''); runHooks(routeHooks, parseRoute()); },
|
|
7945
|
+
setCrumb: function (text) { crumbDetail = String(text || ''); navLabel(crumbDetail); paintTrail(); runHooks(routeHooks, parseRoute()); },
|
|
7800
7946
|
crumb: function () { return crumbDetail; },
|
|
7801
7947
|
// Panes that read with their own fetch call this on a 401 so the sign-in screen shows.
|
|
7802
7948
|
showAuth: function (message) { showAuth(message); },
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/* COS Control Web: Control's own chrome and pane kit, loaded after app.css.
|
|
2
2
|
Measurements follow Control 0.5.366 (ActivityWindow.swift, COSBrand.swift): the 248pt rail with a
|
|
3
3
|
story under each name from 1100px, the icon rail below that, the navigation bar off Today, and
|
|
4
|
-
the shared pane pieces (tinted hero, text switch with a gold rule, rows, tags,
|
|
4
|
+
the shared pane pieces (tinted hero, text switch with a gold rule, rows, tags, and the reader's sides for who said what).
|
|
5
5
|
Phone sizes are set for reading on the iPhone Duo; the desktop steps down toward Control's. */
|
|
6
6
|
|
|
7
7
|
:root {
|
|
@@ -236,6 +236,18 @@
|
|
|
236
236
|
.cx-card { background: var(--panel); border: 1px solid var(--panel-border); border-radius: 14px; padding: 12px 14px 14px; margin-bottom: 12px; }
|
|
237
237
|
.cx-card.you { background: var(--qopen); }
|
|
238
238
|
.cx-card-label { font: 600 9.5px/1 var(--mono); letter-spacing: 0.08em; color: var(--tertiary); margin-bottom: 8px; display: flex; justify-content: space-between; align-items: center; gap: 10px; }
|
|
239
|
+
/* 7.0.30, Miles's pick A (who said what): sides, in the COS chat's shapes (.cc-bubble, .cc-line.cos). His bubble's edge
|
|
240
|
+
is opaque in light, gold 85% with ink, 3.48:1 against the page; the chat's 45% gold edge composites to 1.52:1 there.
|
|
241
|
+
Dark keeps the chat's edge as it is (2.79:1 composited). */
|
|
242
|
+
:root { --said-edge: color-mix(in srgb, var(--gold) 85%, var(--ink)); }
|
|
243
|
+
:root[data-theme='dark'] { --said-edge: color-mix(in srgb, var(--gold) 45%, transparent); }
|
|
244
|
+
@media (prefers-color-scheme: dark) { :root:not([data-theme='light']) { --said-edge: color-mix(in srgb, var(--gold) 45%, transparent); } }
|
|
245
|
+
.cx-said.you { display: flex; flex-direction: column; align-items: flex-end; margin: 4px 0 16px; }
|
|
246
|
+
.cx-said-bubble { max-width: min(80%, 560px); background: color-mix(in srgb, var(--gold) 22%, var(--panel)); border: 1px solid var(--said-edge);
|
|
247
|
+
border-radius: 18px 18px 6px 18px; padding: 10px 14px; line-height: 1.45; }
|
|
248
|
+
.cx-said-who { font-size: 12px; color: var(--sub); margin: 4px 4px 0; }
|
|
249
|
+
.cx-said.cos { display: grid; grid-template-columns: 30px minmax(0, 1fr); gap: 10px; align-items: start; margin: 0 0 14px; }
|
|
250
|
+
.cx-said-text { min-width: 0; }
|
|
239
251
|
.cx-card-body { font-size: 15px; line-height: 1.5; color: var(--ink); overflow-wrap: anywhere; }
|
|
240
252
|
/* md() keeps line breaks as text (shared with the session reader), so the body keeps them too. */
|
|
241
253
|
.cx-card-body.md { white-space: pre-wrap; }
|
|
@@ -262,9 +274,27 @@
|
|
|
262
274
|
.cx-find .cx-find-count { font: 400 11.5px/1 var(--mono); color: var(--sub); }
|
|
263
275
|
.cx-find button { width: 30px; height: 30px; border-radius: 8px; border: 1px solid var(--panel-border); display: grid; place-items: center; color: var(--ink); }
|
|
264
276
|
.cx-find button .icon { width: 14px; height: 14px; }
|
|
265
|
-
.cx-turn.match .cx-
|
|
277
|
+
.cx-turn.match .cx-said-bubble, .cx-turn.match .cx-said-text { background: color-mix(in srgb, var(--green) 7%, var(--panel)); }
|
|
278
|
+
.cx-turn.match .cx-said-text { border-radius: 10px; }
|
|
266
279
|
@media (max-width: 599px) { .cx-find { top: calc(var(--topbar-h) + var(--safe-top)); } .cx-thumb { width: 132px; height: 92px; } }
|
|
267
280
|
|
|
281
|
+
/* 7.0.30, Miles's pick B (how you got here): one plain row of text links under the top bar, on a phone only (from 600 px
|
|
282
|
+
the navigation bar has its crumbs). No box, border or label. It never wraps: steps leave from the middle (app.js
|
|
283
|
+
trailCut) and a long name ends in an ellipsis. A left-out step stays for a screen reader. */
|
|
284
|
+
.trail { display: none; }
|
|
285
|
+
@media (max-width: 599px) {
|
|
286
|
+
.trail:not([hidden]) { display: block; margin: 0 0 2px; }
|
|
287
|
+
.trail ol { display: flex; align-items: center; list-style: none; margin: 0; padding: 0; overflow: hidden; white-space: nowrap; font-size: 13.5px; color: var(--sub); }
|
|
288
|
+
.trail li { display: flex; align-items: center; min-width: 0; flex: 0 1 auto; }
|
|
289
|
+
.trail li + li::before { content: '/'; content: '/' / ''; flex: none; padding: 0 6px; color: var(--tertiary); }
|
|
290
|
+
.trail a, .trail-here span { display: block; min-width: 0; overflow: hidden; text-overflow: ellipsis; line-height: 32px; }
|
|
291
|
+
.trail a { color: var(--accent); }
|
|
292
|
+
.trail-here span { color: var(--ink); font-weight: 600; }
|
|
293
|
+
.trail .trail-more[hidden] { display: none; }
|
|
294
|
+
.trail.trail-fit li { flex-shrink: 0; }
|
|
295
|
+
.trail .trail-cut { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); }
|
|
296
|
+
}
|
|
297
|
+
|
|
268
298
|
/* ── Dictation and Dictionary ─────────────────────────── */
|
|
269
299
|
.cx-pair { display: inline-flex; flex-wrap: wrap; align-items: center; gap: 6px; }
|
|
270
300
|
.cx-pair .heard { color: var(--sub); text-decoration: line-through; text-decoration-color: color-mix(in srgb, var(--sub) 60%, transparent); }
|
|
@@ -173,6 +173,8 @@
|
|
|
173
173
|
</header>
|
|
174
174
|
|
|
175
175
|
<main class="workspace" id="workspace">
|
|
176
|
+
<!-- 7.0.30, Miles's pick B: how you got here, on a phone (app.js paintTrail), from Back's own marks. -->
|
|
177
|
+
<nav class="trail" id="nav-trail" aria-label="How you got here" hidden></nav>
|
|
176
178
|
<!-- W1: the Mac not answering, and the sign-in ending soon (app.js "Mac reachability and sign-in lifetime") -->
|
|
177
179
|
<div id="conn-notice" class="conn-notice" hidden></div>
|
|
178
180
|
<!-- SESSION QUESTIONS (panes/questions.js, 6.99.12): over every pane while one waits -->
|
|
@@ -215,7 +215,11 @@ html.kb-up .cc-kb .cc-model .field { width: 100%; }
|
|
|
215
215
|
.cx-reply-sub { margin: 2px 0 0; font-size: 13px; line-height: 1.4; color: var(--sub); }
|
|
216
216
|
.cx-reply-feed { min-height: 0; padding: 10px 0 0; }
|
|
217
217
|
.cx-reply-feed:empty { display: none; }
|
|
218
|
-
|
|
218
|
+
/* 7.0.30 item 2: the Reply box is the reader's last child, outside .cx-reply, so .cc-composer's own rule pins it to the
|
|
219
|
+
bottom of the screen (sticky, the page's colour, a hairline above). With the keyboard up for it (cos-chat.js sets
|
|
220
|
+
html.kb-reply; app.js publishes --vv-below, the layout viewport under the visible box) it sits on the keyboard, the
|
|
221
|
+
same way the chat's box does; the home indicator is under the keyboard, so its inset goes. */
|
|
222
|
+
html.kb-reply .cx-reply-form { bottom: var(--vv-below, 0px); padding-bottom: 8px; }
|
|
219
223
|
|
|
220
224
|
/* ── the Messages badge in the rail and the section menu ─ */
|
|
221
225
|
.inbox-count { min-width: 18px; height: 18px; padding: 0 5px; border-radius: 9px; background: var(--hot); color: var(--panel); font: 600 11px/18px var(--body); text-align: center; }
|
|
@@ -1549,8 +1549,11 @@
|
|
|
1549
1549
|
return '<section class="cc cx-reply" data-reply-host="' + esc(key) + '" aria-label="' + esc(label) + '">' +
|
|
1550
1550
|
'<h3 class="cx-reply-title">Reply</h3>' +
|
|
1551
1551
|
'<p class="cx-reply-sub">' + esc(k.sessionId ? 'Continues this conversation. The answer lands in Messages with its own number.' : 'Starts a conversation. The answer lands in Messages with its own number.') + '</p>' +
|
|
1552
|
-
'<div class="cc-feed cx-reply-feed" data-reply-feed="' + esc(key) + '" aria-live="polite">' + replyFeedHtml(key) + '</div>' +
|
|
1553
|
-
|
|
1552
|
+
'<div class="cc-feed cx-reply-feed" data-reply-feed="' + esc(key) + '" aria-live="polite">' + replyFeedHtml(key) + '</div>' + '</section>' +
|
|
1553
|
+
// 7.0.30 item 2 (Anand: "the reply box stays at the bottom"): the box sits OUTSIDE the section, as a child of the
|
|
1554
|
+
// reader's column, so the composer's own sticky rule keeps it at the bottom of the screen while he reads the
|
|
1555
|
+
// message above (a sticky box cannot leave its parent, and the section is only at the end of the page).
|
|
1556
|
+
composerHtml(k, { drop: false, placeholder: label, label: label }).replace('class="cc-composer"', 'class="cc-composer cx-reply-form"');
|
|
1554
1557
|
}
|
|
1555
1558
|
function replyFeedHtml(key) {
|
|
1556
1559
|
return C.asks.filter(function (a) { return a.replyKey === replyName(key); }).map(function (a) { return askHtml(a, false); }).join('');
|
|
@@ -2396,6 +2399,9 @@
|
|
|
2396
2399
|
if (form && form.classList) form.classList.add('cc-kb');
|
|
2397
2400
|
var inChat = !!(a && C.host && C.host.contains && C.host.contains(a));
|
|
2398
2401
|
if (de && de.classList) de.classList.toggle('kb-chat', inChat);
|
|
2402
|
+
// 7.0.30 item 2: the message reader's Reply box, the same kit (cos-chat.css lifts it onto the keyboard).
|
|
2403
|
+
var inReply = !!(form && form.classList && typeof form.classList.contains === 'function' && form.classList.contains('cx-reply-form'));
|
|
2404
|
+
if (de && de.classList) de.classList.toggle('kb-reply', inReply);
|
|
2399
2405
|
return inChat;
|
|
2400
2406
|
}
|
|
2401
2407
|
// The composer that has the keyboard, brought into view on open and on every later change of the visible box (a fold
|
|
@@ -2403,7 +2409,7 @@
|
|
|
2403
2409
|
// (a steer box or the search box is left where it is).
|
|
2404
2410
|
function revealComposer() {
|
|
2405
2411
|
var a = document.activeElement, de = document.documentElement;
|
|
2406
|
-
if (!a || !de || !de.classList || !de.classList.contains('kb-chat')) return false;
|
|
2412
|
+
if (!a || !de || !de.classList || !(de.classList.contains('kb-chat') || de.classList.contains('kb-reply'))) return false;
|
|
2407
2413
|
var form = a.closest ? a.closest('[data-cos-form]') : null;
|
|
2408
2414
|
if (!form || !form.scrollIntoView) return false;
|
|
2409
2415
|
form.scrollIntoView({ block: 'nearest' });
|
|
@@ -28,6 +28,10 @@
|
|
|
28
28
|
.mt-day:not(:disabled):hover { background: var(--qopen); }
|
|
29
29
|
.mt-day[aria-pressed='true'] { background: color-mix(in srgb, var(--tint) 18%, transparent); font-weight: 600; }
|
|
30
30
|
.mt-day:focus-visible { outline: 2px solid var(--accent); outline-offset: -2px; }
|
|
31
|
+
/* 7.0.30 item 4: today is marked with a ring in the theme's accent (no fill, so a picked day still reads as picked). */
|
|
32
|
+
.mt-day.mt-today { box-shadow: inset 0 0 0 1.5px var(--accent); }
|
|
33
|
+
.mt-day.mt-today .mt-dnum { font-weight: 600; }
|
|
34
|
+
.mt-jump-row { margin: 0 2px 12px; }
|
|
31
35
|
.mt-dot { width: 5px; height: 5px; border-radius: 50%; background: transparent; }
|
|
32
36
|
.mt-dot.on { background: var(--tint); }
|
|
33
37
|
.mt-all { margin-top: 8px; }
|
|
@@ -525,10 +525,12 @@
|
|
|
525
525
|
for (var w = 0; w < 7; w++) heads += '<span class="mt-wd" aria-hidden="true">' + esc(new Date(2023, 0, 1 + (ws + w) % 7).toLocaleDateString(undefined, { weekday: 'narrow' })) + '</span>';
|
|
526
526
|
var cells = '', n = 0;
|
|
527
527
|
for (var i = 0; i < lead; i++) { cells += '<span class="mt-blank"></span>'; n++; }
|
|
528
|
+
// 7.0.30 item 4: today is marked (a ring, aria-current) and can be picked even with no meetings.
|
|
529
|
+
var todayKey = dayKey(new Date());
|
|
528
530
|
for (var day = 1; day <= dim; day++) {
|
|
529
|
-
var key = S.month + '-' + pad2(day), c = counts[key] || 0, on = S.day === key;
|
|
530
|
-
cells += '<button type="button" class="mt-day" data-mt="day" data-day="' + key + '" aria-pressed="' + on + '"' + (c === 0 ? ' disabled' : '') +
|
|
531
|
-
' aria-label="' + esc(key + (c === 0 ? ', no meetings' : ', ' + c + ' meetings')) + '"><span class="mt-dnum">' + day + '</span><span class="mt-dot' + (c > 0 ? ' on' : '') + '"></span></button>';
|
|
531
|
+
var key = S.month + '-' + pad2(day), c = counts[key] || 0, on = S.day === key, isToday = key === todayKey;
|
|
532
|
+
cells += '<button type="button" class="mt-day' + (isToday ? ' mt-today' : '') + '" data-mt="day" data-day="' + key + '" aria-pressed="' + on + '"' + (isToday ? ' aria-current="date"' : '') + (c === 0 && !isToday ? ' disabled' : '') +
|
|
533
|
+
' aria-label="' + esc(key + (isToday ? ', today' : '') + (c === 0 ? ', no meetings' : ', ' + c + ' meetings')) + '"><span class="mt-dnum">' + day + '</span><span class="mt-dot' + (c > 0 ? ' on' : '') + '"></span></button>';
|
|
532
534
|
n++;
|
|
533
535
|
}
|
|
534
536
|
while (n % 7 !== 0) { cells += '<span class="mt-blank"></span>'; n++; }
|
|
@@ -594,6 +596,20 @@
|
|
|
594
596
|
(m.sessionId ? pillsHtml(m, isNew(m.sessionId)) : '') + '</span><span class="chev"></span></button>';
|
|
595
597
|
}
|
|
596
598
|
function emptyBox(text) { return '<div class="cx-list"><p class="cx-empty mt-empty">' + esc(text) + '</p></div>'; }
|
|
599
|
+
// 7.0.30 item 4: today with no meetings says so in plain words, with one tap to the latest earlier day that has some
|
|
600
|
+
// (this month), or to last month when this month has none. The calendar stays as it is.
|
|
601
|
+
function latestDayBefore(key) {
|
|
602
|
+
var best = '';
|
|
603
|
+
(S.days || []).forEach(function (d) { if (num(d.count) > 0 && d.date < key && d.date > best) best = d.date; });
|
|
604
|
+
return best;
|
|
605
|
+
}
|
|
606
|
+
function emptyToday() {
|
|
607
|
+
var last = latestDayBefore(dayKey(new Date()));
|
|
608
|
+
var jump = last
|
|
609
|
+
? '<button type="button" class="cx-btn quiet mt-jump" data-mt="jump-day" data-day="' + esc(last) + '">Go to ' + esc(W.plainDay ? W.plainDay(last) : last) + '</button>'
|
|
610
|
+
: '<button type="button" class="cx-btn quiet mt-jump" data-mt="month" data-delta="-1">Go to last month</button>';
|
|
611
|
+
return '<div class="cx-list"><p class="cx-empty mt-empty">No meetings today.</p><p class="mt-jump-row">' + jump + '</p></div>';
|
|
612
|
+
}
|
|
597
613
|
function paintList() {
|
|
598
614
|
var el = $('mt-list'); if (!el) return;
|
|
599
615
|
var html;
|
|
@@ -611,6 +627,7 @@
|
|
|
611
627
|
var rows = visibleRows();
|
|
612
628
|
html = rows.length ? '<div class="cx-list">' + rows.map(function (m) { return rowHtml(m, false); }).join('') + '</div>' +
|
|
613
629
|
(monthCapped() ? '<p class="mt-capnote">Showing the newest ' + S.rows.length + ' of ' + monthTotal() + ' in ' + esc(monthTitle(S.month)) + '. Pick a day in the calendar to see all of its meetings.</p>' : '')
|
|
630
|
+
: S.day && S.day === dayKey(new Date()) ? emptyToday()
|
|
614
631
|
: emptyBox(S.day ? 'No meetings stored on ' + S.day + '. Pick another day, or show the whole month.' : 'No meetings stored in ' + monthTitle(S.month) + '.');
|
|
615
632
|
}
|
|
616
633
|
if (S.listHtml === html) return;
|
|
@@ -643,9 +660,10 @@
|
|
|
643
660
|
S.days = days;
|
|
644
661
|
var kept = S.dayRows; S.dayRows = {};
|
|
645
662
|
if (S.day && kept[S.day]) S.dayRows[S.day] = kept[S.day];
|
|
663
|
+
// 7.0.30 item 4 (Miles 10:15: "Meetings should SHOW the current day"): the current month opens on today, with
|
|
664
|
+
// meetings or without (the list then says so and offers the latest day that has some).
|
|
646
665
|
if (!S.autoDay && S.month === monthKey(new Date())) {
|
|
647
|
-
|
|
648
|
-
if (days.some(function (x) { return x.date === today && num(x.count) > 0; })) S.day = today;
|
|
666
|
+
S.day = dayKey(new Date());
|
|
649
667
|
S.autoDay = true;
|
|
650
668
|
}
|
|
651
669
|
S.error = ''; S.loading = false;
|
|
@@ -2089,7 +2107,7 @@
|
|
|
2089
2107
|
var hit = lists.reduce(function (all, l) { return all.concat(l); }, []).filter(function (m) { return m.sessionId === f.sid || (m.g2SessionIds || []).indexOf(f.sid) >= 0; })[0];
|
|
2090
2108
|
if (hit && S.view === 'live' && S.liveSid === f.sid) {
|
|
2091
2109
|
S.rowsStale = true;
|
|
2092
|
-
|
|
2110
|
+
W.replace('#/meetings/' + [hit.domain, hit.month, hit.filename].map(encodeURIComponent).join('/'));
|
|
2093
2111
|
} else if (!hit && f.endedAt && now - (f.retryAt || f.endedAt) >= FINAL_DEADLINE_MS + (f.start ? Math.max(0, f.endedAt - f.start) / 12 : 0)) {
|
|
2094
2112
|
f.notFound = true; paintLiveHead();
|
|
2095
2113
|
}
|
|
@@ -2667,6 +2685,7 @@
|
|
|
2667
2685
|
case 'month': shiftMonth(Number(t.getAttribute('data-delta')) || 0); return;
|
|
2668
2686
|
case 'day': { var d = t.getAttribute('data-day'); S.day = S.day === d ? null : d; S.autoDay = true; paintBrowse(); ensureDay(); return; }
|
|
2669
2687
|
case 'all-month': S.day = null; S.autoDay = true; paintBrowse(); return;
|
|
2688
|
+
case 'jump-day': { S.day = t.getAttribute('data-day'); S.autoDay = true; paintBrowse(); ensureDay(); return; }
|
|
2670
2689
|
case 'clear': {
|
|
2671
2690
|
S.q = ''; S.hits = null; S.ranFor = ''; S.searching = false; S.searchError = ''; clearTimeout(S.qTimer);
|
|
2672
2691
|
var q = $('mt-q'); if (q) { q.value = ''; q.focus(); }
|
|
@@ -105,7 +105,7 @@
|
|
|
105
105
|
function readerArgs(r) { return r.pane === 'memories' && !tabRoute(r.args) ? r.args : []; }
|
|
106
106
|
function showTab(v) {
|
|
107
107
|
var h = '#/memories/t/' + encodeURIComponent(v);
|
|
108
|
-
if (location.hash === h) paint(); else
|
|
108
|
+
if (location.hash === h) paint(); else W.replace(h);
|
|
109
109
|
}
|
|
110
110
|
function isWide() { return !!(window.matchMedia && window.matchMedia('(min-width: 760px)').matches); }
|
|
111
111
|
function el(id) { return document.getElementById(id); }
|
|
@@ -2262,7 +2262,7 @@
|
|
|
2262
2262
|
// #/memories/view/<view> (older links) and a bare #/memories become the tab's own address; an unknown tab, the stored one.
|
|
2263
2263
|
if (args[0] === 'view' || !args.length || (args[0] === 't' && !knownView(args[1]))) {
|
|
2264
2264
|
if (args[0] === 'view' && knownView(args[1])) { S.view = args[1]; persist(); }
|
|
2265
|
-
|
|
2265
|
+
W.replace('#/memories/t/' + encodeURIComponent(S.view)); return;
|
|
2266
2266
|
}
|
|
2267
2267
|
if (tabRoute(args) && S.view !== args[1]) { S.view = args[1]; persist(); }
|
|
2268
2268
|
if (args[0] === 'k') { S.view = 'knowledge'; if (KTABS.indexOf(args[1]) >= 0) { S.ktab = args[1]; S.ktabChosen = true; } persist(); }
|
|
@@ -469,9 +469,14 @@
|
|
|
469
469
|
}
|
|
470
470
|
function turnCards(t, msgKey) {
|
|
471
471
|
var a = atts(t);
|
|
472
|
-
|
|
472
|
+
// 7.0.30, Miles's pick A: who said what by side, in the COS chat's own shapes. His words on the right in the chat's
|
|
473
|
+
// gold bubble with "You" under it; COS on the left beside its mark, on the page. A screen reader hears "You said"
|
|
474
|
+
// and "COS said" first, since the side and the mark are visual only.
|
|
475
|
+
return '<div class="cx-said you"><span class="sr-only">You said</span><div class="cx-said-bubble cx-card-body" data-hl>' + esc(String(t.query || '').trim() || '(empty query)').replace(/\n/g, '<br>') + '</div>' +
|
|
476
|
+
'<span class="cx-said-who" aria-hidden="true">You</span></div>' +
|
|
473
477
|
stripHtml(a.filter(isUserAtt), true, msgKey) +
|
|
474
|
-
'<div class="cx-
|
|
478
|
+
'<div class="cx-said cos"><span class="cc-avatar" aria-hidden="true"><svg class="icon"><use href="#i-mark"/></svg></span><div class="cx-said-text"><span class="sr-only">COS said</span>' +
|
|
479
|
+
'<div class="cx-card-body md" data-hl>' + W.md(W.structureLensAnswer(String(t.text || ''))) + '</div></div></div>' +
|
|
475
480
|
stripHtml(a.filter(function (x) { return !isUserAtt(x); }), false, msgKey);
|
|
476
481
|
}
|
|
477
482
|
function copyTurnText(t) {
|
|
@@ -123,7 +123,7 @@
|
|
|
123
123
|
return '<section class="cx-set-cat"><h2 class="cx-col-title">This browser</h2>' +
|
|
124
124
|
group('Appearance', '', themeBtn('system', 'Match this device') + themeBtn('light', 'Light') + themeBtn('dark', 'Dark')) +
|
|
125
125
|
group('Sign-in', '',
|
|
126
|
-
'<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
|
|
126
|
+
'<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 phone’s 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>' +
|
|
127
127
|
'<div id="device-code-panel" class="device-code hidden" aria-live="polite"></div>' +
|
|
128
128
|
'<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>' +
|
|
129
129
|
'<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>' +
|
|
@@ -2363,8 +2363,11 @@
|
|
|
2363
2363
|
var acts = '';
|
|
2364
2364
|
if (rv && rv.recordId) acts += '<button type="button" class="cx-btn quiet" data-sp="open-meeting" title="Open this meeting’s transcript, summary and files">' + icon('meetings') + 'Open meeting</button>';
|
|
2365
2365
|
if (R.content && R.content.clipboardSummary) {
|
|
2366
|
-
|
|
2367
|
-
|
|
2366
|
+
// 7.0.30 item 3 (Anand: Summary and Full did not say they copy): the buttons say Copy, and the one tapped says
|
|
2367
|
+
// Copied for COPIED_MS (spCopied), while a status line the screen reader hears says what was copied.
|
|
2368
|
+
var cp = R.copied && Date.now() - R.copied.at < COPIED_MS ? R.copied.which : '';
|
|
2369
|
+
acts += '<button type="button" class="cx-btn quiet" id="sp-copy-summary" data-sp="copy-summary" title="Copy the meeting without the transcript">' + (cp === 'summary' ? 'Copied' : 'Copy summary') + '</button>' +
|
|
2370
|
+
'<button type="button" class="cx-btn quiet" id="sp-copy-full" data-sp="copy-full" title="Copy the meeting including the full transcript">' + (cp === 'full' ? 'Copied' : 'Copy full (' + Math.max(1, Math.floor(R.content.fullChars / 1024)) + ' KB)') + '</button>';
|
|
2368
2371
|
}
|
|
2369
2372
|
var next = S.meetings ? nextUnnamed(R.sid) : null;
|
|
2370
2373
|
acts += '<button type="button" class="cx-btn quiet" data-sp="next-unnamed"' + (next ? '' : ' disabled') + ' title="' +
|
|
@@ -2424,6 +2427,27 @@
|
|
|
2424
2427
|
|
|
2425
2428
|
// ── render dispatch ────────────────────────────────────
|
|
2426
2429
|
var expiryTimer = null;
|
|
2430
|
+
// 7.0.30 item 3: a copy confirms on its own button, then the button goes back to Copy; the words go to a status region
|
|
2431
|
+
// that lives outside the rebuilt pane, so a screen reader hears them (a region made in the same rebuild may not be).
|
|
2432
|
+
var COPIED_MS = 2500;
|
|
2433
|
+
function spAnnounce(text) {
|
|
2434
|
+
var live = document.getElementById('sp-copy-live');
|
|
2435
|
+
if (!live) {
|
|
2436
|
+
live = document.createElement('div');
|
|
2437
|
+
live.id = 'sp-copy-live'; live.className = 'sr-only';
|
|
2438
|
+
live.setAttribute('role', 'status'); live.setAttribute('aria-live', 'polite');
|
|
2439
|
+
document.body.appendChild(live);
|
|
2440
|
+
}
|
|
2441
|
+
live.textContent = '';
|
|
2442
|
+
setTimeout(function () { live.textContent = text; }, 30);
|
|
2443
|
+
}
|
|
2444
|
+
function spCopied(R, which, words) {
|
|
2445
|
+
var at = Date.now();
|
|
2446
|
+
R.copied = { which: which, at: at };
|
|
2447
|
+
render();
|
|
2448
|
+
spAnnounce(words);
|
|
2449
|
+
setTimeout(function () { if (R.copied && R.copied.at === at) { R.copied = null; render(); } }, COPIED_MS);
|
|
2450
|
+
}
|
|
2427
2451
|
function render() {
|
|
2428
2452
|
if (W.parseRoute().pane !== 'speakers') return;
|
|
2429
2453
|
var rt = route();
|
|
@@ -2641,7 +2665,8 @@
|
|
|
2641
2665
|
if (!text) { R.copyNote = 'Nothing to copy'; render(); return; }
|
|
2642
2666
|
W.copyText(text).then(function () {
|
|
2643
2667
|
var kb = Math.max(1, Math.floor((full ? c.fullChars : c.summaryChars) / 1024));
|
|
2644
|
-
R.copyNote = full ? 'Copied full meeting (' + kb + ' KB)' : 'Copied summary (' + kb + ' KB)';
|
|
2668
|
+
R.copyNote = full ? 'Copied full meeting (' + kb + ' KB)' : 'Copied summary (' + kb + ' KB)';
|
|
2669
|
+
spCopied(R, full ? 'full' : 'summary', R.copyNote);
|
|
2645
2670
|
}, function (err) { W.toast(err.message); });
|
|
2646
2671
|
return;
|
|
2647
2672
|
}
|
|
@@ -471,8 +471,8 @@
|
|
|
471
471
|
close('leave');
|
|
472
472
|
O.depth = 1;
|
|
473
473
|
if (!replace) { W.go(pane, args || []); return; }
|
|
474
|
-
if (extra) { quietBack(extra, function () {
|
|
475
|
-
|
|
474
|
+
if (extra) { quietBack(extra, function () { W.replace(hash); }); return; }
|
|
475
|
+
W.replace(hash);
|
|
476
476
|
}
|
|
477
477
|
// A Back step the overlay takes itself (X, Back to sessions, leaving for a tab): its popstate is ours, not the phone's
|
|
478
478
|
// Back, and `then` runs once it lands. If it never arrives (history.go with nothing to go back to, or a browser that
|
|
@@ -2813,7 +2813,7 @@
|
|
|
2813
2813
|
if (args[0] === 'sort') {
|
|
2814
2814
|
S.tab = 'sort'; S.sortGroupId = ''; S.sortIndex = 0; S.moreOpen = false;
|
|
2815
2815
|
if (!S.intake && !S.intakeLoading) loadIntake(true);
|
|
2816
|
-
|
|
2816
|
+
W.replace('#/work');
|
|
2817
2817
|
return;
|
|
2818
2818
|
}
|
|
2819
2819
|
startPoll(); resumeHandoffPolls();
|
|
@@ -263,7 +263,8 @@
|
|
|
263
263
|
}
|
|
264
264
|
crumbs.innerHTML = parts.join('');
|
|
265
265
|
$('nav-back').disabled = onToday;
|
|
266
|
-
//
|
|
266
|
+
// Below 600 px the section name gets the detail too. On a reader that label is hidden (the section button gives way
|
|
267
|
+
// to Back, which names where it goes), so this shows only on a list that has a detail.
|
|
267
268
|
var label = $('section-name');
|
|
268
269
|
if (label && detail && railHidden()) label.textContent = detail;
|
|
269
270
|
}
|
|
@@ -3,9 +3,9 @@
|
|
|
3
3
|
"algorithm": "sha256",
|
|
4
4
|
"files": {
|
|
5
5
|
"/index.html": "3bea8ab14118e48c8bbf5f09de2e8b65ca1225b7e7b564982a84477bafc8341e",
|
|
6
|
-
"/signer-proof.js": "
|
|
6
|
+
"/signer-proof.js": "13b2d3f2709501b3bacff63b5a041282cacdf5bce6be168779c59ba89cf2e0c3",
|
|
7
7
|
"/signer-store.js": "ae123328f922a509eea4d8f9e8440c9ef6972eeb14bf90b9e8bde3c84f7132bc",
|
|
8
8
|
"/signer.css": "e19f5ec70c9dae28347ad40cd08742842e964b3d0b973a5c39f48f9cafdd319b",
|
|
9
|
-
"/signer.js": "
|
|
9
|
+
"/signer.js": "098f484b452c0c2b8ff33c9ca7764fb179e3e5f52c77bebf47eb540e73e7a566"
|
|
10
10
|
}
|
|
11
11
|
}
|
|
@@ -129,13 +129,28 @@
|
|
|
129
129
|
if (!Object.prototype.hasOwnProperty.call(channels, s.action) || channels[s.action].indexOf(s.channel) < 0
|
|
130
130
|
|| !/^[a-z0-9@][a-z0-9@._/-]{0,127}$/.test(s.artifact) || !/^[0-9]{1,5}\.[0-9]{1,5}\.[0-9]{1,6}(-[0-9A-Za-z.-]{1,32})?$/.test(s.version)
|
|
131
131
|
|| !/^(sha256:[0-9a-f]{64}|sha512:[0-9a-f]{128}|sha512-[A-Za-z0-9+/]{86}==)$/.test(s.digest) || !HEX64.test(s.packetSha256)) throw fail('scope_not_allowed')
|
|
132
|
-
var
|
|
133
|
-
if (
|
|
132
|
+
var manifest = manifestArtifact(s.channel)
|
|
133
|
+
if (manifest && (s.artifact !== manifest || !/^sha256:[0-9a-f]{64}$/.test(s.digest))) throw fail('scope_not_allowed')
|
|
134
134
|
return { kind: 'approve', action: s.action, artifact: s.artifact, version: s.version, digest: s.digest, channel: s.channel, packetSha256: s.packetSha256 }
|
|
135
135
|
}
|
|
136
136
|
throw fail('scope_not_allowed')
|
|
137
137
|
}
|
|
138
138
|
|
|
139
|
+
// What an approval on each channel does, one fixed line per channel (Miles, 2026-11-12: a publish approval covers
|
|
140
|
+
// its own push). A copy of APPROVE_CHANNEL_EFFECT in shared/web-turn-envelope.ts; signer-proof.test.ts pins the two.
|
|
141
|
+
var CHANNEL_EFFECT = { 'mac-local': 'Installs on this Mac. No git push.', 'npm-next': 'Publishes to npm under the next tag, from the tested package. No git push.', 'npm-latest': 'Publishes to npm under the latest tag, from the tested package. No git push.', 'control-appcast': 'Pushes ukaoma/cos-starter main (control/appcast.json): the COS Control update feed on gotcos.com.', 'hub-alias': 'Pushes ukaoma/evenhub-g2-cos main: moves the Even Hub alias to this package.', 'gotcos-site': 'Pushes ukaoma/cos-starter main: republishes www.gotcos.com.', 'control-downloads': 'Creates a GitHub Release on ukaoma/cos-starter (a tag, no branch push) with the ZIP and its SHA-256 file.', 'release-docs': 'Pushes ukaoma/cos-starter main (docs/): republishes the public COS docs.' }
|
|
142
|
+
// Channels that approve one canonical deployment manifest, and the artifact each must name. A copy of
|
|
143
|
+
// PUBLISH_MANIFEST_ARTIFACTS in shared/web-turn-envelope.ts; signer-proof.test.ts pins the two (Q1, 2026-11-12).
|
|
144
|
+
var MANIFEST_ARTIFACTS = { 'gotcos-site': 'gotcos-site-manifest', 'control-downloads': 'cos-control-downloads-manifest', 'release-docs': 'cos-release-docs-manifest' }
|
|
145
|
+
/** The manifest artifact a channel requires, or '' when the channel is not a manifest channel. */
|
|
146
|
+
function manifestArtifact(channel) {
|
|
147
|
+
return typeof channel === 'string' && Object.prototype.hasOwnProperty.call(MANIFEST_ARTIFACTS, channel) ? MANIFEST_ARTIFACTS[channel] : ''
|
|
148
|
+
}
|
|
149
|
+
/** The fixed line for a channel this page can sign, or '' for anything else. Never built from packet text. */
|
|
150
|
+
function channelEffect(channel) {
|
|
151
|
+
return typeof channel === 'string' && Object.prototype.hasOwnProperty.call(CHANNEL_EFFECT, channel) ? CHANNEL_EFFECT[channel] : ''
|
|
152
|
+
}
|
|
153
|
+
|
|
139
154
|
/** Verify the full packet before display AND again before signing. A packet is still only a request. */
|
|
140
155
|
function validateHandoffPacket(h, nowMs) {
|
|
141
156
|
return Promise.resolve().then(function () {
|
|
@@ -340,7 +355,7 @@
|
|
|
340
355
|
canonicalJson: canonicalJson, jcsBytes: jcsBytes,
|
|
341
356
|
signedWords: signedWords, wordsDigest: wordsDigest,
|
|
342
357
|
buildEnvelope: buildEnvelope, passkeyChallenge: passkeyChallenge, passkeyHeader: passkeyHeader,
|
|
343
|
-
assembleTurnProof: assembleTurnProof, validateHandoffPacket: validateHandoffPacket,
|
|
358
|
+
assembleTurnProof: assembleTurnProof, validateHandoffPacket: validateHandoffPacket, channelEffect: channelEffect, manifestArtifact: manifestArtifact,
|
|
344
359
|
sasCommit: sasCommit, sasCode: sasCode, kidOf: kidOf, fingerprintOf: fingerprintOf, enrollProofMessage: enrollProofMessage,
|
|
345
360
|
spkiFromXY: spkiFromXY, xyFromSpki: xyFromSpki, coseFromXY: coseFromXY, xyFromCose: xyFromCose,
|
|
346
361
|
xyFromAttestation: xyFromAttestation, passkeyXY: passkeyXY,
|
|
@@ -124,8 +124,10 @@
|
|
|
124
124
|
return 'Unknown'
|
|
125
125
|
}
|
|
126
126
|
function deploymentMeaning(scope) {
|
|
127
|
-
|
|
128
|
-
|
|
127
|
+
// The fixed line for the channel (P.channelEffect), never text from the packet's requester (2026-11-12).
|
|
128
|
+
var effect = scope && scope.kind === 'approve' ? P.channelEffect(scope.channel) : ''
|
|
129
|
+
if (!effect) return ''
|
|
130
|
+
return '\nWhat this approves: ' + effect + (P.manifestArtifact(scope.channel) ? '\nApproval is limited to this exact manifest SHA-256. The receiving release tool checks its files and destination.' : '')
|
|
129
131
|
}
|
|
130
132
|
function claim(id) {
|
|
131
133
|
show('loading')
|
|
@@ -37,8 +37,15 @@ export const WEB_TURN_MAX_LIFETIME_S = 15 * 60
|
|
|
37
37
|
/** How far in the future `iat` may sit (clock skew between the phone and this Mac), in seconds. */
|
|
38
38
|
export const WEB_TURN_IAT_SKEW_S = 60
|
|
39
39
|
|
|
40
|
-
/**
|
|
41
|
-
|
|
40
|
+
/**
|
|
41
|
+
* The only actions an `approve` scope can name. Anything else, including every never-list item, is unexpressible.
|
|
42
|
+
* Miles, 2026-11-12 11:11: a phone approval may sign everything the build allows except pushing main, which stays
|
|
43
|
+
* typed at the desk ("Keep allow for all action but the push to main. That way we can resolve when at the desk").
|
|
44
|
+
*/
|
|
45
|
+
export const APPROVE_ACTIONS: ReadonlySet<string> = new Set(['install', 'publish'])
|
|
46
|
+
|
|
47
|
+
/** Actions a phone can never sign. Not expressible, and still named in every `notAuthorized` list. */
|
|
48
|
+
export const APPROVE_ACTIONS_REMOVED: readonly string[] = Object.freeze(['push-main'])
|
|
42
49
|
|
|
43
50
|
/** What a `message` scope means: conversation only (review B4). */
|
|
44
51
|
export const MESSAGE_SCOPE_MEANING = 'conversation'
|
|
@@ -55,10 +62,28 @@ export const MESSAGE_SCOPE_NOT_AUTHORIZED: readonly string[] = Object.freeze([
|
|
|
55
62
|
])
|
|
56
63
|
|
|
57
64
|
/** The channels each approve action may name. Nothing else is signable (review B4). */
|
|
58
|
-
export const APPROVE_ACTION_CHANNELS: Readonly<Record<'install' | 'publish'
|
|
65
|
+
export const APPROVE_ACTION_CHANNELS: Readonly<Record<'install' | 'publish', readonly string[]>> = Object.freeze({
|
|
59
66
|
install: Object.freeze(['mac-local']),
|
|
60
67
|
publish: Object.freeze(['npm-next', 'npm-latest', 'control-appcast', 'hub-alias', 'gotcos-site', 'control-downloads', 'release-docs']),
|
|
61
|
-
|
|
68
|
+
})
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* What an approval on each channel does, in one fixed line the phone shows (Miles, 2026-11-12: "Publish includes its
|
|
72
|
+
* own push"). A publish approval covers the push that publish consists of, and only that: cos-starter main for the
|
|
73
|
+
* site, the appcast and the docs; evenhub-g2-cos main for the Hub alias; npm goes out from the tested package with no
|
|
74
|
+
* push. Pushing the code main of any repo stays typed at the desk. The line is derived from the CHANNEL alone, never
|
|
75
|
+
* from text the requester sent. The phone's page keeps a copy (server/web-signer/signer-proof.js, CHANNEL_EFFECT),
|
|
76
|
+
* pinned to this one by signer-proof.test.ts.
|
|
77
|
+
*/
|
|
78
|
+
export const APPROVE_CHANNEL_EFFECT: Readonly<Record<string, string>> = Object.freeze({
|
|
79
|
+
'mac-local': 'Installs on this Mac. No git push.',
|
|
80
|
+
'npm-next': 'Publishes to npm under the next tag, from the tested package. No git push.',
|
|
81
|
+
'npm-latest': 'Publishes to npm under the latest tag, from the tested package. No git push.',
|
|
82
|
+
'control-appcast': 'Pushes ukaoma/cos-starter main (control/appcast.json): the COS Control update feed on gotcos.com.',
|
|
83
|
+
'hub-alias': 'Pushes ukaoma/evenhub-g2-cos main: moves the Even Hub alias to this package.',
|
|
84
|
+
'gotcos-site': 'Pushes ukaoma/cos-starter main: republishes www.gotcos.com.',
|
|
85
|
+
'control-downloads': 'Creates a GitHub Release on ukaoma/cos-starter (a tag, no branch push) with the ZIP and its SHA-256 file.',
|
|
86
|
+
'release-docs': 'Pushes ukaoma/cos-starter main (docs/): republishes the public COS docs.',
|
|
62
87
|
})
|
|
63
88
|
|
|
64
89
|
export const APPROVE_ARTIFACT_RE = /^[a-z0-9@][a-z0-9@._/-]{0,127}$/
|
|
@@ -88,13 +113,13 @@ export function approveFieldsValid(action: unknown, artifact: unknown, version:
|
|
|
88
113
|
/** The verifier's `notAuthorized` list for a scope: the never-list plus what this scope does not cover. */
|
|
89
114
|
export function notAuthorizedFor(scope: WebTurnScope): string[] {
|
|
90
115
|
if (scope.kind === 'message') return [...WEB_KEY_NEVER_AUTHORIZED, ...MESSAGE_SCOPE_NOT_AUTHORIZED]
|
|
91
|
-
const others = [...APPROVE_ACTIONS].filter(a => a !== scope.action)
|
|
116
|
+
const others = [...APPROVE_ACTIONS, ...APPROVE_ACTIONS_REMOVED].filter(a => a !== scope.action)
|
|
92
117
|
return [...WEB_KEY_NEVER_AUTHORIZED, ...others, 'push-branch', 'hub-file', 'other-artifact-version-digest-or-channel']
|
|
93
118
|
}
|
|
94
119
|
|
|
95
120
|
export type WebTurnScope =
|
|
96
121
|
| { kind: 'message' }
|
|
97
|
-
| { kind: 'approve'; action: 'install' | 'publish'
|
|
122
|
+
| { kind: 'approve'; action: 'install' | 'publish'; artifact: string; version: string; digest: string; channel: string; packetSha256: string }
|
|
98
123
|
|
|
99
124
|
export interface WebTurnAttachment { id: string; sha256: string; bytes: number; name: string }
|
|
100
125
|
|
|
@@ -194,7 +219,7 @@ export function parseWebTurnScope(v: unknown): WebTurnScope | null {
|
|
|
194
219
|
// The closed action list, the strict field patterns and the channel table (approveFieldsValid).
|
|
195
220
|
if (!approveFieldsValid(v.action, v.artifact, v.version, v.digest, v.channel)) return null
|
|
196
221
|
if (typeof v.packetSha256 !== 'string' || !HEX64.test(v.packetSha256)) return null
|
|
197
|
-
return { kind: 'approve', action: v.action as 'install' | 'publish'
|
|
222
|
+
return { kind: 'approve', action: v.action as 'install' | 'publish', artifact: v.artifact as string, version: v.version as string, digest: v.digest as string, channel: v.channel as string, packetSha256: v.packetSha256 }
|
|
198
223
|
}
|
|
199
224
|
return null
|
|
200
225
|
}
|
|
@@ -249,7 +274,7 @@ export function parseWebTurnEnvelope(v: unknown): { ok: true; envelope: WebTurnE
|
|
|
249
274
|
export interface WebApprovalPacket {
|
|
250
275
|
v: typeof WEB_APPROVAL_PACKET_VERSION
|
|
251
276
|
packetId: string
|
|
252
|
-
action: 'install' | 'publish'
|
|
277
|
+
action: 'install' | 'publish'
|
|
253
278
|
artifact: string
|
|
254
279
|
version: string
|
|
255
280
|
digest: string
|