Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 5 additions & 3 deletions docs/specs/alert.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
2 changes: 1 addition & 1 deletion docs/specs/pricing.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`.

Expand Down
2 changes: 1 addition & 1 deletion docs/specs/website-docs.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`.

Expand Down
3 changes: 3 additions & 0 deletions lib/.storybook/preview.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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
Expand Down
63 changes: 63 additions & 0 deletions lib/src/components/AlarmUpsellLine.tsx
Original file line number Diff line number Diff line change
@@ -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<TopicId, 'notifications' | 'network'>;

/** 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<AlarmUpsell, { lead?: string } & ({ action: string; topic: UpsellTopic } | { plans: HostedRef })> = {
'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 (
<div
data-alarm-upsell={upsell}
className="mt-3 border-t border-border pt-2 text-sm leading-relaxed"
onMouseDown={(event) => event.preventDefault()}
onClick={(event) => {
if (event.target instanceof Element && event.target.closest('button')) onDone();
}}
>
{offer.lead ? <span className="text-muted">{offer.lead} </span> : null}
{'plans' in offer ? (
<HostedPlansLink from={offer.plans} />
) : (
<button
type="button"
className={`${TEXT_LINK_CLASS} text-left focus-visible:outline focus-visible:outline-1 focus-visible:outline-focus-ring`}
onClick={() => onShowSettings(offer.topic)}
>
{offer.action}
</button>
)}
</div>
);
}
192 changes: 191 additions & 1 deletion lib/src/components/Baseboard.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -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<typeof platform.getPlatform>);
// Rendered, and the network policy answered.
await act(async () => root.render(<Baseboard items={[]} onReattach={() => {}} />));
}
/** Toggle, then let the line's decision land. */
const toggle = async (sink: 'speech' | 'push') => {
act(() => container.querySelector<HTMLButtonElement>(`[data-alarm-setting="${sink}"]`)!.click());
await act(async () => {});
};
const line = () => document.querySelector<HTMLElement>('[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<typeof platform.getPlatform>);
await act(async () => root.render(<Baseboard items={[]} onReattach={() => {}} />));
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<HTMLButtonElement>('[data-alarm-setting="push"]')!.click());
// Settings opened before the status read landed.
act(() => container.querySelector<HTMLButtonElement>('[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');
Expand Down
Loading
Loading