From 842f0ba341286786cfcd73b0328d62992edfd1e5 Mon Sep 17 00:00:00 2001 From: ba2slk <130782318+ba2slk@users.noreply.github.com> Date: Wed, 19 Aug 2026 02:43:42 +0900 Subject: [PATCH 1/8] feat: pick the release to offer from GitHub's list, by SemVer Co-Authored-By: Claude Opus 5 --- src/main/update-check.test.ts | 97 +++++++++++++++++++++++++++++ src/main/update-check.ts | 113 ++++++++++++++++++++++++++++++++++ 2 files changed, 210 insertions(+) create mode 100644 src/main/update-check.test.ts create mode 100644 src/main/update-check.ts diff --git a/src/main/update-check.test.ts b/src/main/update-check.test.ts new file mode 100644 index 0000000..5f6f496 --- /dev/null +++ b/src/main/update-check.test.ts @@ -0,0 +1,97 @@ +import { describe, expect, it } from 'vitest' +import { compareSemver, parseSemver, pickUpdate } from './update-check' + +const release = (tag: string, extra: Record = {}) => ({ + tag_name: tag, + html_url: `https://github.com/ba2slk/termspace/releases/tag/${tag}`, + draft: false, + prerelease: tag.includes('-'), + ...extra, +}) + +describe('parseSemver', () => { + it('reads a plain version, with or without the v', () => { + expect(parseSemver('1.2.3')).toEqual({ major: 1, minor: 2, patch: 3, prerelease: [] }) + expect(parseSemver('v1.2.3')).toEqual({ major: 1, minor: 2, patch: 3, prerelease: [] }) + }) + + it('splits the prerelease part, numbers as numbers', () => { + expect(parseSemver('1.1.0-beta.4')).toEqual({ major: 1, minor: 1, patch: 0, prerelease: ['beta', 4] }) + }) + + it('returns null for anything else', () => { + expect(parseSemver('1.2')).toBeNull() + expect(parseSemver('nightly')).toBeNull() + expect(parseSemver('')).toBeNull() + }) +}) + +describe('compareSemver', () => { + const v = (s: string) => parseSemver(s)! + it('orders by major, minor, patch', () => { + expect(compareSemver(v('1.0.0'), v('1.0.1'))).toBeLessThan(0) + expect(compareSemver(v('1.10.0'), v('1.9.9'))).toBeGreaterThan(0) + expect(compareSemver(v('2.0.0'), v('2.0.0'))).toBe(0) + }) + it('puts a prerelease before its release, and orders prereleases', () => { + expect(compareSemver(v('1.1.0-beta.4'), v('1.1.0'))).toBeLessThan(0) + expect(compareSemver(v('1.1.0'), v('1.1.1'))).toBeLessThan(0) + expect(compareSemver(v('1.1.0-beta.4'), v('1.1.0-beta.10'))).toBeLessThan(0) + expect(compareSemver(v('1.1.0-alpha.1'), v('1.1.0-beta.1'))).toBeLessThan(0) + }) +}) + +describe('pickUpdate', () => { + it('offers the newest stable release above a stable build', () => { + const picked = pickUpdate([release('v1.0.0'), release('v1.2.0'), release('v1.1.0')], '1.0.0') + expect(picked).toEqual({ version: '1.2.0', url: 'https://github.com/ba2slk/termspace/releases/tag/v1.2.0' }) + }) + + it('reports nothing when the running version is the newest', () => { + expect(pickUpdate([release('v1.0.0'), release('v1.1.0')], '1.1.0')).toBeNull() + }) + + it('reports nothing for a local build newer than anything published', () => { + expect(pickUpdate([release('v1.0.0'), release('v1.1.0')], '1.2.0')).toBeNull() + }) + + it('hides prereleases from a stable build', () => { + expect(pickUpdate([release('v1.0.0'), release('v1.1.0-beta.4')], '1.0.0')).toBeNull() + }) + + it('shows prereleases to a prerelease build', () => { + expect(pickUpdate([release('v1.1.0-beta.3'), release('v1.1.0-beta.4')], '1.1.0-beta.3')?.version).toBe('1.1.0-beta.4') + }) + + it('never offers a beta build an older stable', () => { + expect(pickUpdate([release('v1.0.0'), release('v1.1.0-beta.4')], '1.1.0-beta.4')).toBeNull() + }) + + it('offers a beta build the stable that supersedes it', () => { + expect(pickUpdate([release('v1.1.0-beta.4'), release('v1.1.0')], '1.1.0-beta.4')?.version).toBe('1.1.0') + }) + + it('treats a release GitHub flags as prerelease that way even with a plain tag', () => { + expect(pickUpdate([release('v1.5.0', { prerelease: true })], '1.0.0')).toBeNull() + }) + + it('skips drafts, unparseable tags and malformed entries', () => { + const payload = [ + release('v9.0.0', { draft: true }), + release('nightly'), + { html_url: 'x' }, + 'garbage', + release('v1.0.1'), + ] + expect(pickUpdate(payload, '1.0.0')?.version).toBe('1.0.1') + }) + + it('returns null for a payload that is not a list, or a version that does not parse', () => { + expect(pickUpdate({ message: 'rate limited' }, '1.0.0')).toBeNull() + expect(pickUpdate([release('v1.0.1')], 'dev')).toBeNull() + }) + + it('requires the html_url to be an https github.com page', () => { + expect(pickUpdate([release('v1.0.1', { html_url: 'file:///etc/passwd' })], '1.0.0')).toBeNull() + }) +}) diff --git a/src/main/update-check.ts b/src/main/update-check.ts new file mode 100644 index 0000000..fcf8ae3 --- /dev/null +++ b/src/main/update-check.ts @@ -0,0 +1,113 @@ +/** + * Which published release, if any, to offer the running build. Pure. + * + * The GitHub payload is untrusted input: every field is checked before it is + * read, and anything that does not fit is skipped rather than thrown on. + */ + +export interface SemVer { + readonly major: number + readonly minor: number + readonly patch: number + /** Dot-separated identifiers after the hyphen; numeric ones as numbers. */ + readonly prerelease: readonly (string | number)[] +} + +const SEMVER = /^v?(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?(?:\+[0-9A-Za-z.-]+)?$/ + +export function parseSemver(input: string): SemVer | null { + const m = SEMVER.exec(input) + if (m === null) return null + const prerelease = (m[4] ?? '') + .split('.') + .filter((part) => part !== '') + .map((part) => (/^\d+$/.test(part) ? Number(part) : part)) + return { major: Number(m[1]), minor: Number(m[2]), patch: Number(m[3]), prerelease } +} + +/** SemVer 2.0 precedence: a prerelease sorts before its release. */ +export function compareSemver(a: SemVer, b: SemVer): number { + if (a.major !== b.major) return a.major - b.major + if (a.minor !== b.minor) return a.minor - b.minor + if (a.patch !== b.patch) return a.patch - b.patch + if (a.prerelease.length === 0 && b.prerelease.length === 0) return 0 + if (a.prerelease.length === 0) return 1 + if (b.prerelease.length === 0) return -1 + const n = Math.min(a.prerelease.length, b.prerelease.length) + for (let i = 0; i < n; i++) { + const x = a.prerelease[i] as string | number + const y = b.prerelease[i] as string | number + if (x === y) continue + // Numbers sort before strings; numbers numerically, strings lexically. + if (typeof x === 'number' && typeof y === 'number') return x - y + if (typeof x === 'number') return -1 + if (typeof y === 'number') return 1 + return x < y ? -1 : 1 + } + return a.prerelease.length - b.prerelease.length +} + +export interface ReleaseCandidate { + /** Without the leading v. */ + readonly version: string + readonly url: string +} + +interface ParsedRelease extends ReleaseCandidate { + readonly semver: SemVer + readonly prerelease: boolean +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null +} + +/** Only a page on github.com may leave the app from here. */ +function isReleasePage(url: unknown): url is string { + if (typeof url !== 'string') return false + try { + const parsed = new URL(url) + return parsed.protocol === 'https:' && parsed.hostname === 'github.com' + } catch { + return false + } +} + +function parseRelease(entry: unknown): ParsedRelease | null { + if (!isRecord(entry)) return null + if (entry['draft'] === true) return null + if (typeof entry['tag_name'] !== 'string') return null + const semver = parseSemver(entry['tag_name']) + if (semver === null) return null + if (!isReleasePage(entry['html_url'])) return null + return { + version: entry['tag_name'].replace(/^v/, ''), + url: entry['html_url'], + semver, + prerelease: entry['prerelease'] === true || semver.prerelease.length > 0, + } +} + +/** + * The newest release the running build should hear about, or null. + * + * A stable build only hears about stable releases; a prerelease build hears + * about both. A build newer than everything published is a local one and gets + * nothing. + */ +export function pickUpdate(payload: unknown, currentVersion: string): ReleaseCandidate | null { + if (!Array.isArray(payload)) return null + const current = parseSemver(currentVersion) + if (current === null) return null + const acceptPrerelease = current.prerelease.length > 0 + + let best: ParsedRelease | null = null + for (const entry of payload) { + const release = parseRelease(entry) + if (release === null) continue + if (release.prerelease && !acceptPrerelease) continue + if (compareSemver(release.semver, current) <= 0) continue + if (best === null || compareSemver(release.semver, best.semver) > 0) best = release + } + return best === null ? null : { version: best.version, url: best.url } +} From 51594e0fd3265da4ad636a26c5bd5232c36d594d Mon Sep 17 00:00:00 2001 From: ba2slk <130782318+ba2slk@users.noreply.github.com> Date: Wed, 19 Aug 2026 02:44:28 +0900 Subject: [PATCH 2/8] feat: an updateCheck setting, on by default Co-Authored-By: Claude Opus 5 --- src/main/app-settings.test.ts | 7 +++++++ src/main/app-settings.ts | 3 +++ src/shared/protocol.ts | 7 +++++++ src/shared/settings-defaults.ts | 1 + 4 files changed, 18 insertions(+) diff --git a/src/main/app-settings.test.ts b/src/main/app-settings.test.ts index cc7e27e..82cfccc 100644 --- a/src/main/app-settings.test.ts +++ b/src/main/app-settings.test.ts @@ -62,4 +62,11 @@ describe('normalizeSettings', () => { expect(normalizeSettings({ paneLabels: 0.4 }).paneLabels).toBe(0) expect(normalizeSettings({ paneLabels: 'off' }).paneLabels).toBe(DEFAULT_SETTINGS.paneLabels) }) + + it('reads updateCheck as 0 or 1 and defaults it on', () => { + expect(normalizeSettings({}).updateCheck).toBe(1) + expect(normalizeSettings({ updateCheck: 0 }).updateCheck).toBe(0) + expect(normalizeSettings({ updateCheck: 5 }).updateCheck).toBe(1) + expect(normalizeSettings({ updateCheck: 'no' }).updateCheck).toBe(1) + }) }) diff --git a/src/main/app-settings.ts b/src/main/app-settings.ts index 7c09915..6e78926 100644 --- a/src/main/app-settings.ts +++ b/src/main/app-settings.ts @@ -38,6 +38,7 @@ export const SETTING_LIMITS = { inheritWorkingDir: { min: 0, max: 1, step: 1 }, // Below 80 the chrome stops being readable; above 160 it crowds out the canvas. uiScale: { min: 80, max: 160, step: 5 }, + updateCheck: { min: 0, max: 1, step: 1 }, } as const export function settingsFile(env: NodeJS.ProcessEnv): string { @@ -110,6 +111,7 @@ export function normalizeSettings(raw: unknown): AppSettings { paneLabels: Math.round(clampNumber(input['paneLabels'], 'paneLabels')), idleDim: clampNumber(input['idleDim'], 'idleDim'), notifications: Math.round(clampNumber(input['notifications'], 'notifications')), + updateCheck: Math.round(clampNumber(input['updateCheck'], 'updateCheck')), inheritWorkingDir: Math.round(clampNumber(input['inheritWorkingDir'], 'inheritWorkingDir')), fontFamily: cleanFontFamily(input['fontFamily']), theme: cleanId(input['theme']), @@ -147,6 +149,7 @@ const HEADER = `# Termspace settings # focusBorderColor the colour custom uses, as #rrggbb # locale interface language: en, ko, or empty to follow the system # textRendering terminal glyph antialiasing: grayscale, or subpixel for the browser's LCD text (Linux only) +# updateCheck 1 asks GitHub for a newer release at startup and once a day. Nothing else is sent ` /** diff --git a/src/shared/protocol.ts b/src/shared/protocol.ts index 5529085..35b6eeb 100644 --- a/src/shared/protocol.ts +++ b/src/shared/protocol.ts @@ -140,6 +140,13 @@ export interface AppSettings { * so switching without a restart would need all of them rebuilt. */ readonly locale: string + /** + * 1 asks GitHub for a newer release at startup and once a day. + * + * One anonymous GET, nothing sent but the request itself. "Check now" in the + * settings ignores this: the user just asked. + */ + readonly updateCheck: number } export interface SessionSummary { diff --git a/src/shared/settings-defaults.ts b/src/shared/settings-defaults.ts index dc182a3..e937a24 100644 --- a/src/shared/settings-defaults.ts +++ b/src/shared/settings-defaults.ts @@ -41,6 +41,7 @@ export const DEFAULT_SETTINGS: AppSettings = { // What other terminals draw. 'subpixel' is the pre-1.1 look, kept for eyes // used to it. textRendering: 'grayscale', + updateCheck: 1, } /** True when this key still holds what the app ships with. */ From 47bea41288096914f45477b8dfb986b15ee2bcd4 Mon Sep 17 00:00:00 2001 From: ba2slk <130782318+ba2slk@users.noreply.github.com> Date: Wed, 19 Aug 2026 02:46:33 +0900 Subject: [PATCH 3/8] feat: main asks GitHub for a newer release at startup and once a day Co-Authored-By: Claude Opus 5 --- src/main/ipc-bridge.ts | 21 +++++++- src/main/updater.test.ts | 111 +++++++++++++++++++++++++++++++++++++++ src/main/updater.ts | 87 ++++++++++++++++++++++++++++++ src/preload/index.ts | 10 ++++ src/shared/protocol.ts | 20 +++++++ 5 files changed, 248 insertions(+), 1 deletion(-) create mode 100644 src/main/updater.test.ts create mode 100644 src/main/updater.ts diff --git a/src/main/ipc-bridge.ts b/src/main/ipc-bridge.ts index d452511..9773a2e 100644 --- a/src/main/ipc-bridge.ts +++ b/src/main/ipc-bridge.ts @@ -2,7 +2,7 @@ * Routes renderer requests to pty-host and session-config, and batches pty output back. */ import { writeFile } from 'node:fs/promises' -import { clipboard, dialog, ipcMain, Notification, shell, type BrowserWindow } from 'electron' +import { app, clipboard, dialog, ipcMain, Notification, shell, type BrowserWindow } from 'electron' import type { LayoutSnapshot, PaneAttention, @@ -18,6 +18,7 @@ import { RC_LINE, RC_LINE_ZSH } from './shell-integration' import { activateWindow } from './window-manager' import { loadSettings, saveSettings, settingsFile } from './app-settings' import { loadKeybindings, saveKeybindings } from './keybindings-file' +import { createUpdater } from './updater' import { listMonoFonts } from './font-list' import { ensureThemesDir, listUserThemes } from './theme-config' import { OutputBatcher } from './output-batcher' @@ -72,6 +73,7 @@ const INVOKE_CHANNELS = [ 'pty:foreground-commands', 'pty:titles', 'pty:cwd', + 'update:check', ] const ON_CHANNELS = [ 'pty:write', @@ -88,6 +90,7 @@ const ON_CHANNELS = [ 'settings:reveal', 'themes:reveal', 'app:visible-pane', + 'update:open-release', ] export function registerIpcHandlers( @@ -410,6 +413,21 @@ export function registerIpcHandlers( })() }) + /* + * Release check. The URL never crosses to the renderer: it gets a state and + * asks main to open the page. + */ + const updater = createUpdater({ + currentVersion: app.getVersion(), + fetch: (input, init) => fetch(input, init), + automatic: async () => (await loadSettings(env)).updateCheck === 1, + onState: (state) => send('update:state', state), + }) + ipcMain.handle('update:check', () => updater.checkNow()) + ipcMain.on('update:open-release', () => void shell.openExternal(updater.releaseUrl())) + // Not under the self-check: four instances asking GitHub at once is noise. + if (env['VITE_SELFCHECK'] !== '1') updater.start() + /* * Screenshots for the self-check. DOM queries can't see a layout that is * present and correctly classed but visually wrong. @@ -445,6 +463,7 @@ export function registerIpcHandlers( win.off('maximize', notifyMaximize) win.off('unmaximize', notifyMaximize) batcher.dispose() + updater.stop() for (const channel of INVOKE_CHANNELS) ipcMain.removeHandler(channel) for (const channel of ON_CHANNELS) ipcMain.removeAllListeners(channel) } diff --git a/src/main/updater.test.ts b/src/main/updater.test.ts new file mode 100644 index 0000000..bb5562d --- /dev/null +++ b/src/main/updater.test.ts @@ -0,0 +1,111 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { createUpdater, RELEASES_PAGE, type Updater } from './updater' +import type { UpdateState } from '../shared/protocol' + +const releases = (...tags: string[]) => + tags.map((tag) => ({ + tag_name: tag, + html_url: `https://github.com/ba2slk/termspace/releases/tag/${tag}`, + draft: false, + prerelease: tag.includes('-'), + })) + +const ok = (body: unknown): typeof fetch => vi.fn(async () => new Response(JSON.stringify(body), { status: 200 })) +const down: typeof fetch = vi.fn(async () => { throw new TypeError('fetch failed') }) + +let states: UpdateState[] +let updater: Updater | null + +function make(fetchImpl: typeof fetch, automatic = true, version = '1.0.0'): Updater { + states = [] + updater = createUpdater({ + currentVersion: version, + fetch: fetchImpl, + automatic: async () => automatic, + onState: (s) => states.push(s), + intervalMs: 1000, + }) + return updater +} + +beforeEach(() => vi.useFakeTimers()) +afterEach(() => { + updater?.stop() + updater = null + vi.useRealTimers() +}) + +describe('checkNow', () => { + it('reports the newer release and remembers its page', async () => { + const u = make(ok(releases('v1.0.0', 'v1.2.0'))) + await expect(u.checkNow()).resolves.toEqual({ kind: 'available', version: '1.2.0' }) + expect(u.releaseUrl()).toBe('https://github.com/ba2slk/termspace/releases/tag/v1.2.0') + }) + + it('reports up to date when nothing is newer, and points at the releases index', async () => { + const u = make(ok(releases('v1.0.0'))) + await expect(u.checkNow()).resolves.toEqual({ kind: 'up-to-date' }) + expect(u.releaseUrl()).toBe(RELEASES_PAGE) + }) + + it('reports failure when GitHub is unreachable or answers oddly', async () => { + await expect(make(down).checkNow()).resolves.toEqual({ kind: 'failed' }) + const notOk: typeof fetch = vi.fn(async () => new Response('{}', { status: 403 })) + await expect(make(notOk).checkNow()).resolves.toEqual({ kind: 'failed' }) + const notJson: typeof fetch = vi.fn(async () => new Response('', { status: 200 })) + await expect(make(notJson).checkNow()).resolves.toEqual({ kind: 'failed' }) + }) + + it('runs even when the automatic check is off', async () => { + const u = make(ok(releases('v1.2.0')), false) + await expect(u.checkNow()).resolves.toEqual({ kind: 'available', version: '1.2.0' }) + }) + + it('sends the request with a User-Agent and the GitHub accept header', async () => { + const f = ok(releases('v1.0.0')) + await make(f).checkNow() + const init = (f as ReturnType).mock.calls[0]?.[1] as RequestInit + const headers = new Headers(init.headers) + expect(headers.get('user-agent')).toMatch(/^Termspace\/1\.0\.0/) + expect(headers.get('accept')).toContain('application/vnd.github') + }) +}) + +describe('start', () => { + it('checks at once and pushes only when something is available', async () => { + const f = ok(releases('v1.2.0')) + make(f).start() + await vi.advanceTimersByTimeAsync(0) + expect(f).toHaveBeenCalledTimes(1) + expect(states).toEqual([{ kind: 'available', version: '1.2.0' }]) + }) + + it('says nothing for a background check that finds nothing or fails', async () => { + make(ok(releases('v1.0.0'))).start() + await vi.advanceTimersByTimeAsync(0) + expect(states).toEqual([]) + updater?.stop() + make(down).start() + await vi.advanceTimersByTimeAsync(0) + expect(states).toEqual([]) + }) + + it('checks again every interval, and stops when told', async () => { + const f = ok(releases('v1.0.0')) + const u = make(f) + u.start() + await vi.advanceTimersByTimeAsync(2500) + expect(f).toHaveBeenCalledTimes(3) + u.stop() + await vi.advanceTimersByTimeAsync(5000) + expect(f).toHaveBeenCalledTimes(3) + }) + + it('skips the automatic check while the setting is off', async () => { + const f = ok(releases('v1.2.0')) + make(f, false).start() + await vi.advanceTimersByTimeAsync(2500) + expect(f).not.toHaveBeenCalled() + expect(states).toEqual([]) + }) +}) diff --git a/src/main/updater.ts b/src/main/updater.ts new file mode 100644 index 0000000..a59df4d --- /dev/null +++ b/src/main/updater.ts @@ -0,0 +1,87 @@ +/** + * Asks GitHub whether a newer release exists, at startup and once a day. + * + * `fetch` and the setting come in from outside so the whole thing runs under a + * unit test; the decision itself is in update-check. Nothing is downloaded and + * nothing is retried — the next check is at most a day away. + */ +import type { UpdateState } from '../shared/protocol' +import { pickUpdate } from './update-check' + +export const RELEASES_API = 'https://api.github.com/repos/ba2slk/termspace/releases?per_page=20' +export const RELEASES_PAGE = 'https://github.com/ba2slk/termspace/releases' + +const DAY_MS = 24 * 60 * 60 * 1000 +const TIMEOUT_MS = 10_000 + +export interface UpdaterOptions { + readonly currentVersion: string + readonly fetch: typeof globalThis.fetch + /** Whether the automatic checks are on; read at each tick, not once. */ + readonly automatic: () => Promise + /** Called after a background check that found something to say. */ + readonly onState: (state: UpdateState) => void + readonly intervalMs?: number +} + +export interface Updater { + /** A check the user asked for. Always runs; resolves to what it found. */ + checkNow(): Promise + /** The offered release's page, or the releases index. */ + releaseUrl(): string + /** Startup check plus the timer. */ + start(): void + stop(): void +} + +export function createUpdater(options: UpdaterOptions): Updater { + const interval = options.intervalMs ?? DAY_MS + let offeredUrl: string | null = null + let timer: ReturnType | null = null + + async function check(): Promise { + let payload: unknown + try { + const response = await options.fetch(RELEASES_API, { + headers: { + accept: 'application/vnd.github+json', + // GitHub refuses requests without one. + 'user-agent': `Termspace/${options.currentVersion}`, + }, + signal: AbortSignal.timeout(TIMEOUT_MS), + }) + if (!response.ok) return { kind: 'failed' } + payload = await response.json() + } catch { + return { kind: 'failed' } + } + const picked = pickUpdate(payload, options.currentVersion) + if (picked === null) { + offeredUrl = null + return { kind: 'up-to-date' } + } + offeredUrl = picked.url + return { kind: 'available', version: picked.version } + } + + async function tick(): Promise { + if (!(await options.automatic())) return + const state = await check() + if (state.kind === 'available') options.onState(state) + } + + return { + checkNow: check, + releaseUrl: () => offeredUrl ?? RELEASES_PAGE, + start() { + if (timer !== null) return + void tick() + timer = setInterval(() => void tick(), interval) + }, + stop() { + if (timer === null) return + clearInterval(timer) + timer = null + }, + } +} diff --git a/src/preload/index.ts b/src/preload/index.ts index fdf96f0..35d7a7f 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -5,6 +5,7 @@ import type { PtyExit, SpawnRequest, TermspaceApi, + UpdateState, } from '../shared/protocol' /** @@ -74,6 +75,15 @@ const api: TermspaceApi = { return () => ipcRenderer.off('window:maximize-changed', listener) }, }, + update: { + check: () => ipcRenderer.invoke('update:check'), + openRelease: () => ipcRenderer.send('update:open-release'), + onState: (handler) => { + const listener = (_e: unknown, state: UpdateState): void => handler(state) + ipcRenderer.on('update:state', listener) + return () => ipcRenderer.off('update:state', listener) + }, + }, onData: (handler) => { const listener = (_e: unknown, paneId: string, data: string): void => handler(paneId, data) ipcRenderer.on('pty:data', listener) diff --git a/src/shared/protocol.ts b/src/shared/protocol.ts index 35b6eeb..34e095d 100644 --- a/src/shared/protocol.ts +++ b/src/shared/protocol.ts @@ -206,6 +206,17 @@ export interface ShellIntegrationStatus { readonly active: boolean } +/** + * What a release check found. `up-to-date` and `failed` only answer a check + * the user asked for; a background check is silent unless something is + * available. + */ +export type UpdateState = + | { readonly kind: 'idle' } + | { readonly kind: 'available'; readonly version: string } + | { readonly kind: 'up-to-date' } + | { readonly kind: 'failed' } + export interface SpawnRequest { readonly paneId: string readonly cwd: string @@ -345,6 +356,15 @@ export interface TermspaceApi { toggleDevTools(): void onMaximizeChange(handler: (maximized: boolean) => void): () => void } + /** Release checks. Main keeps the URL; the renderer only sees a state. */ + readonly update: { + /** A check the user asked for. Resolves to what it found. */ + check(): Promise + /** Open the offered release's page, or the releases index. */ + openRelease(): void + /** A background check found something. Returns an unsubscribe function. */ + onState(handler: (state: UpdateState) => void): () => void + } /** Open the sessions folder in the file manager. */ openSessionsDir(): void /** Screenshot the window. Self-check builds only. */ From ca23631f924b42c350fcf05f6f1b7edd87861c3b Mon Sep 17 00:00:00 2001 From: ba2slk <130782318+ba2slk@users.noreply.github.com> Date: Wed, 19 Aug 2026 02:48:38 +0900 Subject: [PATCH 4/8] feat: a title-bar chip names the newer release and opens its page Co-Authored-By: Claude Opus 5 --- src/renderer/app-bar.ts | 13 ++++++- src/renderer/main.ts | 3 ++ src/renderer/styles/app.css | 37 ++++++++++++++++++ src/renderer/update-chip.dom.test.ts | 53 ++++++++++++++++++++++++++ src/renderer/update-chip.ts | 57 ++++++++++++++++++++++++++++ src/shared/ui-strings.ts | 7 ++++ 6 files changed, 169 insertions(+), 1 deletion(-) create mode 100644 src/renderer/update-chip.dom.test.ts create mode 100644 src/renderer/update-chip.ts diff --git a/src/renderer/app-bar.ts b/src/renderer/app-bar.ts index 9f0e740..1e83b0d 100644 --- a/src/renderer/app-bar.ts +++ b/src/renderer/app-bar.ts @@ -10,6 +10,8 @@ import { createAppMark, MARK_CHROME } from './app-mark' import { t } from './i18n' import { IS_MAC } from './platform' import { createCommandMenu, type CommandItem } from './command-menu' +import { createUpdateChip } from './update-chip' +import type { UpdateState } from '../shared/protocol' import type { ActionId } from '../shared/keybindings' export interface AppBarHooks { @@ -43,6 +45,8 @@ export interface AppBar { setSidebarVisible(visible: boolean): void /** Re-evaluate whether the split controls are enabled. */ syncControls(): void + /** A newer release exists (or not). The chip decides whether to show it. */ + setUpdate(state: UpdateState): void /** Collapse any open dropdown before another surface comes forward. */ closeMenus(): void destroy(): void @@ -191,8 +195,12 @@ export function createAppBar(host: HTMLElement, hooks: AppBarHooks): AppBar { const divider = document.createElement('span') divider.className = 'app-bar__divider' + // End of the left group: the pan strip in the middle must stay untouched, + // and the right end differs between platforms. + const updateChip = createUpdateChip({ onOpen: () => api.update.openRelease() }) + // Splitting and saving both act on the arrangement, so they share a group. - left.append(menuButton, panelButton, divider, splitButton, saveButton) + left.append(menuButton, panelButton, divider, splitButton, saveButton, updateChip.element) const title = document.createElement('div') title.className = 'app-bar__title' @@ -291,6 +299,9 @@ export function createAppBar(host: HTMLElement, hooks: AppBarHooks): AppBar { pan.classList.toggle('app-bar__pan--off', !pans) if (!pans) bar.classList.remove('app-bar--pannable') }, + setUpdate(state) { + updateChip.setState(state) + }, closeMenus() { menu.close() splitMenu.close() diff --git a/src/renderer/main.ts b/src/renderer/main.ts index dbfa636..a5627d2 100644 --- a/src/renderer/main.ts +++ b/src/renderer/main.ts @@ -222,6 +222,9 @@ const appBar = createAppBar(shell, { }) shell.prepend(appBar.element) +// A background check found a newer release. The chip hides itself if dismissed. +api.update.onState((state) => appBar.setUpdate(state)) + // ── Sidebar ───────────────────────────────────────────── const sidebar = createSessionSidebar(workspace, { diff --git a/src/renderer/styles/app.css b/src/renderer/styles/app.css index 8d846c6..8825fd0 100644 --- a/src/renderer/styles/app.css +++ b/src/renderer/styles/app.css @@ -1489,6 +1489,43 @@ button.keys__chord:hover { color: var(--fg-faint); } +/* The update chip: a small pill at the end of the left group, only while a + newer release exists. It is a control, so it opts out of window drag. */ +.update-chip { + display: flex; + align-items: center; + height: var(--ctl-h); + margin-left: 6px; + border: 1px solid var(--border); + border-radius: var(--radius-sm); + background: var(--bg-raised); + color: var(--fg); + font-size: var(--fs-ui-sm); + white-space: nowrap; + -webkit-app-region: no-drag; +} + +.update-chip__open, +.update-chip__dismiss { + height: 100%; + padding: 0 8px; + background: none; + border: none; + color: inherit; + font: inherit; + cursor: pointer; +} + +.update-chip__dismiss { + padding: 0 6px 0 4px; + color: var(--fg-dim); +} + +.update-chip__open:hover, +.update-chip__dismiss:hover { + background: var(--fill-hover); +} + /* For values that suit neither a slider nor a toggle, like a font name. */ .settings__select { max-width: 260px; diff --git a/src/renderer/update-chip.dom.test.ts b/src/renderer/update-chip.dom.test.ts new file mode 100644 index 0000000..08e4b07 --- /dev/null +++ b/src/renderer/update-chip.dom.test.ts @@ -0,0 +1,53 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +vi.stubGlobal('termspace', { platform: 'linux' }) +const { createUpdateChip } = await import('./update-chip') +const { t } = await import('./i18n') + +let onOpen: ReturnType void>> +let chip: ReturnType + +beforeEach(() => { + document.body.replaceChildren() + onOpen = vi.fn<() => void>() + chip = createUpdateChip({ onOpen }) + document.body.append(chip.element) +}) + +const button = () => chip.element.querySelector('.update-chip__open') + +describe('update chip', () => { + it('is hidden at idle', () => { + chip.setState({ kind: 'idle' }) + expect(chip.element.hidden).toBe(true) + }) + + it('shows the version through the catalog when available', () => { + chip.setState({ kind: 'available', version: '1.2.0' }) + expect(chip.element.hidden).toBe(false) + expect(button()?.textContent).toBe(t.appBar.updateAvailable('1.2.0')) + expect(button()?.title).toBe(t.appBar.updateOpen) + }) + + it('opens the release on click', () => { + chip.setState({ kind: 'available', version: '1.2.0' }) + button()?.click() + expect(onOpen).toHaveBeenCalledTimes(1) + }) + + it('stays hidden for the run once dismissed, even when available arrives again', () => { + chip.setState({ kind: 'available', version: '1.2.0' }) + chip.element.querySelector('.update-chip__dismiss')?.click() + expect(chip.element.hidden).toBe(true) + chip.setState({ kind: 'available', version: '1.2.0' }) + expect(chip.element.hidden).toBe(true) + }) + + it('ignores states that are not an offer', () => { + chip.setState({ kind: 'available', version: '1.2.0' }) + chip.setState({ kind: 'up-to-date' }) + expect(chip.element.hidden).toBe(true) + chip.setState({ kind: 'failed' }) + expect(chip.element.hidden).toBe(true) + }) +}) diff --git a/src/renderer/update-chip.ts b/src/renderer/update-chip.ts new file mode 100644 index 0000000..3fe69bf --- /dev/null +++ b/src/renderer/update-chip.ts @@ -0,0 +1,57 @@ +/** + * The title-bar chip that says a newer release exists. + * + * Present only while there is something to say. Dismissing hides it for the + * run: a restart is when an update can be applied, so it is also when one is + * worth mentioning again. + */ +import { t } from './i18n' +import type { UpdateState } from '../shared/protocol' + +export interface UpdateChipHooks { + readonly onOpen: () => void +} + +export interface UpdateChip { + readonly element: HTMLElement + setState(state: UpdateState): void +} + +export function createUpdateChip(hooks: UpdateChipHooks): UpdateChip { + const element = document.createElement('div') + element.className = 'update-chip' + element.hidden = true + + const open = document.createElement('button') + open.type = 'button' + open.className = 'update-chip__open' + open.title = t.appBar.updateOpen + open.addEventListener('click', () => hooks.onOpen()) + + const dismiss = document.createElement('button') + dismiss.type = 'button' + dismiss.className = 'update-chip__dismiss' + dismiss.title = t.appBar.updateDismiss + dismiss.setAttribute('aria-label', t.appBar.updateDismiss) + dismiss.textContent = '×' + + let dismissed = false + dismiss.addEventListener('click', () => { + dismissed = true + element.hidden = true + }) + + element.append(open, dismiss) + + return { + element, + setState(state) { + if (state.kind !== 'available' || dismissed) { + element.hidden = true + return + } + open.textContent = t.appBar.updateAvailable(state.version) + element.hidden = false + }, + } +} diff --git a/src/shared/ui-strings.ts b/src/shared/ui-strings.ts index 3fad0f0..00782ee 100644 --- a/src/shared/ui-strings.ts +++ b/src/shared/ui-strings.ts @@ -82,6 +82,10 @@ const en = { maximize: 'Maximize', restore: 'Restore', close: 'Close', + // The update chip, shown only while a newer release exists + updateAvailable: (version: string) => `${version} available`, + updateOpen: 'Open the release page', + updateDismiss: 'Hide until the next start', }, /** session-sidebar.ts: the resident session list. */ @@ -398,6 +402,9 @@ const ko: Catalog = { maximize: '최대화', restore: '이전 크기로', close: '닫기', + updateAvailable: (version: string) => `${version} 나옴`, + updateOpen: '릴리스 페이지 열기', + updateDismiss: '다음 실행까지 숨기기', }, sidebar: { From 7eea2d10d84601f2b0aec6309fec41e25c3a150e Mon Sep 17 00:00:00 2001 From: ba2slk <130782318+ba2slk@users.noreply.github.com> Date: Wed, 19 Aug 2026 02:51:29 +0900 Subject: [PATCH 5/8] feat: an Updates section with the toggle and a check-now row Co-Authored-By: Claude Opus 5 --- src/renderer/settings-view.dom.test.ts | 56 ++++++++++++++++++++++ src/renderer/settings-view.ts | 64 ++++++++++++++++++++++++++ src/shared/ui-strings.ts | 22 +++++++++ 3 files changed, 142 insertions(+) diff --git a/src/renderer/settings-view.dom.test.ts b/src/renderer/settings-view.dom.test.ts index dba9011..43eb9f1 100644 --- a/src/renderer/settings-view.dom.test.ts +++ b/src/renderer/settings-view.dom.test.ts @@ -24,9 +24,15 @@ vi.stubGlobal('termspace', { listMonoFonts: async () => [], listUserThemes: async () => onDisk, shellIntegrationStatus: async () => null, + update: { + check: vi.fn(async () => ({ kind: 'up-to-date' as const })), + openRelease: vi.fn(), + onState: () => () => {}, + }, }) const { createSettingsView } = await import('./settings-view') +const { t } = await import('./i18n') let view: SettingsView let latest: AppSettings @@ -209,3 +215,53 @@ describe('user palettes', () => { expect(themeById(latest.theme, owned).background).toBe('#0b0b0b') }) }) + +describe('updates section', () => { + /* + * The view re-renders once the theme folder and the shell status come back, + * which replaces every row — so let those land before touching the buttons. + */ + async function openSettled(): Promise { + open({}) + await new Promise((resolve) => setTimeout(resolve, 0)) + } + + it('has the updateCheck toggle, on by default', () => { + open({}) + const group = document.body.querySelector('[aria-label="' + t.settings.updateCheckLabel + '"]') + expect(group).not.toBeNull() + const on = group!.querySelectorAll('.settings__segment--on') + expect(on).toHaveLength(1) + expect(on[0]?.textContent).toBe(t.settings.on) + }) + + it('check now reports the result in the row and offers the page when a release exists', async () => { + const check = (window as unknown as { termspace: { update: { check: ReturnType } } }).termspace.update.check + check.mockResolvedValueOnce({ kind: 'available', version: '1.2.0' }) + await openSettled() + const button = document.body.querySelector('button[data-action="check-updates"]')! + button.click() + const result = document.body.querySelector('.settings__update-result')! + await vi.waitFor(() => { + expect(result.textContent).toBe(t.settings.checkNowAvailable('1.2.0')) + }) + const openButton = document.body.querySelector('button[data-action="open-release"]')! + expect(openButton.hidden).toBe(false) + }) + + it('check now says up to date, and failed, through the catalog', async () => { + const check = (window as unknown as { termspace: { update: { check: ReturnType } } }).termspace.update.check + await openSettled() + const button = document.body.querySelector('button[data-action="check-updates"]')! + const result = () => document.body.querySelector('.settings__update-result')!.textContent + button.click() + await vi.waitFor(() => { + expect(result()).toBe(t.settings.checkNowUpToDate) + }) + check.mockResolvedValueOnce({ kind: 'failed' }) + button.click() + await vi.waitFor(() => { + expect(result()).toBe(t.settings.checkNowFailed) + }) + }) +}) diff --git a/src/renderer/settings-view.ts b/src/renderer/settings-view.ts index 2acfccc..5cc77b9 100644 --- a/src/renderer/settings-view.ts +++ b/src/renderer/settings-view.ts @@ -94,6 +94,13 @@ const NOTIFICATIONS: ToggleSpec = { description: t.settings.notificationsDesc, } +/** Its own section: it is about the app reaching out, not about any terminal. */ +const UPDATE_CHECK: ToggleSpec = { + key: 'updateCheck', + label: t.settings.updateCheckLabel, + description: t.settings.updateCheckDesc, +} + const INHERIT_WORKING_DIR: ToggleSpec = { key: 'inheritWorkingDir', label: t.settings.inheritWorkingDirLabel, @@ -320,6 +327,58 @@ export function createSettingsView(host: HTMLElement, hooks: SettingsHooks): Set return row } + /** + * "Check now" runs regardless of the toggle — the user just asked — and + * shows what it found here rather than in the title-bar chip, which stays + * dismissed if it was dismissed. + */ + function checkNowRow(): HTMLElement { + const row = document.createElement('div') + row.className = 'settings__row' + const text = document.createElement('div') + text.className = 'settings__text' + const name = document.createElement('span') + name.textContent = t.settings.checkNowLabel + const result = document.createElement('small') + result.className = 'settings__update-result' + text.append(name, result) + + const control = document.createElement('div') + control.className = 'settings__control' + const openRelease = document.createElement('button') + openRelease.type = 'button' + openRelease.className = 'button' + openRelease.dataset['action'] = 'open-release' + openRelease.textContent = t.settings.checkNowOpen + openRelease.hidden = true + openRelease.addEventListener('click', () => api.update.openRelease()) + + const check = document.createElement('button') + check.type = 'button' + check.className = 'button' + check.dataset['action'] = 'check-updates' + check.textContent = t.settings.checkNowButton + check.addEventListener('click', () => { + check.disabled = true + result.textContent = t.settings.checkNowChecking + openRelease.hidden = true + void api.update.check().then((state) => { + check.disabled = false + if (state.kind === 'available') { + result.textContent = t.settings.checkNowAvailable(state.version) + openRelease.hidden = false + } else if (state.kind === 'failed') { + result.textContent = t.settings.checkNowFailed + } else { + result.textContent = t.settings.checkNowUpToDate + } + }) + }) + control.append(openRelease, check) + row.append(text, control) + return row + } + /** One rc file, its line, and a button that copies it. */ function shellLine(label: string, rcLine: string): readonly HTMLElement[] { const row = document.createElement('div') @@ -752,6 +811,10 @@ export function createSettingsView(host: HTMLElement, hooks: SettingsHooks): Set files.append(row) } + const updates = document.createElement('div') + updates.append(toggleRow(UPDATE_CHECK, settings.updateCheck)) + updates.append(checkNowRow()) + const note = document.createElement('p') note.className = 'settings__note' note.textContent = t.settings.note @@ -763,6 +826,7 @@ export function createSettingsView(host: HTMLElement, hooks: SettingsHooks): Set section(t.settings.sectionKeyboard, keyboard), section(t.settings.sectionMouse, toggles), section(t.settings.sectionFiles, files), + section(t.settings.sectionUpdates, updates), ...(shellIntegration === null ? [] : [section(t.settings.sectionShell, shellBody(shellIntegration))]), diff --git a/src/shared/ui-strings.ts b/src/shared/ui-strings.ts index 00782ee..f64892b 100644 --- a/src/shared/ui-strings.ts +++ b/src/shared/ui-strings.ts @@ -152,6 +152,7 @@ const en = { sectionKeyboard: 'Keyboard', sectionMouse: 'Mouse', sectionFiles: 'Files', + sectionUpdates: 'Updates', uiScaleLabel: 'Interface size', uiScaleDesc: "Scales the app's own text and title bar. It leaves the terminal's font size alone.", focusBorderLabel: 'Focused pane border', @@ -218,6 +219,16 @@ const en = { themesDirPath: '~/.config/termspace/themes/', openButton: 'Open', + updateCheckLabel: 'Check for updates', + updateCheckDesc: 'Asks GitHub for a newer release at startup and once a day. Nothing else is sent.', + checkNowLabel: 'Check now', + checkNowButton: 'Check', + checkNowChecking: 'Checking…', + checkNowUpToDate: 'Up to date', + checkNowAvailable: (version: string) => `${version} is available`, + checkNowFailed: 'Could not reach GitHub', + checkNowOpen: 'Open the release page', + sectionShell: 'Shell integration', shellLead: 'Add the line for your shell and a saved session records the alias you typed instead of what it expanded into.', @@ -463,6 +474,7 @@ const ko: Catalog = { sectionKeyboard: '키보드', sectionMouse: '마우스', sectionFiles: '파일', + sectionUpdates: '업데이트', uiScaleLabel: '화면 배율', uiScaleDesc: '앱 글자와 제목 표시줄만 조정합니다. 터미널 글자 크기에는 영향을 주지 않습니다.', focusBorderLabel: '포커스 pane 테두리', @@ -529,6 +541,16 @@ const ko: Catalog = { themesDirPath: '~/.config/termspace/themes/', openButton: '열기', + updateCheckLabel: '업데이트 확인', + updateCheckDesc: '시작할 때와 하루에 한 번 GitHub에서 새 릴리스를 확인합니다. 다른 정보는 보내지 않습니다.', + checkNowLabel: '지금 확인', + checkNowButton: '확인', + checkNowChecking: '확인 중…', + checkNowUpToDate: '최신 버전입니다', + checkNowAvailable: (version: string) => `${version} 업데이트가 있습니다`, + checkNowFailed: 'GitHub에 연결할 수 없습니다', + checkNowOpen: '릴리스 페이지 열기', + sectionShell: '셸 연동', shellLead: '쓰는 셸에 맞는 줄을 넣으면, 세션을 저장할 때 alias가 풀린 긴 명령 대신 직접 입력한 이름이 기록됩니다.', From 0c632c6c32298f189145eaa525eca78227b8ad40 Mon Sep 17 00:00:00 2001 From: ba2slk <130782318+ba2slk@users.noreply.github.com> Date: Wed, 19 Aug 2026 02:52:51 +0900 Subject: [PATCH 6/8] docs: the update check in the README, manual QA and the engineering notes Co-Authored-By: Claude Opus 5 --- README.md | 6 ++++++ docs/MANUAL-QA.md | 10 ++++++++++ docs/engineering-notes.md | 21 +++++++++++++++++++++ 3 files changed, 37 insertions(+) diff --git a/README.md b/README.md index c7db522..2a71f68 100644 --- a/README.md +++ b/README.md @@ -185,6 +185,12 @@ palettes** — Catppuccin, Gruvbox, Nord, Tokyo Night, Solarized, Dracula, the Termspace default. Drop a YAML into `~/.config/termspace/themes/` for your own. +Termspace checks GitHub for a newer release when it starts and once a day: one +anonymous request to the releases API, nothing else sent. A newer release shows +as a chip in the title bar that opens the release page. `updateCheck: 0` in +`~/.config/termspace/settings.yaml` (or the toggle in Settings › Updates) turns +the check off; "Check now" in the same section works either way. + ## Developing Setup, the test commands, and the bar for a PR are in diff --git a/docs/MANUAL-QA.md b/docs/MANUAL-QA.md index 00d37aa..1fa68d8 100644 --- a/docs/MANUAL-QA.md +++ b/docs/MANUAL-QA.md @@ -471,6 +471,16 @@ These can't be replaced by automated judgment. They're matters of impression, no appear as typed (both English and Korean), Enter commits, and the session file has the new title without pressing save. Escape leaves the card as it was +### Update check (no self-check covers this) + +- [ ] **Update chip.** Run the previous release (`~/Applications/Termspace.AppImage` + before `install:local`, or an older AppImage from the releases page). Within a few + seconds of startup the title bar's left group ends in a chip naming the new + version. Clicking it opens that release's GitHub page in the browser. Close the + chip: it stays hidden; Settings › Updates › Check now still reports the version and + offers the page. Why manual: the only path is a live request to GitHub, which a + self-check must not depend on + ### Shell integration (bash and zsh) The OSC round trip itself is automated — the check emits the hook's sequences with diff --git a/docs/engineering-notes.md b/docs/engineering-notes.md index c2e8b85..3c6aa65 100644 --- a/docs/engineering-notes.md +++ b/docs/engineering-notes.md @@ -890,3 +890,24 @@ nothing — the app would be arguing with them. The directory exists from the moment anything is written, so an empty directory is a deliberate state and is left alone. + +## Updates + +**The update check ships as a notice, not an installer.** The first design replaced +the AppImage in place: download beside `$APPIMAGE`, verify against `SHA256SUMS.txt`, +rename over the running file. It was cut to a check plus a link before the first +public launch. The notice is the part that cannot be added retroactively, and an +installer that overwrites the running binary is the wrong thing to ship untested to +strangers. In-place replacement is deferred, with two decisions kept for it: the file +is replaced at the same path and name, because launchers and the AppArmor profile pin +`~/Applications/Termspace.AppImage`, and every failure leaves the installed binary +untouched. + +**Prerelease visibility follows the running version, not a setting.** A stable build +sees only stable releases; a `-beta.N` build sees both. That rules out the one wrong +prompt that matters — telling a beta user to "update" to an older stable — and it is +pinned by `update-check.test.ts`. + +**The self-check has no group for this.** The one path is a network request, and a +check that fails when GitHub is unreachable would be reporting the network, not the +app. From 152be59f953124969230cae867a434e7ed4c9532 Mon Sep 17 00:00:00 2001 From: ba2slk <130782318+ba2slk@users.noreply.github.com> Date: Wed, 19 Aug 2026 02:56:30 +0900 Subject: [PATCH 7/8] style: the Korean update chip reads as a noun phrase Co-Authored-By: Claude Opus 5 --- src/shared/ui-strings.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/shared/ui-strings.ts b/src/shared/ui-strings.ts index f64892b..95cc3a4 100644 --- a/src/shared/ui-strings.ts +++ b/src/shared/ui-strings.ts @@ -413,7 +413,7 @@ const ko: Catalog = { maximize: '최대화', restore: '이전 크기로', close: '닫기', - updateAvailable: (version: string) => `${version} 나옴`, + updateAvailable: (version: string) => `${version} 업데이트`, updateOpen: '릴리스 페이지 열기', updateDismiss: '다음 실행까지 숨기기', }, From 75cd3bb5dfe4f87fb954a9529b272e2b2c34fb38 Mon Sep 17 00:00:00 2001 From: ba2slk <130782318+ba2slk@users.noreply.github.com> Date: Wed, 19 Aug 2026 03:09:07 +0900 Subject: [PATCH 8/8] style: the update chip is an outlined pill Co-Authored-By: Claude Fable 5 --- src/renderer/styles/app.css | 25 ++++++++++++++++--------- src/renderer/styles/tokens.css | 2 ++ 2 files changed, 18 insertions(+), 9 deletions(-) diff --git a/src/renderer/styles/app.css b/src/renderer/styles/app.css index 8825fd0..7a693e6 100644 --- a/src/renderer/styles/app.css +++ b/src/renderer/styles/app.css @@ -1494,21 +1494,28 @@ button.keys__chord:hover { .update-chip { display: flex; align-items: center; - height: var(--ctl-h); - margin-left: 6px; - border: 1px solid var(--border); - border-radius: var(--radius-sm); - background: var(--bg-raised); - color: var(--fg); + height: calc(var(--ctl-h) - 6px); + margin-left: 8px; + /* Outline only: a filled block in the bar reads as a fifth button. */ + border: 1px solid var(--border-focus); + border-radius: var(--radius-pill); + background: none; + color: var(--fg-dim); font-size: var(--fs-ui-sm); white-space: nowrap; + overflow: hidden; -webkit-app-region: no-drag; } +.update-chip:hover { + border-color: var(--border-active); + color: var(--fg); +} + .update-chip__open, .update-chip__dismiss { height: 100%; - padding: 0 8px; + padding: 0 4px 0 10px; background: none; border: none; color: inherit; @@ -1517,8 +1524,8 @@ button.keys__chord:hover { } .update-chip__dismiss { - padding: 0 6px 0 4px; - color: var(--fg-dim); + padding: 0 8px 0 4px; + color: var(--fg-faint); } .update-chip__open:hover, diff --git a/src/renderer/styles/tokens.css b/src/renderer/styles/tokens.css index 0566c2a..e853f13 100644 --- a/src/renderer/styles/tokens.css +++ b/src/renderer/styles/tokens.css @@ -78,6 +78,8 @@ --radius-xs: 3px; /* Tiny markers — swatches, the drag ridge. */ --radius-dot: 2px; + /* Full round-off for text pills; any value past half the height. */ + --radius-pill: 999px; --gap: 6px; --edge: 6px; /* The floor under the panels: the canvas bar runs there. Mirrors CANVAS_BOTTOM. */