Compare commits

...
Author SHA1 Message Date
james.maes 952492f5d3 Merge remote-tracking branch 'origin/main' into fix/tui-saver-online
# Conflicts:
#	CHANGELOG.md
2026-11-01 13:26:18 -06:00
james.maesandClaude Fable 5.1 708ac66251 fix(tui): saver Online tile counts sessions and you; Query art from the logo
Online: the primary source is the session store's table (sessions active
in the last 10 minutes, their users, your own included), else you plus
collaboration presence; never "nobody around" while signed in. The saver
keeps the presence lease of the screen under it instead of leaving, so
other clients see an idle viewer, not a gone one.

Query: all sizes are generated from the drawing on qrun.io/query
(scripts/gen-query-art.mjs: large 42x22, new medium 25x13, small 17x9
cells) in its indigo (#818cf8); the medium is used where there is room.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-11-01 13:19:07 -06:00
9 changed files with 366 additions and 99 deletions

No files matched your search

+4
View File
@@ -5,6 +5,10 @@ All notable changes to the qqq CLI and its packages. Versions follow [semver](ht
## [Unreleased]
### Fixed
- The screen saver's Online tile said "nobody around" with two terminals signed in. It now counts signed-in sessions first (the session store's table: sessions active in the last 10 minutes and their users, your own included, "2 sessions · 1 user (you ×2)"), else you plus the people in collaboration presence; it never says nobody is around while you are signed in. The saver also keeps its presence lease on the screen under it (the extension has no idle state), so other clients see you still viewing it instead of gone. A server that stores no sessions and merges presence per user (mock auth) shows both terminals as "1 online · you".
- Query in the screen saver looked like a different dog. All sizes are now generated from the drawing on https://www.qrun.io/query/ (`scripts/gen-query-art.mjs`) in its indigo, with a new medium size (25×13 cells, used where there is room) between the large (42×22) and the small (17×9).
### Added
- **F12 shows and hides the Assistant panel** on every screen (Ctrl+G, `:ask` and a click on `12Ask` do
+2 -2
View File
@@ -477,7 +477,7 @@ the plain sample server; one that fails shows `…`, then its last value dimmed
| Tile | Needs | Shows |
|------|-------|-------|
| Online | collaboration | people in presence across a few tables per refresh (favourites and recents first), who and on what; the signed-in user's other sessions as "you elsewhere" |
| Online | the session store, or collaboration | who is signed in: with the session store qbit, the sessions active in the last 10 minutes and their users ("3 sessions · 2 users (you ×2, bob)"; your own sessions count); otherwise you plus the people in presence across a few tables per refresh (favourites and recents first), who and on what. It never says "nobody around" while you are signed in. A server that stores no sessions (mock auth) or merges presence per user cannot tell two terminals of one user apart: both show "1 online · you". The saver keeps the presence lease of the screen under it, so others see you still viewing it, not gone |
| Jobs | `durableJobs` | running, queued and recently failed jobs (all users with jobs_admin, else yours) |
| Health | health | the summary status and up / degraded / down / unknown counts |
| Database | db-performance | live sessions, connections of the maximum, cache hit ratio, blocking chains |
@@ -497,7 +497,7 @@ the other tiles under it as one centred stack, rotating every 8 s). In a tall na
the tile panel under Query. The block letters say the profile's
short name when it has 6 characters or fewer (else the profile name), with the server host in plain text
beneath; letters, digits, `-`, `_` and `.` are drawn in block letters, anything else falls back to plain
text. Query blinks, wags and hops every ten seconds and faces the way he drifts; the art (braille, with a
text. Query blinks, wags and hops every ten seconds and faces the way he drifts. His art is Query's own drawing from https://www.qrun.io/query/ (one colour, indigo `#818cf8`) rasterised into braille at three sizes (42×22, 25×13 and 17×9 cells; the medium is used where there is room, from 100 columns) by `scripts/gen-query-art.mjs`; the art (braille, with a
line-art version for `--ascii`) is in `src/saver/query-art.ts`.
Burn-in guard (F9 › Settings › Screen saver burn-in guard, `:saver burnin on|off`, default on): nothing
+71
View File
@@ -0,0 +1,71 @@
#!/usr/bin/env node
/*
* Copyright 2026 QRun.IO, Inc.
* Licensed under the Apache License, Version 2.0 (see LICENSE).
*/
// Generates the screen saver's Query art (src/saver/query-art.ts: LARGE, MEDIUM, SMALL and the dot
// boxes of the eye and the tail) from Query's own drawing: the inline SVG on https://www.qrun.io/query/
// (viewBox 0 0 331.41 348.44; the first path is the sitting dog, the second his eye). Needs `inkscape`
// and ImageMagick (`magick`) on the PATH; run by hand when the drawing changes:
// node scripts/gen-query-art.mjs query-hero.svg [outdir]
// Each size is the drawing rasterised at 8× the dot grid, averaged down to it (a box filter, so a thin
// stroke keeps its weight as coverage) and thresholded per size (thinner strokes need a lower cut).
import { execFileSync } from 'node:child_process'
import { mkdtempSync, readFileSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
const [svgPath, outDir = '.'] = process.argv.slice(2)
if (!svgPath) throw new Error('usage: gen-query-art.mjs query-hero.svg [outdir]')
const svg = readFileSync(svgPath, 'utf8').replaceAll('currentColor', '#ffffff')
const paths = [...svg.matchAll(/<path[^>]*>/g)].map((m) => m[0])
const wrap = (body) => `<svg viewBox="0 0 331.41 348.44" xmlns="http://www.w3.org/2000/svg"><rect width="331.41" height="348.44" fill="#000"/>${body}</svg>`
const work = mkdtempSync(join(tmpdir(), 'query-art-'))
/** Coverage 0…1 per dot of an SVG body, on a grid of w×h dots. */
function raster(body, w, h) {
const file = join(work, 'in.svg')
writeFileSync(file, wrap(body))
const png = join(work, 'out.png')
execFileSync('inkscape', [file, '-o', png, '-w', String(w * 8), '-h', String(h * 8)], { stdio: 'ignore' })
const raw = execFileSync('magick', [png, '-colorspace', 'Gray', '-filter', 'Box', '-resize', `${w}x${h}!`, '-depth', '8', 'gray:-'], { maxBuffer: 1 << 24 })
return Array.from({ length: h }, (_, y) => Array.from({ length: w }, (_, x) => raw[y * w + x] / 255))
}
const DOT = [[0x01, 0x02, 0x04, 0x40], [0x08, 0x10, 0x20, 0x80]]
function braille(bits) {
const rows = []
for (let cy = 0; cy < bits.length / 4; cy++) {
let line = ''
for (let cx = 0; cx < bits[0].length / 2; cx++) {
let code = 0
for (let dx = 0; dx < 2; dx++) for (let dy = 0; dy < 4; dy++) if (bits[cy * 4 + dy]?.[cx * 2 + dx]) code |= DOT[dx][dy]
line += code ? String.fromCodePoint(0x2800 + code) : ' '
}
rows.push(line)
}
return rows
}
const SIZES = { large: { w: 42, h: 22, cut: 0.42 }, medium: { w: 25, h: 13, cut: 0.28 }, small: { w: 17, h: 9, cut: 0.2 } }
const out = {}
for (const [name, { w, h, cut }] of Object.entries(SIZES)) {
const dw = w * 2
const dh = h * 4
const bits = raster(paths[0], dw, dh).map((r) => r.map((v) => v >= cut))
// his eye: the second path alone, any coverage at all
const eyeCov = raster(paths[1], dw, dh)
const eyeDots = []
eyeCov.forEach((r, y) => r.forEach((v, x) => { if (v >= 0.12) eyeDots.push([x, y]) }))
const eye = eyeDots.length ? [Math.min(...eyeDots.map((d) => d[0])), Math.min(...eyeDots.map((d) => d[1])), Math.max(...eyeDots.map((d) => d[0])), Math.max(...eyeDots.map((d) => d[1]))] : [0, 0, 0, 0]
// the eye is part of the dog path's raster too: keep the eye dots drawn
for (const [x, y] of eyeDots) bits[y][x] = true
// the tail: the low left corner of the drawing
const tail = [0, Math.round(dh * 0.62), Math.round(dw * 0.2), dh - 1]
out[name] = { rows: braille(bits), eye, tail, w, h }
}
const ts = Object.entries(out).map(([name, o]) => `/** ${name} Query, ${o.w}×${o.h} cells, facing right (generated by scripts/gen-query-art.mjs). */\nconst ${name.toUpperCase()} = [\n${o.rows.map((r) => ` ${JSON.stringify(r)},`).join('\n')}\n]\n`).join('\n')
const parts = `const PARTS: Record<QuerySize, Parts> = {\n${Object.entries(out).map(([n, o]) => ` ${n}: { eye: [${o.eye}], tail: [${o.tail}] },`).join('\n')}\n}\n`
writeFileSync(join(outDir, 'query-art.generated.txt'), `${ts}\n${parts}`)
for (const [n, o] of Object.entries(out)) console.log(`--- ${n} ${o.w}x${o.h} eye ${o.eye} tail ${o.tail}\n${o.rows.join('\n')}`)
+5
View File
@@ -72,6 +72,11 @@ export class SaverOverlay implements Overlay {
features: s.version?.features ?? [],
status: () => s.status,
events: realtimeEvents,
sessionTable: () => {
// the session store qbit's table (storedSession)
const t = Object.values(tables).find((x) => /^(stored_?)?sessions?$/i.test(x.name))
return t && t.readPermission !== false ? t.name : undefined
},
favouriteTables: favourites,
tableLabel: (name) => tables[name]?.label ?? name,
intervalMs: () => this.model.intervalMs,
+26 -16
View File
@@ -24,7 +24,7 @@ import { pad, trunc } from '../util/text'
import { Canvas, type Style } from './canvas'
import { FONT_HEIGHT, blockText, blockWidth, canDraw } from './font'
import { sparkline, type TileState } from './model'
import { queryFrame, queryPoseAt, querySize, type QueryPose } from './query-art'
import { QUERY_COLOR, queryFrame, queryPoseAt, querySize, type QueryPose, type QuerySize } from './query-art'
import { SAVER_STYLES, type SaverStyle, type SaverStyleSetting } from './settings'
export interface SaverTile {
@@ -226,7 +226,7 @@ function drawPin(c: Canvas, input: SaverInput): void {
}
function palette(t: ThemeTokens): string[] {
return [t.acc, t.acc2, t.ok, t.warn, t.markFg, t.err]
return [QUERY_COLOR, t.acc2, t.ok, t.warn, t.markFg, t.err]
}
/** The block-letter logo under Query: the profile's name, with the server host beneath. */
@@ -262,16 +262,18 @@ export interface Box {
interface BounceSprite {
w: number
h: number
size: 'large' | 'small'
size: QuerySize
logo: boolean
}
/** Query with the name logo under him when there is room (large Query only when it all fits). */
function bounceSprite(bodyH: number, width: number, logo: NameLogo): BounceSprite {
const large = querySize('large')
const medium = querySize('medium')
const small = querySize('small')
const extra = 2 + logo.rows.length
if (bodyH >= large.height + extra && width >= Math.max(large.width, logo.w) + 4) return { w: Math.max(large.width, logo.w), h: large.height + extra, size: 'large', logo: true }
if (bodyH >= medium.height + extra && width >= Math.max(medium.width, logo.w) + 4) return { w: Math.max(medium.width, logo.w), h: medium.height + extra, size: 'medium', logo: true }
if (bodyH >= small.height + extra) return { w: Math.min(width, Math.max(small.width, logo.w)), h: small.height + extra, size: 'small', logo: true }
return { w: small.width, h: small.height, size: 'small', logo: false }
}
@@ -301,7 +303,7 @@ export interface BounceLayout {
/** The sprite is drawn (false in the panel half of the alternation). */
showSprite: boolean
panel?: Box
size: 'large' | 'small'
size: QuerySize
showLogo: boolean
dx: 1 | -1
dy: 1 | -1
@@ -354,7 +356,7 @@ export function bounceLayout(input: SaverInput): BounceLayout {
return { ...base, sprite: box, showSprite: true }
}
function drawQuery(c: Canvas, x: number, y: number, size: 'large' | 'small', pose: QueryPose, input: SaverInput, color: string): void {
function drawQuery(c: Canvas, x: number, y: number, size: QuerySize, pose: QueryPose, input: SaverInput, color: string): void {
c.block(x, y, queryFrame(size, pose, input.ascii), { fg: color, bold: true })
}
@@ -420,13 +422,14 @@ function paintStarfield(c: Canvas, input: SaverInput): Box {
c.put(sx, sy, chars[level]!, { fg: level >= 3 ? t.acc2 : level >= 2 ? t.fg : t.dim })
}
// Query drifts through left to right, bobbing
const q = querySize('small')
const gsize = guestSize(input.width, bodyH)
const q = querySize(gsize)
const step = Math.floor(input.now / FRAME_MILLIS)
const span = input.width + q.width * 2
const qx = (step % span) - q.width
const qy = Math.round(1 + bodyH * 0.25 + Math.sin(input.now / 1500) * 2)
const pose = queryPoseAt(input.now, { dx: 1, dy: Math.cos(input.now / 1500) > 0 ? 1 : -1 })
drawQuery(c, qx, Math.max(1, qy + pose.dy), 'small', pose, input, t.acc2)
drawQuery(c, qx, Math.max(1, qy + pose.dy), gsize, pose, input, QUERY_COLOR)
// the tiles float in the middle
const hud = hudSize(input, input.width - 4, bodyH - 2)
const ox = Math.round(Math.sin(input.now / 4000) * 2)
@@ -437,6 +440,11 @@ function paintStarfield(c: Canvas, input: SaverInput): Box {
return { x: hx, y: hy, w: hud.w, h: hud.h }
}
/** Query's size where he is a guest (not the sprite): medium when the screen is wide and tall enough for him, else small. */
function guestSize(width: number, bodyH: number): QuerySize {
return width >= 100 && bodyH >= 34 ? 'medium' : 'small'
}
/** Whether the flip clock is mid-flip (the first 500 ms of a minute). */
function flipping(now: number): boolean {
return now % 60_000 < 500
@@ -471,7 +479,8 @@ function paintFlip(c: Canvas, input: SaverInput): Box {
const now = hhmm(input.now)
const before = hhmm(input.now - (input.now % 60_000) - 1)
const half = flipping(input.now) && input.now % 60_000 < 250
const q = querySize('small')
const qsize = guestSize(input.width, bodyH)
const q = querySize(qsize)
const columns = input.tiles.length > 1 && input.width >= 100 ? 2 : 1
const wantRows = Math.ceil(input.tiles.length / columns)
// double-size digits when the screen has room for them and the tiles
@@ -488,7 +497,7 @@ function paintFlip(c: Canvas, input: SaverInput): Box {
const date = trunc(new Date(input.now).toLocaleDateString('en-GB', { weekday: 'long', day: 'numeric', month: 'long', year: 'numeric' }), input.width - 4)
const host = trunc(`${input.server} · ${input.profile}`, input.width - 4)
// the tiles with Query beside them, bottom-aligned
const colW = Math.max(0, Math.min(columns === 2 ? 43 : 56, Math.floor((input.width - 6 - q.width - 3 - (columns - 1) * 3) / columns)))
const colW = Math.max(0, Math.min(columns === 2 ? 43 : 56, Math.floor((input.width - 6 - (queryBeside ? q.width + 3 : 0) - (columns - 1) * 3) / columns)))
const tilesW = input.tiles.length ? columns * colW + (columns - 1) * 3 : 0
const blockW = input.tiles.length ? (queryBeside ? tilesW + 3 + q.width : Math.max(tilesW, q.width)) : q.width
const rowsCap = Math.max(1, bodyH - (cardH + 4) - (queryBeside ? 0 : q.height + 1))
@@ -537,12 +546,12 @@ function paintFlip(c: Canvas, input: SaverInput): Box {
if (input.tiles.length) tileBlock(c, bx, y, input.tiles, columns, colW, tileRows, input)
// Query peeks up beside the tiles: ears and eyes, all of him when he hops
const pose = queryPoseAt(input.now, { dx: -1 })
const shown = Math.min(blockH, pose.action ? q.height : 3)
const frame = queryFrame('small', pose, input.ascii).slice(0, shown)
const shown = Math.min(blockH, pose.action ? q.height : qsize === 'medium' ? 5 : 3)
const frame = queryFrame(qsize, pose, input.ascii).slice(0, shown)
const qx = input.tiles.length && queryBeside ? bx + tilesW + 3 : bx + Math.floor((blockW - q.width) / 2)
const qy = y + blockH - shown
c.fill(qx, qy, q.width, shown, {})
c.block(qx, qy, frame, { fg: t.acc2, bold: true })
c.block(qx, qy, frame, { fg: QUERY_COLOR, bold: true })
return { x: gx, y: gy, w: groupW, h: groupH }
}
@@ -562,7 +571,8 @@ function paintBig(c: Canvas, input: SaverInput): Box {
.map((x, i) => ({ x, i }))
.sort((a, b) => (b.x.state.value!.interest ?? 0) - (a.x.state.value!.interest ?? 0) || a.i - b.i)
.map((e) => e.x)
const q = querySize('small')
const qsize = guestSize(input.width, bodyH)
const q = querySize(qsize)
const rotation = Math.floor(input.now / 8000)
// compact: the stack is at most about 90 columns wide, however wide the screen
const avail = Math.min(input.width - 4, 90)
@@ -614,7 +624,7 @@ function paintBig(c: Canvas, input: SaverInput): Box {
const color = tile ? (levelColor(t, tile) === t.fg ? t.acc2 : levelColor(t, tile)) : t.acc2
const heroX = centred(heroW)
if (!tile) {
drawQuery(c, heroX, y, 'small', queryPoseAt(input.now), input, t.acc2)
drawQuery(c, heroX, y, qsize, queryPoseAt(input.now), input, QUERY_COLOR)
} else {
const pose: QueryPose = { ...pose0, ...(asleep ? { blink: true, wag: false } : {}) }
const numberAt = (x: number, ny: number) => {
@@ -622,12 +632,12 @@ function paintBig(c: Canvas, input: SaverInput): Box {
else c.put(x, ny, big, { fg: color, bold: true })
}
if (beside) {
drawQuery(c, heroX, y, 'small', pose, input, t.acc2)
drawQuery(c, heroX, y, qsize, pose, input, QUERY_COLOR)
if (asleep) c.put(heroX + q.width - 1, y - 1, input.now % 2000 < 1000 ? 'z' : 'zZ', { fg: t.dim })
numberAt(heroX + q.width + 3, y + Math.floor((heroH - numH) / 2))
} else {
numberAt(heroX + Math.floor((heroW - numW) / 2), y)
drawQuery(c, heroX + Math.floor((heroW - q.width) / 2), y + numH + 1, 'small', pose, input, t.acc2)
drawQuery(c, heroX + Math.floor((heroW - q.width) / 2), y + numH + 1, qsize, pose, input, QUERY_COLOR)
if (asleep) c.put(heroX + Math.floor((heroW + q.width) / 2) - 1, y + numH, input.now % 2000 < 1000 ? 'z' : 'zZ', { fg: t.dim })
}
}
+65 -54
View File
@@ -7,57 +7,73 @@
* Query, the qrun.io mascot, for the screen saver.
*
* Source: the sitting-dog line drawing on Query's page, https://www.qrun.io/query/ (the inline SVG,
* viewBox 0 0 331.41 348.44, that the page's hero bounces). The large art is that drawing traced
* into braille dots (2×4 dots per cell, 66×76 dots); the small art is hand-drawn on a 24×24 dot
* grid after it. Each braille cell is a bitmap, so the frames are made from the art itself: blink
* (the eye's dots cleared), tail wag (the tail's dots moved), mirrored to face the way he drifts,
* eyes glancing up or down. The ASCII glyph set gets a plain line-art version of the same bitmap
* (hand-drawn for the small size).
* viewBox 0 0 331.41 348.44, in one colour: indigo-400, #818cf8). All three sizes are that drawing
* rasterised and thresholded into braille dots (2×4 dots per cell) by scripts/gen-query-art.mjs, so
* they share its proportions: large 42×22 cells, medium 25×13, small 17×9. Each braille cell is a
* bitmap, so the frames are made from the art itself: blink (the eye's dots cleared), tail wag (the
* tail's dots moved), mirrored to face the way he drifts, eyes glancing up or down. The ASCII glyph
* set gets a plain line-art version of the same bitmap.
*/
export type QuerySize = 'small' | 'large'
/** The drawing's colour (the logo's own fill). */
export const QUERY_COLOR = '#818cf8'
/** Large Query, 33×19 cells, facing right. */
export type QuerySize = 'small' | 'medium' | 'large'
/** large Query, 42×22 cells, facing right (generated by scripts/gen-query-art.mjs). */
const LARGE = [
"⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⣠⡴⠞⠛⠛⠛⠛⠳⢦⣄⠀⠀⠀⠀⠀",
"⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⢠⡾⠋⠀⠀⠀⠀⠀⠀⢀⡀⠹⣇⠀⠀⠀⠀",
"⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⢠⡟⠀⠀⢠⡆⠀⠀⠀⠀⠸⠗⠀⠻⣤⣤⣤⣀",
"⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⣿⠁⠀⠀⢸⡇⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⣿",
"⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⢸⡇⠀⠀⠀⠈⢿⡀⠀⠀⠀⠀⠀⠀⠀⢀⣀⡼⠋",
"⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠘⣧⠀⠀⠀⠀⢸⡇⠀⠀⠀⠘⣿⠛⠛⠋⠉⠀⠀",
"⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠙⢷⣤⣀⣤⡾⠁⠀⠀⠀⠀⣿⠀⠀⠀⠀⠀⠀",
"⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⣠⡟⠁⠉⠀⠀⠀⠀⠀⠀⠀⣿⡀⠀⠀⠀⠀⠀",
"⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⣠⡾⠋⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⢸⡇⠀⠀⠀⠀⠀",
"⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⢀⣴⠞⠁⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⣿⠀⠀⠀⠀⠀",
"⠀⠀⠀⠀⠀⠀⠀⠀⠀⣰⠟⠁⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⢻⡆⠀⠀⠀⠀",
"⠀⠀⠀⠀⠀⠀⠀⢀⡾⠃⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⣿⠁⠀⠀⠀⠀",
"⠀⣀⣀⠀⠀⠀⢀⡾⠁⠀⠀⠀⠀⣤⡴⠶⠶⠶⠶⣤⡀⠀⠀⠀⠀⠀⣰⠏⠀⠀⠀⠀⠀",
"⠀⠙⣿⣷⠀⠀⣼⠃⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⢹⣆⠀⠀⠀⢰⡏⠀⠀⠀⠀⠀⠀",
"⠀⠀⣿⣿⠃⠀⣿⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⢰⡏⠀⠀⠀⠈⣷⠀⠀⠀⠀⠀⠀",
"⠀⢠⡿⣿⠀⢸⡇⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⢀⣴⠟⠀⠀⡀⠀⠀⣿⡀⠀⠀⠀⠀⠀",
"⠀⣼⢻⡇⠀⢸⡇⠀⠀⠀⠀⠀⠀⠀⠀⠀⢀⣴⠟⠁⠀⠀⠀⢿⡀⠀⢸⡇⠀⠀⠀⠀⠀",
"⠀⣿⡈⠷⠶⠟⢁⠀⠀⠀⠀⠀⠀⠀⠀⠘⠛⠛⠛⠛⠛⠳⣦⡸⣧⠀⠈⠛⠳⣦⡀⠀⠀",
"⠀⠈⠳⣦⣤⡴⠛⢧⣤⣤⣤⣤⣤⣤⣤⣤⣤⣤⣤⣤⣤⣤⣼⡇⠹⣦⣤⣤⣤⣬⡗⠀⠀",
" ",
" ⣀⣀⣄⣀⣀ ",
" ⢀⡴⠋⠁ ⠈⠙⢢⡀ ",
" ⣰⠋ ⢠⣦ ⣧ ",
" ⢰⠇ ⢸⠁ ⠉ ⠈⠓⠒⠲⡆ ",
" ⡟ ⢸⡄ ⢠⠇ ",
" ⡇ ⢷ ⢀⣀⣀⣤⡤⠴⠋ ",
" ⢻⡀ ⣼ ⢸ ",
" ⣹⠓⠒⠚⠁ ⢸ ",
" ⢀⡴⠃ ⠸⡆ ",
" ⢀⡴⠋ ⢧ ",
" ⢀⡴⠉ ⢸ ",
" ⣰⠋ ⢸⠃ ",
" ⡼⠁ ⢀⣀⣀⣀⡀ ⡼ ",
" ⠰⣶⣄ ⢰⠃ ⠘⠉⠉ ⠈⠉⠓⣆ ⡼⠁ ",
" ⢸⢹ ⡟ ⢸⠆ ⣷ ",
" ⣼⣸ ⢠⡇ ⢀⡞ ⢸ ",
" ⢠⡇⡏ ⢸⡇ ⢀⡴⠋ ⢰⡀ ⠸⡇ ",
" ⢸ ⣇⣀⡼⠁ ⠠⣴⣯⣤⣤⣤⣄ ⣧ ⢧⣤⡀ ",
" ⠘⢧⡀⠁⣀⢶⡀ ⠈⢳⠸⣄ ⠙⡆ ",
" ⠉⠉⠁ ⠉⠉⠉⠉⠙⠛⠛⠛⠛⠛⠛⠛⠛⠋⠉ ⠈⠉⠉⠉⠉⠁ ",
" ",
]
/** Small Query, 12×6 cells, facing right (hand-drawn on 24×24 dots). */
/** medium Query, 25×13 cells, facing right (generated by scripts/gen-query-art.mjs). */
const MEDIUM = [
" ",
" ⢀⠔⠋⠉⠉⠑⢆ ",
" ⢠⠃ ⡆ ⠛⠘⠤⠤⡀ ",
" ⣾ ⢧ ⢀⣀⡼⠁ ",
" ⠹⣄⣀⡼ ⢹⠉ ",
" ⡰⠃⠁ ⢸⡀ ",
" ⢀⡴⠊ ⡇ ",
" ⢠⠎ ⡇ ",
" ⣦⡀⢠⠇ ⠐⠚⠙⠒⢦ ⣰⠁ ",
" ⣸⡇⢸ ⡸ ⢹ ",
" ⣿⠁⣸ ⣠⠞⠁⠐⡆⢸⡄ ",
" ⢧⣉⡵⣄⣀⣀⣀⣈⣉⣉⣉⣱⢱⣀⣉⣳ ",
" ",
]
/** small Query, 17×9 cells, facing right (generated by scripts/gen-query-art.mjs). */
const SMALL = [
'⠀⠀⠀⠀⢀⠤⠒⠒⠢⢄⠀⠀',
'⠀⠀⠀⠀⡇⢸⠀⠁⣀⣀⡩⠕',
'⠀⠀⠀⠀⡨⠃⠀⠀⢇⠀⠀⠀',
'⠀⡀⢠⠊⢀⠤⢄⠀⢸⠀⠀⠀',
'⠀⢇⡇⠀⠀⠀⡠⠃⢸⠀⠀⠀',
'⠀⠀⠣⠤⠤⠼⠶⠶⠮⠵⠀⠀',
]
/** Small Query for the ASCII glyph set, 12×6 cells, facing right. */
const SMALL_ASCII = [
' .--. ',
' /( o`-.',
" \\_) .-'",
' _ / | ',
' ( | _/ | ',
" `-'--'-' ",
" ⣀⣤⣀⡀ ",
" ⡞⢡ ⠰⠹⣀⡀ ",
" ⢸ ⢸⡀⢀⣀⠤⠃ ",
" ⢈⡗⠚ ⠘⡇ ",
" ⢀⡴⠋ ⣇ ",
" ⢀⡀⢀⠎ ⡠⠤⣀ ⡏ ",
" ⡷⡼ ⢨⠃⠸⡄ ",
" ⢸⢧⢇ ⠴⠥⢜⡄⠧⡀ ",
" ⠉⠉⠓⠒⠒⠒⠒⠊⠙⠒⠃ ",
]
interface Parts {
@@ -67,8 +83,9 @@ interface Parts {
}
const PARTS: Record<QuerySize, Parts> = {
large: { eye: [51, 7, 53, 10], tail: [2, 51, 8, 68] },
small: { eye: [14, 4, 14, 4], tail: [2, 15, 3, 18] },
large: { eye: [57,13,59,16], tail: [0,55,17,87] },
medium: { eye: [34,8,35,9], tail: [0,32,10,51] },
small: { eye: [23,5,23,6], tail: [0,22,7,35] },
}
// braille dot bits by (column, row) inside a cell
@@ -158,11 +175,11 @@ export function mirrorAscii(row: string): string {
return [...row].reverse().map((c) => MIRROR[c] ?? c).join('')
}
const BASE: Record<QuerySize, Bitmap> = { large: decodeBraille(LARGE), small: decodeBraille(SMALL) }
const BASE: Record<QuerySize, Bitmap> = { large: decodeBraille(LARGE), medium: decodeBraille(MEDIUM), small: decodeBraille(SMALL) }
/** Width and height in cells of Query at a size. */
export function querySize(size: QuerySize): { width: number; height: number } {
return size === 'large' ? { width: 33, height: 19 } : { width: 12, height: 6 }
return size === 'large' ? { width: 42, height: 22 } : size === 'medium' ? { width: 25, height: 13 } : { width: 17, height: 9 }
}
export interface QueryPose {
@@ -178,12 +195,6 @@ export interface QueryPose {
/** One frame of Query: rows of text (braille, or ASCII line art), all the same width. */
export function queryFrame(size: QuerySize, pose: QueryPose = {}, ascii = false): string[] {
if (ascii && size === 'small') {
let rows = SMALL_ASCII.map((r) => (pose.blink ? r.replace('o', '-') : r))
if (pose.wag) rows = rows.map((r, i) => (i === 3 ? ` _${r.slice(2)}` : i === 4 ? `( ${r.slice(3)}` : r))
if (pose.facing === 'left') rows = rows.map(mirrorAscii)
return rows
}
const parts = PARTS[size]
const bits = BASE[size].map((r) => [...r])
const [ex0, ey0, ex1, ey1] = parts.eye
+73 -17
View File
@@ -8,7 +8,7 @@
*
* | tile | needs | reads |
* |---------|----------------------|----------------------------------------------------------|
* | people | collaboration | GET presence per table, a few tables per refresh |
* | people | session store, or collaboration | the session table (sessions active in the last 10 minutes), else GET presence per table, a few tables per refresh |
* | jobs | durableJobs feature | GET /jobs?state=recent (scope all, else mine) |
* | health | health | GET /health/summary |
* | db | db-performance | GET overview + activity |
@@ -34,6 +34,8 @@ export interface SaverContext {
events: EventRate
/** Tables to sample for presence, most likely first (favourites, recents, then the rest). */
presenceTables(): string[]
/** The server's session table (the session store qbit's), when it has one the user can read. */
sessionTable?(): string | undefined
/** The profile's favourite tables (count tiles). */
favouriteTables: string[]
tableLabel(name: string): string
@@ -51,22 +53,75 @@ interface Viewer {
mode?: string
}
/** Everyone seen in presence lately across the sampled tables (this terminal is not: it left). */
/** Sessions that count as online: active in the last 10 minutes. */
export const SESSION_ACTIVE_MILLIS = 10 * 60_000
function userLabel(userId: string): string {
return userId.includes(':') ? userId.slice(userId.indexOf(':') + 1) : userId
}
/**
* Who is online: signed-in sessions from the session store's table when the server has one and it
* holds sessions active in the last 10 minutes (own sessions count: "2 sessions · 1 user (you ×2)"),
* else this terminal plus everyone seen in collaboration presence across a few tables per refresh
* (the saver keeps its lease, so the signed-in viewer is always one of them). Never "nobody around"
* while the viewer is signed in.
*/
export function presenceSource(ctx: SaverContext): TileSource {
const seen = new Map<string, { name: string; table: string; mode?: string; at: number }>()
const skip = new Set<string>()
let cursor = 0
let sessionsGone = false
const now = ctx.now ?? Date.now
const names = new Map<string, string>()
async function sessions(): Promise<TileValue | undefined> {
const table = ctx.sessionTable?.()
if (!table || sessionsGone) return undefined
const cutoff = new Date(now() - SESSION_ACTIVE_MILLIS).toISOString()
let rows: Array<{ values: Record<string, unknown> }>
try {
rows = await ctx.client.query(table, {
criteria: [{ fieldName: 'modifyDate', operator: 'GREATER_THAN', values: [cutoff] }, { fieldName: 'expiresAt', operator: 'GREATER_THAN', values: [new Date(now()).toISOString()] }],
booleanOperator: 'AND', limit: 500,
})
} catch (error) {
const status = (error as { status?: number }).status
if (status === 403 || status === 404) sessionsGone = true
return undefined
}
if (!rows.length) return undefined
const self = ctx.status()
const perUser = new Map<string, number>()
for (const r of rows) {
const id = String(r.values.userId ?? '')
if (id) perUser.set(id, (perUser.get(id) ?? 0) + 1)
}
const isSelf = (id: string) => id === self.userId || (Boolean(self.user) && (names.get(id) === self.user || userLabel(id).toLowerCase() === self.user.toLowerCase()))
const list = [...perUser.entries()].sort((a, b) => Number(isSelf(b[0])) - Number(isSelf(a[0])) || a[0].localeCompare(b[0]))
const who = list.map(([id, n]) => `${isSelf(id) ? 'you' : names.get(id) ?? userLabel(id)}${n > 1 ? ` ×${n}` : ''}`)
const total = rows.length
const users = perUser.size
return {
value: `${total} online`,
detail: `${total} session${total === 1 ? '' : 's'} · ${users} user${users === 1 ? '' : 's'} (${who.join(', ')})`,
big: String(total), interest: 50 + total, level: 'ok',
}
}
return {
id: 'people', label: 'Online', needs: 'collaboration',
present: () => ctx.has('collaboration'),
id: 'people', label: 'Online', needs: 'collaboration or the session store',
present: () => ctx.has('collaboration') || Boolean(ctx.sessionTable?.()),
async load(): Promise<TileValue> {
const all = ctx.presenceTables().filter((t) => !skip.has(t))
if (!all.length) throw new SourceAbsent('no tables to watch')
const bySession = await sessions()
if (bySession) return bySession
const self = ctx.status()
const signedIn = Boolean(self.userId || self.user)
const all = ctx.has('collaboration') ? ctx.presenceTables().filter((t) => !skip.has(t)) : []
if (!all.length && !signedIn) throw new SourceAbsent('no tables to watch')
const batch: string[] = []
for (let i = 0; i < Math.min(PRESENCE_TABLES_PER_REFRESH, all.length); i++) batch.push(all[(cursor + i) % all.length]!)
cursor = (cursor + batch.length) % Math.max(1, all.length)
const self = ctx.status()
let failures = 0
await Promise.all(batch.map(async (table) => {
try {
@@ -74,10 +129,11 @@ export function presenceSource(ctx: SaverContext): TileSource {
const t = now()
for (const [id, v] of seen) if (v.table === table) seen.delete(id)
for (const v of snap.viewers ?? []) {
// this terminal left presence when the saver came up: the signed-in user seen here is
// another session of theirs (a browser tab, another terminal)
const you = v.userId === self.userId || (Boolean(self.user) && v.displayName === self.user)
seen.set(v.userId, { name: you ? 'you elsewhere' : v.displayName || v.userId, table, mode: v.mode, at: t })
// this terminal is you (it keeps its lease while the saver shows): not a second person
if (you) continue
names.set(v.userId, v.displayName || v.userId)
seen.set(v.userId, { name: v.displayName || v.userId, table, mode: v.mode, at: t })
}
} catch (error) {
const status = (error as { status?: number }).status
@@ -85,16 +141,16 @@ export function presenceSource(ctx: SaverContext): TileSource {
else failures++
}
}))
if (failures === batch.length) throw new Error('presence did not answer')
if (batch.length && failures === batch.length && !signedIn) throw new Error('presence did not answer')
// a person not seen again within one sweep of the list (plus a lease) has left
const ttl = Math.ceil(all.length / PRESENCE_TABLES_PER_REFRESH) * ctx.intervalMs() + 45_000
const ttl = Math.ceil(Math.max(1, all.length) / PRESENCE_TABLES_PER_REFRESH) * ctx.intervalMs() + 45_000
const t = now()
for (const [id, v] of seen) if (t - v.at > ttl) seen.delete(id)
const people = [...seen.values()].sort((a, b) => a.name.localeCompare(b.name))
const detail = people.length
? people.sort((a, b) => Number(a.name === 'you elsewhere') - Number(b.name === 'you elsewhere')).map((p) => `${p.name}${p.mode === 'EDITING' ? ' editing' : ''} · ${ctx.tableLabel(p.table)}`).join(', ')
: 'nobody around'
return { value: String(people.length), detail, big: String(people.length), interest: people.length ? 50 + people.length : 5 }
const others = [...seen.values()].sort((a, b) => a.name.localeCompare(b.name))
const total = others.length + (signedIn ? 1 : 0)
const parts = [...(signedIn ? ['you'] : []), ...others.map((p) => `${p.name}${p.mode === 'EDITING' ? ' editing' : ''} · ${ctx.tableLabel(p.table)}`)]
const detail = parts.length ? `${total} online · ${parts.join(', ')}` : 'nobody around'
return { value: String(total), detail, big: String(total), interest: total ? 50 + total : 5 }
},
}
}
+8 -3
View File
@@ -201,10 +201,15 @@ export class AppStore {
this.bump()
}
/** Tells the presence source which scope is on top (called on every repaint; cheap when unchanged). */
/**
* Tells the presence source which scope is on top (called on every repaint; cheap when unchanged).
* The screen saver or lock keeps the lease on the scope that was on top (the extension has no idle
* state: the user stays as a viewer rather than vanishing from the others' screens, as a person
* who stepped away from a record) and waking re-syncs.
*/
private syncPresence(): void {
const scope = this.lock ? undefined : this.screen?.presenceScope?.()
presenceSource().enter?.(scope)
if (this.lock) return
presenceSource().enter?.(this.screen?.presenceScope?.())
}
/** Replaces the top screen (a form that saved becomes the record). */
+112 -7
View File
@@ -7,6 +7,7 @@ import { afterAll, beforeAll, describe, expect, test } from 'bun:test'
import { removePin, setPin } from '@qrun-io/qqq-core'
import { UiApiError } from '@qrun-io/qqq-ui-api-client'
import { SaverOverlay } from '../src/overlays/saver'
import { setPresenceSource } from '../src/presence'
import { FONT_HEIGHT, blockText, blockWidth, canDraw } from '../src/saver/font'
import { ALTERNATE_MILLIS, AUTO_STYLE_MILLIS, FRAME_MILLIS, SHIFT_MILLIS, bounce, bounceLayout, isDimmed, logoName, nextFrameDelay, paintSaver, saverFrame, styleAt, type SaverInput, type SaverTile } from '../src/saver/layout'
import { EventRate, MAX_BACKOFF_MS, TileModel, isAbsent, sparkline, type TileSource, type TileValue } from '../src/saver/model'
@@ -126,17 +127,53 @@ describe('sources', () => {
const s = presenceSource(ctx({ now: () => t, data: (_id, input) => ({ viewers: viewers[(input as { query: { table: string } }).query.table] ?? [] }) }))
const v = await s.load()
expect(v.value).toBe('3')
expect(v.detail).toMatch(/^Bob · PERSON, Carol editing · ORDER, you elsewhere · PERSON$/)
expect(v.detail).toBe('3 online · you, Bob · PERSON, Carol editing · ORDER')
viewers.person = []
viewers.order = []
t = 120_000
expect((await s.load()).value).toBe('0')
expect((await s.load()).value).toBe('1')
})
test('presence on a table that refuses it is skipped; the source hides when nothing is left', async () => {
const s = presenceSource(ctx({ data: () => { throw new UiApiError(404, {}) } }))
await s.load()
await expect(s.load()).rejects.toThrow('no tables')
const refuse = () => { throw new UiApiError(404, {}) }
// signed out: nothing to show once every table refuses
const out = presenceSource(ctx({ status: () => ({ apiVersion: '1', pingMs: 1, stream: 'connected', lastEvent: '', user: '' }), data: refuse }))
await out.load()
await expect(out.load()).rejects.toThrow('no tables')
// signed in: never "nobody around", this terminal is online
const me = presenceSource(ctx({ data: refuse }))
await me.load()
const v = await me.load()
expect(v.value).toBe('1')
expect(v.detail).toBe('1 online · you')
})
test('online by session: sessions active in the last 10 minutes, own sessions counted, others by user', async () => {
const queries: unknown[] = []
const rows = [{ userId: 'core:me' }, { userId: 'core:me' }, { userId: 'core:bob' }]
const client = { query: async (_t: string, f: unknown) => { queries.push(f); return rows.map((values) => ({ values })) }, data: async () => ({}), count: async () => 0 }
const mk = (over: Partial<SaverContext> & { data?: (id: string, input: unknown) => unknown } = {}) => ctx({ client: client as never, sessionTable: () => 'storedSession', now: () => 1_000_000_000_000, ...over })
const v = await presenceSource(mk()).load()
expect(v.value).toBe('3 online')
expect(v.detail).toBe('3 sessions · 2 users (you ×2, bob)')
expect(v.big).toBe('3')
const f = queries[0] as { criteria: Array<{ fieldName: string; operator: string; values: string[] }> }
expect(f.criteria[0]).toMatchObject({ fieldName: 'modifyDate', operator: 'GREATER_THAN', values: [new Date(1_000_000_000_000 - 600_000).toISOString()] })
// the owner's case: the same user twice
rows.splice(2, 1)
expect((await presenceSource(mk()).load()).detail).toBe('2 sessions · 1 user (you ×2)')
// an empty session table (a mock-auth server stores none) falls back to presence and still counts you
rows.length = 0
const fb = await presenceSource(mk({ data: () => ({ viewers: [] }) })).load()
expect(fb.value).toBe('1')
expect(fb.detail).toBe('1 online · you')
// no session table, no collaboration: the tile hides
expect(presenceSource(ctx({ has: () => false })).present()).toBe(false)
expect(presenceSource(ctx({ has: () => false, sessionTable: () => 'storedSession' })).present()).toBe(true)
// a session table the user may not read is skipped for good
const denied = { ...client, query: async () => { throw new UiApiError(403, {}) } }
const d = presenceSource(mk({ client: denied as never, data: () => ({ viewers: [] }) }))
expect((await d.load()).value).toBe('1')
})
test('jobs: counts by status, falls back to my jobs on 403', async () => {
@@ -170,7 +207,7 @@ describe('sources', () => {
describe('Query', () => {
test('both sizes have their documented size; braille round-trips; mirrored and blinking frames differ', () => {
for (const size of ['small', 'large'] as const) {
for (const size of ['small', 'medium', 'large'] as const) {
const { width, height } = querySize(size)
const frame = queryFrame(size)
expect(frame.length).toBe(height)
@@ -187,6 +224,49 @@ describe('Query', () => {
expect(mirrorAscii("(/`")).toBe("'\\)")
})
test('every size keeps the logo\'s silhouette: floppy ear left of the head and snout, tail curled at the low left, sitting body, front legs and the ground line at the bottom', () => {
expect(querySize('large')).toEqual({ width: 42, height: 22 })
expect(querySize('medium')).toEqual({ width: 25, height: 13 })
expect(querySize('small')).toEqual({ width: 17, height: 9 })
for (const size of ['large', 'medium', 'small'] as const) {
const rows = queryFrame(size)
const bits = decodeBraille(rows)
const H = bits.length
const W = bits[0]!.length
const dots = (y0: number, y1: number, x0 = 0, x1 = W) => {
const out: Array<[number, number]> = []
for (let y = Math.floor(y0 * H); y < Math.ceil(y1 * H); y++) for (let x = Math.floor(x0 * W); x < Math.ceil(x1 * W); x++) if (bits[y]?.[x]) out.push([x, y])
return out
}
const minX = (d: Array<[number, number]>) => Math.min(...d.map((p) => p[0]))
const maxX = (d: Array<[number, number]>) => Math.max(...d.map((p) => p[0]))
// the picture is the logo's: dots across the whole height and most of the width, nothing past the cells
expect(dots(0, 1).length).toBeGreaterThan(H)
expect(rows.length).toBe(querySize(size).height)
// head and ear: the top third; the ear (its left edge) is well left of the snout (the right edge), the snout is the rightmost thing up there
const head = dots(0, 0.34)
expect(head.length).toBeGreaterThan(5)
expect(maxX(head)).toBeGreaterThan(W * 0.7)
expect(minX(head)).toBeLessThan(W * 0.55)
expect(maxX(head) - minX(head)).toBeGreaterThan(W * 0.3)
// the top of the head sits above the middle of the back, right of the tail
expect(minX(head)).toBeGreaterThan(W * 0.2)
// the tail: ink in the low-left corner, left of where the body's back reaches the ground
const tail = dots(0.6, 1, 0, 0.22)
expect(tail.length).toBeGreaterThan(4)
// the sitting body: the chest edge at the right in the middle rows and the haunch at the left
const middle = dots(0.4, 0.75)
expect(maxX(middle)).toBeGreaterThan(W * 0.6)
expect(minX(middle)).toBeLessThan(W * 0.5)
// front legs and the ground line: the bottom row of cells spans most of the width
const ground = dots(0.84, 1)
expect(maxX(ground) - minX(ground)).toBeGreaterThan(W * 0.6)
expect(maxX(ground)).toBeGreaterThan(W * 0.7)
// the eye is inside the head, right of the ear's left edge
expect(queryFrame(size, { blink: true })).not.toEqual(rows)
}
})
test('poses: blinks, acts every ten seconds, faces the way he drifts', () => {
expect(queryPoseAt(0).blink).toBe(true)
expect(queryPoseAt(1000).blink).toBe(false)
@@ -520,7 +600,7 @@ describe('burn-in guard', () => {
const cy = (minY + maxY) / 2
for (const r of spans) {
expect(Math.abs((r.from + r.to) / 2 - cx)).toBeLessThanOrEqual(width * 0.4)
expect(Math.abs(r.y - cy)).toBeLessThanOrEqual(height * 0.4 + 1)
expect(Math.abs(r.y - cy)).toBeLessThanOrEqual(height * 0.4 + 2)
}
{
// the group as a whole stays near the middle of the screen, fully on it
@@ -688,6 +768,31 @@ describe('in the TUI', () => {
await command(h, 'saver dim 10')
})
test('the saver keeps the presence lease of the screen under it (others see an idle viewer, not a gone one); waking resyncs', async () => {
const calls: Array<string | undefined> = []
const none = { count: 0, people: [] }
setPresenceSource({ get: () => none, subscribe: () => () => undefined, enter: (scope) => { calls.push(scope) } })
try {
await command(h, 'table query person')
await waitFor(h, (f) => f.includes('30 records'))
expect(calls.at(-1)).toBe('table:person')
calls.length = 0
await command(h, 'saver flip')
await waitFor(h, (f) => f.includes('preview: flip'))
// never left (the same scope may be announced again; the source ignores a repeat)
expect(calls.every((c) => c === 'table:person')).toBe(true)
await press(h, () => h.setup.mockInput.typeText('x'))
expect(h.store.lock).toBeUndefined()
h.store.bump()
await waitFor(h, (f) => f.includes('30 records'))
expect(calls.includes(undefined)).toBe(false)
expect(calls.every((c) => c === 'table:person')).toBe(true)
} finally {
setPresenceSource(undefined)
h.store.back()
}
})
test('F9 → Settings lists the saver style, refresh and preview', () => {
const labels = (h.store.commands() as unknown as { entries(f: string): Array<{ label: string }> }).entries('').map((e) => e.label)
expect(labels.some((l) => l.startsWith('Screen saver style:'))).toBe(true)