@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.
- package/CHANGELOG.md +91 -0
- package/docs/work-orchestrator.md +20 -0
- package/package.json +1 -1
- package/server/index.ts +2 -1
- package/server/lib/web-control-static.ts +226 -0
- package/server/lib/web-memories-bridge.ts +9 -8
- package/server/lib/web-pet-choice.ts +225 -0
- package/server/lib/web-pet.ts +226 -0
- package/server/lib/work-lens-rounds.ts +29 -4
- package/server/routes/web-control.ts +110 -7
- package/server/routes/work-lens-rounds.ts +19 -5
- package/server/web-control/app.css +24 -5
- package/server/web-control/app.js +646 -108
- package/server/web-control/control.css +1 -1
- package/server/web-control/index.html +27 -13
- package/server/web-control/memories/memory-workspace.js +1 -1
- package/server/web-control/panes/cos-chat.css +4 -0
- package/server/web-control/panes/cos-chat.js +26 -4
- package/server/web-control/panes/meetings.css +15 -2
- package/server/web-control/panes/memories.css +291 -0
- package/server/web-control/panes/memories.js +2208 -0
- package/server/web-control/panes/questions.js +59 -21
- package/server/web-control/panes/settings.js +102 -4
- package/server/web-control/panes/today-actions.css +109 -0
- package/server/web-control/panes/today-actions.js +972 -0
- package/server/web-control/panes/work.css +54 -0
- package/server/web-control/panes/work.js +1037 -18
- package/server/web-control/shell.js +6 -2
- package/server/web-control/theme.js +0 -2
- package/server/web-control/memories/memories-app.js +0 -1561
- package/server/web-control/memories/memories-theme.css +0 -73
- package/server/web-control/memories/memories.html +0 -72
- package/server/web-control/memories/memory-stewardship.js +0 -63
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The session pet on COS Control Web (Miles 11/06: "pets brought to the COS ... on home").
|
|
3
|
+
*
|
|
4
|
+
* COS Control keeps the active pet as horizontal PNG strips plus `session-pet-states.json`
|
|
5
|
+
* in `~/Library/Application Support/COS Control`. The web reads that same map, so the phone
|
|
6
|
+
* shows the pet the Mac shows, with no copy to keep in step.
|
|
7
|
+
*
|
|
8
|
+
* The Mac's 1x strips are 256 px tall and up to ~56,000 px wide (Windu's working cast is
|
|
9
|
+
* 184 frames, 3.2 MB). That is too much for a phone to decode at once, so each pose is
|
|
10
|
+
* resized to WEB_PET_HEIGHT with macOS `sips` and cut into chunks no wider than
|
|
11
|
+
* WEB_PET_MAX_CHUNK_WIDTH, each holding whole frames. The result is cached on disk, keyed by
|
|
12
|
+
* the source file's size and mtime, so a pet change on the Mac shows on the next load.
|
|
13
|
+
*
|
|
14
|
+
* `sips` quirk (measured 11/06): `--cropOffset 0 0` crops from the CENTER, while any other
|
|
15
|
+
* offset is measured from the top-left. The first chunk is therefore cut by flipping the
|
|
16
|
+
* strip, cropping from the far end, and flipping back. Pixel-identical to a direct crop.
|
|
17
|
+
*/
|
|
18
|
+
import crypto from 'crypto'
|
|
19
|
+
import { execFile } from 'child_process'
|
|
20
|
+
import fs from 'fs'
|
|
21
|
+
import os from 'os'
|
|
22
|
+
import path from 'path'
|
|
23
|
+
|
|
24
|
+
/** The poses COS Control's live pet uses (PetSpritePose.liveCases). */
|
|
25
|
+
export const WEB_PET_POSES = ['idle', 'patrol', 'waiting', 'working', 'duel', 'trio', 'swarm', 'done', 'error', 'attention'] as const
|
|
26
|
+
export type WebPetPose = typeof WEB_PET_POSES[number]
|
|
27
|
+
/** Frame height sent to the phone, in pixels. */
|
|
28
|
+
export const WEB_PET_HEIGHT = 160
|
|
29
|
+
/** Widest chunk sent, in pixels. Keeps each decoded image near 5 MB. */
|
|
30
|
+
export const WEB_PET_MAX_CHUNK_WIDTH = 8192
|
|
31
|
+
|
|
32
|
+
export interface PetPoseSource { pose: WebPetPose; file: string; frames: number; interval: number; width: number; height: number; size: number; mtimeMs: number }
|
|
33
|
+
export interface PetPosePlan extends PetPoseSource { outHeight: number; frameWidth: number; perChunk: number; chunks: number; key: string }
|
|
34
|
+
|
|
35
|
+
export function controlSupportDir(env: NodeJS.ProcessEnv = process.env): string {
|
|
36
|
+
return env.COS_CONTROL_SUPPORT_DIR || path.join(os.homedir(), 'Library', 'Application Support', 'COS Control')
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** Width and height from a PNG's IHDR, or null for anything that is not a PNG. */
|
|
40
|
+
export function pngSize(file: string): { width: number; height: number } | null {
|
|
41
|
+
let fd: number | null = null
|
|
42
|
+
try {
|
|
43
|
+
// Non-blocking: a FIFO planted at this path would otherwise hang the whole server (hazard-invariants.test.ts).
|
|
44
|
+
fd = fs.openSync(file, fs.constants.O_RDONLY | fs.constants.O_NONBLOCK)
|
|
45
|
+
if (!fs.fstatSync(fd).isFile()) return null
|
|
46
|
+
const head = Buffer.alloc(24)
|
|
47
|
+
if (fs.readSync(fd, head, 0, 24, 0) < 24) return null
|
|
48
|
+
if (head.readUInt32BE(0) !== 0x89504e47 || head.toString('ascii', 12, 16) !== 'IHDR') return null
|
|
49
|
+
return { width: head.readUInt32BE(16), height: head.readUInt32BE(20) }
|
|
50
|
+
} catch {
|
|
51
|
+
return null
|
|
52
|
+
} finally {
|
|
53
|
+
if (fd !== null) fs.closeSync(fd)
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** A file name from the state map, kept inside the support folder. */
|
|
58
|
+
function insideDir(dir: string, name: unknown): string | null {
|
|
59
|
+
if (typeof name !== 'string' || !name || name.includes('\0') || path.isAbsolute(name)) return null
|
|
60
|
+
const full = path.resolve(dir, name)
|
|
61
|
+
return full.startsWith(path.resolve(dir) + path.sep) ? full : null
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** Read the Mac's pet. Poses with a missing, unreadable or mismatched strip are left out. */
|
|
65
|
+
export function readPetSources(dir: string): Partial<Record<WebPetPose, PetPoseSource>> {
|
|
66
|
+
let map: { poses?: Record<string, { file?: unknown; frames?: unknown; interval?: unknown }> }
|
|
67
|
+
const mapFile = path.join(dir, 'session-pet-states.json')
|
|
68
|
+
try {
|
|
69
|
+
// Only a regular file is read: readFileSync on a FIFO blocks the event loop.
|
|
70
|
+
if (!fs.statSync(mapFile).isFile()) return {}
|
|
71
|
+
map = JSON.parse(fs.readFileSync(mapFile, 'utf8'))
|
|
72
|
+
} catch { return {} }
|
|
73
|
+
const out: Partial<Record<WebPetPose, PetPoseSource>> = {}
|
|
74
|
+
for (const pose of WEB_PET_POSES) {
|
|
75
|
+
const row = map?.poses?.[pose]
|
|
76
|
+
if (!row) continue
|
|
77
|
+
const file = insideDir(dir, row.file)
|
|
78
|
+
const frames = Number(row.frames)
|
|
79
|
+
if (!file || !Number.isInteger(frames) || frames < 1 || frames > 1000) continue
|
|
80
|
+
let stat: fs.Stats
|
|
81
|
+
try { stat = fs.statSync(file) } catch { continue }
|
|
82
|
+
if (!stat.isFile()) continue
|
|
83
|
+
const size = pngSize(file)
|
|
84
|
+
if (!size || size.height < 8 || size.width % frames !== 0) continue
|
|
85
|
+
const interval = Number(row.interval)
|
|
86
|
+
out[pose] = {
|
|
87
|
+
pose, file, frames, interval: Number.isFinite(interval) && interval >= 0.01 && interval <= 2 ? interval : 0.12,
|
|
88
|
+
width: size.width, height: size.height, size: stat.size, mtimeMs: stat.mtimeMs,
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
return out
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** How a pose is resized and chunked. Frame width is rounded once, so every chunk holds whole frames. */
|
|
95
|
+
export function planPose(src: PetPoseSource, height = WEB_PET_HEIGHT, maxChunk = WEB_PET_MAX_CHUNK_WIDTH): PetPosePlan {
|
|
96
|
+
const frameWidth = Math.max(1, Math.round((src.width / src.frames) * (height / src.height)))
|
|
97
|
+
const perChunk = Math.max(1, Math.floor(maxChunk / frameWidth))
|
|
98
|
+
const chunks = Math.ceil(src.frames / perChunk)
|
|
99
|
+
const key = crypto.createHash('sha256')
|
|
100
|
+
.update(`${src.file}\0${src.size}\0${src.mtimeMs}\0${src.frames}\0${height}\0${maxChunk}\0v1`)
|
|
101
|
+
.digest('hex').slice(0, 16)
|
|
102
|
+
return { ...src, outHeight: height, frameWidth, perChunk, chunks, key }
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
export function webPetManifest(dir: string = controlSupportDir(), pet = 'mac') {
|
|
106
|
+
const sources = readPetSources(dir)
|
|
107
|
+
const poses: Record<string, { frames: number; interval: number; frameWidth: number; height: number; perChunk: number; chunks: string[] }> = {}
|
|
108
|
+
for (const pose of WEB_PET_POSES) {
|
|
109
|
+
const src = sources[pose]
|
|
110
|
+
if (!src) continue
|
|
111
|
+
const p = planPose(src)
|
|
112
|
+
poses[pose] = {
|
|
113
|
+
frames: p.frames, interval: p.interval, frameWidth: p.frameWidth, height: WEB_PET_HEIGHT, perChunk: p.perChunk,
|
|
114
|
+
chunks: Array.from({ length: p.chunks }, (_, i) => `/api/web-pet/sheet/${pose}/${i}?pet=${encodeURIComponent(pet)}&v=${p.key}`),
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
return { available: Object.keys(poses).length > 0, poses }
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
type Run = (cmd: string, args: string[]) => Promise<void>
|
|
121
|
+
const sips: Run = (cmd, args) => new Promise((resolve, reject) => {
|
|
122
|
+
execFile(cmd, args, { timeout: 30_000 }, (err) => (err ? reject(err) : resolve()))
|
|
123
|
+
})
|
|
124
|
+
|
|
125
|
+
export interface PetChunkOptions { cacheDir: string; run?: Run; sipsPath?: string }
|
|
126
|
+
const inflight = new Map<string, Promise<string>>()
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* The PNG path for one chunk of one pose, building it on first request. Every chunk of the pose
|
|
130
|
+
* is built together, so a pose costs one resize. Concurrent requests share one build.
|
|
131
|
+
*/
|
|
132
|
+
export function petChunk(plan: PetPosePlan, index: number, opts: PetChunkOptions): Promise<string> {
|
|
133
|
+
if (!Number.isInteger(index) || index < 0 || index >= plan.chunks) return Promise.reject(new Error('no_such_chunk'))
|
|
134
|
+
const base = path.join(opts.cacheDir, `${plan.pose}-${plan.key}`)
|
|
135
|
+
const target = `${base}-${index}.png`
|
|
136
|
+
if (fs.existsSync(target)) return Promise.resolve(target)
|
|
137
|
+
let job = inflight.get(base)
|
|
138
|
+
if (!job) {
|
|
139
|
+
job = buildPose(plan, base, opts).finally(() => inflight.delete(base))
|
|
140
|
+
inflight.set(base, job)
|
|
141
|
+
}
|
|
142
|
+
return job.then(() => target)
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
async function buildPose(plan: PetPosePlan, base: string, opts: PetChunkOptions): Promise<string> {
|
|
146
|
+
const run = opts.run ?? sips
|
|
147
|
+
const bin = opts.sipsPath ?? '/usr/bin/sips'
|
|
148
|
+
fs.mkdirSync(opts.cacheDir, { recursive: true })
|
|
149
|
+
const tmp = fs.mkdtempSync(path.join(opts.cacheDir, '.build-'))
|
|
150
|
+
try {
|
|
151
|
+
const H = plan.outHeight
|
|
152
|
+
const total = plan.frameWidth * plan.frames
|
|
153
|
+
const resized = path.join(tmp, 'resized.png')
|
|
154
|
+
await run(bin, ['-z', String(H), String(total), plan.file, '--out', resized])
|
|
155
|
+
let flipped: string | null = null
|
|
156
|
+
for (let i = 0; i < plan.chunks; i++) {
|
|
157
|
+
const x = i * plan.perChunk * plan.frameWidth
|
|
158
|
+
const w = Math.min(plan.perChunk, plan.frames - i * plan.perChunk) * plan.frameWidth
|
|
159
|
+
const out = path.join(tmp, `${i}.png`)
|
|
160
|
+
if (plan.chunks === 1) {
|
|
161
|
+
fs.copyFileSync(resized, out)
|
|
162
|
+
} else if (x === 0) {
|
|
163
|
+
// `--cropOffset 0 0` means centre to sips: cut the first chunk from the mirrored strip's far end.
|
|
164
|
+
if (!flipped) { flipped = path.join(tmp, 'flipped.png'); await run(bin, ['-f', 'horizontal', resized, '--out', flipped]) }
|
|
165
|
+
const far = path.join(tmp, `${i}-far.png`)
|
|
166
|
+
await run(bin, ['-c', String(H), String(w), '--cropOffset', '0', String(total - w), flipped, '--out', far])
|
|
167
|
+
await run(bin, ['-f', 'horizontal', far, '--out', out])
|
|
168
|
+
} else {
|
|
169
|
+
await run(bin, ['-c', String(H), String(w), '--cropOffset', '0', String(x), resized, '--out', out])
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
for (let i = 0; i < plan.chunks; i++) fs.renameSync(path.join(tmp, `${i}.png`), `${base}-${i}.png`)
|
|
173
|
+
// Drop this pose's older builds (a pet change on the Mac makes a new key).
|
|
174
|
+
const keep = path.basename(base) + '-'
|
|
175
|
+
for (const name of fs.readdirSync(opts.cacheDir)) {
|
|
176
|
+
if (name.startsWith(`${plan.pose}-`) && !name.startsWith(keep) && name.endsWith('.png')) fs.rmSync(path.join(opts.cacheDir, name), { force: true })
|
|
177
|
+
}
|
|
178
|
+
return base
|
|
179
|
+
} finally {
|
|
180
|
+
fs.rmSync(tmp, { recursive: true, force: true })
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/** Height of the first-frame previews in the pet picker. */
|
|
185
|
+
export const WEB_PET_PREVIEW_HEIGHT = 96
|
|
186
|
+
|
|
187
|
+
/**
|
|
188
|
+
* One still frame (the first frame of idle, else of the first pose this pet has) for the picker, built once
|
|
189
|
+
* with sips and cached. Uses the same flip-and-crop as the first chunk, since sips reads offset 0 as centre.
|
|
190
|
+
*/
|
|
191
|
+
export function petPreview(dir: string, cacheDir: string, opts: { run?: Run; sipsPath?: string } = {}): Promise<string> {
|
|
192
|
+
const sources = readPetSources(dir)
|
|
193
|
+
const src = sources.idle ?? WEB_PET_POSES.map((p) => sources[p]).find(Boolean)
|
|
194
|
+
if (!src) return Promise.reject(new Error('no_pet_art'))
|
|
195
|
+
const plan = planPose(src, WEB_PET_PREVIEW_HEIGHT)
|
|
196
|
+
const target = path.join(cacheDir, `preview-${plan.key}.png`)
|
|
197
|
+
if (fs.existsSync(target)) return Promise.resolve(target)
|
|
198
|
+
let job = inflight.get(target)
|
|
199
|
+
if (!job) {
|
|
200
|
+
job = (async () => {
|
|
201
|
+
const run = opts.run ?? sips
|
|
202
|
+
const bin = opts.sipsPath ?? '/usr/bin/sips'
|
|
203
|
+
fs.mkdirSync(cacheDir, { recursive: true })
|
|
204
|
+
const tmp = fs.mkdtempSync(path.join(cacheDir, '.preview-'))
|
|
205
|
+
try {
|
|
206
|
+
const H = plan.outHeight, total = plan.frameWidth * plan.frames, w = plan.frameWidth
|
|
207
|
+
const resized = path.join(tmp, 'resized.png'), out = path.join(tmp, 'out.png')
|
|
208
|
+
await run(bin, ['-z', String(H), String(total), plan.file, '--out', resized])
|
|
209
|
+
if (plan.frames === 1) {
|
|
210
|
+
fs.copyFileSync(resized, out)
|
|
211
|
+
} else {
|
|
212
|
+
const flipped = path.join(tmp, 'flipped.png'), far = path.join(tmp, 'far.png')
|
|
213
|
+
await run(bin, ['-f', 'horizontal', resized, '--out', flipped])
|
|
214
|
+
await run(bin, ['-c', String(H), String(w), '--cropOffset', '0', String(total - w), flipped, '--out', far])
|
|
215
|
+
await run(bin, ['-f', 'horizontal', far, '--out', out])
|
|
216
|
+
}
|
|
217
|
+
fs.renameSync(out, target)
|
|
218
|
+
return target
|
|
219
|
+
} finally {
|
|
220
|
+
fs.rmSync(tmp, { recursive: true, force: true })
|
|
221
|
+
}
|
|
222
|
+
})().finally(() => inflight.delete(target))
|
|
223
|
+
inflight.set(target, job)
|
|
224
|
+
}
|
|
225
|
+
return job
|
|
226
|
+
}
|
|
@@ -36,7 +36,14 @@ const MAX_SENDS_KEPT = 8
|
|
|
36
36
|
const MAX_STOPS_KEPT = 8
|
|
37
37
|
|
|
38
38
|
const WORK_ID = /^task:[a-z_]+:[a-f0-9]{12}$/
|
|
39
|
-
const CLIENT_ROUND_ID = /^lens-[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/
|
|
39
|
+
const CLIENT_ROUND_ID = /^(lens|web)-[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/
|
|
40
|
+
/**
|
|
41
|
+
* Who made the plan: the lens, or COS Control Web (Work chat on the phone or in a browser; GET /api/work/chat/web-plans
|
|
42
|
+
* says this server takes it). The client round id starts with its source (`lens-`, `web-`). A web round is the same contract as a
|
|
43
|
+
* lens round: recorded, sent only by COS Control through its own Send, never by this server.
|
|
44
|
+
*/
|
|
45
|
+
export const ROUND_SOURCES = ['lens', 'web'] as const
|
|
46
|
+
export type RoundSource = typeof ROUND_SOURCES[number]
|
|
40
47
|
const SID = /^(claude|codex|cursor):[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$/
|
|
41
48
|
const ITEM_ID = /^[a-z]{1,8}(:[a-f0-9]{12})?$/
|
|
42
49
|
const RECEIPT_ID = /^[A-Za-z0-9_.:-]{1,80}$/
|
|
@@ -72,6 +79,8 @@ export interface LensRound {
|
|
|
72
79
|
/** The card's sessions (Control snapshot links and receipts) when the plan was recorded; null when no snapshot. */
|
|
73
80
|
cardSessions: string[] | null
|
|
74
81
|
sends: LensSendRequest[]; stops: LensStopRequest[]; updatedAt: number
|
|
82
|
+
/** Set only on a round COS Control Web made. A lens round has no `source`, so its stored shape is unchanged. */
|
|
83
|
+
source?: 'web'
|
|
75
84
|
/** COS Control's own Send of this round's assignments on the Mac (the lens was told to send from the Mac): when, and which. */
|
|
76
85
|
mac?: { at: number; ids: string[] } | null
|
|
77
86
|
}
|
|
@@ -89,7 +98,7 @@ const newId = (prefix: string) => `${prefix}_${randomBytes(8).toString('hex')}`
|
|
|
89
98
|
|
|
90
99
|
export interface LensRoundInput {
|
|
91
100
|
workID: string; clientRoundId: string; question: string; answer: string; planner: string | null; effort: string | null
|
|
92
|
-
orchestrator: boolean; assignments: Omit<LensAssignment, 'state'>[]; createdAt: string
|
|
101
|
+
orchestrator: boolean; assignments: Omit<LensAssignment, 'state'>[]; createdAt: string; source: RoundSource
|
|
93
102
|
}
|
|
94
103
|
|
|
95
104
|
/** The record body. Unknown keys are ignored and never stored. */
|
|
@@ -103,7 +112,8 @@ export function parseLensRound(v: unknown): LensRoundInput {
|
|
|
103
112
|
if (v.planner !== undefined && !(WORK_CHAT_PLANNERS as readonly unknown[]).includes(v.planner)) throw invalid('planner')
|
|
104
113
|
if (v.effort !== undefined && !(WORK_CHAT_EFFORTS as readonly unknown[]).includes(v.effort)) throw invalid('effort')
|
|
105
114
|
if (v.orchestrator !== undefined && typeof v.orchestrator !== 'boolean') throw invalid('orchestrator')
|
|
106
|
-
if (v.source
|
|
115
|
+
if (!(ROUND_SOURCES as readonly unknown[]).includes(v.source)) throw invalid('source')
|
|
116
|
+
if (!v.clientRoundId.startsWith(`${v.source}-`)) throw invalid('clientRoundId', 'The round id must start with its source.')
|
|
107
117
|
if (!iso(v.createdAt)) throw invalid('createdAt')
|
|
108
118
|
if (!Array.isArray(v.assignments) || v.assignments.length > WORK_CHAT_MAX_ASSIGNMENTS) throw invalid('assignments')
|
|
109
119
|
const assignments: LensRoundInput['assignments'] = []
|
|
@@ -122,7 +132,19 @@ export function parseLensRound(v: unknown): LensRoundInput {
|
|
|
122
132
|
...(a.contextItemIDs ? { contextItemIDs: [...new Set<string>(a.contextItemIDs)] } : {}) })
|
|
123
133
|
})
|
|
124
134
|
return { workID: v.workID, clientRoundId: v.clientRoundId, question: v.question, answer: v.answer, planner: v.planner ?? null,
|
|
125
|
-
effort: v.effort ?? null, orchestrator: v.orchestrator === true, assignments, createdAt: v.createdAt }
|
|
135
|
+
effort: v.effort ?? null, orchestrator: v.orchestrator === true, assignments, createdAt: v.createdAt, source: v.source }
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* A web round sent again under the same id must be the same plan. The lens keeps its replay rule (the stored round is
|
|
140
|
+
* answered, whatever the repeat carries); a web repeat with any other question, answer, planner, effort or assignment is
|
|
141
|
+
* refused, so one id can never stand for two plans. createdAt is when the plan was made and may be re-sent as it was.
|
|
142
|
+
*/
|
|
143
|
+
export function sameRoundContent(round: LensRound, input: LensRoundInput): boolean {
|
|
144
|
+
const plan = (r: { workID: string; question: string; answer: string; planner: string | null; effort: string | null; orchestrator: boolean;
|
|
145
|
+
assignments: Omit<LensAssignment, 'state'>[] }) => JSON.stringify([r.workID, r.question, r.answer, r.planner, r.effort, r.orchestrator,
|
|
146
|
+
r.assignments.map(a => [a.id, a.mode, a.sessionID ?? null, a.provider ?? null, a.prompt, a.contextItemIDs ?? null])])
|
|
147
|
+
return plan(round) === plan(input)
|
|
126
148
|
}
|
|
127
149
|
|
|
128
150
|
/** The card's sessions as COS Control's snapshot records them: confirmed links, receipt targets and fork children. */
|
|
@@ -240,6 +262,7 @@ export class LensRoundStore {
|
|
|
240
262
|
answer: input.answer, planner: input.planner, effort: input.effort, orchestrator: input.orchestrator,
|
|
241
263
|
assignments: input.assignments.map(a => ({ ...a, state: 'pending' as const })), createdAt: input.createdAt, recordedAt: now,
|
|
242
264
|
inHistory: false, historyAt: null, autoSendCard: false, cardSessions, sends: [], stops: [], updatedAt: now,
|
|
265
|
+
...(input.source === 'web' ? { source: 'web' as const } : {}),
|
|
243
266
|
}
|
|
244
267
|
this.file.rounds.push(round)
|
|
245
268
|
while (this.file.rounds.length > LENS_ROUNDS_MAX) {
|
|
@@ -320,6 +343,7 @@ export class LensRoundStore {
|
|
|
320
343
|
if (changed) this.persist()
|
|
321
344
|
const content = (r: LensRound) => ({ roundId: r.roundId, workID: r.workID, clientRoundId: r.clientRoundId, question: r.question, answer: r.answer,
|
|
322
345
|
planner: r.planner, effort: r.effort, orchestrator: r.orchestrator, createdAt: r.createdAt, recordedAt: new Date(r.recordedAt).toISOString(),
|
|
346
|
+
...(r.source === 'web' ? { source: 'web' as const } : {}),
|
|
323
347
|
assignments: r.assignments.map(a => ({ id: a.id, mode: a.mode, ...(a.sessionID ? { sessionID: a.sessionID } : {}), ...(a.provider ? { provider: a.provider } : {}),
|
|
324
348
|
prompt: a.prompt, ...(a.contextItemIDs ? { contextItemIDs: a.contextItemIDs } : {}) })) })
|
|
325
349
|
return {
|
|
@@ -432,6 +456,7 @@ export function roundView(r: LensRound) {
|
|
|
432
456
|
}
|
|
433
457
|
return {
|
|
434
458
|
roundId: r.roundId, workID: r.workID, state, inHistory: r.inHistory, autoSendCard: r.autoSendCard,
|
|
459
|
+
...(r.source === 'web' ? { source: 'web' as const } : {}),
|
|
435
460
|
assignments: r.assignments.map(a => ({ id: a.id, state: a.state, ...(a.reportedSessionID ?? a.sessionID ? { sessionID: a.reportedSessionID ?? a.sessionID } : {}),
|
|
436
461
|
...(a.receiptId ? { receiptId: a.receiptId } : {}), ...(a.detail ? { detail: a.detail } : {}) })),
|
|
437
462
|
updatedAt: new Date(r.updatedAt).toISOString(),
|
|
@@ -1,4 +1,7 @@
|
|
|
1
1
|
import express, { Router, type Request, type Response } from 'express'
|
|
2
|
+
import fs from 'node:fs'
|
|
3
|
+
import crypto from 'node:crypto'
|
|
4
|
+
import path from 'node:path'
|
|
2
5
|
import { hostname, networkInterfaces } from 'node:os'
|
|
3
6
|
import {
|
|
4
7
|
hasProxyForwardedHeaders,
|
|
@@ -39,11 +42,20 @@ import {
|
|
|
39
42
|
} from '../lib/web-memories-bridge.js'
|
|
40
43
|
import { listLiveTranscriptSessions, readLiveTranscriptSession } from '../lib/web-live-chunks.js'
|
|
41
44
|
import { getWebUsageSummary } from '../lib/web-usage-bridge.js'
|
|
45
|
+
import { WEB_PET_POSES, controlSupportDir, petChunk, petPreview, planPose, readPetSources, webPetManifest, type WebPetPose } from '../lib/web-pet.js'
|
|
46
|
+
import { controlResourcesDir, galleryPets, galleryThumb, parsePetChoice, petChoiceKey, resolvePet, shippedPets, type PetChoice, type PetFetcher } from '../lib/web-pet-choice.js'
|
|
47
|
+
import { dataPath } from '../lib/data-dir.js'
|
|
42
48
|
|
|
43
49
|
export { isMemoryWebOpReadOnly, MUTATING_MEMORY_WEB_OPS, READ_ONLY_MEMORY_WEB_OPS }
|
|
44
50
|
|
|
45
51
|
export interface WebControlRouterDeps {
|
|
46
52
|
apiToken: () => string
|
|
53
|
+
/** Tests: where the pet's state map and strips live, the chunk cache, and a stand-in for sips. */
|
|
54
|
+
petSupportDir?: () => string
|
|
55
|
+
petCacheDir?: () => string
|
|
56
|
+
petRun?: (cmd: string, args: string[]) => Promise<void>
|
|
57
|
+
petResourcesDir?: () => string | null
|
|
58
|
+
petFetch?: PetFetcher
|
|
47
59
|
port: () => number
|
|
48
60
|
publicPort?: () => number
|
|
49
61
|
httpsPort?: () => number | null
|
|
@@ -166,17 +178,15 @@ export function webControlEnabled(env: NodeJS.ProcessEnv = process.env): boolean
|
|
|
166
178
|
}
|
|
167
179
|
|
|
168
180
|
/**
|
|
169
|
-
* CSP for `/control/*`.
|
|
170
|
-
*
|
|
171
|
-
*
|
|
172
|
-
* `onclick` handlers, so only `/control/memories/*` keeps `'unsafe-inline'`.
|
|
181
|
+
* CSP for `/control/*`. No page has inline script, so every path gets `script-src 'self'`: an injected
|
|
182
|
+
* `<img onerror>` in rendered session or meeting text cannot run. 6.99.25: Memories is a native pane
|
|
183
|
+
* (panes/memories.js); the hosted page and its `/control/memories/*` 'unsafe-inline' exception are gone.
|
|
173
184
|
* `frame-ancestors 'self'` stops another site framing the signed-in page.
|
|
174
185
|
*/
|
|
175
|
-
export function webControlCsp(
|
|
176
|
-
const inlineScripts = requestPath.startsWith('/memories/')
|
|
186
|
+
export function webControlCsp(_requestPath: string): string {
|
|
177
187
|
return [
|
|
178
188
|
"default-src 'self'",
|
|
179
|
-
|
|
189
|
+
"script-src 'self'",
|
|
180
190
|
"style-src 'self' 'unsafe-inline'",
|
|
181
191
|
"font-src 'self' data:",
|
|
182
192
|
"img-src 'self' data: blob:",
|
|
@@ -550,6 +560,99 @@ export function createWebControlRouter(deps: WebControlRouterDeps): Router {
|
|
|
550
560
|
res.json(summary)
|
|
551
561
|
})
|
|
552
562
|
|
|
563
|
+
// The session pet on Home (lib/web-pet.ts) and the pet picker (lib/web-pet-choice.ts). `?pet=` is the browser's
|
|
564
|
+
// choice: mac (default), shipped:<id> or gallery:<id>. Everything here reads COS Control's files; nothing writes them.
|
|
565
|
+
const petDir = (): string => deps.petSupportDir?.() ?? controlSupportDir()
|
|
566
|
+
const petResources = (): string | null => (deps.petResourcesDir ? deps.petResourcesDir() : controlResourcesDir())
|
|
567
|
+
const petCacheRoot = (): string => deps.petCacheDir?.() ?? dataPath('web-pet')
|
|
568
|
+
const petCacheFor = (name: string): string => (name ? path.join(petCacheRoot(), name) : petCacheRoot())
|
|
569
|
+
const petChoiceOf = (req: Request): PetChoice | null => parsePetChoice(req.query.pet ?? 'mac')
|
|
570
|
+
const resolveFor = (choice: PetChoice) => resolvePet(choice, { supportDir: petDir(), resources: petResources() })
|
|
571
|
+
const sendImage = async (res: Response, file: string, type: string, immutable: boolean): Promise<void> => {
|
|
572
|
+
res.set('Cache-Control', immutable ? 'private, max-age=31536000, immutable' : 'private, no-cache')
|
|
573
|
+
// Not sendFile: it refuses any path with a dot folder, and the cache lives under ~/.cos-glasses.
|
|
574
|
+
res.type(type).send(await fs.promises.readFile(file))
|
|
575
|
+
}
|
|
576
|
+
const fileTag = (file: string): string => { try { const st = fs.statSync(file); return `${st.size}-${Math.round(st.mtimeMs)}` } catch { return '0' } }
|
|
577
|
+
|
|
578
|
+
router.get('/web-pet', (req: Request, res: Response) => {
|
|
579
|
+
res.set('Cache-Control', 'private, no-store')
|
|
580
|
+
const choice = petChoiceOf(req)
|
|
581
|
+
if (!choice) return res.status(400).json({ error: 'pet_choice_invalid' })
|
|
582
|
+
const key = petChoiceKey(choice)
|
|
583
|
+
const found = resolveFor(choice)
|
|
584
|
+
if (!found) return res.json({ available: false, pet: key, poses: {} })
|
|
585
|
+
if (found.kind === 'strips') return res.json({ ...webPetManifest(found.dir, key), pet: key, kind: 'strips' })
|
|
586
|
+
const tag = found.kind === 'still' ? fileTag(found.file) : found.preview
|
|
587
|
+
return res.json({ available: true, pet: key, kind: 'still', poses: {}, still: `/api/web-pet/still?pet=${encodeURIComponent(key)}&v=${encodeURIComponent(crypto.createHash('sha256').update(tag).digest('hex').slice(0, 16))}` })
|
|
588
|
+
})
|
|
589
|
+
|
|
590
|
+
router.get('/web-pet/sheet/:pose/:chunk', async (req: Request, res: Response) => {
|
|
591
|
+
const choice = petChoiceOf(req)
|
|
592
|
+
const found = choice ? resolveFor(choice) : null
|
|
593
|
+
if (!found || found.kind !== 'strips') return res.status(404).json({ error: 'pet_unavailable' })
|
|
594
|
+
const pose = String(req.params.pose) as WebPetPose
|
|
595
|
+
const src = (WEB_PET_POSES as readonly string[]).includes(pose) ? readPetSources(found.dir)[pose] : undefined
|
|
596
|
+
if (!src) return res.status(404).json({ error: 'pet_pose_unavailable' })
|
|
597
|
+
const plan = planPose(src)
|
|
598
|
+
const index = Number(req.params.chunk)
|
|
599
|
+
if (!Number.isInteger(index) || index < 0 || index >= plan.chunks) return res.status(404).json({ error: 'pet_chunk_unavailable' })
|
|
600
|
+
try {
|
|
601
|
+
const file = await petChunk(plan, index, { cacheDir: petCacheFor(found.cacheName), run: deps.petRun })
|
|
602
|
+
await sendImage(res, file, 'png', req.query.v === plan.key)
|
|
603
|
+
} catch (err) {
|
|
604
|
+
log(`[web-pet] could not build ${pose} chunk ${index}: ${err instanceof Error ? err.message : String(err)}`)
|
|
605
|
+
res.status(503).json({ error: 'pet_build_failed' })
|
|
606
|
+
}
|
|
607
|
+
})
|
|
608
|
+
|
|
609
|
+
// A still pet: COS Control's single sprite, Cloud Puff, or a gallery picture (downloaded once, as the Mac does).
|
|
610
|
+
router.get('/web-pet/still', async (req: Request, res: Response) => {
|
|
611
|
+
const choice = petChoiceOf(req)
|
|
612
|
+
const found = choice ? resolveFor(choice) : null
|
|
613
|
+
if (!found || found.kind === 'strips') return res.status(404).json({ error: 'pet_unavailable' })
|
|
614
|
+
try {
|
|
615
|
+
const art = found.kind === 'still' ? found : await galleryThumb(found, petCacheFor('gallery'), deps.petFetch, petDir())
|
|
616
|
+
await sendImage(res, art.file, art.type, typeof req.query.v === 'string' && req.query.v.length > 0)
|
|
617
|
+
} catch (err) {
|
|
618
|
+
log(`[web-pet] gallery picture failed for ${found.kind === 'gallery' ? found.id : 'still'}: ${err instanceof Error ? err.message : String(err)}`)
|
|
619
|
+
res.status(502).json({ error: 'pet_picture_unavailable' })
|
|
620
|
+
}
|
|
621
|
+
})
|
|
622
|
+
|
|
623
|
+
// The picker's small picture: an animated pet's first frame, or the still itself.
|
|
624
|
+
router.get('/web-pet/preview', async (req: Request, res: Response) => {
|
|
625
|
+
const choice = petChoiceOf(req)
|
|
626
|
+
const found = choice ? resolveFor(choice) : null
|
|
627
|
+
if (!found) return res.status(404).json({ error: 'pet_unavailable' })
|
|
628
|
+
try {
|
|
629
|
+
if (found.kind === 'strips') return await sendImage(res, await petPreview(found.dir, petCacheFor(found.cacheName || 'mac-preview'), { run: deps.petRun }), 'png', typeof req.query.v === 'string')
|
|
630
|
+
const art = found.kind === 'still' ? found : await galleryThumb(found, petCacheFor('gallery'), deps.petFetch, petDir())
|
|
631
|
+
await sendImage(res, art.file, art.type, typeof req.query.v === 'string')
|
|
632
|
+
} catch (err) {
|
|
633
|
+
log(`[web-pet] preview failed: ${err instanceof Error ? err.message : String(err)}`)
|
|
634
|
+
res.status(502).json({ error: 'pet_picture_unavailable' })
|
|
635
|
+
}
|
|
636
|
+
})
|
|
637
|
+
|
|
638
|
+
// What the picker lists: the Mac's pet, the characters COS Control ships, and its OpenPets gallery.
|
|
639
|
+
router.get('/web-pet/catalog', (_req: Request, res: Response) => {
|
|
640
|
+
res.set('Cache-Control', 'private, no-store')
|
|
641
|
+
const preview = (key: string, tag: string) => `/api/web-pet/preview?pet=${encodeURIComponent(key)}&v=${encodeURIComponent(crypto.createHash('sha256').update(tag).digest('hex').slice(0, 16))}`
|
|
642
|
+
const mac = resolveFor({ kind: 'mac' })
|
|
643
|
+
const macTag = mac ? (mac.kind === 'still' ? fileTag(mac.file) : fileTag(path.join(petDir(), 'session-pet-states.json'))) : ''
|
|
644
|
+
res.json({
|
|
645
|
+
mac: { available: !!mac, preview: mac ? preview('mac', 'mac:' + macTag) : null },
|
|
646
|
+
// The tag follows the art on disk, so a COS Control update with new art gets a new preview URL.
|
|
647
|
+
shipped: shippedPets(petResources()).map((p) => {
|
|
648
|
+
const r = resolveFor({ kind: 'shipped', id: p.id })
|
|
649
|
+
const tag = r?.kind === 'strips' ? fileTag(path.join(r.dir, 'session-pet-states.json')) : r?.kind === 'still' ? fileTag(r.file) : ''
|
|
650
|
+
return { ...p, pet: `shipped:${p.id}`, preview: preview(`shipped:${p.id}`, `shipped:${p.id}:${tag}`) }
|
|
651
|
+
}),
|
|
652
|
+
gallery: galleryPets(petDir()).map((p) => ({ id: p.id, name: p.name, description: p.description, category: p.category, pet: `gallery:${p.id}`, preview: preview(`gallery:${p.id}`, p.preview) })),
|
|
653
|
+
})
|
|
654
|
+
})
|
|
655
|
+
|
|
553
656
|
// Today / Work "Your Move" compatibility route
|
|
554
657
|
router.get('/work-board/your-move', async (_req: Request, res: Response) => {
|
|
555
658
|
res.set('Cache-Control', 'private, no-store')
|
|
@@ -1,11 +1,16 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* 6.83.0: lens plans in Work chat history, and "Send this plan" (docs/work-orchestrator.md, "Lens plans").
|
|
3
3
|
*
|
|
4
|
-
* Lens (over the network, token
|
|
5
|
-
* POST /api/work/chat/lens-rounds record a plan the lens made (idempotent on clientRoundId
|
|
4
|
+
* Lens, and COS Control Web (over the network, token or browser sign-in from the parent /api mount):
|
|
5
|
+
* POST /api/work/chat/lens-rounds record a plan the lens or the web made (idempotent on clientRoundId;
|
|
6
|
+
* source "lens" with a lens- id, or "web" with a web- id)
|
|
6
7
|
* GET /api/work/chat/lens-rounds/:roundId the round's live state, never its prompts
|
|
7
8
|
* POST /api/work/chat/lens-rounds/:roundId/send ask COS Control to send it (two-step confirm, or card Auto-send)
|
|
8
9
|
* POST /api/work/chat/lens-rounds/:roundId/stop ask COS Control to Stop assignments
|
|
10
|
+
* COS Control Web:
|
|
11
|
+
* GET /api/work/chat/web-plans { protocolVersion: 1, webPlans: 1, sources }: this server records web plans
|
|
12
|
+
* (a web round is refused 409 round_id_conflict when its id is reused
|
|
13
|
+
* for a different plan; GET /api/work/chat is unchanged)
|
|
9
14
|
* COS Control (this Mac only):
|
|
10
15
|
* GET /api/work/chat/lens-inbox?since=<cursor>[&autoSend=1] rounds not yet in history, send and stop requests
|
|
11
16
|
* POST /api/work/chat/lens-rounds/:roundId/history { state: "in_history", autoSendCard? }
|
|
@@ -17,8 +22,8 @@
|
|
|
17
22
|
* (index.ts leaves these paths to this router so the error code is the contract's).
|
|
18
23
|
*/
|
|
19
24
|
import { Router, type Request, type Response } from 'express'
|
|
20
|
-
import { LensRoundError, LensRoundStore, LENS_ROUND_BODY_BYTES, ROUND_ID, SEND_ID, cardSessionsOf, parseLensRound, parseResultBody,
|
|
21
|
-
parseSendBody, parseStopBody, roundView, defaultLensRoundStore } from '../lib/work-lens-rounds.js'
|
|
25
|
+
import { LensRoundError, LensRoundStore, LENS_ROUND_BODY_BYTES, ROUND_ID, ROUND_SOURCES, SEND_ID, cardSessionsOf, parseLensRound, parseResultBody,
|
|
26
|
+
parseSendBody, parseStopBody, roundView, sameRoundContent, defaultLensRoundStore } from '../lib/work-lens-rounds.js'
|
|
22
27
|
import type { ControlSnapshot } from '../lib/work-orchestrator/snapshot.js'
|
|
23
28
|
import { orchestratorSnapshots } from '../lib/work-orchestrator/runtime.js'
|
|
24
29
|
import { acquireMaintenanceWork, maintenanceAdmissionsOpen } from '../lib/maintenance-lifecycle.js'
|
|
@@ -96,6 +101,9 @@ export function createWorkLensRoundsRouter(given: Partial<WorkLensRoundsDeps> =
|
|
|
96
101
|
const input = parseLensRound(req.body)
|
|
97
102
|
// A repeat is answered from the store, even when the board has since changed or is unreachable.
|
|
98
103
|
const replay = deps.store.replay(input.clientRoundId)
|
|
104
|
+
if (replay && input.source === 'web' && !sameRoundContent(replay, input)) {
|
|
105
|
+
throw new LensRoundError('round_id_conflict', 409, 'This round id was used for a different plan. Plan again.')
|
|
106
|
+
}
|
|
99
107
|
if (!replay) {
|
|
100
108
|
let known: boolean
|
|
101
109
|
try { known = await deps.knownWork(input.workID) }
|
|
@@ -103,10 +111,16 @@ export function createWorkLensRoundsRouter(given: Partial<WorkLensRoundsDeps> =
|
|
|
103
111
|
if (!known) throw new LensRoundError('unknown_work', 404, 'This card is not on the Work board any more.')
|
|
104
112
|
}
|
|
105
113
|
const { round, created } = deps.store.record(input, cardSessionsOf(deps.snapshot(), input.workID))
|
|
106
|
-
if (created) console.log(`[work-lens-rounds] recorded ${round.roundId} work=${round.workID} assignments=${round.assignments.length}`)
|
|
114
|
+
if (created) console.log(`[work-lens-rounds] recorded ${round.roundId} work=${round.workID} assignments=${round.assignments.length}${round.source === 'web' ? ' source=web' : ''}`)
|
|
107
115
|
return res.status(created ? 201 : 200).json({ roundId: round.roundId, workID: round.workID, state: roundView(round).state, control: { connected: connected() } })
|
|
108
116
|
}))
|
|
109
117
|
|
|
118
|
+
// COS Control Web asks this before it shows Work chat. A server without it answers 404, and the web says it needs an update.
|
|
119
|
+
router.get('/work/chat/web-plans', (_req, res) => {
|
|
120
|
+
res.set('Cache-Control', 'private, no-store')
|
|
121
|
+
return res.json({ protocolVersion: 1, webPlans: 1, sources: [...ROUND_SOURCES] })
|
|
122
|
+
})
|
|
123
|
+
|
|
110
124
|
router.get('/work/chat/lens-rounds/:roundId', (req, res) => {
|
|
111
125
|
try {
|
|
112
126
|
const round = deps.store.find(roundIdOf(req))
|
|
@@ -200,6 +200,30 @@ select.field {
|
|
|
200
200
|
|
|
201
201
|
/* ── Today ───────────────────────────────────────────────── */
|
|
202
202
|
.today-date { color: var(--sub); font-size: 15px; margin: 6px 0 0; }
|
|
203
|
+
/* The session pet on Today: the pet COS Control shows, in the pose the Mac would pick. */
|
|
204
|
+
.today-pet { display: flex; flex-direction: column; align-items: center; margin: 6px 0 14px; min-height: 166px; }
|
|
205
|
+
.today-pet.hidden { display: none; }
|
|
206
|
+
.pet-btn { background: none; border: 0; padding: 0; cursor: pointer; display: flex; flex-direction: column; align-items: center; gap: 4px; color: inherit; font: inherit; }
|
|
207
|
+
.pet-btn:focus-visible { outline: 2px solid var(--sub); outline-offset: 4px; border-radius: 8px; }
|
|
208
|
+
.pet-sprite { height: 140px; background-repeat: no-repeat; }
|
|
209
|
+
.pet-caption { color: var(--sub); font-size: 14px; }
|
|
210
|
+
/* A still pet (gallery pick, Cloud Puff, single image). Small pixel art scales without blur. */
|
|
211
|
+
.pet-still { height: 140px; width: auto; max-width: 220px; object-fit: contain; }
|
|
212
|
+
.pet-still.pixel { image-rendering: pixelated; }
|
|
213
|
+
.pet-change { background: none; border: 0; padding: 6px 10px; margin-top: 2px; color: var(--sub); font: inherit; font-size: 13px; cursor: pointer; text-decoration: underline; text-underline-offset: 3px; }
|
|
214
|
+
.pet-change:focus-visible { outline: 2px solid var(--sub); outline-offset: 2px; border-radius: 6px; }
|
|
215
|
+
/* Settings > Pet */
|
|
216
|
+
.pet-choice .pet-thumb { width: 44px; height: 44px; flex: 0 0 44px; object-fit: contain; image-rendering: pixelated; border-radius: 8px; }
|
|
217
|
+
.pet-search { width: 100%; box-sizing: border-box; margin: 4px 0 10px; padding: 10px 12px; border-radius: 10px; border: 1px solid var(--panel-border); background: var(--panel); color: inherit; font: inherit; }
|
|
218
|
+
.pet-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(88px, 1fr)); gap: 8px; }
|
|
219
|
+
.pet-tile { display: flex; flex-direction: column; align-items: center; gap: 6px; padding: 10px 6px; border-radius: 12px; border: 1px solid var(--panel-border); background: var(--panel); color: inherit; font: inherit; font-size: 12px; cursor: pointer; min-width: 0; }
|
|
220
|
+
.pet-tile img { width: 56px; height: 56px; object-fit: contain; image-rendering: pixelated; }
|
|
221
|
+
.pet-tile span { max-width: 100%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
222
|
+
.pet-tile[aria-pressed="true"] { border-color: var(--accent); box-shadow: 0 0 0 1px var(--accent) inset; }
|
|
223
|
+
.pet-tile.hidden { display: none; }
|
|
224
|
+
.pet-tile:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
|
|
225
|
+
/* Unfolded: top of the right-hand column, above Needs you (app.js placePet). */
|
|
226
|
+
.today-side > .today-pet { margin: 0 0 14px; }
|
|
203
227
|
.ask-bar {
|
|
204
228
|
display: flex; align-items: center; gap: 10px; width: 100%;
|
|
205
229
|
min-height: 52px; padding: 0 14px; margin-top: 16px;
|
|
@@ -365,9 +389,6 @@ select.field {
|
|
|
365
389
|
.speaker-row audio { width: 100%; height: 40px; margin-top: 8px; grid-column: 1 / -1; }
|
|
366
390
|
.speaker-row .capture-row { grid-column: 1 / -1; margin-top: 8px; }
|
|
367
391
|
|
|
368
|
-
/* Memories: the native Memories page, hosted as-is. */
|
|
369
|
-
.pane.flush { max-width: none; margin: -4px calc(-1 * (var(--gutter) + var(--safe-right))) 0 calc(-1 * (var(--gutter) + var(--safe-left))); }
|
|
370
|
-
.memories-frame { display: block; width: 100%; height: calc(100dvh - var(--topbar-h) - var(--safe-top)); border: 0; background: var(--page); }
|
|
371
392
|
|
|
372
393
|
/* Ask sheet (⌘K on desktop) */
|
|
373
394
|
.sheet-backdrop { position: fixed; inset: 0; z-index: 50; background: rgba(23, 18, 14, 0.45); display: flex; align-items: flex-start; justify-content: center; animation: fade 160ms ease; }
|
|
@@ -445,8 +466,6 @@ select.field {
|
|
|
445
466
|
.run-title, .run-meta { grid-column: 2; }
|
|
446
467
|
.composer { position: sticky; bottom: calc(var(--safe-bottom) + 12px); }
|
|
447
468
|
.toast { top: auto; bottom: 24px; }
|
|
448
|
-
.pane.flush { margin: calc(-1 * (var(--safe-top) + 22px)) calc(-1 * (var(--gutter) + var(--safe-right))) calc(-1 * (var(--safe-bottom) + 40px)) calc(-1 * var(--gutter)); }
|
|
449
|
-
.memories-frame { height: 100dvh; }
|
|
450
469
|
.sheet-backdrop { padding-top: 8vh; }
|
|
451
470
|
.sheet { max-width: 620px; border-radius: 18px; padding: 16px 18px 18px; max-height: 80dvh; box-shadow: 0 24px 60px rgba(0, 0, 0, 0.25); }
|
|
452
471
|
}
|