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 5c97d679b..5e2d5a2c0 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. `takeVisitRef` answers it, an unlisted value as `other`, and the page's pageview carries it (`docs/specs/website-docs.md` -> "Visit analytics"). 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 62bc90b75..ac1af3cbd 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/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..76007c51f 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,193 @@ 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('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()); + // 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 4dd0112c2..faad3e0b3 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'; @@ -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'; @@ -139,19 +140,36 @@ 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 [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. @@ -162,7 +180,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 +441,7 @@ export function Baseboard({ items, onReattach, notice, onDoorDragStart }: Basebo data-open-settings="true" onClick={() => { closeSettingsPreview(); - setSettingsOpen('application'); + setSettingsOpen({ dialog: 'application' }); }} > @@ -437,16 +455,19 @@ export function Baseboard({ items, onReattach, notice, onDoorDragStart }: Basebo key={settingsPreview.sequence} sink={settingsPreview.sink} anchor={settingsPreview.anchor} + upsell={settingsPreview.upsell} + onShowSettings={showSettingsAt} onClose={closeSettingsPreview} /> )} - {settingsOpen === 'application' && ( + {settingsOpen?.dialog === 'application' && ( setSettingsOpen(null)} /> )} - {settingsOpen === 'workspace' && ( + {settingsOpen?.dialog === 'workspace' && ( { setSettingsOpen(null); workspaceSettingsTrigger.current?.focus(); 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/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 643836b44..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'; @@ -69,8 +68,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 +99,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}.`; @@ -121,7 +123,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 +134,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 +187,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 +255,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 +301,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 && ( @@ -543,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, @@ -579,7 +604,7 @@ export function AlarmSettingsSection({ sink, preview = false, onShowNetwork }: { {networkOff ? ( <>Push is off while is set to Nowhere. ) : ( - + )} ); @@ -589,10 +614,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..794e85337 100644 --- a/lib/src/components/SettingsPreview.tsx +++ b/lib/src/components/SettingsPreview.tsx @@ -1,25 +1,44 @@ 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 { useAnchoredMenu } from './use-anchored-menu'; +import type { AlarmUpsell } from '../lib/alarm-upsell'; +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; +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(); 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', @@ -27,13 +46,23 @@ 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); + 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); }; - }, [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; @@ -50,6 +79,18 @@ export function SettingsPreview({ + {upsell && ( +
setHovered(true)} + onPointerLeave={() => setHovered(false)} + onFocus={() => setFocused(true)} + onBlur={() => setFocused(false)} + > + +
+ )} , document.body, ); 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]); } 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) { 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'); 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. diff --git a/scripts/outbound-lint.mjs b/scripts/outbound-lint.mjs index 2acfa756e..f1c532f2e 100644 --- a/scripts/outbound-lint.mjs +++ b/scripts/outbound-lint.mjs @@ -176,7 +176,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', @@ -309,7 +309,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'], diff --git a/scripts/spec-word-budgets.json b/scripts/spec-word-budgets.json index f678c7c5c..5e3152e17 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,