From 54652e9a3a17f1e456dd11fded0e56257d79c8c9 Mon Sep 17 00:00:00 2001 From: Ned Twigg Date: Wed, 28 Oct 2026 02:14:42 -0700 Subject: [PATCH 1/6] Settings opens at a topic SettingsDialog takes an initialTopic: it scrolls there once the topic shows (the search index hides every section on the first frame), once only, and for two seconds re-scrolls when a section above grows, until the reader's own wheel, pointer, or key input. Baseboard's dialog state carries the topic. Co-Authored-By: Claude Opus 5.5 --- lib/src/components/Baseboard.tsx | 13 +++--- lib/src/components/SettingsDialog.tsx | 43 ++++++++++++++++---- lib/src/stories/SettingsDialog.stories.tsx | 46 ++++++++++++++++++++-- 3 files changed, 85 insertions(+), 17 deletions(-) diff --git a/lib/src/components/Baseboard.tsx b/lib/src/components/Baseboard.tsx index 4dd0112c2..268fea9e6 100644 --- a/lib/src/components/Baseboard.tsx +++ b/lib/src/components/Baseboard.tsx @@ -15,7 +15,7 @@ import { clsx } from 'clsx'; import { chromeButton, DOOR_TAB_CLASS, TERMINAL_TOP_RADIUS_CLASS, TODO_PILL_TRACKING_CLASS } from './design'; import { AlertRingInset } from './alert-ring'; import { TODO_PILL_BODY } from './TodoPillBody'; -import { SettingsDialog } from './SettingsDialog'; +import { SettingsDialog, type TopicId } from './SettingsDialog'; import { WorkspaceAlarmSettingsDialog } from './WorkspaceAlarmSettings'; import type { AlertSink } from '../lib/alert-delivery-model'; import { SettingsPreview } from './SettingsPreview'; @@ -139,7 +139,7 @@ export function Baseboard({ items, onReattach, notice, onDoorDragStart }: Basebo const rightClusterEl = useRef(null); const [rightClusterWidth, setRightClusterWidth] = useState(0); const layoutMetrics = useRef({ doorGap: 0, arrowWidth: 0 }); - const [settingsOpen, setSettingsOpen] = useState<'application' | 'workspace' | null>(null); + const [settingsOpen, setSettingsOpen] = useState<{ dialog: 'application'; topic?: TopicId } | { dialog: 'workspace' } | null>(null); const workspaceSettingsTrigger = useRef(null); const [settingsPreview, setSettingsPreview] = useState<{ sink: AlertSink; anchor: HTMLElement; sequence: number } | null>(null); const previewSequence = useRef(0); @@ -162,7 +162,7 @@ export function Baseboard({ items, onReattach, notice, onDoorDragStart }: Basebo event.stopPropagation(); workspaceSettingsTrigger.current = event.currentTarget; closeSettingsPreview(); - setSettingsOpen('workspace'); + setSettingsOpen({ dialog: 'workspace' }); }; const workspaceSettingsActions = { onContextMenu: openWorkspaceSettings, @@ -423,7 +423,7 @@ export function Baseboard({ items, onReattach, notice, onDoorDragStart }: Basebo data-open-settings="true" onClick={() => { closeSettingsPreview(); - setSettingsOpen('application'); + setSettingsOpen({ dialog: 'application' }); }} > @@ -441,12 +441,13 @@ export function Baseboard({ items, onReattach, notice, onDoorDragStart }: Basebo /> )} - {settingsOpen === 'application' && ( + {settingsOpen?.dialog === 'application' && ( setSettingsOpen(null)} /> )} - {settingsOpen === 'workspace' && ( + {settingsOpen?.dialog === 'workspace' && ( { setSettingsOpen(null); workspaceSettingsTrigger.current?.focus(); diff --git a/lib/src/components/SettingsDialog.tsx b/lib/src/components/SettingsDialog.tsx index 643836b44..c212e2252 100644 --- a/lib/src/components/SettingsDialog.tsx +++ b/lib/src/components/SettingsDialog.tsx @@ -121,7 +121,7 @@ const TOPICS = [ { id: 'labs', label: 'Labs', icon: FlaskIcon, groups: ['delayedKill'] }, { id: 'about', label: 'About', icon: InfoIcon, groups: ['about'] }, ] as const; -type TopicId = typeof TOPICS[number]['id']; +export type TopicId = typeof TOPICS[number]['id']; type GroupId = typeof TOPICS[number]['groups'][number]; /** Search matches a group by its topic's label as well as its own text. */ const TOPIC_LABEL_OF = Object.fromEntries( @@ -132,6 +132,8 @@ const TOPIC_LABEL_OF = Object.fromEntries( export const TOPIC_GAP_PX = 16; /** A slower, consistent pace than the browser's native smooth scrolling. */ export const SETTINGS_SCROLL_MS = 700; +/** How long a dialog opened at a topic keeps it in view while the sections above it fill in. */ +const SETTLE_MS = 2000; /** Search the mounted controls themselves so descriptions, options, and live * command/device names have no second copy to drift. Hidden groups stay mounted @@ -183,8 +185,8 @@ function TopicSection({ id, hidden, children }: { id: TopicId; hidden: boolean; ); } -/** App-global settings. */ -export function SettingsDialog({ onClose }: { onClose: () => void }) { +/** App-global settings, opened at `initialTopic` (the top by default). */ +export function SettingsDialog({ onClose, initialTopic }: { onClose: () => void; initialTopic?: TopicId }) { const watched = useSyncExternalStore(subscribeToWatchedCommands, getWatchedCommandsSnapshot); const settings = useSyncExternalStore(subscribeToAlertSettings, getAlertSettings); const shellState = useSyncExternalStore(subscribeToShells, getShellsSnapshot); @@ -251,6 +253,12 @@ export function SettingsDialog({ onClose }: { onClose: () => void }) { scrollFrame.current = null; delete contentRef.current?.dataset.topicScrolling; }, []); + /** The topic opened at, held in view until `until` while the sections above it fill in. */ + const settling = useRef<{ id: TopicId; until: number } | null>(null); + const takeOverScroll = useCallback(() => { + settling.current = null; + cancelScroll(); + }, [cancelScroll]); const scrollToTopic = useCallback((id: TopicId | null) => { cancelScroll(); @@ -291,14 +299,33 @@ export function SettingsDialog({ onClose }: { onClose: () => void }) { followScroll(); const content = contentRef.current; if (!content) return; - const observer = new ResizeObserver(followScroll); + const observer = new ResizeObserver(() => { + followScroll(); + // A status read landing in a section above the opened topic would push + // it away once its scroll has finished. + const target = settling.current; + if (target && scrollFrame.current === null && performance.now() < target.until) scrollToTopic(target.id); + }); observer.observe(content); content.querySelectorAll('[data-settings-topic]').forEach((section) => observer.observe(section)); return () => observer.disconnect(); - }, [followScroll, visibleTopicIds]); + }, [followScroll, scrollToTopic, visibleTopicIds]); useEffect(() => cancelScroll, [cancelScroll]); + // Opened at a topic: go there once it shows, which waits for the search + // index, since every section is hidden on the first frame. Once only, so the + // reader is never pulled back. + const pendingTopic = useRef(initialTopic); + useEffect(() => { + const id = pendingTopic.current; + if (!id || !visibleTopicIds.split(',').includes(id)) return; + pendingTopic.current = undefined; + settling.current = { id, until: performance.now() + SETTLE_MS }; + setTopic(id); + scrollToTopic(id); + }, [visibleTopicIds, scrollToTopic]); + return ( void }) { onPointerLeave={() => setHoveredTopic(null)} onScroll={followScroll} // The user's own input takes over from a chosen topic. - onWheel={cancelScroll} - onPointerDown={cancelScroll} - onKeyDown={cancelScroll} + onWheel={takeOverScroll} + onPointerDown={takeOverScroll} + onKeyDown={takeOverScroll} className="min-h-0 min-w-0 flex-1 overflow-y-auto overscroll-contain scroll-smooth px-3 py-4 break-words sm:px-6 [&_label]:flex-wrap" > {searching && ( diff --git a/lib/src/stories/SettingsDialog.stories.tsx b/lib/src/stories/SettingsDialog.stories.tsx index 2a35f7860..2969bd299 100644 --- a/lib/src/stories/SettingsDialog.stories.tsx +++ b/lib/src/stories/SettingsDialog.stories.tsx @@ -3,7 +3,7 @@ import { useState } from 'react'; import { expect, fireEvent, userEvent, waitFor, within } from 'storybook/test'; import type { DormouseTheme } from '../lib/themes'; import { OVERLAY_MAX_HEIGHT_VAR } from '../components/design'; -import { SettingsDialog, SETTINGS_SCROLL_MS, TOPIC_GAP_PX } from '../components/SettingsDialog'; +import { SettingsDialog, SETTINGS_SCROLL_MS, TOPIC_GAP_PX, type TopicId } from '../components/SettingsDialog'; import { WorkspaceIdContext } from '../components/wall/wall-context'; import { enrolledStatus, SELF_HOST_UNENROLLED_STATUS, UNENROLLED_STATUS } from '../host/remote/test-burrow-link'; import type { BuildStamp } from '../lib/build-info'; @@ -26,12 +26,12 @@ function dialog(canvasElement: HTMLElement) { * rule set, the settings, and the push-device list are app-global stores rather * than props. */ -function DialogStory() { +function DialogStory({ initialTopic }: { initialTopic?: TopicId }) { const [open, setOpen] = useState(true); return <> {open && - setOpen(false)} /> + setOpen(false)} /> } ; } @@ -235,6 +235,46 @@ export const PushSelfHostRemoved: Story = { }, }; +/** + * Opened at a topic, as the Baseboard preview's live line opens it: the dialog + * scrolls there once the topic shows, and holds it while Remote control's + * status read fills the sections above. + */ +export const OpensAtNetwork: Story = { + args: { initialTopic: 'network' }, + parameters: { + primedWatchedCommands: ['claude'], + primedAlertSettings: { pushEnabled: true }, + primedPushDevices: { status: 'no-burrow', devices: [] }, + primedBurrow: { status: UNENROLLED_STATUS }, + }, + play: async ({ canvasElement }) => { + const body = dialog(canvasElement); + const network = await body.findByRole('region', { name: 'Network' }); + await waitForTopicScroll(network); + + // A section above it growing late, as a status read landing does, is + // followed for a moment, until the reader's own input. Scroll anchoring, + // where a browser has it, would follow it anyway, so it is off here. + const content = network.parentElement!; + content.style.overflowAnchor = 'none'; + const offset = () => network.getBoundingClientRect().top - content.getBoundingClientRect().top - TOPIC_GAP_PX; + const general = body.getByRole('region', { name: 'General' }); + const grow = (px: number) => { general.style.minHeight = `${general.offsetHeight + px}px`; }; + grow(200); + await waitFor(() => expect(content).not.toHaveAttribute('data-topic-scrolling'), { timeout: SETTINGS_SCROLL_MS + 2000 }); + await waitFor(() => expect(Math.abs(offset())).toBeLessThan(1), { timeout: SETTINGS_SCROLL_MS + 2000 }); + fireEvent.wheel(content); + grow(200); + await new Promise((resolve) => setTimeout(resolve, 100)); + await expect(content).not.toHaveAttribute('data-topic-scrolling'); + await expect(offset()).toBeGreaterThan(100); + general.style.minHeight = ''; + content.style.overflowAnchor = ''; + content.scrollTo({ top: content.scrollTop + offset(), behavior: 'instant' }); + }, +}; + /** * Non-default timings, proving every number field renders the stored value * rather than a hardcoded one. From 3b0f6117c09f7174c503e7c94fb51e7832a7363d Mon Sep 17 00:00:00 2001 From: Ned Twigg Date: Wed, 28 Oct 2026 02:17:16 -0700 Subject: [PATCH 2/6] Alarm upsell: the daily gate and which offer each sink earns takeAlarmUpsell decides the one line a Baseboard alarm toggle's preview may carry: at most once in a rolling day per machine, across both sinks (dormouse:alarm-upsell-shown-at; a clock set back is a new day, storage that cannot be read has not shown, and only a line that shows spends it). Nothing before the network policy answers. Speech reads managed voice's status; push reads the Burrow's status with one capped readBurrowStatusOnce, which never starts the poll. Under Nothing each offer points at the policy instead of what Nothing blocks. Adds the upsell-voice and upsell-push refs. Co-Authored-By: Claude Opus 5.5 --- lib/src/lib/alarm-upsell.test.ts | 188 ++++++++++++++++++ lib/src/lib/alarm-upsell.ts | 113 +++++++++++ lib/src/lib/hosted-links.ts | 2 + .../remote/burrow/burrow-status-store.test.ts | 31 +++ lib/src/remote/burrow/burrow-status-store.ts | 17 ++ 5 files changed, 351 insertions(+) create mode 100644 lib/src/lib/alarm-upsell.test.ts create mode 100644 lib/src/lib/alarm-upsell.ts diff --git a/lib/src/lib/alarm-upsell.test.ts b/lib/src/lib/alarm-upsell.test.ts new file mode 100644 index 000000000..3ea3d1346 --- /dev/null +++ b/lib/src/lib/alarm-upsell.test.ts @@ -0,0 +1,188 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +vi.mock('./platform', () => ({ getPlatform: vi.fn() })); +vi.mock('../remote/burrow/network-policy-store', () => ({ getNetworkPolicySnapshot: vi.fn() })); + +import { getPlatform } from './platform'; +import { getNetworkPolicySnapshot } from '../remote/burrow/network-policy-store'; +import { + ALARM_UPSELL_SHOWN_AT_KEY, + alarmUpsellShownToday, + choosePushUpsell, + chooseVoiceUpsell, + STATUS_READ_MS, + takeAlarmUpsell, +} from './alarm-upsell'; +import { installLocalStorageStub } from './test-local-storage'; +import { makeStubManagedVoicePort } from './platform/test-ports'; +import type { PlatformAdapter } from './platform/types'; +import type { BurrowConsoleStatus } from '../host/remote/service-protocol'; +import type { ManagedVoiceStatus } from './platform/managed-voice-types'; +import { + enrolledStatus, + makeStubBurrowLink, + networkOn, + SELF_HOST_UNENROLLED_STATUS, + UNENROLLED_STATUS, +} from '../host/remote/test-burrow-link'; +import { networkPolicyResult, nothingPolicy } from '../remote/network-policy'; + +const DAY = 24 * 60 * 60 * 1000; +const NOW = 1_800_000_000_000; +const voice = (over: Partial): ManagedVoiceStatus => + ({ configured: false, voiceId: 'v', notEntitled: false, ...over }); +const hosted = (over: Partial): BurrowConsoleStatus => + ({ ...UNENROLLED_STATUS, enrolled: true, connection: 'connected', ...over }); + +describe('chooseVoiceUpsell', () => { + it('points at turning managed voice on, or at the policy under Nothing', () => { + expect(chooseVoiceUpsell(voice({}), false)).toBe('voice-turn-on'); + expect(chooseVoiceUpsell(voice({}), true)).toBe('voice-network'); + }); + + it('points an account with no plan at the plans, whatever the policy', () => { + expect(chooseVoiceUpsell(voice({ configured: true, notEntitled: true }), false)).toBe('voice-plans'); + expect(chooseVoiceUpsell(voice({ configured: true, notEntitled: true }), true)).toBe('voice-plans'); + }); + + it('offers nothing without a port’s answer, to a member, or over an approval under way', () => { + expect(chooseVoiceUpsell(null, false)).toBeNull(); + expect(chooseVoiceUpsell(voice({ configured: true }), false)).toBeNull(); + expect(chooseVoiceUpsell(voice({ enrollment: { status: 'waiting' } as ManagedVoiceStatus['enrollment'] }), false)).toBeNull(); + expect(chooseVoiceUpsell(voice({ enrollment: { status: 'redeeming' } as ManagedVoiceStatus['enrollment'] }), false)).toBeNull(); + }); +}); + +describe('choosePushUpsell', () => { + it('points a Hosted build not enrolled, or removed, at the Hosted Relay, or at the policy under Nothing', () => { + expect(choosePushUpsell(UNENROLLED_STATUS, false)).toBe('push-hosted-relay'); + expect(choosePushUpsell(hosted({ connection: 'removed' }), false)).toBe('push-hosted-relay'); + expect(choosePushUpsell(UNENROLLED_STATUS, true)).toBe('push-network'); + }); + + it('points an account with no plan at the plans', () => { + expect(choosePushUpsell(hosted({ connection: 'not-entitled' }), false)).toBe('push-plans'); + }); + + it('points a build without Hosted mode at setting up a phone, never at Hosted', () => { + expect(choosePushUpsell(SELF_HOST_UNENROLLED_STATUS, false)).toBe('push-set-up-phone'); + expect(choosePushUpsell(SELF_HOST_UNENROLLED_STATUS, true)).toBe('push-set-up-phone'); + expect(choosePushUpsell(enrolledStatus({ connection: 'not-entitled' }), false)).toBeNull(); + }); + + it('offers nothing without an answer, or to an enrolled member', () => { + expect(choosePushUpsell(null, false)).toBeNull(); + expect(choosePushUpsell(hosted({}), false)).toBeNull(); + expect(choosePushUpsell(enrolledStatus(), false)).toBeNull(); + }); +}); + +describe('alarmUpsellShownToday', () => { + beforeEach(() => installLocalStorageStub()); + afterEach(() => vi.unstubAllGlobals()); + + it('holds for a day after a showing', () => { + expect(alarmUpsellShownToday(NOW)).toBe(false); + localStorage.setItem(ALARM_UPSELL_SHOWN_AT_KEY, String(NOW)); + expect(alarmUpsellShownToday(NOW + DAY - 1)).toBe(true); + expect(alarmUpsellShownToday(NOW + DAY)).toBe(false); + }); + + it('counts a clock set back past the last showing as a new day', () => { + localStorage.setItem(ALARM_UPSELL_SHOWN_AT_KEY, String(NOW)); + expect(alarmUpsellShownToday(NOW - 1000)).toBe(false); + }); + + it('has not shown when storage cannot be read, or holds a malformed record', () => { + localStorage.setItem(ALARM_UPSELL_SHOWN_AT_KEY, '"soon"'); + expect(alarmUpsellShownToday(NOW)).toBe(false); + vi.stubGlobal('localStorage', { + getItem: () => { throw new Error('blocked'); }, + setItem: () => { throw new Error('blocked'); }, + removeItem: () => {}, + }); + expect(alarmUpsellShownToday(NOW)).toBe(false); + }); +}); + +describe('takeAlarmUpsell', () => { + const statusRead = vi.fn(); + /** A Hosted desktop build: a Burrow service answering `status`, and a managed-voice port. */ + function build({ status = UNENROLLED_STATUS, port = true, nothing = false, policy = 'ready' } = {}) { + const link = makeStubBurrowLink({ status }); + const command = link.command; + link.command = (cmd, params) => { + if (cmd === 'status') statusRead(); + return command(cmd, params); + }; + vi.mocked(getPlatform).mockReturnValue({ + burrow: link, + managedVoice: port ? makeStubManagedVoicePort(false) : undefined, + } as unknown as PlatformAdapter); + vi.mocked(getNetworkPolicySnapshot).mockReturnValue( + policy === 'ready' + ? { kind: 'ready', network: nothing ? networkPolicyResult(nothingPolicy(), status.relayMode, []) : networkOn(status.relayMode) } + : { kind: 'loading' }, + ); + } + const shown = () => true; + const spent = () => localStorage.getItem(ALARM_UPSELL_SHOWN_AT_KEY); + + beforeEach(() => { + installLocalStorageStub(); + statusRead.mockReset(); + }); + afterEach(() => { + vi.unstubAllGlobals(); + vi.useRealTimers(); + }); + + it('spends the day only on a line that shows', async () => { + build(); + expect(await takeAlarmUpsell('speech', () => false, NOW)).toBeNull(); + expect(spent()).toBeNull(); + expect(await takeAlarmUpsell('speech', shown, NOW)).toBe('voice-turn-on'); + expect(spent()).toBe(String(NOW)); + // Once a day, across both sinks. + expect(await takeAlarmUpsell('push', shown, NOW + 1)).toBeNull(); + expect(await takeAlarmUpsell('push', shown, NOW + DAY)).toBe('push-hosted-relay'); + }); + + it('offers under Nothing, pointing at the policy', async () => { + build({ nothing: true }); + expect(await takeAlarmUpsell('push', shown, NOW)).toBe('push-network'); + }); + + it('asks the Burrow nothing before the policy answers, once the day is spent, or for speech', async () => { + build({ policy: 'loading' }); + expect(await takeAlarmUpsell('push', shown, NOW)).toBeNull(); + build(); + localStorage.setItem(ALARM_UPSELL_SHOWN_AT_KEY, String(NOW)); + expect(await takeAlarmUpsell('push', shown, NOW)).toBeNull(); + localStorage.clear(); + expect(await takeAlarmUpsell('speech', shown, NOW)).toBe('voice-turn-on'); + expect(statusRead).not.toHaveBeenCalled(); + expect(await takeAlarmUpsell('push', shown, NOW + DAY)).toBe('push-hosted-relay'); + expect(statusRead).toHaveBeenCalledOnce(); + }); + + it('offers no voice in a build without the port, and nothing to an entitled member', async () => { + build({ status: SELF_HOST_UNENROLLED_STATUS, port: false }); + expect(await takeAlarmUpsell('speech', shown, NOW)).toBeNull(); + build({ status: hosted({}) }); + expect(await takeAlarmUpsell('push', shown, NOW)).toBeNull(); + expect(spent()).toBeNull(); + }); + + it('gives up on a status read that outlives the preview', async () => { + vi.useFakeTimers(); + vi.mocked(getPlatform).mockReturnValue({ + burrow: { ...makeStubBurrowLink({}), command: () => new Promise(() => {}) }, + } as unknown as PlatformAdapter); + vi.mocked(getNetworkPolicySnapshot).mockReturnValue({ kind: 'ready', network: networkOn('hosted') }); + const taken = takeAlarmUpsell('push', shown, NOW); + await vi.advanceTimersByTimeAsync(STATUS_READ_MS); + expect(await taken).toBeNull(); + expect(spent()).toBeNull(); + }); +}); diff --git a/lib/src/lib/alarm-upsell.ts b/lib/src/lib/alarm-upsell.ts new file mode 100644 index 000000000..1a5ea10f0 --- /dev/null +++ b/lib/src/lib/alarm-upsell.ts @@ -0,0 +1,113 @@ +import type { AlertSink } from './alert-delivery-model'; +import type { BurrowConsoleStatus } from '../host/remote/service-protocol'; +import type { ManagedVoiceStatus } from './platform/managed-voice-types'; +import { getPlatform } from './platform'; +import { loadJson, saveJson } from './local-json-store'; +import { getNetworkPolicySnapshot } from '../remote/burrow/network-policy-store'; +import { readBurrowStatusOnce } from '../remote/burrow/burrow-status-store'; + +/** + * The one live line a Baseboard alarm toggle's preview may carry + * (`docs/specs/alert.md` -> "Settings dialog"). Each opens a Settings topic or + * the Hosted plans; `-network` is its sink's offer under Nothing, which points + * at the policy instead of at what Nothing blocks. + * + * - `voice-turn-on` / `voice-network`: managed voice is not turned on; + * - `voice-plans` / `push-plans`: Hosted said the account has no plan; + * - `push-hosted-relay` / `push-network`: a Hosted build not enrolled, or + * removed from its account; + * - `push-set-up-phone`: a build without Hosted mode not enrolled with its Relay. + */ +export type AlarmUpsell = + | 'voice-turn-on' + | 'voice-network' + | 'voice-plans' + | 'push-hosted-relay' + | 'push-network' + | 'push-plans' + | 'push-set-up-phone'; + +/** + * Speech's offer, from managed voice's status: `null` without a port, which + * only a Hosted build has, or before the host answers. + */ +export function chooseVoiceUpsell(voice: ManagedVoiceStatus | null, nothing: boolean): AlarmUpsell | null { + if (!voice) return null; + if (voice.configured) return voice.notEntitled ? 'voice-plans' : null; + // An approval under way is already the user's next step. + if (voice.enrollment?.status === 'waiting' || voice.enrollment?.status === 'redeeming') return null; + return nothing ? 'voice-network' : 'voice-turn-on'; +} + +/** Push's offer, from the Burrow's status: `null` without a service or an answer. */ +export function choosePushUpsell( + status: Pick | null, + nothing: boolean, +): AlarmUpsell | null { + if (!status) return null; + // Never Hosted in a build without Hosted mode. Enrolled, the device line + // already says what else is missing. + if (status.relayMode === 'self-host') return status.enrolled ? null : 'push-set-up-phone'; + if (!status.enrolled || status.connection === 'removed') return nothing ? 'push-network' : 'push-hosted-relay'; + return status.connection === 'not-entitled' ? 'push-plans' : null; +} + +/** When a line last showed, on this machine: every window shares the origin's storage. */ +export const ALARM_UPSELL_SHOWN_AT_KEY = 'dormouse:alarm-upsell-shown-at'; +const DAY_MS = 24 * 60 * 60 * 1000; +/** Past the plain preview's life, so a later answer has no preview left to join. */ +export const STATUS_READ_MS = 2500; +const isNumber = (value: unknown): value is number => typeof value === 'number'; + +/** Whether a line showed in the last day. Storage that cannot be read means it has not. */ +export function alarmUpsellShownToday(now: number): boolean { + const last = loadJson(ALARM_UPSELL_SHOWN_AT_KEY, 0, isNumber); + // A clock set back past the last showing counts as a new day. + return now >= last && now - last < DAY_MS; +} + +function managedVoiceStatus(): ManagedVoiceStatus | null { + try { + return getPlatform().managedVoice?.status() ?? null; + } catch { + return null; + } +} + +/** The Burrow's status, or `null` once {@link STATUS_READ_MS} passes without one. */ +async function readStatusCapped(): Promise { + let timer: ReturnType | undefined; + const late = new Promise((resolve) => { + timer = setTimeout(() => resolve(null), STATUS_READ_MS); + }); + try { + return await Promise.race([readBurrowStatusOnce(), late]); + } finally { + clearTimeout(timer); + } +} + +/** + * The line turning `sink` on earns, if none showed in the last day and its + * preview is `stillShown` once decided; recording the showing spends the day. + * The cheap answers come first, so a toggle that can earn nothing never asks + * the Burrow service, and speech never does. + */ +export async function takeAlarmUpsell( + sink: AlertSink, + stillShown: () => boolean, + now: number = Date.now(), +): Promise { + const network = getNetworkPolicySnapshot(); + if (network.kind !== 'ready' || alarmUpsellShownToday(now)) return null; + const nothing = network.network.policy.level === 'nothing'; + let upsell: AlarmUpsell | null; + if (sink === 'speech') { + upsell = chooseVoiceUpsell(managedVoiceStatus(), nothing); + } else { + upsell = choosePushUpsell(await readStatusCapped(), nothing); + } + if (!upsell || !stillShown()) return null; + saveJson(ALARM_UPSELL_SHOWN_AT_KEY, now); + return upsell; +} diff --git a/lib/src/lib/hosted-links.ts b/lib/src/lib/hosted-links.ts index 92610cb19..d90fbb51c 100644 --- a/lib/src/lib/hosted-links.ts +++ b/lib/src/lib/hosted-links.ts @@ -15,6 +15,8 @@ export const HOSTED_REFS = { selfHost: 'self-host', readme: 'readme', hostedNoPlan: 'hosted-no-plan', + upsellVoice: 'upsell-voice', + upsellPush: 'upsell-push', } as const; export type HostedRef = typeof HOSTED_REFS[keyof typeof HOSTED_REFS]; diff --git a/lib/src/remote/burrow/burrow-status-store.test.ts b/lib/src/remote/burrow/burrow-status-store.test.ts index 5607276e9..f705a620a 100644 --- a/lib/src/remote/burrow/burrow-status-store.test.ts +++ b/lib/src/remote/burrow/burrow-status-store.test.ts @@ -11,8 +11,10 @@ vi.mock('../../lib/platform', () => ({ import { clearBurrowEnrollment, getBurrowStatusSnapshot, + readBurrowStatusOnce, subscribeToBurrowStatus, } from './burrow-status-store'; +import { makeStubBurrowLink, UNENROLLED_STATUS } from '../../host/remote/test-burrow-link'; afterEach(() => { burrowLink = undefined; @@ -320,3 +322,32 @@ describe('a Hosted enrollment in the status', () => { } }); }); + +describe('readBurrowStatusOnce', () => { + it('reads once and publishes nothing, or takes the answer a poll already holds', async () => { + const link = makeStubBurrowLink({ status: UNENROLLED_STATUS }); + const command = vi.spyOn(link, 'command'); + const on = vi.spyOn(link, 'on'); + burrowLink = link; + expect(await readBurrowStatusOnce()).toEqual(UNENROLLED_STATUS); + expect(command).toHaveBeenCalledOnce(); + expect(on).not.toHaveBeenCalled(); + expect(getBurrowStatusSnapshot()).toEqual({ kind: 'loading' }); + + const unsubscribe = subscribeToBurrowStatus(() => {}); + try { + await vi.waitFor(() => expect(getBurrowStatusSnapshot().kind).toBe('ready')); + command.mockClear(); + expect(await readBurrowStatusOnce()).toEqual(UNENROLLED_STATUS); + expect(command).not.toHaveBeenCalled(); + } finally { + unsubscribe(); + } + }); + + it('is null without a service, or when it cannot answer', async () => { + expect(await readBurrowStatusOnce()).toBeNull(); + burrowLink = makeStubBurrowLink({ statusError: 'down' }); + expect(await readBurrowStatusOnce()).toBeNull(); + }); +}); diff --git a/lib/src/remote/burrow/burrow-status-store.ts b/lib/src/remote/burrow/burrow-status-store.ts index 529ff2462..c327a981e 100644 --- a/lib/src/remote/burrow/burrow-status-store.ts +++ b/lib/src/remote/burrow/burrow-status-store.ts @@ -235,6 +235,23 @@ function refreshAfterMutation(): Promise { return refreshBurrowStatus(); } +/** + * The status now, for a caller that holds no subscription and must not start + * the poll: the one a subscriber's poll holds, else one read, which publishes + * nothing. `null` without a service, or on any failure. + */ +export async function readBurrowStatusOnce(): Promise { + if (state.kind === 'ready') return state.status; + const active = burrowLink(); + if (!active) return null; + try { + const status = (await active.command('status')) as BurrowConsoleStatus | null; + return status ? { ...status, hostedEnrollment: parseHostedEnrollmentState(status.hostedEnrollment) } : null; + } catch { + return null; + } +} + async function readBurrowStatus(): Promise { const active = burrowLink(); if (!active) { From ea32d4a337a7a94bac0eeaa8465759f44fe6868f Mon Sep 17 00:00:00 2001 From: Ned Twigg Date: Wed, 28 Oct 2026 02:23:05 -0700 Subject: [PATCH 3/6] Alarm upsell: the preview's live line Turning a Baseboard alarm button on asks takeAlarmUpsell for its line; one that arrives while its preview is up joins it under the inert section, keeps the preview up 6 s instead of 2 s, and brings back a preview that had begun to fade. The line opens Settings at its topic, or the Hosted plans with the upsell-voice / upsell-push ref, and closes the preview. It is a button that never takes focus from the terminal. Closing the preview bumps the sequence, so an offer still being decided is neither shown nor spent. The inert preview's push line drops its own plans link, which it could never open. Storybook clears the daily key between stories. Co-Authored-By: Claude Opus 5.5 --- lib/.storybook/preview.ts | 3 + lib/src/components/AlarmUpsellLine.tsx | 63 ++++++++++ lib/src/components/Baseboard.test.tsx | 146 +++++++++++++++++++++++- lib/src/components/Baseboard.tsx | 32 +++++- lib/src/components/ExternalTextLink.tsx | 5 +- lib/src/components/SettingsDialog.tsx | 17 +-- lib/src/components/SettingsPreview.tsx | 26 ++++- lib/src/stories/Baseboard.stories.tsx | 114 +++++++++++++++++- 8 files changed, 387 insertions(+), 19 deletions(-) create mode 100644 lib/src/components/AlarmUpsellLine.tsx diff --git a/lib/.storybook/preview.ts b/lib/.storybook/preview.ts index cc3964535..8c26182b9 100644 --- a/lib/.storybook/preview.ts +++ b/lib/.storybook/preview.ts @@ -45,6 +45,7 @@ import { getWorkspacesSnapshot, resetWorkspaces, setWorkspaces, type WorkspaceMe import { resetWorkspaceSurfaces, setWorkspaceSurfaces } from '../src/lib/workspace-surfaces'; import { resetWorkspaceUi } from '../src/lib/workspace-ui-store'; import { resetSurfaceIdPool } from '../src/lib/surface-ids'; +import { ALARM_UPSELL_SHOWN_AT_KEY } from '../src/lib/alarm-upsell'; /** `parameters.primedWorkspaces`: the Window a Workspace story renders. */ interface PrimedWorkspaces { @@ -377,6 +378,8 @@ const preview: Preview = { // A picker story's selection must not change later stories' theme restore. window.localStorage.removeItem('dormouse:active-theme'); + // Nor may one story's alarm-toggle line spend the next one's day. + window.localStorage.removeItem(ALARM_UPSELL_SHOWN_AT_KEY); // Installed themes normally arrive from OpenVSX and live in localStorage, // which every story shares — so a story that wants them names them, and diff --git a/lib/src/components/AlarmUpsellLine.tsx b/lib/src/components/AlarmUpsellLine.tsx new file mode 100644 index 000000000..801975063 --- /dev/null +++ b/lib/src/components/AlarmUpsellLine.tsx @@ -0,0 +1,63 @@ +import type { AlarmUpsell } from '../lib/alarm-upsell'; +import { HOSTED_REFS, type HostedRef } from '../lib/hosted-links'; +import { TEXT_LINK_CLASS } from './ExternalTextLink'; +import { HostedPlansLink } from './HostedEnrollment'; +import { NO_PLAN_COPY } from './ManagedVoiceSection'; +import type { TopicId } from './SettingsDialog'; + +/** The Settings topics a line opens: managed voice's, and the Relay's. */ +export type UpsellTopic = Extract; + +/** Choosing a network level, in Network's own words, for an offer Nothing blocks. */ +const CHOOSE_LEVEL = 'Choose Local networks or Anywhere in Settings → Network'; + +/** + * Each offer: an action opening a Settings topic, or the plans with the + * offer's ref. Push's no-plan line has no lead because the preview's inert + * device line above it already says why no push is sent. + */ +const OFFERS: Record = { + 'voice-turn-on': { action: 'Turn on Natural Voice in Settings → Notifications', topic: 'notifications' }, + 'voice-network': { action: `${CHOOSE_LEVEL} to turn on Natural Voice`, topic: 'network' }, + 'voice-plans': { lead: NO_PLAN_COPY, plans: HOSTED_REFS.upsellVoice }, + 'push-hosted-relay': { action: 'Get push on your phone with the Hosted Relay in Settings → Network', topic: 'network' }, + 'push-network': { action: `${CHOOSE_LEVEL} to get push on your phone`, topic: 'network' }, + 'push-plans': { plans: HOSTED_REFS.upsellPush }, + 'push-set-up-phone': { action: 'Set up a phone in Settings → Network', topic: 'network' }, +}; + +/** + * The preview's live line. Pressing it never moves focus, so the terminal + * keeps the keyboard; its button is still one Tab reaches. + */ +export function AlarmUpsellLine({ upsell, onShowSettings, onDone }: { + upsell: AlarmUpsell; + onShowSettings: (topic: UpsellTopic) => void; + /** The line did its job; the preview closes. */ + onDone: () => void; +}) { + const offer = OFFERS[upsell]; + return ( +
event.preventDefault()} + onClick={(event) => { + if (event.target instanceof Element && event.target.closest('button')) onDone(); + }} + > + {offer.lead ? {offer.lead} : null} + {'plans' in offer ? ( + + ) : ( + + )} +
+ ); +} diff --git a/lib/src/components/Baseboard.test.tsx b/lib/src/components/Baseboard.test.tsx index 7be11e3e7..b9a004b24 100644 --- a/lib/src/components/Baseboard.test.tsx +++ b/lib/src/components/Baseboard.test.tsx @@ -30,7 +30,10 @@ import { resetShellStore, seedShellStore } from '../lib/shell-store'; import { resetPushDevices, setPushDevices, setPushDevicesRefresher } from '../lib/push-devices'; import { clearTerminalActivity, setTerminalActivity } from '../lib/session-activity-store'; import { createAlertEpisode } from '../lib/alert-episode'; -import { makeStubBurrowLink } from '../host/remote/test-burrow-link'; +import { makeStubBurrowLink, SELF_HOST_UNENROLLED_STATUS, UNENROLLED_STATUS } from '../host/remote/test-burrow-link'; +import { ALARM_UPSELL_SHOWN_AT_KEY } from '../lib/alarm-upsell'; +import type { BurrowConsoleStatus } from '../host/remote/service-protocol'; +import type { ManagedVoicePort } from '../lib/platform/managed-voice-types'; import { makeStubManagedVoicePort } from '../lib/platform/test-ports'; import { networkPolicyResult, nothingPolicy } from '../remote/network-policy'; @@ -421,6 +424,147 @@ describe('Baseboard settings controls', () => { }); }); +describe('Baseboard alarm upsell', () => { + const openExternal = vi.fn(); + const HOSTED_ENROLLED: BurrowConsoleStatus = { ...UNENROLLED_STATUS, enrolled: true, serving: true, burrowId: 'burrow-1', connection: 'connected' }; + + /** A desktop build whose Burrow service answers `status`; Hosted and not enrolled by default. */ + async function renderBuild({ status = UNENROLLED_STATUS, voice = makeStubManagedVoicePort(false) as ManagedVoicePort | null } = {}) { + const platform = await import('../lib/platform'); + vi.spyOn(platform, 'getPlatform').mockReturnValue({ + alertPublishSettings: vi.fn(), + openExternal, + burrow: makeStubBurrowLink({ status }), + managedVoice: voice ?? undefined, + } as unknown as ReturnType); + // Rendered, and the network policy answered. + await act(async () => root.render( {}} />)); + } + /** Toggle, then let the line's decision land. */ + const toggle = async (sink: 'speech' | 'push') => { + act(() => container.querySelector(`[data-alarm-setting="${sink}"]`)!.click()); + await act(async () => {}); + }; + const line = () => document.querySelector('[data-alarm-upsell]'); + const lineButton = () => line()?.querySelector('button') ?? null; + const preview = () => document.querySelector('[role="status"]'); + + beforeEach(() => openExternal.mockReset()); + + it('adds one live line that keeps the keyboard with the terminal, and opens Settings', async () => { + await renderBuild(); + const terminalInput = document.createElement('textarea'); + container.appendChild(terminalInput); + terminalInput.focus(); + await toggle('speech'); + + expect(line()?.dataset.alarmUpsell).toBe('voice-turn-on'); + expect(line()?.closest('[inert]')).toBeNull(); + expect(lineButton()?.textContent).toBe('Turn on Natural Voice in Settings → Notifications'); + expect(document.activeElement).toBe(terminalInput); + const mouseDown = new MouseEvent('mousedown', { bubbles: true, cancelable: true }); + lineButton()!.dispatchEvent(mouseDown); + expect(mouseDown.defaultPrevented).toBe(true); + + act(() => lineButton()!.click()); + expect(preview()).toBeNull(); + expect(document.querySelector('[role="dialog"]')?.textContent).toContain('Settings'); + expect(openExternal).not.toHaveBeenCalled(); + }); + + it('shows once a day, across both buttons, and never on turning off', async () => { + await renderBuild(); + await toggle('speech'); + expect(line()).not.toBeNull(); + await toggle('speech'); + expect(preview()).not.toBeNull(); + expect(line()).toBeNull(); + await toggle('push'); + expect(line()).toBeNull(); + + localStorage.removeItem(ALARM_UPSELL_SHOWN_AT_KEY); + await toggle('push'); + expect(line()).toBeNull(); + }); + + it('stays up longer with a line, past a plain preview’s life', async () => { + vi.useFakeTimers(); + await renderBuild(); + await toggle('speech'); + act(() => vi.advanceTimersByTime(2500)); + expect(preview()?.classList.contains('opacity-100')).toBe(true); + act(() => vi.advanceTimersByTime(3500)); + expect(preview()?.classList.contains('opacity-0')).toBe(true); + act(() => vi.advanceTimersByTime(250)); + expect(preview()).toBeNull(); + }); + + it('brings back a fading preview when its line arrives', async () => { + vi.useFakeTimers(); + const platform = await import('../lib/platform'); + const link = makeStubBurrowLink({ status: UNENROLLED_STATUS }); + const command = link.command; + // A status read slower than the plain preview's life, inside the cap. + link.command = (cmd, params) => cmd === 'status' + ? new Promise((resolve) => setTimeout(() => resolve(command(cmd, params)), 2100)) + : command(cmd, params); + vi.spyOn(platform, 'getPlatform').mockReturnValue({ + alertPublishSettings: vi.fn(), + burrow: link, + } as unknown as ReturnType); + await act(async () => root.render( {}} />)); + await toggle('push'); + act(() => vi.advanceTimersByTime(2000)); + expect(preview()?.classList.contains('opacity-0')).toBe(true); + await act(async () => vi.advanceTimersByTimeAsync(100)); + expect(line()?.dataset.alarmUpsell).toBe('push-hosted-relay'); + expect(preview()?.classList.contains('opacity-100')).toBe(true); + }); + + it('neither shows nor spends an offer whose preview closed before it was decided', async () => { + await renderBuild(); + act(() => container.querySelector('[data-alarm-setting="push"]')!.click()); + // Settings opened before the status read landed. + act(() => container.querySelector('[data-open-settings]')!.click()); + await act(async () => {}); + expect(localStorage.getItem(ALARM_UPSELL_SHOWN_AT_KEY)).toBeNull(); + }); + + it('links the plans with the offer’s ref where the account has none', async () => { + await renderBuild({ status: { ...HOSTED_ENROLLED, connection: 'not-entitled' } }); + await toggle('push'); + expect(line()?.dataset.alarmUpsell).toBe('push-plans'); + // The inert device line above says why, without a link of its own. + expect(preview()?.querySelector('[inert]')?.textContent).toContain('Your account has no Hosted plan, so no push is sent.'); + expect(preview()?.querySelector('[inert]')?.textContent).not.toContain('See Hosted plans'); + act(() => lineButton()!.click()); + expect(openExternal).toHaveBeenCalledWith('https://dormouse.sh/hosted/?ref=upsell-push#pricing'); + expect(preview()).toBeNull(); + }); + + it('never names Hosted in a build without Hosted mode', async () => { + await renderBuild({ status: SELF_HOST_UNENROLLED_STATUS, voice: null }); + await toggle('speech'); + expect(line()).toBeNull(); + localStorage.removeItem(ALARM_UPSELL_SHOWN_AT_KEY); + await toggle('push'); + expect(line()?.dataset.alarmUpsell).toBe('push-set-up-phone'); + expect(preview()?.textContent).not.toContain('Hosted'); + act(() => lineButton()!.click()); + expect(openExternal).not.toHaveBeenCalled(); + expect(document.querySelector('[role="dialog"]')).not.toBeNull(); + }); + + it('offers nothing to a member', async () => { + await renderBuild({ status: HOSTED_ENROLLED, voice: makeStubManagedVoicePort(true) }); + await toggle('speech'); + await toggle('push'); + await toggle('push'); + expect(line()).toBeNull(); + expect(localStorage.getItem(ALARM_UPSELL_SHOWN_AT_KEY)).toBeNull(); + }); +}); + describe('Baseboard one-time connection', () => { it('shows a connected phone in the measured right cluster, after the notice', async () => { const platform = await import('../lib/platform'); diff --git a/lib/src/components/Baseboard.tsx b/lib/src/components/Baseboard.tsx index 268fea9e6..faad3e0b3 100644 --- a/lib/src/components/Baseboard.tsx +++ b/lib/src/components/Baseboard.tsx @@ -38,6 +38,7 @@ import { } from '../lib/terminal-registry'; import { deriveDisplayedSurfaceLabel } from '../lib/session-label'; import { subscribeToNetworkPolicy } from '../remote/burrow/network-policy-store'; +import { takeAlarmUpsell, type AlarmUpsell } from '../lib/alarm-upsell'; /** Shared by every baseboard-level button (DESIGN.md -> Navigation). */ const BASEBOARD_BUTTON_BASE_CLASS = 'h-6 shrink-0 justify-center pb-px text-sm font-medium font-mono'; @@ -141,17 +142,34 @@ export function Baseboard({ items, onReattach, notice, onDoorDragStart }: Basebo const layoutMetrics = useRef({ doorGap: 0, arrowWidth: 0 }); const [settingsOpen, setSettingsOpen] = useState<{ dialog: 'application'; topic?: TopicId } | { dialog: 'workspace' } | null>(null); const workspaceSettingsTrigger = useRef(null); - const [settingsPreview, setSettingsPreview] = useState<{ sink: AlertSink; anchor: HTMLElement; sequence: number } | null>(null); + const [settingsPreview, setSettingsPreview] = useState<{ + sink: AlertSink; + anchor: HTMLElement; + upsell: AlarmUpsell | null; + sequence: number; + } | null>(null); const previewSequence = useRef(0); - const closeSettingsPreview = useCallback(() => setSettingsPreview(null), []); + const closeSettingsPreview = useCallback(() => { + // An offer still being decided for the closed preview is then neither shown nor spent. + previewSequence.current++; + setSettingsPreview(null); + }, []); const toggleAlarm = (sink: AlertSink, anchor: HTMLElement) => { - const patch = sink === 'speech' - ? { speakEnabled: !settings.speakEnabled } - : { pushEnabled: !settings.pushEnabled }; + const turnedOn = sink === 'speech' ? !settings.speakEnabled : !settings.pushEnabled; + const patch = sink === 'speech' ? { speakEnabled: turnedOn } : { pushEnabled: turnedOn }; if (workspaceId) setWorkspaceAlertDelivery(workspaceId, { ...overrides, ...patch }); else updateAlertSettings(patch); - setSettingsPreview({ sink, anchor, sequence: ++previewSequence.current }); + const sequence = ++previewSequence.current; + setSettingsPreview({ sink, anchor, upsell: null, sequence }); + if (!turnedOn) return; + // The offer may wait on one Burrow status read, so the line joins the + // preview that earned it, if that preview is still up. + const stillShown = () => previewSequence.current === sequence; + void takeAlarmUpsell(sink, stillShown).then((upsell) => { + if (upsell) setSettingsPreview((shown) => shown && { ...shown, upsell }); + }); }; + const showSettingsAt = useCallback((topic: TopicId) => setSettingsOpen({ dialog: 'application', topic }), []); // Suppress command-mode key dispatch while the Settings dialog owns the // keyboard, so typing a timeout doesn't trigger pane shortcuts. @@ -437,6 +455,8 @@ export function Baseboard({ items, onReattach, notice, onDoorDragStart }: Basebo key={settingsPreview.sequence} sink={settingsPreview.sink} anchor={settingsPreview.anchor} + upsell={settingsPreview.upsell} + onShowSettings={showSettingsAt} onClose={closeSettingsPreview} /> )} diff --git a/lib/src/components/ExternalTextLink.tsx b/lib/src/components/ExternalTextLink.tsx index eb4851f70..53cc50312 100644 --- a/lib/src/components/ExternalTextLink.tsx +++ b/lib/src/components/ExternalTextLink.tsx @@ -1,6 +1,9 @@ import type { ReactNode } from 'react'; import { getPlatform } from '../lib/platform'; +/** An inline prose link's look, for a link that opens something in the app too. */ +export const TEXT_LINK_CLASS = 'text-foreground underline underline-offset-2 hover:text-muted'; + /** * Inline prose link out to the web. A `button`, not an ``: the webview hosts * cannot navigate themselves, so every outbound href goes through the @@ -17,7 +20,7 @@ export function ExternalTextLink({ href, children }: { href: string; children: R diff --git a/lib/src/components/SettingsDialog.tsx b/lib/src/components/SettingsDialog.tsx index c212e2252..63477245d 100644 --- a/lib/src/components/SettingsDialog.tsx +++ b/lib/src/components/SettingsDialog.tsx @@ -69,8 +69,9 @@ function describePushTargets( push: PushDevicesState, burrow: BurrowConsoleStatus | null, remoteControlBelow: boolean, + preview: boolean, ): ReactNode { - if (burrow && relayRefuses(burrow.connection)) return describeRefusal(burrow, remoteControlBelow); + if (burrow && relayRefuses(burrow.connection)) return describeRefusal(burrow, remoteControlBelow, preview); if (push.status === 'loading') return 'Looking for phones…'; if (push.status === 'error') return 'Could not reach the Relay to list phones.'; // The preview never shows Remote control, so it also omits "below". @@ -99,16 +100,18 @@ function describePushTargets( /** * Why a refusing Relay sends no push, and the way back: the plans for an * account with none, otherwise enrolling again under the persistent Relay's - * heading in Remote control. A self-host build's line never names Hosted. + * heading in Remote control. A self-host build's line never names Hosted. The + * inert preview carries no plans link of its own; its live line may. */ -function describeRefusal(status: BurrowConsoleStatus, remoteControlBelow: boolean): ReactNode { +function describeRefusal(status: BurrowConsoleStatus, remoteControlBelow: boolean, preview: boolean): ReactNode { const where = remoteControlBelow ? 'below' : 'in Settings → Network'; if (status.relayMode === 'self-host') { return `${hostOf(status.relayOrigin)} no longer accepts this computer, so no push is sent. ` + `Disconnect and enroll it again under Persistent Relay ${where}.`; } if (status.connection === 'not-entitled') { - return <>Your account has no Hosted plan, so no push is sent. ; + const sentence = 'Your account has no Hosted plan, so no push is sent.'; + return preview ? sentence : <>{sentence} ; } return `This computer was removed from your Hosted account, so no push is sent. ` + `Enroll it again under Hosted Relay ${where}.`; @@ -606,7 +609,7 @@ export function AlarmSettingsSection({ sink, preview = false, onShowNetwork }: { {networkOff ? ( <>Push is off while is set to Nowhere. ) : ( - + )} ); @@ -616,10 +619,10 @@ export function AlarmSettingsSection({ sink, preview = false, onShowNetwork }: { * {@link describePushTargets} over the live stores: its own component so only * the push sink, not speech, keeps the Burrow status polled. */ -function PushTargets({ remoteControlBelow }: { remoteControlBelow: boolean }) { +function PushTargets({ remoteControlBelow, preview }: { remoteControlBelow: boolean; preview: boolean }) { const push = useSyncExternalStore(subscribeToPushDevices, getPushDevices); const burrow = useSyncExternalStore(subscribeToBurrowStatus, getBurrowStatusSnapshot); - return describePushTargets(push, burrow.kind === 'ready' ? burrow.status : null, remoteControlBelow); + return describePushTargets(push, burrow.kind === 'ready' ? burrow.status : null, remoteControlBelow, preview); } /** diff --git a/lib/src/components/SettingsPreview.tsx b/lib/src/components/SettingsPreview.tsx index b9bca214d..ab94afecc 100644 --- a/lib/src/components/SettingsPreview.tsx +++ b/lib/src/components/SettingsPreview.tsx @@ -3,19 +3,31 @@ import { useEffect, useLayoutEffect, useState } from 'react'; import { createPortal } from 'react-dom'; import { POPUP_SURFACE_CLASS } from './design'; import { AlarmSettingsSection } from './SettingsDialog'; +import { AlarmUpsellLine, type UpsellTopic } from './AlarmUpsellLine'; import type { AlertSink } from '../lib/alert-delivery-model'; +import type { AlarmUpsell } from '../lib/alarm-upsell'; import { useAnchoredMenu } from './use-anchored-menu'; import { OVERLAY_VIEWPORT_MARGIN_PX } from '../lib/ui-geometry'; +const SHOW_MS = 2000; +/** Long enough to read the live line and reach it. */ +const SHOW_WITH_UPSELL_MS = 6000; +const FADE_MS = 250; + /** Remount for every toggle, including repeated clicks on the same setting, * so an earlier confirmation's fade/removal cannot retire the newest one. */ export function SettingsPreview({ sink, anchor, + upsell, + onShowSettings, onClose, }: { sink: AlertSink; anchor: HTMLElement; + /** The one live line under the inert section, once earned (`takeAlarmUpsell`). */ + upsell: AlarmUpsell | null; + onShowSettings: (topic: UpsellTopic) => void; onClose: () => void; }) { const { policy: settings } = useWorkspaceAlertPolicy(); @@ -27,13 +39,16 @@ export function SettingsPreview({ useLayoutEffect(() => setTriggerEl(anchor), [anchor, setTriggerEl]); useEffect(() => { - const fade = window.setTimeout(() => setFading(true), 2000); - const close = window.setTimeout(onClose, 2250); + // A line that arrives as the preview fades brings it back. + setFading(false); + const show = upsell ? SHOW_WITH_UPSELL_MS : SHOW_MS; + const fade = window.setTimeout(() => setFading(true), show); + const close = window.setTimeout(onClose, show + FADE_MS); return () => { window.clearTimeout(fade); window.clearTimeout(close); }; - }, [onClose]); + }, [upsell, onClose]); const label = sink === 'speech' ? 'Spoken alarms' : 'Push notifications'; const enabled = sink === 'speech' ? settings.speakEnabled : settings.pushEnabled; @@ -50,6 +65,11 @@ export function SettingsPreview({ + {upsell && ( +
+ +
+ )} , document.body, ); diff --git a/lib/src/stories/Baseboard.stories.tsx b/lib/src/stories/Baseboard.stories.tsx index fdf6d181c..ce770741a 100644 --- a/lib/src/stories/Baseboard.stories.tsx +++ b/lib/src/stories/Baseboard.stories.tsx @@ -1,10 +1,17 @@ import type { ReactNode } from 'react'; import type { Meta, StoryObj } from '@storybook/react'; -import { expect, fireEvent, userEvent, within } from 'storybook/test'; +import { expect, fireEvent, userEvent, waitFor, within } from 'storybook/test'; import { Baseboard } from '../components/Baseboard'; +import { TOPIC_GAP_PX } from '../components/SettingsDialog'; import { WorkspaceIdContext } from '../components/wall/wall-context'; import type { DoorChip } from '../components/Wall'; import { createTerminalPaneState, type TerminalPaneState } from '../lib/terminal-state'; +import type { AlarmUpsell } from '../lib/alarm-upsell'; +import { getNetworkPolicySnapshot } from '../remote/burrow/network-policy-store'; +import { networkPolicyResult, nothingPolicy } from '../remote/network-policy'; +import type { BurrowConsoleStatus } from '../host/remote/service-protocol'; +import { enrolledStatus, SELF_HOST_UNENROLLED_STATUS, UNENROLLED_STATUS } from '../host/remote/test-burrow-link'; +import { waitForPrimedState } from './settle-terminals'; const BASE_TIME = 1_700_000_000_000; @@ -267,3 +274,108 @@ export const OneTimePhoneConnected: Story = { await expect(canvas.getByRole('button', { name: 'End the one-time connection' })).toBeVisible(); }, }; + +/** + * Turning an alarm on may add one live line to its preview, once a day + * (`docs/specs/alert.md` -> "Settings dialog"). Each story turns one on in a + * build primed by `parameters`. + */ +function alarmUpsellStory(sink: 'speech' | 'push', upsell: AlarmUpsell, parameters: Record): Story { + return { + args: { items: [] }, + parameters: { ...parameters, docs: { story: { inline: false, height: '420px' } } }, + decorators: [ + (Story) => ( + // Room above, as at a window's foot, for the preview that opens upward. +
+ +
+ ), + ], + play: async ({ canvasElement }) => { + await waitForPrimedState(); + // No line before the Burrow service answers the policy. + await waitFor(() => expect(getNetworkPolicySnapshot().kind).toBe('ready')); + const body = within(canvasElement.ownerDocument.body); + await userEvent.click(body.getByRole('button', { name: sink === 'speech' ? 'Spoken alarms' : 'Push notifications' })); + await waitFor(() => expect(canvasElement.ownerDocument.querySelector(`[data-alarm-upsell="${upsell}"]`)).toBeVisible()); + }, + }; +} + +const HOSTED_ENROLLED: BurrowConsoleStatus = { ...UNENROLLED_STATUS, enrolled: true, serving: true, burrowId: 'burrow-6f1c2a90', connection: 'connected' }; +const UNDER_NOTHING = (status: BurrowConsoleStatus) => ({ status, network: networkPolicyResult(nothingPolicy(), status.relayMode, []) }); + +/** A Hosted build whose managed voice is not on turns spoken alarms on. */ +export const AlarmUpsellTurnOnVoice = alarmUpsellStory('speech', 'voice-turn-on', { + primedManagedVoice: { configured: false }, + primedBurrow: { status: UNENROLLED_STATUS }, +}); + +/** The same under Nothing, which managed voice cannot reach Hosted through: the line points at the policy. */ +export const AlarmUpsellVoiceUnderNothing = alarmUpsellStory('speech', 'voice-network', { + primedManagedVoice: { configured: false }, + primedBurrow: UNDER_NOTHING(UNENROLLED_STATUS), +}); + +/** Managed voice is on, and speak said the account has no plan. */ +export const AlarmUpsellVoicePlans = alarmUpsellStory('speech', 'voice-plans', { + primedManagedVoice: { configured: true, notEntitled: true }, + primedBurrow: { status: HOSTED_ENROLLED }, +}); + +/** A Hosted build not enrolled turns push on. */ +export const AlarmUpsellHostedRelay = alarmUpsellStory('push', 'push-hosted-relay', { + primedBurrow: { status: UNENROLLED_STATUS }, +}); + +/** The same under Nothing: the line points at the policy, since no Relay runs under it. */ +export const AlarmUpsellPushUnderNothing = alarmUpsellStory('push', 'push-network', { + primedBurrow: UNDER_NOTHING(UNENROLLED_STATUS), +}); + +/** The Hosted Relay refuses the account for having no plan; the inert line above says so, and this one links the plans. */ +export const AlarmUpsellPushPlans = alarmUpsellStory('push', 'push-plans', { + primedBurrow: { status: { ...HOSTED_ENROLLED, connection: 'not-entitled' } }, +}); + +/** A build without Hosted mode, not enrolled with its Relay: no word of Hosted. */ +export const AlarmUpsellSetUpPhone = alarmUpsellStory('push', 'push-set-up-phone', { + primedBurrow: { status: SELF_HOST_UNENROLLED_STATUS }, +}); + +/** An enrolled self-host build earns no line: the preview is the plain one. */ +export const AlarmUpsellNoneEnrolled: Story = { + ...alarmUpsellStory('push', 'push-set-up-phone', { primedBurrow: { status: enrolledStatus() } }), + play: async ({ canvasElement }) => { + await waitForPrimedState(); + await waitFor(() => expect(getNetworkPolicySnapshot().kind).toBe('ready')); + const body = within(canvasElement.ownerDocument.body); + await userEvent.click(body.getByRole('button', { name: 'Push notifications' })); + await body.findByRole('status'); + await new Promise((resolve) => setTimeout(resolve, 100)); + await expect(canvasElement.ownerDocument.querySelector('[data-alarm-upsell]')).toBeNull(); + }, +}; + +/** Press a story's line; Settings opens scrolled to `topic`. */ +function opensTopic(story: Story, topic: 'Notifications' | 'Network'): Story { + return { + ...story, + play: async (context) => { + await story.play!(context); + const document = context.canvasElement.ownerDocument; + await userEvent.click(within(document.querySelector('[data-alarm-upsell]')!).getByRole('button')); + const dialog = within(document.body).getByRole('dialog', { name: 'Settings' }); + const section = within(dialog).getByRole('region', { name: topic }); + const content = section.parentElement!; + await waitFor(() => expect(Math.abs(section.getBoundingClientRect().top - content.getBoundingClientRect().top - TOPIC_GAP_PX)).toBeLessThan(1), { timeout: 3000 }); + }, + }; +} + +/** Turning managed voice on happens in Notifications. */ +export const AlarmUpsellOpensNotifications = opensTopic(AlarmUpsellTurnOnVoice, 'Notifications'); + +/** Setting up a phone happens in Network. */ +export const AlarmUpsellOpensNetwork = opensTopic(AlarmUpsellSetUpPhone, 'Network'); From 4a5026bf4a79d2414391edeb367375af8c7eae5f Mon Sep 17 00:00:00 2001 From: Ned Twigg Date: Wed, 28 Oct 2026 02:25:09 -0700 Subject: [PATCH 4/6] Alarm upsell: hold and dismiss the preview's line Hovering or focusing the line holds the preview up, and letting it go gives it the plain 2 s. Escape, heard in the capture phase because a terminal swallows its own keys and never stopped, or a press off the line closes it. useCloseOnOutsideAndEscape takes its anchor and the new captureKeys in an options object. Co-Authored-By: Claude Opus 5.5 --- lib/src/components/Baseboard.test.tsx | 46 +++++++++++++++++++++++++ lib/src/components/SettingsPreview.tsx | 31 ++++++++++++++--- lib/src/components/WorkspaceTabMenu.tsx | 2 +- lib/src/components/use-anchored-menu.ts | 20 +++++++---- 4 files changed, 87 insertions(+), 12 deletions(-) diff --git a/lib/src/components/Baseboard.test.tsx b/lib/src/components/Baseboard.test.tsx index b9a004b24..76007c51f 100644 --- a/lib/src/components/Baseboard.test.tsx +++ b/lib/src/components/Baseboard.test.tsx @@ -521,6 +521,52 @@ describe('Baseboard alarm upsell', () => { expect(preview()?.classList.contains('opacity-100')).toBe(true); }); + it('holds while the line is hovered or focused, then shows the plain while once let go', async () => { + vi.useFakeTimers(); + await renderBuild(); + await toggle('speech'); + const hold = line()!.parentElement!; + act(() => hold.dispatchEvent(new PointerEvent('pointerover', { bubbles: true }))); + act(() => vi.advanceTimersByTime(10_000)); + expect(preview()?.classList.contains('opacity-100')).toBe(true); + act(() => hold.dispatchEvent(new PointerEvent('pointerout', { bubbles: true }))); + act(() => vi.advanceTimersByTime(2000)); + expect(preview()?.classList.contains('opacity-0')).toBe(true); + act(() => vi.advanceTimersByTime(250)); + expect(preview()).toBeNull(); + + localStorage.removeItem(ALARM_UPSELL_SHOWN_AT_KEY); + await toggle('speech'); + await toggle('speech'); + act(() => lineButton()!.focus()); + act(() => vi.advanceTimersByTime(10_000)); + expect(preview()?.classList.contains('opacity-100')).toBe(true); + act(() => lineButton()!.blur()); + act(() => vi.advanceTimersByTime(2250)); + expect(preview()).toBeNull(); + }); + + it('closes on Escape, heard before the terminal and left for it, or on a press off the line', async () => { + await renderBuild(); + await toggle('speech'); + const terminalInput = document.createElement('textarea'); + container.appendChild(terminalInput); + // A terminal stops its own keys from bubbling. + const terminalHeard = vi.fn((event: Event) => event.stopPropagation()); + terminalInput.addEventListener('keydown', terminalHeard); + act(() => terminalInput.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true, cancelable: true }))); + expect(preview()).toBeNull(); + expect(terminalHeard).toHaveBeenCalledOnce(); + + localStorage.removeItem(ALARM_UPSELL_SHOWN_AT_KEY); + await toggle('speech'); + await toggle('speech'); + act(() => line()!.dispatchEvent(new PointerEvent('pointerdown', { bubbles: true }))); + expect(preview()).not.toBeNull(); + act(() => document.body.dispatchEvent(new PointerEvent('pointerdown', { bubbles: true }))); + expect(preview()).toBeNull(); + }); + it('neither shows nor spends an offer whose preview closed before it was decided', async () => { await renderBuild(); act(() => container.querySelector('[data-alarm-setting="push"]')!.click()); diff --git a/lib/src/components/SettingsPreview.tsx b/lib/src/components/SettingsPreview.tsx index ab94afecc..794e85337 100644 --- a/lib/src/components/SettingsPreview.tsx +++ b/lib/src/components/SettingsPreview.tsx @@ -1,14 +1,15 @@ import { useWorkspaceAlertPolicy } from './wall/use-workspace-alert-policy'; -import { useEffect, useLayoutEffect, useState } from 'react'; +import { useEffect, useLayoutEffect, useRef, useState } from 'react'; import { createPortal } from 'react-dom'; import { POPUP_SURFACE_CLASS } from './design'; import { AlarmSettingsSection } from './SettingsDialog'; import { AlarmUpsellLine, type UpsellTopic } from './AlarmUpsellLine'; import type { AlertSink } from '../lib/alert-delivery-model'; import type { AlarmUpsell } from '../lib/alarm-upsell'; -import { useAnchoredMenu } from './use-anchored-menu'; +import { useAnchoredMenu, useCloseOnOutsideAndEscape } from './use-anchored-menu'; import { OVERLAY_VIEWPORT_MARGIN_PX } from '../lib/ui-geometry'; +/** How long the preview shows before it fades; also the grace once the line is let go. */ const SHOW_MS = 2000; /** Long enough to read the live line and reach it. */ const SHOW_WITH_UPSELL_MS = 6000; @@ -32,6 +33,12 @@ export function SettingsPreview({ }) { const { policy: settings } = useWorkspaceAlertPolicy(); const [fading, setFading] = useState(false); + // Hovered or focused, the line holds the preview up. + const [hovered, setHovered] = useState(false); + const [focused, setFocused] = useState(false); + const held = hovered || focused; + const wasHeld = useRef(false); + const lineRef = useRef(null); const { setTriggerEl, setMenuEl, menuStyle } = useAnchoredMenu(true, 416, { side: 'above', align: 'end', @@ -41,14 +48,21 @@ export function SettingsPreview({ useEffect(() => { // A line that arrives as the preview fades brings it back. setFading(false); - const show = upsell ? SHOW_WITH_UPSELL_MS : SHOW_MS; + if (held) { + wasHeld.current = true; + return; + } + const show = upsell && !wasHeld.current ? SHOW_WITH_UPSELL_MS : SHOW_MS; const fade = window.setTimeout(() => setFading(true), show); const close = window.setTimeout(onClose, show + FADE_MS); return () => { window.clearTimeout(fade); window.clearTimeout(close); }; - }, [upsell, onClose]); + }, [held, upsell, onClose]); + + // A live line can be dismissed; the Escape or press still reaches the terminal. + useCloseOnOutsideAndEscape(upsell !== null, lineRef, onClose, { captureKeys: true }); const label = sink === 'speech' ? 'Spoken alarms' : 'Push notifications'; const enabled = sink === 'speech' ? settings.speakEnabled : settings.pushEnabled; @@ -66,7 +80,14 @@ export function SettingsPreview({ {upsell && ( -
+
setHovered(true)} + onPointerLeave={() => setHovered(false)} + onFocus={() => setFocused(true)} + onBlur={() => setFocused(false)} + >
)} diff --git a/lib/src/components/WorkspaceTabMenu.tsx b/lib/src/components/WorkspaceTabMenu.tsx index 27893678d..d0064597e 100644 --- a/lib/src/components/WorkspaceTabMenu.tsx +++ b/lib/src/components/WorkspaceTabMenu.tsx @@ -64,7 +64,7 @@ export function WorkspaceTabMenu({ const placed = menuStyle.visibility !== 'hidden'; const restoreFocus = () => (menu.keyboard ? tabButton(id) : opener)?.focus({ preventScroll: true }); - useCloseOnOutsideAndEscape(true, menuRef, closeWorkspaceMenu, anchorRef); + useCloseOnOutsideAndEscape(true, menuRef, closeWorkspaceMenu, { anchor: anchorRef }); const items: MenuItem[] = [ { key: 'rename', label: 'Rename', restoresFocus: false, run: () => requestWorkspaceRename(id) }, diff --git a/lib/src/components/use-anchored-menu.ts b/lib/src/components/use-anchored-menu.ts index 8678ddbd6..fced1eb6b 100644 --- a/lib/src/components/use-anchored-menu.ts +++ b/lib/src/components/use-anchored-menu.ts @@ -165,9 +165,17 @@ export function useCloseOnOutsideAndEscape( open: boolean, ref: RefObject, onClose: () => void, - /** What a scroll must move to close the menu, when the menu itself is - * positioned away from its trigger (a portal); defaults to `ref`. */ - anchor?: RefObject, + { + anchor, + captureKeys = false, + }: { + /** What a scroll must move to close the menu, when the menu itself is + * positioned away from its trigger (a portal); defaults to `ref`. */ + anchor?: RefObject; + /** Hear Escape in the capture phase, for an overlay that leaves the + * keyboard with a terminal, which swallows its own keys. It stops nothing. */ + captureKeys?: boolean; + } = {}, ) { useEffect(() => { if (!open) return; @@ -191,12 +199,12 @@ export function useCloseOnOutsideAndEscape( }; window.addEventListener('pointerdown', closeOnPointerDown, true); - window.addEventListener('keydown', closeOnEscape); + window.addEventListener('keydown', closeOnEscape, captureKeys); window.addEventListener('scroll', closeOnScroll, true); return () => { window.removeEventListener('pointerdown', closeOnPointerDown, true); - window.removeEventListener('keydown', closeOnEscape); + window.removeEventListener('keydown', closeOnEscape, captureKeys); window.removeEventListener('scroll', closeOnScroll, true); }; - }, [open, ref, onClose, anchor]); + }, [open, ref, onClose, anchor, captureKeys]); } From 9d99745d38d54062df2ad26e145c69211c3d8d29 Mon Sep 17 00:00:00 2001 From: Ned Twigg Date: Wed, 28 Oct 2026 02:27:56 -0700 Subject: [PATCH 5/6] Speech row names no Hosted without Hosted mode Without a managed-voice port, which only a build with Hosted mode has, the spoken-alarm row read "Get ElevenLabs voices with Natural Voice" and linked /hosted#voice, so self-host builds named Hosted. It now names only the browser or system voice. A Hosted build always has the port, so the link had no build left to show in; Hosted builds reach Natural Voice through its own section and the preview's line. HOSTED_VOICE_URL goes. Co-Authored-By: Claude Opus 5.5 --- lib/src/components/HostedEnrollment.tsx | 2 -- lib/src/components/ManagedVoiceSection.test.tsx | 10 ++++------ lib/src/components/SettingsDialog.tsx | 11 +++-------- scripts/outbound-lint.mjs | 4 ++-- 4 files changed, 9 insertions(+), 18 deletions(-) diff --git a/lib/src/components/HostedEnrollment.tsx b/lib/src/components/HostedEnrollment.tsx index f9abf93f2..1a3f54411 100644 --- a/lib/src/components/HostedEnrollment.tsx +++ b/lib/src/components/HostedEnrollment.tsx @@ -10,8 +10,6 @@ import { BURROW_IS_AN_APP } from '../remote/setup-copy'; import { beginHostedEnrollment, cancelHostedEnrollment } from '../remote/burrow/burrow-status-store'; const HOSTED_PAGE_URL = 'https://dormouse.sh/hosted/'; -/** The Hosted page's voice section, the spoken-alarm row's link where the build has no managed voice. */ -export const HOSTED_VOICE_URL = withHostedRef(HOSTED_PAGE_URL, HOSTED_REFS.settingsVoice, 'voice'); /** * The Hosted page's plans, the one way every no-plan line names them; `from` diff --git a/lib/src/components/ManagedVoiceSection.test.tsx b/lib/src/components/ManagedVoiceSection.test.tsx index c296d18fc..0c55134ed 100644 --- a/lib/src/components/ManagedVoiceSection.test.tsx +++ b/lib/src/components/ManagedVoiceSection.test.tsx @@ -199,7 +199,7 @@ describe('ManagedVoiceSection', () => { describe('the spoken-alarm copy', () => { it.each([ - ['no port', false, 'Get ElevenLabs voices with Natural Voice.'], + ['no port', false, 'Uses your browser or system voice.'], ['a Hosted build’s port', true, 'Uses Natural Voice when turned on below'], ])('with %s', async (_label, port, copy) => { const adapter = new FakePtyAdapter(); @@ -208,11 +208,9 @@ describe('the spoken-alarm copy', () => { expect(text()).toContain(copy); }); - it('links the Hosted page’s voice section from a build without the port', async () => { - const openExternal = vi.fn(); - setPlatform(Object.assign(new FakePtyAdapter(), { openExternal })); + it('names neither Hosted nor Natural Voice in a build without the port, which has no Hosted mode', async () => { + setPlatform(new FakePtyAdapter()); await act(async () => root.render()); - await act(async () => button('Get ElevenLabs voices with Natural Voice.')!.click()); - expect(openExternal).toHaveBeenCalledWith('https://dormouse.sh/hosted/?ref=settings-voice#voice'); + expect(text()).not.toMatch(/Hosted|Natural Voice|ElevenLabs/); }); }); diff --git a/lib/src/components/SettingsDialog.tsx b/lib/src/components/SettingsDialog.tsx index 63477245d..ee2745e84 100644 --- a/lib/src/components/SettingsDialog.tsx +++ b/lib/src/components/SettingsDialog.tsx @@ -15,14 +15,13 @@ import { Shortcut, UNDER_SWITCH_INDENT, } from './design'; -import { ExternalTextLink } from './ExternalTextLink'; import { ThemePicker } from './ThemePicker'; import { ShellPicker } from './ShellPicker'; import { WatchedCommandList } from './WatchedCommandList'; import { NetworkPhones, NetworkSettings, NetworkUpdates } from './NetworkSettings'; import { hostOf, useNetworkPolicy } from './remote-control-shared'; import { PushTestButton, SpeakTestButton } from './AlarmTestButtons'; -import { HOSTED_VOICE_URL, HostedPlansLink } from './HostedEnrollment'; +import { HostedPlansLink } from './HostedEnrollment'; import { HOSTED_REFS } from '../lib/hosted-links'; import { relayRefuses, type BurrowConsoleStatus } from '../host/remote/service-protocol'; import { getBurrowStatusSnapshot, subscribeToBurrowStatus } from '../remote/burrow/burrow-status-store'; @@ -573,13 +572,9 @@ export function AlarmSettingsSection({ sink, preview = false, onShowNetwork }: { onCommitDelay={(speakDelayMs) => updateAlertSettings({ speakDelayMs })} action={preview ? null : } > + {/* Only a build with Hosted mode has the port, so without one it names no Hosted. */} {!managedVoiceOffered ? ( - <> - Uses your browser or system voice.{' '} - - Get ElevenLabs voices with Natural Voice. - - + 'Uses your browser or system voice.' ) : networkOff ? ( <> Natural Voice is off while is set to Nowhere, diff --git a/scripts/outbound-lint.mjs b/scripts/outbound-lint.mjs index 63029b0a8..3290c7b52 100644 --- a/scripts/outbound-lint.mjs +++ b/scripts/outbound-lint.mjs @@ -173,7 +173,7 @@ const OUTBOUND_SITES = { }, 'lib/src/components/HostedEnrollment.tsx': { class: 'user:link-click', - reason: 'The Hosted plans and voice page links, opened only when the user clicks one.', + reason: 'The Hosted plans links, opened only when the user clicks one.', }, 'lib/src/lib/clipboard-failure.ts': { class: 'user:link-click', @@ -306,7 +306,7 @@ const REMOTE_LITERALS = { }, 'lib/src/components/HostedEnrollment.tsx': { hosts: ['dormouse.sh'], - reason: 'The Hosted plans and voice page links.', + reason: 'The Hosted plans links.', }, 'lib/src/lib/clipboard-failure.ts': { hosts: ['github.com'], From 13eac6a637bb776ac5e2f4a8b12aaabd13251adc Mon Sep 17 00:00:00 2001 From: Ned Twigg Date: Wed, 28 Oct 2026 02:27:56 -0700 Subject: [PATCH 6/6] Specs: the alarm upsell and never naming Hosted without Hosted mode alert.md -> "Settings dialog" gains the once-a-day preview line and the rule never to name Hosted in a build without Hosted mode; the managed voice group no longer promises a /hosted link. website-docs.md's link obligation narrows to the settings where an account has no plan, and pricing.md's front-door paragraph follows. alert.md's budget is re-baselined for the two rules. Co-Authored-By: Claude Opus 5.5 --- docs/specs/alert.md | 8 +++++--- docs/specs/pricing.md | 2 +- docs/specs/website-docs.md | 2 +- scripts/spec-word-budgets.json | 2 +- 4 files changed, 8 insertions(+), 6 deletions(-) diff --git a/docs/specs/alert.md b/docs/specs/alert.md index 3cf328b7c..af10370cc 100644 --- a/docs/specs/alert.md +++ b/docs/specs/alert.md @@ -433,11 +433,13 @@ The Settings dialog's Notifications topic is this spec's; Network's content is ` - **Must show only application-wide settings**, excluding Workspace overrides. - A baseboard alarm button toggles only its own setting, as an override for that Workspace; components without a Workspace scope edit application defaults. **Must open a separate Workspace alert dialog from either button's right-click or focused `Shift+F10`/`ContextMenu`**, only with a Workspace scope, preserving per-field inheritance and reset-all. - The watched-command list removes rules and cannot add one — creating a rule stays the terminal context of a Pane running the command. It is the only place a rule set on a since-closed Pane can be removed. -- **The push group's device line must name every device a push would reach**, and otherwise say why there is none; a refusing Relay's reason comes from the Burrow's status (`relayRefuses`), which the device list cannot tell, with the plans where the account has none. -- **The Managed voice group is shown wherever the adapter has `managedVoice`**, which a self-host build's omits; there the speech row names managed voice instead of linking the `/hosted` preview ([website-docs.md](./website-docs.md) -> `/hosted`). **Must state what is sent to Hosted, and that ElevenLabs' copy has no guaranteed deletion time, before the enrollment that enables it.** Managed voice has its own approval and Disconnect, independent of remote control; under Nowhere it explains and links Network instead, changing no policy. Enabled, it offers the curated voices and Disconnect, or, where speak says the plan lapsed, the plans. **No control takes a token, and no copy names one.** +- **The push group's device line must name every device a push would reach**, and otherwise say why there is none; a refusing Relay's reason comes from the Burrow's status (`relayRefuses`), with the plans where the account has none. +- **Never name Hosted in a build without Hosted mode** (`docs/specs/relay.md` -> "Relay origin"). +- **The Managed voice group is shown wherever the adapter has `managedVoice`**, which only a build with Hosted mode has. **Must state what is sent to Hosted, and that ElevenLabs' copy has no guaranteed deletion time, before the enrollment that enables it.** Managed voice has its own approval and Disconnect, independent of remote control; under Nowhere it explains and links Network instead, changing no policy. Enabled, it offers the curated voices and Disconnect, or, where speak says the plan lapsed, the plans. **No control takes a token, and no copy names one.** +- **Turning a baseboard alarm button on may add one live line to its preview, at most once in a rolling day per machine across both buttons**, the day spent only when a line shows; never on turning off, before the network policy answers, or for a sink already served. It points at what the sink lacks (managed voice, a Relay, or a plan, linked with the offer's `ref`), and under Nothing at the policy instead. - Each sink carries a test control, usable while the sink is off: the test sound goes through the shared speech queue and selected voice, reporting admission or an unavailable backend, never Session delivery state; the test push takes the real Burrow→ACL→Relay path and distinguishes no targets, zero, partial, and full delivery, hidden without a Burrow service. -Source of truth: `SettingsDialog` in `lib/src/components/SettingsDialog.tsx`; `ManagedVoiceSection` in `lib/src/components/ManagedVoiceSection.tsx`; `WorkspaceAlarmSettingsDialog` in `lib/src/components/WorkspaceAlarmSettings.tsx`; `Baseboard` in `lib/src/components/Baseboard.tsx`. +Source of truth: `SettingsDialog` in `lib/src/components/SettingsDialog.tsx`; `ManagedVoiceSection` in `lib/src/components/ManagedVoiceSection.tsx`; `WorkspaceAlarmSettingsDialog` in `lib/src/components/WorkspaceAlarmSettings.tsx`; `Baseboard` in `lib/src/components/Baseboard.tsx`; `takeAlarmUpsell` in `lib/src/lib/alarm-upsell.ts`. ## Workspace union diff --git a/docs/specs/pricing.md b/docs/specs/pricing.md index 5d08d026a..caa939049 100644 --- a/docs/specs/pricing.md +++ b/docs/specs/pricing.md @@ -9,7 +9,7 @@ **`/hosted` is canonical; `/pricing` 301-redirects to it.** The tool is free and open source, and Hosted is the optional service with a price, so pricing is a section of the Hosted page, never a page of its own. -**Settings is the front door.** The spoken-alarm row's managed-voice link lands on `/hosted#voice`, and the Pocket playground tutorial on `/hosted#remote-control`. `#pricing` (the plan cards, which each JSON-LD `Offer` url names), `#remote-control`, and `#voice` keep resolving as section ids. +**Settings is the front door.** A Hosted build turns on managed voice and the Hosted Relay there, and an account with no plan is linked to `#pricing` from Settings and the baseboard alarm buttons (`docs/specs/alert.md` -> "Settings dialog"); the Pocket playground tutorial lands on `/hosted#remote-control`. `#pricing` (the plan cards, which each JSON-LD `Offer` url names), `#remote-control`, and `#voice` keep resolving as section ids. **Must give every link to the page a `?ref=` from `HOSTED_REFS`, one per link, ahead of its fragment, and strip it from the address bar on arrival**, so a copied link never carries it. Nothing counts or sends it yet; `takeVisitRef` answers it, an unlisted value as `other`, for whatever will. Source of truth: `lib/src/lib/hosted-links.ts`; `takeVisitRef` in `website/src/lib/hosted-ref.ts`. diff --git a/docs/specs/website-docs.md b/docs/specs/website-docs.md index 1676f2769..3943adb49 100644 --- a/docs/specs/website-docs.md +++ b/docs/specs/website-docs.md @@ -134,7 +134,7 @@ Source of truth: `DOCS_PAGES` in `website/src/lib/docs-pages.ts`; `DocsLayout` i **Must open `/self-host` with the Relay boundary:** Dormouse needs none; a new install opens no connection on its own (`docs/specs/remote-network.md` -> "Policy"); push and a paired phone need a Relay, and a one-time connection only Hosted's rendezvous of its handshake; it links `/hosted`. `/hosted`'s Hosted Relay section discloses metadata, labels the review pending, and links the model. `website/src/lib/docs-rail.test.tsx` pins both. -**Must also link it from** Pocket marketing/tutorial, self-host docs, and the speech and remote-control settings; `linkedFrom` owns the rest. +**Must also link it from** Pocket marketing/tutorial, self-host docs, and the settings where an account has no plan (`docs/specs/alert.md` -> "Settings dialog"); `linkedFrom` owns the rest. Source of truth: `Hosted` in `website/src/pages/Hosted.tsx`; `HostingRequirementNotice` in `website/src/components/HostingRequirementNotice.tsx`. diff --git a/scripts/spec-word-budgets.json b/scripts/spec-word-budgets.json index d00038f6f..13a0999fb 100644 --- a/scripts/spec-word-budgets.json +++ b/scripts/spec-word-budgets.json @@ -3,7 +3,7 @@ "SECURITY.md": 200, "SELF_HOST.md": 4600, "docs/compatible-agents.md": 1800, - "docs/specs/alert.md": 7150, + "docs/specs/alert.md": 7250, "docs/specs/auto-update.md": 1000, "docs/specs/deploy.md": 2300, "docs/specs/dor-browser.md": 5850,