diff --git a/developer-skills/pr-writing/SKILL.md b/developer-skills/pr-writing/SKILL.md index e666889d..31844fef 100644 --- a/developer-skills/pr-writing/SKILL.md +++ b/developer-skills/pr-writing/SKILL.md @@ -13,6 +13,8 @@ Explain the system change, not merely the changed files or "implements feature X Organize around the final behavior, architecture, validation, and important decisions. Neither the latest incremental edits nor the sequence of branch work describes the full result the reviewer must assess. +Draft and ready PRs serve the same reader: someone assessing the change without the working conversation. Keep routine coordination in task records or conversation: approvals, review assignments, workflow transitions and pending process steps do not belong in the description. Leave out session details and agent names; when identifying an agent matters, say "an agent". + ## Write for the visible diff Apply [cold-reader](../cold-reader/SKILL.md) and [plain-English](../plain-english-full/SKILL.md), including the cold-reader self-audit search before publishing. @@ -21,7 +23,7 @@ Comparisons with the base are useful when both sides appear in the review: "remo Explain rationale positively: "Library X provides Y." Include it when a reviewer would plausibly wonder about the choice. Rationale that depends on discussing an alternative absent from the final diff belongs in design records or the relevant incremental commit, not the PR body. -State what was actually validated and any material limits. Never imply an unperformed check passed. +State what was actually validated and any material limits, explaining what the evidence establishes about the changed behavior. Include detail when it helps the reader assess confidence or investigate a relevant uncertainty, and retain any check record a spec explicitly requires. Never imply an unperformed check passed. ## Publish exact text diff --git a/developer-skills/tvdev-contribute/SKILL.md b/developer-skills/tvdev-contribute/SKILL.md index 75b4da70..d6fe4690 100644 --- a/developer-skills/tvdev-contribute/SKILL.md +++ b/developer-skills/tvdev-contribute/SKILL.md @@ -81,9 +81,9 @@ Several agents may run this skill in different worktrees on one host. Never link Once the work is complete, perform pre-merge docs prep as the docs spec defines it (`specs/spec-docs.md`, “Pre-merge docs prep”), before treating the contribution as ready to merge into a shared branch. Work-in-progress and draft PRs retain their working documents, including when presented for human spec review. Use that section's step 1 to decide which working documents to archive; add the archive preamble to each and move it to `docs/archive/YYYY-MM/`; delete the rest so `docs/working/` is empty. The archived files appear in the PR so the human sees what you judged worth keeping. -Prepare the PR using [pr-writing](../pr-writing/SKILL.md). +Prepare the PR using [pr-writing](../pr-writing/SKILL.md) and [cold-reader](../cold-reader/SKILL.md). A draft has the same purpose as a ready PR: explain the complete net change and useful verification evidence to a reader outside the working session. Keep routine approvals, review assignments, workflow transitions and pending process steps in task notes or conversation; deleting completed notes during docs prep does not require moving them into the PR. Omit session details and agent names (use "an agent" when needed), while retaining check records explicitly required by a spec. -When presenting the PR, remind the human what their review must establish and make the relevant changes easy to find. Help them perform the minimum PR review defined by spec policy (`specs/spec-policy.md`, “Human PR review”): confirm that every spec delta is fully acceptable and scan the code and test footprint for unexpected scope or complexity. Specs are the product's core authority and slop-free zone; the human must understand and stand behind their changes under the spec ownership rules. +In the conversation accompanying the PR, remind the human what their review must establish and make the relevant changes easy to find. Help them perform the minimum PR review defined by spec policy (`specs/spec-policy.md`, “Human PR review”): confirm that every spec delta is fully acceptable and scan the code and test footprint for unexpected scope or complexity. Specs are the product's core authority and slop-free zone; the human must understand and stand behind their changes under the spec ownership rules. Make red flags easy to assess: scope expansion, new dependencies, substantial new production or test-harness machinery, and edits to files or behaviors whose connection to the task is unclear. Explain why consequential changes belong and whether their cost is proportionate. This is an impact scan, not a requirement for the human to review every code or test line. Agents remain responsible for detailed correctness, coverage, and verification. Help the human inspect or test the result further when useful. diff --git a/docs/archive/2026-10/desktop-connect-links/implementation-plan.md b/docs/archive/2026-10/desktop-connect-links/implementation-plan.md new file mode 100644 index 00000000..3ddd0d79 --- /dev/null +++ b/docs/archive/2026-10/desktop-connect-links/implementation-plan.md @@ -0,0 +1,140 @@ +> **Archived 2026-10 from PR #9.** This plan guided the three converged implementation slices; signed-Mac checks and release handoffs remain outstanding at archiving. It preserves the allocation of proofs across slices and the expected intermediate baselines as one account of how the work was divided, which the final implementation and PR description do not reconstruct. The body below is unchanged from its working state and is a clue to the change, not a record of it. + +# Desktop connect links: implementation plan + +The result is a single connect-link workflow: the CLI supplies links, the desktop app accepts one link and remembers the connection, and connection dialogs explain recovery without asking for a separate token. + +This plan builds on the specs at `ca22ebbe` and the proofs independently approved in round two at `b7f3e899`. The spec deltas include the [connection states explainer](../../../specs/arch/explainer-connection-states.md), the local/served dialog theme clarification, and Josh's approved desktop theme-attribution rule. The [proposal](./proposal.md) records intent, [task record](./task.md) records the method and deferred obligations, and [proof derivation](./proof-derivation.md) records the coverage decisions. The plan converged in independent review round two and was merged with the proofs into `940a744a`. Slices 1 and 2 have converged under independent review; their evidence is in the task record. The supervisor coordinates independent review. + +## Approach and slice boundaries + +Keep the CLI, served application's connection owner, and desktop main process as their respective lifecycle owners. Share presentation and stylesheet sources where their dependencies allow it. The desktop identity-check retry loop and the served application's websocket session remain distinct mechanisms. + +Three slices separate the CLI process boundary, served-app state and shared presentation, and the packaged desktop lifecycle. Slice 1 is independent of the UI work. Slice 2 supplies the controls and modal presentation used by slice 3. Keeping local rendering, main-process persistence/cancellation, preload IPC and navigation together in slice 3 lets its real-Electron tests prove the whole lifecycle. Each slice receives whole-slice independent review, followed by integrated review under [Planning and slices](../../../specs/spec-workflow.md#planning-and-slices). + +The converged proofs determine the tests, their boundaries, mocks and forfeits. The allocations below locate their implementation work; they do not re-derive the assertions. Slice 3 also revises the standing licensing proofs for the material the desktop will ship; those proofs were outside the converged set and their changes receive independent review with that slice. Where a shared assertion spans slices, its completion point is explicit. File names locate the current implementation and tests; implementation may change those boundaries when that makes the result simpler. + +The imported UI frames supply the visual design. The setup card uses frame chrome around static content, with no artifact runtime, and its two steps have no tracked current step. The task imports no product, architecture or testing obligations from prerelease PR #387. + +**Observed starting baseline.** The independent plan reviewer ran these files at `22c3b2cd` and reported the following failures. All are assigned to slice 2. This records the reviewer's targeted results, not a full-suite baseline. + +| File | Failing test titles | +| --- | --- | +| `packages/web/test/foundation-copy.test.ts` | “committed copies are byte-identical and aggregate in owner order”; “every implemented element, app, theme, and Markdown-editor stylesheet has only byte-identical production crossings” | +| `packages/canonical/test/build-canonical.test.ts` | “builds live canonical v2 from its authoritative sources into fresh production output” | +| `test/repo/theming-reference.test.ts` | “the authored theme reference covers current application authorities” | + +## Slice 1 — CLI connect links and startup output + +**Deliverable.** `tv links` prints the running server's links, one per line. All CLI link output uses OSC-8 only when its output stream reports `isTTY === true`; captured output contains plain URLs. + +**Implementation.** Start in `packages/cli/src/index.ts`, its `CLIEnvironment`, and the existing shared client and connect-URL helpers. Follow [product CLI](../../../specs/product/cli.md#connect-links) and [the client boundary](../../../specs/arch/cli/index.md#client-boundary): + +- Register `links` in parsing and help. Reuse home/config/token and client-port resolution. Contact localhost, then use the running server's health-reported addresses and bound port for the output. +- Follow health with authenticated and unauthenticated requests through the existing client, using a read-only operation such as display-state retrieval. Determine token inclusion from those responses even if config has changed since startup. Complete validation before printing links; failures use the specified exit status and stderr with no partial link output. +- Add optional `isTTY` to the output type and route `links`, foreground startup and persisted startup through the same formatter. Startup keeps its headings; `links` prints only links. +- Reconcile the administrator guide with the resulting commands and current-link recovery. Its already-edited installation, upgrade, Docker, SSH and Mac/browser paths remain subject to content review under [the guide spec](../../../specs/arch/cli/admin-guide.md#testing). + +**Proof-derived verification.** + +| Evidence | Work in this slice | +| --- | --- | +| [CLI command contract](../../../proofs/arch/cli/index.md#^cli-links-contract), [formatter contract](../../../proofs/arch/cli/index.md#^cli-osc8-visible), [type contract](../../../proofs/arch/cli/index.md#^cli-contract-type-drift) | Extend `packages/cli/test/cli.test.ts` and `contract-types.test.ts` for runtime/config disagreement, home/port and listener breadth, refusal before output, all three callers and true/false/absent `isTTY`. Replace the unconditional OSC-8 expectations and remove their stale `^cli-osc8-token` comment (proof-review observation 4). | +| [Links success](../../../proofs/product/cli.md#^cli-ac-links), [wrong token](../../../proofs/product/cli.md#^cli-ac-links-unauthorized), [unreachable server](../../../proofs/product/cli.md#^cli-ac-links-unreachable) | Add the three built-process walks in `test/node/cli-acceptance.test.ts`, with real HTTP and pipe output. The success walk changes config while the server runs and restarts authless with a leftover token file. | +| [Tokenless foreground output](../../../proofs/product/cli.md#^c7f9f202), [authenticated foreground output](../../../proofs/arch/cli/index.md#^193e8638) | Update the existing `test/node/cli-integration.test.ts` walks for plain piped URLs, preserving their lifecycle and warning evidence. | +| [Persisted startup output](../../../proofs/product/cli.md#^cli-ac-persist-home-install) | Update the existing installed-service walk in `test/node/daemon-acceptance.test.ts` for headings and a plain token-bearing URL on its pipe. Run the designated-host suite before this slice is complete. | + +Formatter contracts cover terminal inputs; spawned acceptance crosses the actual `process.stdout` handoff with a pipe. Native terminal detection and terminal hyperlink activation remain outside this coverage. Ordinary tests retain their injected daemon peer; the persisted-output assertion uses the existing real-service journey. + +The implementing agent waits for the human to designate the [developer host](../../../specs/arch/test-runner/test-runner.md#production-daemon-acceptance-suite) for `TV_DAEMON_TEST_HOST=1 npm test -- local --suite daemon-acceptance` and never chooses a host itself. That suite replaces the active global package and literal user service and is excluded from full verification; the environment flag guards against accidental execution and is not authorization. This run is required for the changed output, even though service lifecycle behavior is unchanged; an unavailable host is an outstanding slice obligation. + +**Expected baseline after this slice.** CLI obligations above, including the designated-host run, are green. The four observed failures above, changed shared styles and served states remain for slice 2; setup, local asset delivery and desktop lifecycle remain for slice 3. Record any further observed failures by test identity and assigned slice. No CLI failure is assigned to a later slice. + +## Slice 2 — Served-app connection states and shared presentation + +**Deliverable.** Browser and desktop-served pages show one appropriate modal while Television cannot use its server, retain an established shell and live artifacts through an outage, and recover using links. Shared controls and presentation are ready for the desktop local page. + +**Implementation.** Start in `packages/web/src/services/server-connection.ts`, `server-connection-owner.ts`, `application-service.ts`, and the application-state, app and system-modal views. The [app's connection rules](../../../specs/ui/app/index.md#connection-states) and [system modal](../../../specs/ui/app/system-modal/index.md) own the behavior; transport internals remain under the [channel-state carve-out](../../../specs/arch/channel-state/index.md#code-governed-carve-out). + +- Count completed unreachable reconnect attempts at their owner, once per attempt. The initial drop is not a retry; success resets the count. Keep the existing backoff. Auth and gate answers take effect immediately; rejected credentials stop retries and are cleared in a browser. +- Preserve a displayed upgrade gate until reload, including through later auth rejection. It can be connected while preventing use of the server, so transport-disconnected state alone cannot select the modal. +- Keep the same shell and artifact documents mounted through Disconnected, the third failed reconnect's Can't connect with server state, and recovery. Extend the current `hasApplicationShell` treatment to include post-session error. First-connect failure, authorization and gate presentations have no shell. +- Replace the modal token form with the frame/content's `browser`, `desktop` and `local` guidance. Deliver one shared modal presentation, importable by the desktop renderer without application services, so the local contract rows exercise the presentation slice 3 will use. Only local unauthorized/error presentations receive the destructive Disconnect callback. Dialogs wear their host page's theme: the server's theme in the served interface, Clouds on the local page. Keep ordinary dialog dismissal separate from connection dialogs' state-driven removal. +- Compose the 36px desktop drag strip when the modal has no shell behind it, on the local page or a served page. A retained shell, including one with a collapsed sidebar, uses its own drag areas. Place the strip inside the native dialog, outside its scrolling content, as Josh approved during implementation: native modality makes siblings inert. Prove native dialog top-layer hit testing on the served gate in this slice; slice 3 supplies the packaged local-page crossing. +- Deliver the large button/input styles and `--line-control-lg` through web foundation and live canonical v2, updating v2's vocabulary fixture and built author guidance. Frozen v1 stays byte-identical. Extend the shared Copy helper's size composition for setup's standard button size. +- Implement the browser external-page placeholder's Mac-only copy. Review `packages/skills/skills/television/src/app-shell-reference.md` against every changed authority it tracks under `specs/ui/app/`, including app composition and the Copy and modal frames/styles. Reconcile its markup, states and selector guidance, then update the reviewed source fingerprints. Update the production style copies and mappings for these shared surfaces; production consumes production assets. + +**Proof-derived verification.** + +| Evidence | Work in this slice | +| --- | --- | +| [App composition](../../../proofs/ui/app/index.md#^ap-ac-markup-smoke), [state priority](../../../proofs/ui/app/index.md#^ap-ac-one-state), [attempt counting](../../../proofs/ui/app/index.md#^ap-ac-retry-count) | Extend the app-root, application-service and connection-owner contracts for shell retention, no-shell strip selection, desktop authorization context, simultaneous facts, counting/reset and stopping retries. These contracts cover initial unreachable selection and its bare background. | +| [Rejected-token recovery](../../../proofs/ui/app/index.md#^ap-ac-signin-first), [outage continuity](../../../proofs/ui/app/index.md#^ap-ac-drop-holds-shell), [reconnect gate](../../../proofs/ui/app/index.md#^ap-ac-gate-uncovered) | Replace header-substitution evidence in `auth.01.test.ts` with real rejection at boot and after a session. Extend `reload-after-reconnect.02.test.ts` through three real failed attempts and recovery, retaining exact nodes and artifact document state. Cross real reconnect status into the lone gate; contracts cover later-auth priority. | +| [Modal contents](../../../proofs/ui/app/system-modal/index.md#^sm-ac-markup-smoke), [non-dismissal](../../../proofs/ui/app/system-modal/index.md#^sm-ac-blocking) | Use the mocked-DOM state/context table for contents and the local callback; use real browser Escape and backdrop input for blocking. Preserve the existing ordinary-dialog and gate assertions. | +| [Modal dragging: served route](../../../proofs/ui/app/system-modal/index.md#^sm-ac-drag-strip) | Use the existing gated-shell launch and X11 driver to cross native pointer input over the served gate's strip into a move request and actual window bounds. Keep the dialog open and its controls usable. The packaged local route completes this assertion in slice 3. | +| [Copy composition](../../../proofs/ui/app/copy-button/index.md#^cb-ac-markup-smoke), [canonical inventory](../../../proofs/arch/canonical.md#^cn-t-v1-additive), [built guidance](../../../proofs/arch/canonical.md#^cn-t-skill-guidance) | Extend the existing Copy, canonical and skill-build tests. Existing clipboard/confirmation coverage supplies those mechanics for setup too. | +| [Theme-author reference](../../../proofs/arch/themes/authoring.md#^theme-authoring-t-app-shell-freshness) | Reconcile `test/repo/theming-reference.test.ts` through the reference review and source-fingerprint updates above; the existing skill-build test checks shipped guidance. | +| [Foundation delivery](../../../proofs/arch/ui/foundation.md#^ui-t-foundation-copy) | Make the shared web crossings pass in `packages/web/test/foundation-copy.test.ts`. The complete assertion, including setup discovery and Clouds/local copies, is discharged in slice 3 with the desktop package. | + +The outage acceptance crosses real unreachable results, retry timing and recovery; initial-error composition uses contracts rather than an additional browser launch. Preserve the proofs' existing test seams for observation. Update tests that use the token form to recover, retaining token ingestion/storage/transport coverage and using sanctioned authenticated setup for unrelated scenarios. Retire the obsolete `sm-ac-auth-submit` test with the form. + +**Expected baseline after this slice.** The four observed starting failures are resolved. The assigned web, canonical, Copy, shared stylesheet, theme-reference and served-gate drag checks are green. Local controls are covered only at their shared presentation callback boundary; slice 3 proves the real preload/IPC lifecycle. Local native dragging, setup discovery and desktop delivery remain open there. Served desktop guidance may name Disconnect from Server before slice 3 changes the menu. Any test failure specifically due to that menu, the local page or its assets is assigned to slice 3; no served-app behavior failure is deferred. Keep the foundation and modal-drag assertions pending until their final crossings pass. + +## Slice 3 — Packaged setup and desktop connection lifecycle + +**Deliverable.** The desktop app shows setup only without a saved connection, accepts one link, shows Connected before navigation, reconnects saved records automatically, and can forget its server from every state. + +**Local rendering and packaging.** Start in `packages/desktop/src/connect.html`, `connect-page.ts`, `connect-preload.ts` and `packages/desktop/build.mjs`. Implement [setup](../../../specs/ui/setup/index.md) using the authored markup and styles, the shared Copy helper and the modal presentation delivered in slice 2. The local connection owner supplies the modal's state and disconnect callback. + +The shared modal currently imports the upgrade gate and its markdown pipeline, although the local page never shows the gate. Settle the import boundary during this slice: either retain those dependencies with the required desktop notices or separate gate composition if that makes the local bundle simpler. + +Package foundation, surface styles, fonts, icons, Clouds and both wallpaper assets for the local `file:` document. The page carries the application-document marker; its light/dark marker follows the window's effective native appearance. Keep today's [desktop appearance lifecycle](../../../specs/arch/desktop/appearance.md): `system` at launch, then retain the last server's native appearance input when returning to the local page during that run, with Clouds for both setup and local dialogs. Persistence across launches is out of scope, tracked separately as TV-912. + +Desktop notices must carry every third-party asset the local page ships: Hind, Phosphor, the Tailwind foundation colours, and Clouds' Tailwind palette and two photos, together with packages included by the real bundle. Update the asset records' delivery surfaces and the existing licensing tests to match. Copied assets do not enter esbuild's metafile, so the current one-entry desktop expectation cannot establish their attribution. Revise the [product](../../../proofs/product/licensing.md) and [architecture licensing proofs](../../../proofs/arch/licensing.md) for the changed desktop contents and review those revisions with this slice. + +The [theme-material rule](../../../specs/product/licensing.md#^licensing-theme-material), approved in `cd9afc46`, explicitly includes desktop notices when the app ships a theme. The architecture [theme-record rule](../../../specs/arch/licensing.md#^licensing-theme-records), aligned in `ca22ebbe`, gives that theme's asset record the `desktop` surface. These rules settle the licensing wording follow-up for the proof revisions above. + +Implement ready, connecting, connected and error from the frame. Copy leaves both steps available; one press selects the whole prompt. Connect and Return submit the link without requiring Copy. Connecting disables submission; failure retains editable input and connects its invalid state to the inline error. The setup background drags the window while the card remains interactive. + +**Main-process lifecycle.** Start in `packages/desktop/src/index.ts`, `connect-screen.ts`, the connect URL/error/check modules and `connection-store.ts`, following [connection flow](../../../specs/arch/desktop/connect-flow.md): + +- Replace bootstrap/manual-prefill entry with saved versus unsaved entry. Main owns checks, saved state, navigation and disconnect; the local page renders their results. Keep normalization and persistence format, with no inherited token for a tokenless submission. Reword setup's missing/rejected-token messages around the current link. +- Save and navigate only after a successful check. Enable Disconnect as soon as the record is saved. The current connect call resolves after `loadRemote`, so separate successful setup presentation from remote navigation enough to render Connected first. Keep completion tied to the active attempt so a subsequent disconnect cancels navigation. Choose the smallest handoff that meets those outcomes; the proof sets no additional Connected dwell or transition-event mechanism. +- If navigation does wait on a transition event, add a renderer-contract check that reduced motion still completes navigation when that event does not occur, and update the setup proof to describe that evidence. This carries proof-review observation 3: normal-motion Electron acceptance alone does not establish the reduced-motion handoff. The authored reduced-motion styling remains part of conformance in either implementation. +- On saved startup, show Connecting without a setup flash. A `401` shows Access token required and stops attempts; other check failures show Can't connect with server and retry with backoff. Supply the countdown deadline/in-flight status. A gate-requiring valid identity proceeds to the served page, which owns the gate decision. +- Install Disconnect from Server with `CmdOrCtrl+,`, enabled exactly while a valid record exists. Menu and local-button calls use the same operation: invalidate attempts, retries and pending navigation, delete the record, and load setup. Release outstanding work on window close. +- Keep the connection preload operations available on the packaged local page and unavailable on served pages. Product walks prove working local connect/disconnect IPC and absence of those controls remotely. +- Re-enter saved startup on genuine top-level remote load failure, preserving the specified aborted/subframe/local-page exclusions. After saving, a remote load failure is saved-connection recovery. + +**Proof-derived verification.** + +| Evidence | Work in this slice | +| --- | --- | +| [Connection architecture](../../../proofs/arch/desktop/connect-flow.md#assertions), [connect-check ordering](../../../proofs/arch/updates/desktop-upgrade-gate.md#^updates-t-preflight-desktop-contract) | Update connection, URL, storage and main-process contracts for saved/unsaved entry, retry/401 decisions, normalized tokenless input, temporary-write/rename ordering, deletion, menu enablement, cancellation before and after success, and failed-load filtering. Keep identity-response breadth with the connect-check owner. | +| [Setup states/submission](../../../proofs/ui/setup/index.md#^setup-t-states), [Copy composition](../../../proofs/ui/setup/index.md#^setup-t-copy) | Implement renderer contracts with the declared bridge/clipboard substitutes. The product success walk supplies native prompt selection; existing Copy tests own clipboard mechanics. | +| [Five desktop product walks](../../../proofs/product/desktop-app.md#connection-assertions) | Reconcile `packages/desktop/test/e2e/connect-screen.*`: connect/remember/restart/menu-disconnect; setup refusal/correction; unavailable saved server/recovery; rejected saved token/local-button disconnect; gated-server menu disconnect. Enter through the built local page. The success walk keeps CSS motion real and observes Connected before navigation. | +| [Actual failed navigation](../../../proofs/arch/desktop/connect-flow.md#^desktop-t-load-recovery-seam), [local assets](../../../proofs/arch/desktop/connect-flow.md#^desktop-t-local-assets), [local appearance](../../../proofs/arch/desktop/appearance.md#^desktop-appearance-t-local) | Cross the real Electron failed-load event, built package's `file:` resource loading/font readiness, and native appearance into the local renderer. Complete foundation setup discovery and Clouds/desktop byte crossings here. | +| [Real asset records](../../../proofs/arch/licensing.md#^licensing-t-real-assets-seam), [real desktop build](../../../proofs/arch/licensing.md#^licensing-t-build-seam), [desktop upload notices](../../../proofs/product/licensing.md#^licensing-ac-desktop-upload) | Extend the existing manifest, build and upload-directory evidence for the newly delivered assets and bundled packages. The asset records must name desktop delivery, and the real upload notices must carry those assets' attributions. Replace the single-package desktop expectations. Reconcile these assertions in the licensing proofs before implementing the checks; this work extends existing tests. | +| [Setup dragging](../../../proofs/ui/setup/index.md#^setup-t-drag), [modal dragging: local route](../../../proofs/ui/app/system-modal/index.md#^sm-ac-drag-strip) | Use the existing X11 driver to observe native move requests and actual window bounds for setup and a local saved-error modal. Keep controls usable while the modal remains open. Together with slice 2's served-gate crossing, this completes both packaged/served routes of the modal assertion. | + +The appearance seam covers initial `system`, retention of one non-system input delivered through the real display API/bridge, and a contrasting `nativeTheme.themeSource` assignment while local. That assignment substitutes the appearance input source; it does not prove a device-preference change. Installed menu callback invocation proves wiring, not native Mac menu or accelerator activation. The X11 drag driver substitutes the host window manager. Preserve these explicit coverage limits rather than claiming the Mac checks fill them. + +Reconcile helpers that still fill separate address/token fields, invoke manual-prefill entry or expect a tokenless paste to retain a token, including helpers used by unrelated Electron acceptance. Update proof citations as tests become evidence, preserving inherited grades for minimally changed inherited tests. + +**Deferred documentation work.** Execute the replacement setup/disconnect steps of [the real-Electron upgrade staging recipe](../../../specs/arch/updates/runbook-ux-staging.md#desktop-upgrade-gate--real-electron-app), then update step 4 and its surrounding instructions and record the execution. The runbook edit is a later spec delta and receives its required review. Recheck the theme-author app-shell reference against finished markup. + +**Expected baseline after this slice.** All three slices' automated and designated-host obligations are fulfilled and their targeted suites are green. The partial foundation and modal-drag assertions, setup assets and their licensing evidence, and menu mismatch are resolved. No temporary failing-test baseline remains. The integrated gate and applicable signed-Mac checks below remain distinct completion requirements. + +## Integrated verification and delivery + +Use red/green TDD within each slice: add the proof-derived tests, establish their intended failure, then implement to green. Start with the file/title selectors of the [canonical runner](../../../specs/arch/test-runner/test-runner.md#commands), widen to the owning suites as the behavior matures, and return to a narrow reproducer for failures. Record observed temporary failures with their test identity and the assigned healing slice. Before launching Television, create `~/.tv-developer`; use the existing isolated-home, user-data, dynamic-port and cleanup harnesses. + +Integrated review checks agreement among specs, proofs, implemented tests, production code, shipped assets, the administrator guide and bundled guidance. Replace this contribution's pending proof markers with actual evidence; carry discoveries about proving back to proofs and ambiguities about promises back to owning specs. UI conformance includes implementation/visual review of the frames and production surfaces under [TV-649](../../../specs/arch/ui/conformance.md#release-exception-for-tv-649). Its automation deferral leaves stylesheet, browser and delivery assertions active. + +Before merge, follow the [shared-branch workflow](../../../specs/spec-workflow.md#^shared-branch-workflow): incorporate the actual target's current commits with policy reconciliation, commit and push the implementation tree, and obtain full validation through `npm run verify`, ready-PR GitHub CI, or both. Follow the [provider rules](../../../specs/arch/testing-policy.md#verification-provider-and-completion), including committed/pushed remote revisions and explicit permission for a marked host's local bypass. Report the tested revision, result, recovered flakes and specialized evidence. The implementing worker owns `thopter/desktop-connect-links` and pushes it before remote tests. Narrow file-level iteration may run locally; whole surfaces, repeated runs and the full gate run on Blaxel, as Josh directed for this shared host. + +Before the behavior reaches `main`, run the applicable [desktop product checks](../../../specs/product/desktop-app.md#^desktop-checks-before-main) on an unreleased ToDesktop candidate or signed test build of the finished code. The planned packaging, IPC and gate changes call for Mac install, Bridge and IPC, and Updates checks. Assess Electron license-file delivery against the finished build changes. The implementation PR records build identity, source commit, operator, steps and output. Arrange these checks during implementation; Linux Electron acceptance cannot discharge them. + +Human approval covers the spec deltas at `06becc24`, including the connection states explainer, and the product licensing expansion at `cd9afc46`. Josh also approved the later drag-strip wording, local-dialog Clouds clause and aligned licensing architecture. Any further spec changes, including the executed staging recipe, receive the applicable independent and human review before their first shared-branch merge. Keep working documents during development and apply [pre-merge docs prep](../../../specs/spec-docs.md#^pre-pr-docs-prep) once implementation is complete. + +Publishing `television.run/install.md` remains a post-merge obligation: publish the merged administrator guide no later than the desktop release that ships setup. Record that release handoff so source edits do not substitute for publication. This plan does not select a release or version bump. diff --git a/docs/archive/2026-10/desktop-connect-links/proposal.md b/docs/archive/2026-10/desktop-connect-links/proposal.md new file mode 100644 index 00000000..3aab2130 --- /dev/null +++ b/docs/archive/2026-10/desktop-connect-links/proposal.md @@ -0,0 +1,219 @@ +> **Archived 2026-10 from PR #9.** This proposal established the connect-link workflow delivered by the contribution. It preserves the combined product intent and the reasons for departing from prerelease PR #387, which the final diff and individual specs do not explain together. The body below is unchanged from its working state and is a clue to the change, not a record of it. + +# Proposal: connect links, the desktop setup screen, and `tv links` + +Working document. Not authority. It states the intended behavior and presentation of the change; the specs it leads to are the authority once written. Implementation is out of scope here. + +Reference material: Rupert's pull request #387 in `telepath-computer/television-prerelease-archive` (branch `feat/new-onboarding`, head `1fedba8`). Its design material — the markup, styling and copy files for the setup screen, the system modal, and the controls they use — supplies the detailed design when the specs are written; this proposal names where it departs from that design. Everything else in that pull request's specs is not imported: the specs are written fresh from this proposal. + +## Summary + +People connect to Television with one thing: a *connect link*. A connect link is the address of their Television server with the access token in it, such as `http://100.101.102.103:32848/?token=…`. On a server that runs without a token, the connect link is the plain address. The same link works in a browser and in the Mac desktop app. Nobody types or pastes a bare token anywhere in the product. + +The change has six parts: + +1. The Mac desktop app opens on a setup screen with one field for a connect link, in place of the two-field form for an address and a token. +2. The desktop app reconnects a saved connection by itself, and offers **Disconnect from Server** to forget it. +3. The connection dialogs follow one consistent set of rules ([Connection states](#connection-states)): a definite answer from the server shows its own state at once, an outage escalates from Disconnected to Can't connect with server, and no dialog asks for a token. +4. A browser's placeholder for an external web page says the desktop app is currently Mac-only. +5. A new command, `tv links`, prints the running server's connect links. The CLI prints links as terminal hyperlinks only in an interactive terminal. +6. The administrator guide gives users connect links only. A Mac user who starts from the app's connect screen already has the app; another Mac user is recommended it; users on other computers are not told about it. + +## Differences from Rupert's PR #387 + +For orientation: how this proposal differs from what PR #387 specified or planned. Everything not listed here (the setup screen's visual design, the system modal's look, the large controls, Clouds on the local page, Disconnect from Server and its ⌘, shortcut, the three-failure escalation, dialogs that cannot be dismissed, and accepting that 1.4.x apps see a menu name they lack) is carried over as PR #387 had it. + +**How the specs are produced** + +- The specs are written fresh from this proposal. Only PR #387's visual design (markup, styling and the workshop frames) is imported; none of its product, architecture or UI prose, or its testing directives. +- Testing sections keep only guidance a proof could not derive from the spec itself. + +**Setup screen** + +- No step tracking. Both steps are always at full strength, with filled numbered markers; nothing is faded, highlighted as current, or checked off. PR #387 faded step 2 until the person pressed Copy or moved into it, then checked off step 1. The screen's states are ready, connecting, connected and error. +- The prompt is "Read the Television admin guide at https://television.run/install.md and help me get Television installed. I'm on the desktop app connect screen." The last sentence is how the agent knows the person is on a Mac with the app open. +- The card is styled like an artifact frame but is not one: no artifact view, no iframe or webview, no artifact bridge. +- The two token failure messages are reworded to talk about the link, with the wording left to the implementer. + +**Connection states** + +- One consolidated rule set ([Connection states](#connection-states)). A definite answer from the server (a token is required or rejected, or an upgrade is required) shows its own state at once, anywhere in the life cycle. +- A desktop app starting with a saved connection whose token is rejected shows **Access token required**, not Can't connect with server as in PR #387. It stops retrying, and on the local page it carries a Disconnect from Server button with its own wording. +- Only attempts that cannot reach the server count toward the three failed reconnects. +- Disconnect from Server is promised to keep working on the upgrade gate screen. PR #387 deleted the earlier promise that the menu stays usable there. +- A 36px window drag strip appears wherever a connection dialog or the upgrade gate shows without the sidebar's drag handle. PR #387 left this open. +- How the local page matches the web app's design is left to the implementer. PR #387 planned to import the web app's dialog code into the local page. + +**Admin guide** + +- The guide gives users **connect links** only, named that way throughout. No bare token, no token field, no password-manager advice; users ask their agent for the link again. PR #387 planned to keep the bare token as a fallback. +- Mac-first flows: a request from the desktop app's connect screen gets a connect link to paste, with no download instructions; another Mac user is recommended the app; users on other computers are never told about it, and are told it is Mac-only only if they ask. +- The guide teaches `tv links` and reminds users they can always get their links again. + +**CLI and browser** + +- New command `tv links`, printing the running server's connect links, one per line. PR #387 had no CLI change. +- Links the CLI prints are terminal hyperlinks only when output goes to an interactive terminal, so agents always receive plain URLs. This also changes `tv serve` startup output. +- The browser's placeholder for external web pages says the desktop app is currently available only for Macs. + +## Connect links + +- A connect link is the server's address followed by `/?token=` when the server requires a token, and the bare address when it does not. +- Opening a connect link in a browser signs that browser in and leaves the plain address in the address bar, as Television does today. +- Pasting a connect link into the desktop app's setup screen connects the app and saves the connection. +- A server listening on several addresses (for example localhost and a Tailscale address) has one connect link per address. The user needs the one that works from the computer they view Television on. + +## Desktop app + +### The setup screen + +The app shows the setup screen when it has no saved connection: at first launch, and after Disconnect from Server. + +What the user sees: + +- The Clouds theme's wallpaper, blurred, filling the window, with a centred card styled like a Television artifact frame. The title bar shows the Television icon and **Connect to Television**, with no menu or navigation. +- The screen always wears the Clouds theme, whatever theme the server uses, as does everything else on the desktop app's local page ([Connection states](#connection-states)). It is light or dark as the desktop window currently is: the Mac's setting at first launch, and afterwards the appearance the last connected server set, which the app keeps after disconnecting. +- A heading, **Let's connect your [Television logo] Television**, with the logo inline at text height, and under it: "Television runs alongside your AI agent. Ask your agent to help you get connected, then paste the link it gives you." +- Two numbered steps, joined by a line down the left: + 1. **Give your agent this prompt**, with the prompt in monospace on a slightly tilted card and a **Copy** button beside it. + 2. **Paste the link from your agent**, with a link field ("Paste link here") and a **Connect** button. Under the field, a hint shows the shape of the link: "The link looks like `http://…:32848/?token=…`". The screen says "link" rather than "connect link"; on a screen that is only about connecting, the shorter word is clear. + +Both steps are always shown equally, at full strength, with plain numbered markers. Neither step is highlighted as current, neither is faded, and no step turns into a check mark. The numbering tells the user the usual order; nothing enforces it or tracks it. The user can paste into the field and connect without pressing Copy, so someone who already has a connect link goes straight to step 2. + +This is the main departure from Rupert's design. His screen tracked the current step: it faded step 2 until the user pressed Copy or moved into step 2, then highlighted step 2 and checked off step 1. The faded field still worked, but it looked unusable, implying that copying the prompt was the only way forward. Showing both steps at full strength makes clear that the field is ready, and it removes the step tracking and the tests it would need. + +The prompt tells the agent that the user is on the desktop app's connect screen, so the agent knows the user is on a Mac with the app installed and waiting for a connect link. The prompt is: + +> Read the Television admin guide at https://television.run/install.md and help me get Television installed. I'm on the desktop app connect screen. + +Behavior: + +- **Copy** copies the prompt and briefly confirms, as Television's copy button does everywhere. Pressing it changes nothing else on the screen. A single click on the prompt text selects all of it. +- **Connect**, or Return in the field, starts connecting when the field is not empty. The field accepts a connect link, a bare address, or a host and port; an address with no token connects to a server that runs without one. +- While connecting, the field and button are disabled and the button shows a spinner and **Connecting…**. +- On success, the steps fade out and a green circled check with **Connected** appears in their place, at the card's size, and the app then loads the server's interface. The connection is saved only after it succeeds. +- On failure, the screen stays as it was, the field keeps the link and can be edited, and the field is marked invalid with the failure message in place of the hint. Failure messages follow today's connect check (for example "Couldn't reach … is the server running?"), with the two token messages ("This server requires a token", "Token rejected") reworded to talk about the link, for example that the person needs the current link from their agent. The exact wording is left to whoever implements it. +- Dragging the blurred background moves the window. The card does not move the window. + +Presentation details carried from Rupert's design: 16px reading text, a 640px card, 40px-tall field and Connect button (a new large control size, below), and a Copy button at the standard button size. + +### Starting with a saved connection + +With a saved connection, the app never shows the setup screen. It shows the **Connecting** dialog on its local page and connects automatically. What follows is set out in [Connection states](#connection-states). + +### Disconnect from Server + +- The Television application menu's connection item is **Disconnect from Server**, with the keyboard shortcut ⌘,. +- It is always listed, greyed out when no connection is saved, and available as soon as one is. +- It works in every state, including while the app shows the upgrade gate, so a person can always leave a server they cannot use. +- Choosing it, from the menu or from a Disconnect from Server button ([Connection states](#connection-states)), forgets the saved connection without asking for confirmation and shows the setup screen. +- Desktop apps installed before this change keep their "Connect to server…" menu item. On those apps, the desktop wording below names a command the app lacks. This is accepted so current apps get the exact instruction. + +## Connection states + +This section is the single statement of what a person sees while Television can't use its server, in a browser and in the desktop app: while it connects, after the connection drops, when the server can't be reached, when it wants an access token, and when it requires a newer desktop app. + +### Where they appear + +Connection dialogs appear in two places: + +- **The server's interface**: the web app the server delivers, in a browser or loaded into the desktop app's window. +- **The desktop app's local page**: the page packaged inside the desktop app, which shows the setup screen and, while a saved connection is starting, the connection dialogs. It always wears the Clouds theme, whatever theme the server uses, light or dark as the desktop window currently is ([The setup screen](#the-setup-screen)). + +### The states + +| State | When | What it shows | +| ----------------------------- | ------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| **Connecting** | Before the first connection has been made. | A spinning icon and **Connecting**. | +| **Disconnected** | A working connection has dropped. | A spinning icon, **Disconnected**, and the countdown line: "Reconnecting in Ns…", or "Reconnecting now…" while an attempt is under way. | +| **Can't connect with server** | The server cannot be reached ([rules](#the-rules) 2 and 3). | **Can't connect with server**, with no icon; the server's address in monospace; "Check your internet connection and that the server is running."; and the countdown line. | +| **Access token required** | The server has asked for a token or rejected the one it got. | A lock icon, **Access token required**, and one line of text, with no field (wording below). | +| **Upgrade gate** | The server requires a newer desktop app than this one. Desktop app only. | The existing upgrade gate screen, unchanged. | + +The dialogs keep their present layout: centred, 400px wide, an icon above the title where the state has one. + +Access token required wording: + +- In a browser: "This server requires a valid access token to connect. Ask your agent for the current link, and paste the whole link into the address bar." +- In the server's interface in the desktop app: "This server requires a valid access token to connect. Choose Television › Disconnect from Server, then try again." +- On the desktop app's local page, which has a Disconnect from Server button (rule 7): wording that points to the button and to pasting the current link. Draft: "This server requires a valid access token to connect. Disconnect from Server, then paste the current link from your agent." + +### The rules + +1. **A definite answer from the server shows its own state at once.** When the server asks for a token or rejects the one it got, the app shows Access token required. When the server requires a newer desktop app, the app shows the upgrade gate. This holds wherever the app is in its life cycle: the first connection, a desktop app starting with a saved connection, or a reconnect after a drop, including while Disconnected or Can't connect with server is showing. Disconnected and Can't connect with server never stand in for a definite answer. Once the upgrade gate shows, it stays until the app reloads; nothing replaces or covers it, not even a later rejected token, as today. +2. **When the server cannot be reached on the first connection,** the app shows Can't connect with server. A desktop app starting with a saved connection shows it on its local page. +3. **When a working connection drops,** the app shows Disconnected. After three reconnect attempts in a row fail to reach the server, Disconnected turns into Can't connect with server. Only attempts that cannot reach the server count; a definite answer follows rule 1. +4. **Disconnected and Can't connect with server keep retrying,** with backoff, and close by themselves when an attempt succeeds. **Access token required does not retry**: a rejected token does not fix itself, so it waits for the person to act. The upgrade gate behaves as it does today. +5. **What is behind the dialog.** After a drop, the interface and any open artifacts stay rendered behind Disconnected and Can't connect with server, and resume without reloading when the connection comes back. Before the first connection, nothing is rendered behind the dialog. +6. **No connection dialog closes on Escape or a click on the backdrop.** Each closes only when the connection state changes. +7. **Disconnecting in the desktop app.** The Disconnect from Server menu item works in every state, including the upgrade gate. On the desktop app's local page, Access token required and Can't connect with server also carry a red **Disconnect from Server** button, because that page can disconnect directly. The server's interface has no way to disconnect the app, so its dialogs and the upgrade gate carry no such button, and its desktop wording points to the menu. +8. **If the server's interface fails to load** after the desktop app has connected, the app returns to its local page and connects again exactly as it does at startup. +9. **Moving the window.** The desktop window has no title bar; the rendered interface's own areas, such as the channel sidebar's title bar and the navbar, are its drag handle. Wherever a connection dialog or the upgrade gate shows with no interface rendered behind it — on the local page, and in the server's interface while none is rendered — a 36px strip across the top of the window drags it. Over a rendered interface, including one with its sidebar collapsed, the interface's own drag areas remain the handle. The setup screen instead drags by its whole background ([The setup screen](#the-setup-screen)). + +### The setup screen is the exception + +The setup screen shows no connection dialogs. A failed attempt from the setup screen, including a missing or rejected token, appears as the screen's own error state under the link field. A server that requires a newer desktop app is not a failure there: the connection succeeds and is saved, the server's interface loads, and it shows the upgrade gate (rule 1). + +## External web pages in a browser + +A browser cannot show an external web page (a URL artifact), so it shows a placeholder in the artifact's place. The placeholder says that the Television desktop app shows external web pages right inside Television, and that the app is currently available only for Macs. It keeps its pointer to asking an agent to follow the admin guide to install the app. Draft wording: + +> Television can't display external web pages directly inside a web browser. The Television desktop app can show them right inside Television; it is currently available only for Macs. +> +> To install the desktop app on a Mac, ask your agent to follow the Television admin guide: https://television.run/install.md + +## Shared controls + +Buttons and text fields gain a large size, 40px tall with 16px text, used by the setup screen. It becomes available to artifact authors through the shared Television stylesheet, and the copy button gains the ability to use the standard button size. + +## `tv links` + +`tv links` prints the connect links of the running Television server, one per line, for every address it is listening on. + +- Each line is one connect link, with no other text. Links follow the CLI's hyperlink rule below. +- The addresses are the ones the running server is actually listening on, the same addresses `tv serve` reports at startup. +- When the running server requires a token, each link carries it; when it runs without one, each link is the bare address. This follows the running server, not the config file, which the server only reads when it starts. If the server rejects the home's token, the command fails instead of printing links that would not work. +- When the server is not running, the command says so on stderr, prints no links, and exits with status 1. +- It follows the same home and `--port` rules as the other commands that contact the server. + +## Links in CLI output + +Any link the CLI prints is a terminal hyperlink (OSC 8), with the URL as its visible text, only when the command's output goes to an interactive terminal. When output goes anywhere else, such as a pipe, a file, or an agent's shell tool, the link is printed as plain text. Some tools that capture command output strip escape sequences in a way that removes the link entirely, so an agent running `tv` must always receive the bare URL. + +This applies to every link the CLI prints: `tv links`, and the connect links `tv serve` and `tv serve --persist` print at startup ("Open Television:" followed by one link per address). + +## Administrator guide + +### Connect links only + +- The guide calls the link it gives users the *connect link*, consistently, wherever it gives one. +- The guide only ever gives users connect links. It never shows the user a bare token, never asks them to type a token anywhere, and never suggests saving the token in a password manager. When the user needs their link again, they ask their agent for it. +- Every place that gives the user something to connect with gives connect links: first install, the reply after an upgrade that turns authentication on, troubleshooting a rejected token, moving from the npm desktop app, and the "What to tell the user" examples. For an SSH tunnel, the user still gets the `ssh -L` command alongside the connect link. +- The guide teaches agents `tv links` as the way to get the connect links, and the end of setup tells the user they can get their connect links again at any time, by asking their agent or by running `tv links` on the server machine. +- The quick task map gains an entry for giving a user their connect link for an existing installation, including a desktop app user connecting to a server that already runs: check that the user's computer can reach the server, change the network binding if it cannot, then give the connect link. + +The agent still reads the token file for its own checks (for example a `curl` health check); those are agent work and never shown to the user. + +### Mac and other platforms + +The guide recommends the desktop app only to Mac users who do not already have it. A Mac user who starts from the app's connect screen already has it, and there is no app for any other computer. + +- "Mac" means an Apple Silicon Mac running macOS 12 or later, the computers the desktop app supports. Intel Macs, Linux and Windows are other platforms. +- **Request from the connect screen.** When the request says the user is on the desktop app connect screen, as the setup screen's prompt does, the user is on a Mac with the app installed and waiting for a connect link. The agent works out how that Mac reaches the server, installs or reconfigures Television to match, and gives the connect link with a short instruction to paste it into the app. It does not tell the user how to download the app. +- **Any other request.** The agent finds out which computer the user will view Television on, as part of the existing conversation about how they reach the server. If it is a Mac, the agent gives the connect link and recommends the desktop app, with where to download it and how to install it; the connect link works in the app and in a browser. This covers a Mac user who went to their agent before installing the app. +- **Other platforms.** The agent gives a connect link to open in a browser and does not bring up a desktop app, including in its notes about external web pages (URL artifacts). If the user asks about the desktop app, for example after seeing the browser's placeholder for an external web page, the agent says it is available only for Macs and that the browser is how they use Television. +- Upgrades keep their present guidance about how the desktop app updates itself, for users who have it. + +## How this is specified and built + +These are not user-visible behavior, but the specs need to state them. + +- **The connect screen gets a UI spec.** Today the desktop connect screen's presentation is left to the code. The setup screen becomes a specified UI surface, with Rupert's design as its markup and styling. +- **The setup card looks like an artifact frame but is not one.** It reuses the artifact frame's look (its chrome and title bar), not its machinery: no artifact view, no iframe or webview, no artifact bridge. +- **The desktop app's local page looks like the web app.** The setup screen and the local page's connection dialogs match the web app's design and use the Clouds theme and its wallpaper, packaged into the desktop app. How the local page achieves that is deliberately left to the implementer, who chooses whatever is simpler and easier to maintain: for example, using the web app's stylesheets with its own markup, importing the web app's dialog code, or splitting that code so the local page imports only what it needs. The specs leave this open as well. +- **Testing sections.** A spec's `## Testing` section holds only the testing guidance a proof could not derive from the spec's own promises. Where a touched spec's Testing section only restates derivable promises, it is deleted. Where it holds guidance a proof could not derive, that guidance is kept and brought in line with this change. + +## Out of scope + +- How any of this is built, tested or released. +- Publishing the updated guide to `television.run/install.md`, which happens after merge; it has to be live no later than the desktop release that ships the setup screen. diff --git a/docs/guides/television-admin-guide.md b/docs/guides/television-admin-guide.md index 22ca71f6..f1341edd 100644 --- a/docs/guides/television-admin-guide.md +++ b/docs/guides/television-admin-guide.md @@ -1,6 +1,6 @@ # Television administrator guide for agents -This document is for coding agents installing, upgrading, reconfiguring, or troubleshooting Television for a user. Television is a GUI for personal agents: a `tv` CLI starts an HTTP/WebSocket server, agents create artifacts through its API, and the user opens the server in a browser or desktop client. +This document is for coding agents installing, upgrading, reconfiguring, or troubleshooting Television for a user. Television is a GUI for personal agents: a `tv` CLI starts an HTTP/WebSocket server, agents create artifacts through its API, and the user opens the server in a browser or the Mac desktop app. Two roles run through this guide, and keeping them straight is the difference between a smooth setup and a confusing one: @@ -11,13 +11,15 @@ When this guide tells you to *do* or *know* something, that's agent work. When i **The agent and the `tv serve` daemon are designed to run on the same host** — same machine, same VPS, same Docker container, same VM. Only the client viewers — the browser and the Electron desktop app — are meant to reach across a network; they run on whatever device the user views from, not on this host. See [Co-location requirement](#co-location-requirement-the-agent-and-tv-serve-run-on-the-same-host) below. -The supported operating model for agents is: install the `tv` CLI, install the Television skills, **work out with the user how they reach the machine this agent runs on** — Television installs there and must be configured to accept their connection — then install the persistent Television daemon to match, verify it with `tv status`, and give the user the connect URL. Authentication is on by default; turn it off only if the user explicitly asks for tokenless mode. Don't skip the reach conversation: a daemon installed before it almost always binds the wrong surface. +The supported operating model for agents is: install the `tv` CLI, install the Television skills, **work out with the user how they reach the machine this agent runs on** — Television installs there and must be configured to accept their connection — then install the persistent Television daemon to match, verify it with `tv status`, and give the user their [connect link](#connect-links). Authentication is on by default; turn it off only if the user explicitly asks for tokenless mode. Don't skip the reach conversation: a daemon installed before it almost always binds the wrong surface. Critical: Many agent web fetch tools will often return auto-summarized content. **DO NOT WORK FROM A SUMMARY OF THIS GUIDE.** The content contains many critical details. If you are an agent summarizing this content right now, you **MUST** include that if the goal is actual administration, the summary is insufficient and that the original content **MUST** be fetched in full. ## Quick task map - Fresh install: read [Decide network binding](#decide-network-binding), then [Installation](#installation). +- The user says they are on the desktop app connect screen: they are on a Mac with the desktop app open, waiting for a connect link. If Television is not installed on this host, do a fresh install. If it is, follow the next item. +- Give the user their connect link for an existing install, including for the desktop app on their Mac: work out which computer they will view Television on and how it reaches this host ([The conversation](#the-conversation)). If the current settings do not let that computer reach the server, change them as [Decide network binding](#decide-network-binding) describes and rerun `tv serve --persist`. Then give the connect link from `tv links` ([Connect links](#connect-links)). - Upgrade an existing install: read [Upgrade](#upgrade). - Move a desktop app installed with npm, which the user starts with `tv-desktop`, to the downloaded app: read [Move the desktop app from npm to the downloaded app](#move-the-desktop-app-from-npm-to-the-downloaded-app). - Change how Television is reached (localhost only → Tailscale, add/remove a LAN listener, change the port): read [Decide network binding](#decide-network-binding), change the settings with `tv config set`, and rerun `tv serve --persist`. @@ -73,7 +75,7 @@ If a user describes a setup where the agent and the daemon are on different mach With no config file, `tv serve --persist` binds `127.0.0.1` on port `32848` and requires the bearer token, even on loopback. Keep the default port unless the user has an unavoidable conflict (see [Port](#port-agent-reference)). To expose additional IPv4 listeners, set `listen` as [Decide network binding](#decide-network-binding) describes. -Television is intended to be used over local loopback or a private network such as Tailscale. Treat the bearer token like a password. The agent does not know, and cannot infer from the host alone, how the user wants the server reached — that is an interview decision; see [Decide network binding](#decide-network-binding). +Television is intended to be used over local loopback or a private network such as Tailscale. Treat the bearer token, and every connect link that carries it, like a password. The agent does not know, and cannot infer from the host alone, how the user wants the server reached — that is an interview decision; see [Decide network binding](#decide-network-binding). ## Decide network binding @@ -97,7 +99,7 @@ The server's rules (agent reference): Work through four beats, in plain language, one thing at a time: 1. **Explain the premise — don't just jump to a question.** In a sentence or two, tell the user *why* you're asking: Television runs right here on the machine their agent runs on (it is not a service hosted somewhere else), so the one thing to set up is making it reachable from wherever they actually do their computing. Give them that picture before you ask anything; a bare "how do you reach this machine?" with no framing leaves a semi-technical user guessing at what you mean. See [Co-location requirement](#co-location-requirement-the-agent-and-tv-serve-run-on-the-same-host). -2. **Work out how they reach this machine.** This is the heart of the interview. Ask how they get to this machine today and let the answer set the binding: sitting right at it → localhost, expose nothing; they SSH in → an SSH tunnel over the connection they already use; they use Tailscale → bind the tailnet address; it is a machine on their own network → a LAN bind. Ask, listen, and follow up only as needed. If the agent runs inside a container the mechanics differ — see [Docker](#docker-agent-reference). +2. **Work out which computer they will view Television on, and how it reaches this machine.** This is the heart of the interview. Which computer matters because of the desktop app ([Desktop app (Mac)](#desktop-app-mac)): skip asking it when the request already says they are on the desktop app connect screen, because they are on a Mac with the app open. Ask how they get to this machine today and let the answer set the binding: sitting right at it → localhost, expose nothing; they SSH in → an SSH tunnel over the connection they already use; they use Tailscale → bind the tailnet address; it is a machine on their own network → a LAN bind. Ask, listen, and follow up only as needed. If the agent runs inside a container the mechanics differ — see [Docker](#docker-agent-reference). 3. **Confirm where the skills will be installed.** Television ships skills that must land in *this* agent's skills directory, and there is no single standard location — it varies by agent framework and by how the user set things up. Do not silently guess. Work out the most likely directory for this agent (see [Install Television skills](#install-television-skills-for-the-agent) for the common locations), propose it in one plain sentence, and get a yes before installing. Example: *"I'll put the Television skills in `~/.openclaw/workspace/skills` — that's where this agent loads them from. Sound right?"* 4. **Confirm the plan, then install.** Say back in one plain sentence what you're about to do — how they'll reach it and where the skills go — then proceed to [Install the daemon](#install-the-daemon). No further questions are needed once they've confirmed. @@ -158,7 +160,7 @@ Pattern recommendations: ```bash docker run -p 127.0.0.1:32848:32848 ... ``` - Inside the container, set `listen` to `0.0.0.0` and run `tv serve --persist`. User opens `http://localhost:32848` on the host. + Inside the container, set `listen` to `0.0.0.0` and run `tv serve --persist`. Give the user the connect link with `localhost` as its address ([Connect links](#connect-links)), to open on the host. - **Browser on another device on the LAN.** Publish to all host interfaces (Docker's `-p 32848:32848` default) and run the daemon with auth: ```bash @@ -287,58 +289,65 @@ TOKEN="$(cat /state/token)" curl -fsS -H "Authorization: Bearer $TOKEN" http://localhost:32848/channels ``` -## User connection URL +## Connect links -After installation, tell the user how to open Television. Use the listener that matches how they will actually reach it: +A *connect link* is the one thing the user needs to open Television: the server's address with the access token in it, such as `http://100.x.y.z:32848/?token=`. The same link works in a browser and in the desktop app. Give the user connect links and nothing else to connect with: never show them the bare token, never ask them to type a token anywhere, and never suggest saving the token in a password manager. When they need their link again, they ask you for it. For a tokenless server, the connect link is the plain address. + +Get the connect links with: + +```bash +tv links +``` + +It prints one connect link per line, one for each address the running server is listening on, with the token in each when authentication is on. If the server is not running, it says so and prints no links; fix that first ([Troubleshooting](#troubleshooting)). The addresses are the server's own: `127.0.0.1` for loopback, and `0.0.0.0` for a listener on all interfaces, such as inside Docker. When the user reaches the server by a different address — `localhost` through an SSH tunnel, or the Docker host's, LAN or tailnet address for a `0.0.0.0` listener — give them the same link with that address in place of the printed one, keeping the port and the token exactly. Give the user the link whose address matches how they reach this host: ```text # Same machine -http://localhost:32848 +http://localhost:32848/?token= # SSH tunnel: user runs `ssh -L 32848:localhost:32848 user@host`, then opens -http://localhost:32848 +http://localhost:32848/?token= # Tailscale (from another tailnet device) -http://:32848 +http://:32848/?token= # LAN (from another device on the same network) -http://:32848 +http://:32848/?token= ``` -For SSH tunnels, give the user the full ssh command, not just the URL. They will need to run it (and keep that terminal open) before the URL works. +For SSH tunnels, give the user the full ssh command along with the connect link. They will need to run it (and keep that terminal open) before the link works. -`tv status` prints every bound URL the daemon is currently serving — read that and quote the appropriate one back to the user rather than guessing. A required bind failure prevents the daemon from starting; the service manager retries it, and each failed attempt is recorded in `/logs/tv.log`. +A required bind failure prevents the daemon from starting; the service manager retries it, and each failed attempt is recorded in `/logs/tv.log`. -Give the user the URL. With authentication on, `tv serve --persist` prints each URL with the token in it, and the token is also in `/state/token`. Give the user a complete authenticated browser URL (`/?token=`) as the primary click target. Also print the **complete, untruncated** token by itself — the whole string, not a prefix or summary — plus the file path (`/state/token`) as a fallback for manual entry, the desktop client's token field, and password-manager storage. For a tokenless daemon, give the bare URL, tell them no token is required, and mention that the server is open to local processes and browser pages they open. Tailscale remains the preferred remote-access path; a LAN bind is supported but puts more of the trust on the user's network and on the bearer token. +Give the complete, untruncated link — the whole token, not a prefix or summary. For a tokenless daemon, tell the user no token is required, and mention that the server is open to local processes and browser pages they open. Tailscale remains the preferred remote-access path; a LAN bind is supported but puts more of the trust on the user's network and on the bearer token. -Also decide whether to offer the desktop client: it uses the same server URL and is the only client that renders URL artifacts as full external web page content rather than a placeholder. +Once everything is set up, tell the user they can get their connect links again at any time: by asking you, or by running `tv links` on this machine. -### Desktop client (recommended for URL artifacts) +### Desktop app (Mac) -**The desktop client is a client-side viewer, like the browser — it runs on the machine the user views Television from, which is often not this host.** The [co-location requirement](#co-location-requirement-the-agent-and-tv-serve-run-on-the-same-host) does not apply to it. It needs no Node or npm on that machine, and it cannot run on a headless host. +**The desktop app is a client-side viewer, like the browser — it runs on the computer the user views Television from, which is often not this host.** The [co-location requirement](#co-location-requirement-the-agent-and-tv-serve-run-on-the-same-host) does not apply to it. It needs no Node or npm on that computer, and it cannot run on a headless host. It shows external web pages (URL artifacts) right inside Television, which a browser cannot do, and gives a native window and Dock icon. -The Television desktop app runs on Apple Silicon Macs with macOS 12 or later. There is no desktop app for any other computer, including Intel Macs, Linux and Windows; users there view Television in the browser. **The principal reason to install it is full support for URL artifacts.** Browser clients show a placeholder for URL artifacts; the Electron client renders external web pages and local web apps inside the artifact card via webview. If the user wants to display external web pages on a TV screen, the desktop client is the only real option. +The desktop app runs on Apple Silicon Macs with macOS 12 or later; in this guide, "Mac" means one of those. There is no desktop app for any other computer, including Intel Macs, Linux and Windows. What you do depends on the computer the user views Television on ([The conversation](#the-conversation)): -The desktop client also provides a native app experience — a real window, Dock icon, and OS integration — which some users prefer over a browser tab. That's a secondary benefit; URL artifact support is the main one. +- **The request says the user is on the desktop app connect screen.** They are on a Mac with the app installed and open, waiting for a connect link. Give them the connect link and tell them to paste it into the app's link field and press **Connect**. Don't tell them how to download the app. +- **Any other request, from a user on a Mac.** Recommend the desktop app and give them the download link and these steps. The connect link also works in their browser. + 1. On the Mac, open `https://dl.todesktop.com/260923p52umxx/mac/dmg/arm64` in a browser. The link stays the same across releases and downloads a disk image of the latest desktop release. + 2. Open the downloaded disk image and drag Television to Applications. + 3. Open Television from Applications, the Dock or Spotlight. + 4. Paste the connect link into the app's link field and press **Connect**. +- **A user on any other computer.** Give them the connect link to open in their browser and don't bring up a desktop app. If they ask about it — for example after a browser shows that it can't display an external web page — tell them it is available only for Macs and that the browser is how they use Television. -The user installs the app themselves, like any Mac app, even when they view Television on this machine. Give them the download link and these steps: - -1. On the Mac, open `https://dl.todesktop.com/260923p52umxx/mac/dmg/arm64` in a browser. The link stays the same across releases and downloads a disk image of the latest desktop release. -2. Open the downloaded disk image and drag Television to Applications. -3. Open Television from Applications, the Dock or Spotlight. -4. In the connect window, enter the server URL above and the token. Tokenless servers leave the token field blank only when the user explicitly asked to run without a token. +The app remembers its connection and reconnects by itself each time it opens. When it needs a different link — the server's address changed, or the server no longer accepts its token — the user chooses **Television › Disconnect from Server** and pastes the current connect link. The desktop app updates itself. It checks for a new desktop release when it starts and every ten minutes and downloads it in the background. When the download finishes, the update bell lights and a notice offers **Restart to update**, which installs the update and reopens the app. An update the user does not restart into installs after they quit the app. The installation finishes a short time after the app quits, so an app opened again straight away can still be the earlier version; it then offers the update again. Updating it needs no reinstall, and restarting the Television daemon never updates it. -When walking the user through install, ask whether they intend to use URL artifacts (external web pages or web apps on their TV). If yes, and they view Television from an Apple Silicon Mac, give them the link and steps now. If they're unsure or only want path artifacts, mention that the desktop client can be installed anytime later. - ## Upgrade ### How updates reach users Releases worth taking are announced through a curated update channel; running servers pick it up within a few minutes and show the user a corner toast with a **"Copy upgrade prompt"** button. If a user hands you "Please upgrade my Television server following https://television.run/install.md", that button is where it came from, and this section is the procedure. -After you upgrade the server, browser tabs self-heal: an out-of-date tab silently reloads itself once on reconnect, so no hard refresh is needed. The desktop app downloaded from Television's link updates itself ([Desktop client](#desktop-client-recommended-for-url-artifacts)); upgrading the server never updates it. If the upgraded server needs a newer desktop app than the user has, the app shows a full-window screen, in place of the normal interface, saying that the desktop app needs an update. The downloaded app keeps checking for updates behind that screen; once it has downloaded the update, the screen offers **Restart to update**, which installs it and reopens the app. +After you upgrade the server, browser tabs self-heal: an out-of-date tab silently reloads itself once on reconnect, so no hard refresh is needed. The desktop app downloaded from Television's link updates itself ([Desktop app (Mac)](#desktop-app-mac)); upgrading the server never updates it. If the upgraded server needs a newer desktop app than the user has, the app shows a full-window screen, in place of the normal interface, saying that the desktop app needs an update. The downloaded app keeps checking for updates behind that screen; once it has downloaded the update, the screen offers **Restart to update**, which installs it and reopens the app. Desktop apps installed with npm, which the user starts with `tv-desktop`, receive no more updates. They show a desktop upgrade recommendation that gives the user the download link and the steps to move to the downloaded app. If the user asks you about it, [Move the desktop app from npm to the downloaded app](#move-the-desktop-app-from-npm-to-the-downloaded-app) is the procedure. An npm app older than the server requires shows that update screen instead, which gives the user the same download link; the procedure is the same. @@ -389,7 +398,7 @@ The telemetry disclosure is a first-install step, not an upgrade step — the us If a listener came from `tailscale ip -4` or a DHCP LAN address, confirm it is still the host's current address (`tailscale ip -4`) before storing it — see [Literal IP capture caveat](#literal-ip-capture-caveat-agent-reference). Dropping a listener silently downgrades the surface — a Tailscale-reachable daemon becomes localhost-only again; if the surface must change, tell the user. - A service that ran without a token before the upgrade requires one now. Tell the user the auth mode has changed and what to expect: it's very easy — they open a URL with the token already in it (`/?token=`) in the browser, or paste the token into the desktop app. Give them that URL plus the bare token to save in a password manager. If the user then asks to restore tokenless access, run `tv config set auth false` and `tv serve --persist`. If the user wants to change how they reach the server, take them back through [Decide network binding](#decide-network-binding) and set the new values before this reinstall. + A service that ran without a token before the upgrade requires one now. Tell the user the auth mode has changed and what to expect: it's very easy — give them their connect link ([Connect links](#connect-links)). In a browser, they open it. In the desktop app, they choose **Television › Disconnect from Server** and paste it. If the user then asks to restore tokenless access, run `tv config set auth false` and `tv serve --persist`. If the user wants to change how they reach the server, take them back through [Decide network binding](#decide-network-binding) and set the new values before this reinstall. 5. Verify that the daemon is running the installed release: @@ -411,12 +420,12 @@ An npm package upgrade does not require deleting the Television home; keeping it Desktop apps up to Television 1.3 were installed with the npm package `@telepath-computer/television-desktop` and started with `tv-desktop`. That package receives no more updates. The downloaded app replaces it and opens with the server connection the npm app saved, so the user does not reconnect. Use this procedure when the user asks you about the desktop recommendation, when their app shows a screen saying that it needs an update and they start it with `tv-desktop`, or when you find the npm package during an upgrade. -The downloaded app runs only on the Macs that [Desktop client](#desktop-client-recommended-for-url-artifacts) names; on any other computer the user views Television in the browser. +The downloaded app runs only on the Macs that [Desktop app (Mac)](#desktop-app-mac) names; on any other computer the user views Television in the browser. The user makes the move themselves, like installing any Mac app. Give them these steps, which are the ones the desktop recommendation shows: -1. Download the new app and drag it to Applications, as steps 1 and 2 in [Desktop client](#desktop-client-recommended-for-url-artifacts) describe. -2. Quit the npm app, then open Television from Applications. While the npm app is running, macOS may bring it forward in place of the new app. The new app opens with the saved server connection. If it shows the connect window instead, enter the server URL and token as for a first install ([User connection URL](#user-connection-url)). +1. Download the new app and drag it to Applications, as steps 1 and 2 in [Desktop app (Mac)](#desktop-app-mac) describe. +2. Quit the npm app, then open Television from Applications. While the npm app is running, macOS may bring it forward in place of the new app. The new app opens with the saved server connection. If it shows its connect screen instead, give the user their connect link to paste into it ([Connect links](#connect-links)). 3. From then on, open Television from Applications, the Dock or Spotlight, not with `tv-desktop`. It updates itself. macOS may ask again for camera, microphone or screen-recording permission, because the downloaded app is signed differently from the npm app. Once the user has the new app open, remove the npm package if `npm ls -g @telepath-computer/television-desktop` lists it on this machine: `npm uninstall -g @telepath-computer/television-desktop`. Saved connections and settings stay. If the package is on another machine, it does no harm there; the user just stops using `tv-desktop`. @@ -465,16 +474,16 @@ Common issues: - **Daemon is not installed or not running.** Reinstall with `tv serve --persist`; it uses the settings that `tv config show` prints. - **Port already in use.** Stop the conflicting service or stop/reinstall Television after the conflict is resolved. Prefer resolving the conflict on the other side rather than moving Television off `32848`; if it must move, see [Port](#port-agent-reference). - **`tv` commands reach a different installation than the service.** Each command resolves its home separately. Compare `home` in `tv status` with the `--home` in the service definition (platform locations below). If they differ, write the service's home into `~/.tv-home`, or reinstall the service from the intended home. -- **Token mismatch.** Read the current token from `/state/token`, make sure the user has copied it exactly, and test it against the local server: +- **Token rejected.** Television shows **Access token required**, in the browser or in the desktop app. Test the current token against the local server: ```bash TOKEN="$(cat /state/token)" curl -fsS -H "Authorization: Bearer $TOKEN" http://localhost:32848/channels ``` - If the curl command succeeds, the token is valid and the user's browser/client likely has a copied, stale, or malformed token. Give the user the current token again. + If the curl command succeeds, the token is valid and the user's browser or desktop app has a stale or mangled link. Give the user their current connect link from `tv links`. In a browser, they paste the whole link into the address bar. In the desktop app, they choose **Disconnect from Server** (the button on that screen, or the Television menu) and paste it. - **Skills installed to the wrong directory.** Reinstall skills into the agent framework's active skills directory. -- **User cannot open the GUI remotely.** Establish how they are reaching the host. For an SSH tunnel, confirm the `ssh -L :localhost: user@host` command is running and the user is opening `http://localhost:` on the laptop side; the daemon side should be a normal localhost-only install, with no `listen` addresses. For Tailscale, confirm `tailscale ip -4` returns an address and that `tv status` lists it in `bindAddresses`; give the user `http://:32848` and the token. For a LAN bind, confirm the LAN address appears in `bindAddresses` and that the user's firewall lets the port through. +- **User cannot open the GUI remotely.** Establish how they are reaching the host. For an SSH tunnel, confirm the `ssh -L :localhost: user@host` command is running and the user is opening the `localhost` connect link on the laptop side; the daemon side should be a normal localhost-only install, with no `listen` addresses. For Tailscale, confirm `tailscale ip -4` returns an address and that `tv status` lists it in `bindAddresses`; give the user the Tailscale connect link from `tv links`. For a LAN bind, confirm the LAN address appears in `bindAddresses` and that the user's firewall lets the port through. Platform locations and commands: @@ -487,32 +496,24 @@ Platform locations and commands: Give the user: -1. The URL to open in their browser. -2. Whether a token is required. - - With authentication on (the default): read the token from `/state/token`, append it to the chosen browser URL as `?token=`, and give that complete authenticated URL as the primary link to click. Also print the **complete, untruncated** token by itself and name the file path in case they need manual entry, the desktop client's token field, password-manager storage, or retrieval later. - - If tokenless: give the bare URL, tell the user no token is required, token fields stay blank, and the server is open to local processes and browser pages they open. -3. The desktop client situation. URL artifacts (external web pages and web apps on a TV screen) only render in the Electron desktop client; browser clients show a placeholder. - - If the user already has the desktop app: tell them to open Television from Applications when they want to display external web pages, and that it updates itself. - - If they don't: explain that the browser URL still works for file-backed artifacts, but URL artifacts won't render fully there. If they want external-web-page support and view Television from an Apple Silicon Mac, give them the download link and install steps ([Desktop client](#desktop-client-recommended-for-url-artifacts)), or note that they can install it later anytime. +1. Their connect link, from `tv links`, for the computer they view Television on ([Connect links](#connect-links)). For an SSH tunnel, the `ssh -L` command too. For a tokenless install, say no token is required and that the server is open to local processes and browser pages they open. +2. How to open it, by computer ([Desktop app (Mac)](#desktop-app-mac)): + - On the desktop app connect screen: paste the link into the app's link field and press **Connect**. + - On another Mac: the desktop app recommendation, its download link and install steps, and that the link also opens in their browser. + - On any other computer: open the link in their browser. Don't bring up a desktop app. +3. That they can get their connect links again at any time, by asking you or by running `tv links` on this machine. 4. What changed: installed, upgraded, reconfigured, or troubleshot. 5. How to stop or reinstall the daemon if needed. Examples: -Tailscale install: +Tailscale install, requested from the desktop app connect screen: ```text Television is installed and running. -Open it — this link has your token built in, just click: +Paste this connect link into the desktop app and press Connect: http://100.x.y.z:32848/?token=k7m2-9xPq4nV8R3jL1cBwA6sYfHzD0eUtG5oI-token-example-untruncated-string -Your token (only if you enter it by hand — e.g. the desktop client's token field — or to save it; file: ~/.television/state/token): - k7m2-9xPq4nV8R3jL1cBwA6sYfHzD0eUtG5oI-token-example-untruncated-string - -Desktop app (recommended if you want external web pages on your TV): - Not yet installed. On your Mac, download it from - https://dl.todesktop.com/260923p52umxx/mac/dmg/arm64 - open the downloaded file and drag Television to Applications. - Then open Television, point it at http://100.x.y.z:32848 and paste the token. It updates itself. +The same link opens Television in a browser too. If you ever need it again, ask me, or run `tv links` on this machine. I installed the persistent Television daemon and updated the Television skills. Telemetry is on by default; it's anonymous and content-free, used only to understand early usage and improve Television. If you'd rather opt out, just tell me and I'll turn it off. If the machine's Tailscale IP changes, ask me to update Television, or run: @@ -520,38 +521,46 @@ If the machine's Tailscale IP changes, ask me to update Television, or run: tv serve --persist ``` -SSH-tunnel install (VPS): +Tailscale install for a Mac user who started by asking the agent: + +```text +Television is installed and running. +Your connect link: + http://100.x.y.z:32848/?token=k7m2-9xPq4nV8R3jL1cBwA6sYfHzD0eUtG5oI-token-example-untruncated-string +Open it in your browser, or use the Television desktop app for Mac, which also shows external web pages right inside Television. To install it, download + https://dl.todesktop.com/260923p52umxx/mac/dmg/arm64 +open the downloaded file and drag Television to Applications. Open Television, paste the connect link and press Connect. It updates itself. +If you ever need your connect link again, ask me, or run `tv links` on this machine. + +I installed the persistent Television daemon and updated the Television skills. Telemetry is on by default; it's anonymous and content-free, used only to understand early usage and improve Television. If you'd rather opt out, just tell me and I'll turn it off. +``` + +SSH-tunnel install (VPS), for a user on Linux or Windows: ```text Television is installed and running on the VPS, localhost-only. To open it, run on your laptop: ssh -L 32848:localhost:32848 user@vps-host -Then open it — this link has your token built in, just click after the SSH tunnel is up: +Then open your connect link in your browser: http://localhost:32848/?token=k7m2-9xPq4nV8R3jL1cBwA6sYfHzD0eUtG5oI-token-example-untruncated-string -Your token (only if you enter it by hand — e.g. the desktop client's token field — or to save it; file: ~/.television/state/token): - k7m2-9xPq4nV8R3jL1cBwA6sYfHzD0eUtG5oI-token-example-untruncated-string - -Desktop app: optional. If you want URL artifacts (external web pages on your TV) and your laptop is an Apple Silicon Mac, download it there from - https://dl.todesktop.com/260923p52umxx/mac/dmg/arm64 -open the downloaded file and drag Television to Applications. -The desktop app connects to the same URL (http://localhost:32848 via the SSH tunnel) and uses the same token. +If you ever need your connect link again, ask me, or run `tv links` on the VPS. I installed the persistent Television daemon and updated the Television skills. Telemetry is on by default; it's anonymous and content-free, used only to understand early usage and improve Television. If you'd rather opt out, just tell me and I'll turn it off. -Keep the ssh -L session open while you use the GUI. +Keep the ssh -L session open while you use Television. ``` ## Agent checklist 1. Determine the task: install, upgrade, troubleshoot, or uninstall. -2. Have [the conversation](#the-conversation) about how the user reaches this host (sitting at it / SSH / Tailscale / their own LAN) — in plain language, before installing anything, never a jargon checklist. Translate the answer into the `listen` setting yourself. See [Decide network binding](#decide-network-binding). +2. Have [the conversation](#the-conversation) about which computer the user views Television on and how it reaches this host (sitting at it / SSH / Tailscale / their own LAN) — in plain language, before installing anything, never a jargon checklist. A request that says the user is on the desktop app connect screen already tells you the computer: a Mac with the app open. Translate the answer into the `listen` setting yourself. See [Decide network binding](#decide-network-binding). 3. On first install (not upgrades), give the telemetry disclosure from [Telemetry disclosure and agent runtime harness name](#telemetry-disclosure-and-agent-runtime-harness-name). If the user says they want to opt out, note it now and apply it yourself once the server is running (step 7) — `tv telemetry disable` needs a live server, so it can't run before the daemon is up. 4. Check `node --version`, then install or upgrade `@telepath-computer/television` if needed. Node `>=22.12.0` is required; if the runtime is older, stop — Node upgrades are the user's decision (see [Install the CLI](#install-the-cli)). 5. Install bundled Television skills into the directory you proposed and the user confirmed during [the conversation](#the-conversation) — don't silently guess the location. -6. Set `installedByAgent` and any needed `listen` addresses with `tv config set`, then install or reinstall with `tv serve --persist`. **When installing Television for the first time on macOS, tell the user before running that command to expect the generic background/login-item notification described in [Install the daemon](#install-the-daemon).** This warning does not apply to upgrades. An SSH-tunnel setup is still a localhost-only install on the server side. Authentication is on by default, including after upgrading a service that ran without a token; give the user the complete token URL and bare token in step 8. Use tokenless mode only if the user explicitly asks to run without a token. +6. Set `installedByAgent` and any needed `listen` addresses with `tv config set`, then install or reinstall with `tv serve --persist`. **When installing Television for the first time on macOS, tell the user before running that command to expect the generic background/login-item notification described in [Install the daemon](#install-the-daemon).** This warning does not apply to upgrades. An SSH-tunnel setup is still a localhost-only install on the server side. Authentication is on by default, including after upgrading a service that ran without a token; give the user their connect link in step 8. Use tokenless mode only if the user explicitly asks to run without a token. 7. Verify `tv status`. If the user asked to opt out of telemetry, now that the server is running run `tv telemetry disable` and re-check `tv status` — this is the point where the command works, since it needs a live server. -8. Give the user the full authenticated browser URL (and, for SSH tunnels, the `ssh -L` command) and any relevant operational notes. With authentication on, also print the **complete, untruncated** token and token file path as the manual-entry fallback. For tokenless installs, give the bare URL, say no token is required, and say the server is deliberately open on that host. -9. Cover the desktop client in the final response. URL artifacts only render fully in the Electron desktop client; browsers show a placeholder. The desktop client runs on the machine the user views Television from, which is often not this host. - - If the user wants URL artifact support, views Television from an Apple Silicon Mac and doesn't have the desktop app yet, give them the download link and install steps from [Desktop client](#desktop-client-recommended-for-url-artifacts). - - If they already have it, remind them to open it from Applications and that it's the right client for URL artifacts. +8. Give the user their connect link from `tv links` ([Connect links](#connect-links)) (and, for SSH tunnels, the `ssh -L` command) and any relevant operational notes. Never give a bare token. For tokenless installs, say no token is required and that the server is deliberately open on that host. Tell the user they can get their connect links again at any time by asking you or running `tv links`. +9. Cover the desktop app by the user's computer ([Desktop app (Mac)](#desktop-app-mac)): + - On the desktop app connect screen: tell them to paste the connect link into the app and press **Connect**; don't explain how to download it. + - On another Mac: recommend the desktop app with its download link and install steps. - If the user starts the desktop app with `tv-desktop`, follow [Move the desktop app from npm to the downloaded app](#move-the-desktop-app-from-npm-to-the-downloaded-app). - - If the user has no URL artifact intent and declined, note that they can install it later anytime. + - On any other computer: don't bring up a desktop app. diff --git a/frames/app/setup.frame b/frames/app/setup.frame new file mode 100644 index 00000000..31c7ca0b --- /dev/null +++ b/frames/app/setup.frame @@ -0,0 +1,30 @@ +--- +title: Setup screen +params: + appearance: { enum: [light, dark], default: light } + state: { enum: [ready, connecting, connected, error], default: ready, description: "ready, connecting, connected, or error" } +script: | + import { setupPrototype, showConnected } from "../lib/setup.ts"; + + // Staging's stand-in for how long a server takes to answer Connect. + const SIMULATED_CONNECT_MS = 1500; + + let cleanup: (() => void) | undefined; + let connectTimer: ReturnType | undefined; + const wire = () => { + cleanup?.(); + clearTimeout(connectTimer); + const screen = document.querySelector(".setup-screen"); + if (!screen) return; + cleanup = setupPrototype(screen, { + onConnect: () => { + connectTimer = setTimeout(() => showConnected(screen), SIMULATED_CONNECT_MS); + }, + }); + }; + wire(); + window.addEventListener("frameset:rendered", wire); +--- +
+ {% render '../../specs/ui/setup/setup', state: state %} +
diff --git a/frames/app/system-modal.frame b/frames/app/system-modal.frame deleted file mode 100644 index 49a6d248..00000000 --- a/frames/app/system-modal.frame +++ /dev/null @@ -1,11 +0,0 @@ ---- -title: System modal -params: - appearance: { enum: [light, dark], default: light } - theme: { enum: [none, clouds, nord, swiss, tokyo-night], default: none } -style: | - main { min-height: 100vh; box-sizing: border-box; display: flex; align-items: center; justify-content: center; padding: 32px; } ---- -
- -
diff --git a/frames/app/system-modals/browser/access-token.frame b/frames/app/system-modals/browser/access-token.frame new file mode 100644 index 00000000..e3518adc --- /dev/null +++ b/frames/app/system-modals/browser/access-token.frame @@ -0,0 +1,6 @@ +--- +title: Access token required +params: + appearance: { enum: [light, dark], default: light } +--- +{% render '../../../../specs/ui/app/system-modal/system-modal', state: 'unauthorized' %} diff --git a/frames/app/system-modals/browser/cant-connect.frame b/frames/app/system-modals/browser/cant-connect.frame new file mode 100644 index 00000000..2f990ab5 --- /dev/null +++ b/frames/app/system-modals/browser/cant-connect.frame @@ -0,0 +1,6 @@ +--- +title: Can’t connect with server +params: + appearance: { enum: [light, dark], default: light } +--- +{% render '../../../../specs/ui/app/system-modal/system-modal', state: 'error', server_url: 'http://100.101.102.103:32848', seconds: 5 %} diff --git a/frames/app/system-modals/connecting.frame b/frames/app/system-modals/connecting.frame new file mode 100644 index 00000000..1f38d317 --- /dev/null +++ b/frames/app/system-modals/connecting.frame @@ -0,0 +1,6 @@ +--- +title: Connecting +params: + appearance: { enum: [light, dark], default: light } +--- +{% render '../../../specs/ui/app/system-modal/system-modal', state: 'connecting' %} diff --git a/frames/app/system-modals/desktop/access-token.frame b/frames/app/system-modals/desktop/access-token.frame new file mode 100644 index 00000000..b4d908db --- /dev/null +++ b/frames/app/system-modals/desktop/access-token.frame @@ -0,0 +1,7 @@ +--- +title: Access token required +description: "The server's interface inside the desktop app, before it has rendered: no address bar and no drag handle of its own." +params: + appearance: { enum: [light, dark], default: light } +--- +{% render '../../../../specs/ui/app/system-modal/system-modal', state: 'unauthorized', context: 'desktop', drag_strip: true %} diff --git a/frames/app/system-modals/desktop/cant-connect.frame b/frames/app/system-modals/desktop/cant-connect.frame new file mode 100644 index 00000000..412b0649 --- /dev/null +++ b/frames/app/system-modals/desktop/cant-connect.frame @@ -0,0 +1,7 @@ +--- +title: Can’t connect with server +description: "The server's interface inside the desktop app when its first connection fails." +params: + appearance: { enum: [light, dark], default: light } +--- +{% render '../../../../specs/ui/app/system-modal/system-modal', state: 'error', context: 'desktop', drag_strip: true, server_url: 'http://100.101.102.103:32848', seconds: 5 %} diff --git a/frames/app/system-modals/disconnected.frame b/frames/app/system-modals/disconnected.frame new file mode 100644 index 00000000..abea6d64 --- /dev/null +++ b/frames/app/system-modals/disconnected.frame @@ -0,0 +1,11 @@ +--- +title: Disconnected +params: + appearance: { enum: [light, dark], default: light } + seconds: { type: number, minimum: 0, multipleOf: 1, default: 5, description: "seconds until the next reconnection attempt; 0 shows Reconnecting now" } +--- +{% if seconds > 0 %} +{% render '../../../specs/ui/app/system-modal/system-modal', state: 'disconnected', seconds: seconds %} +{% else %} +{% render '../../../specs/ui/app/system-modal/system-modal', state: 'disconnected' %} +{% endif %} diff --git a/frames/app/system-modals/local/access-token.frame b/frames/app/system-modals/local/access-token.frame new file mode 100644 index 00000000..c43d685a --- /dev/null +++ b/frames/app/system-modals/local/access-token.frame @@ -0,0 +1,8 @@ +--- +title: Access token required +description: "The desktop app's local page when the server rejects the saved connection's token." +params: + appearance: { enum: [light, dark], default: light } + theme: { enum: [clouds], default: clouds } +--- +{% render '../../../../specs/ui/app/system-modal/system-modal', state: 'unauthorized', context: 'local', drag_strip: true %} diff --git a/frames/app/system-modals/local/cant-connect.frame b/frames/app/system-modals/local/cant-connect.frame new file mode 100644 index 00000000..5819219c --- /dev/null +++ b/frames/app/system-modals/local/cant-connect.frame @@ -0,0 +1,8 @@ +--- +title: Can’t connect with server +description: "The desktop app's local page when the saved connection's server cannot be reached." +params: + appearance: { enum: [light, dark], default: light } + theme: { enum: [clouds], default: clouds } +--- +{% render '../../../../specs/ui/app/system-modal/system-modal', state: 'error', context: 'local', drag_strip: true, server_url: 'http://100.101.102.103:32848', seconds: 5 %} diff --git a/frames/app/system-modals/local/connecting.frame b/frames/app/system-modals/local/connecting.frame new file mode 100644 index 00000000..e928131b --- /dev/null +++ b/frames/app/system-modals/local/connecting.frame @@ -0,0 +1,8 @@ +--- +title: Connecting +description: "The desktop app's local page, starting a saved connection. It wears Clouds whatever theme the server uses." +params: + appearance: { enum: [light, dark], default: light } + theme: { enum: [clouds], default: clouds } +--- +{% render '../../../../specs/ui/app/system-modal/system-modal', state: 'connecting', context: 'local', drag_strip: true %} diff --git a/frames/lib/setup.ts b/frames/lib/setup.ts new file mode 100644 index 00000000..755f7495 --- /dev/null +++ b/frames/lib/setup.ts @@ -0,0 +1,96 @@ +// Workshop wiring for the setup screen ([[specs/ui/setup/index.md]], Interaction): +// Copy, and Connect's hand-off to the host. Staging only; it binds nothing. At +// rest the markup matches the spec frame's render for the resulting state. + +// The copy button's confirmation dwell, stated in its spec's prose +// ([[specs/ui/app/copy-button/index.md]], Testing); no data file carries it. +const COPY_CONFIRM_MS = 1400; + +export interface SetupWiringOptions { + /** Connect submitted a non-empty link; the screen is already connecting. */ + onConnect?: () => void; +} + +const writeClipboard = (text: string): void => { + if (navigator.clipboard) { + void navigator.clipboard.writeText(text).catch(() => {}); + return; + } + // Plain HTTP has no clipboard API; copy a selected textarea within the press. + const textarea = document.createElement("textarea"); + textarea.value = text; + textarea.setAttribute("readonly", ""); + textarea.style.position = "fixed"; + textarea.style.opacity = "0"; + document.body.append(textarea); + textarea.select(); + try { + document.execCommand("copy"); + } catch { + // Copying is best-effort in the workshop. + } + textarea.remove(); +}; + +/** Pose the connected state on a wired screen, as its render would. */ +export const showConnected = (screen: HTMLElement): void => { + const setupDocument = screen.querySelector(".setup-document"); + if (setupDocument) setupDocument.inert = true; + screen.querySelector(".setup-done")?.removeAttribute("aria-hidden"); + screen.dataset.state = "connected"; +}; + +/** Wire one rendered setup screen. Returns a disposer. */ +export const setupPrototype = (screen: HTMLElement, options: SetupWiringOptions = {}): (() => void) => { + const copyButton = screen.querySelector(".setup-prompt .copy-button"); + const form = screen.querySelector(".setup-link"); + if (!copyButton || !form) { + throw new Error("The setup screen needs its copy button and its link form"); + } + const status = copyButton.nextElementSibling as HTMLElement | null; + const field = form.querySelector("input"); + const submit = form.querySelector(".setup-submit"); + const confirmation = copyButton.querySelector(".copy-button-done")?.textContent?.trim() ?? ""; + let confirmTimer: ReturnType | undefined; + + const onCopy = (): void => { + writeClipboard(copyButton.getAttribute("prompt") ?? ""); + copyButton.toggleAttribute("copied", true); + if (status) status.textContent = confirmation; + clearTimeout(confirmTimer); + confirmTimer = setTimeout(() => { + copyButton.toggleAttribute("copied", false); + if (status) status.textContent = ""; + }, COPY_CONFIRM_MS); + }; + + const onSubmit = (event: Event): void => { + event.preventDefault(); + if (screen.dataset.state !== "ready" && screen.dataset.state !== "error") return; + if (!field?.value.trim()) return; + // Connecting renders without the error treatment: the hint returns and + // the field is no longer invalid. + field.removeAttribute("aria-invalid"); + field.removeAttribute("aria-describedby"); + const error = screen.querySelector("#setup-link-error"); + if (error) { + const hint = document.createElement("p"); + hint.className = "setup-hint"; + hint.innerHTML = 'The link looks like http://…:32848/?token=…'; + error.replaceWith(hint); + } + screen.dataset.state = "connecting"; + field.disabled = true; + if (submit) submit.disabled = true; + options.onConnect?.(); + }; + + copyButton.addEventListener("click", onCopy); + form.addEventListener("submit", onSubmit); + + return () => { + clearTimeout(confirmTimer); + copyButton.removeEventListener("click", onCopy); + form.removeEventListener("submit", onSubmit); + }; +}; diff --git a/frameset.json b/frameset.json index 7884ba88..ec08df7d 100644 --- a/frameset.json +++ b/frameset.json @@ -1,7 +1,7 @@ { "root": ".", "imports": { - "specs/ui/{app,foundation}/**/*.frame": [ + "specs/ui/{app,foundation,setup}/**/*.frame": [ "specs/ui/foundation/index.css", "specs/ui/foundation/tokens/app.css", "frames/lib/foundation.ts" @@ -35,6 +35,11 @@ "path": "frames/app/app.frame", "title": "App" }, + { + "type": "frame", + "path": "frames/app/setup.frame", + "title": "Setup screen" + }, { "type": "folder", "title": "Onboarding", @@ -155,14 +160,78 @@ "title": "Copy button" }, { - "type": "frame", - "path": "frames/app/system-modal.frame", - "title": "System modal" - }, - { - "type": "frame", - "path": "frames/app/upgrade-gate.frame", - "title": "Upgrade gate" + "type": "folder", + "title": "System modals", + "children": [ + { + "type": "frame", + "path": "frames/app/system-modals/connecting.frame", + "title": "Connecting" + }, + { + "type": "frame", + "path": "frames/app/system-modals/disconnected.frame", + "title": "Disconnected" + }, + { + "type": "folder", + "title": "Browser", + "children": [ + { + "type": "frame", + "path": "frames/app/system-modals/browser/access-token.frame", + "title": "Access token required" + }, + { + "type": "frame", + "path": "frames/app/system-modals/browser/cant-connect.frame", + "title": "Can\u2019t connect with server" + } + ] + }, + { + "type": "folder", + "title": "Desktop", + "children": [ + { + "type": "frame", + "path": "frames/app/system-modals/desktop/access-token.frame", + "title": "Access token required" + }, + { + "type": "frame", + "path": "frames/app/system-modals/desktop/cant-connect.frame", + "title": "Can\u2019t connect with server" + } + ] + }, + { + "type": "folder", + "title": "Desktop local page", + "children": [ + { + "type": "frame", + "path": "frames/app/system-modals/local/connecting.frame", + "title": "Connecting" + }, + { + "type": "frame", + "path": "frames/app/system-modals/local/access-token.frame", + "title": "Access token required" + }, + { + "type": "frame", + "path": "frames/app/system-modals/local/cant-connect.frame", + "title": "Can\u2019t connect with server" + } + ] + }, + { + "type": "frame", + "path": "frames/app/upgrade-gate.frame", + "title": "Upgrade gate" + } + ] }, { "type": "frame", diff --git a/package-lock.json b/package-lock.json index 3a55f99d..acdf1554 100644 --- a/package-lock.json +++ b/package-lock.json @@ -17551,8 +17551,10 @@ "devDependencies": { "@telepath-computer/television-artifact": "*", "@telepath-computer/television-shared": "*", + "@telepath-computer/utils": "*", "@todesktop/cli": "1.28.2", - "electron": "43.7.6" + "electron": "43.7.6", + "lit-html": "3.3.2" } }, "packages/server": { diff --git a/packages/canonical/styles/canonical/v2/elements/button/styles.css b/packages/canonical/styles/canonical/v2/elements/button/styles.css index 8d868117..ce4db4cb 100644 --- a/packages/canonical/styles/canonical/v2/elements/button/styles.css +++ b/packages/canonical/styles/canonical/v2/elements/button/styles.css @@ -212,6 +212,16 @@ padding: var(--space-2); } +:where(button[size="lg"]) { + font-size: var(--text-lg); + line-height: var(--line-control-lg); + padding: var(--space-8) var(--space-20); +} + +:where(button[size="lg"][icon]) { + padding: var(--space-8); +} + /* A sized control sizes the icon it holds: whatever size the composer put on the icon, the control's own wins — `tv-icon[size]` names a fixed pixel size, so left alone the icon would ignore the control shrinking around it. diff --git a/packages/canonical/styles/canonical/v2/elements/input/styles.css b/packages/canonical/styles/canonical/v2/elements/input/styles.css index 398d6625..c3f7efca 100644 --- a/packages/canonical/styles/canonical/v2/elements/input/styles.css +++ b/packages/canonical/styles/canonical/v2/elements/input/styles.css @@ -34,6 +34,15 @@ resize: vertical; } +/* The large size matches a large button beside the field + ([[ui/foundation/button/button.css]]): the same text step, line and + vertical padding step, so the two are the same height. */ +:where(:is(input:not([type]), input[type=""], input[type="text"], input[type="email"], input[type="url"], input[type="tel"], input[type="password"], input[type="number"], textarea)[data-size="lg"]) { + font-size: var(--text-lg); + line-height: var(--line-control-lg); + padding: var(--space-8) var(--space-12); +} + /* Shared error-message styling; validation and announcement belong to the surface. */ :where(.tv-error) { margin: 0; diff --git a/packages/canonical/styles/canonical/v2/foundation/text.css b/packages/canonical/styles/canonical/v2/foundation/text.css index 2ba1fc67..a10e0e69 100644 --- a/packages/canonical/styles/canonical/v2/foundation/text.css +++ b/packages/canonical/styles/canonical/v2/foundation/text.css @@ -16,11 +16,14 @@ --text-4xl: round(calc(var(--text-base) * 30 / 14), 1px); /* The control lines: the line-height a control's label uses, and the one - input its height derives from — the line, plus the space-2 padding step + input its height derives from — the line, plus the size's padding step top and bottom, plus a 1px border each side, gives every control the - same height: 18 + 4 + 2 = 24px (14 + 4 + 2 = 20px at the small step). + same height at each size: 18 + 4 + 2 = 24px at the default size (the + space-2 step), 14 + 4 + 2 = 20px at the small step (space-2), and + 22 + 16 + 2 = 40px at the large step (space-8). Stated in pixels, not a ratio: a control's cell is tighter than prose, immune to the leading around it, and lands on even pixels in any font. */ --line-control: 18px; --line-control-sm: 14px; + --line-control-lg: 22px; } diff --git a/packages/canonical/test/build-canonical.test.ts b/packages/canonical/test/build-canonical.test.ts index c780a9dd..7907cf4a 100644 --- a/packages/canonical/test/build-canonical.test.ts +++ b/packages/canonical/test/build-canonical.test.ts @@ -42,6 +42,7 @@ interface CompatibilityFixture { tokens: Record; elements: Record>; iconNames: Record>; + nativeControls: Partial>>>; } interface VersionAuthority { @@ -56,7 +57,7 @@ interface VersionAuthority { const FROZEN_ROOT = path.join(PACKAGE_ROOT, "frozen"); const FROZEN_V1_ROOT = path.join(FROZEN_ROOT, "v1"); const LIVE_UI_ROOT = path.join(REPO_ROOT, "specs", "ui"); -const TOKEN_COUNTS: Record = { v1: 174, v2: 288 }; +const TOKEN_COUNTS: Record = { v1: 174, v2: 289 }; function leaf(production: string, authority: string): { production: string; authority: string } { return { production, authority }; @@ -583,6 +584,16 @@ describe("buildCanonicalVersions", () => { expect(recordedTokens).toHaveLength(TOKEN_COUNTS[version]); expectRecordedNamesIncluded(recordedTokens, currentTokens); + for (const [tag, attributes] of Object.entries(fixture.nativeControls[owner] ?? {})) { + for (const [attribute, values] of Object.entries(attributes)) { + for (const value of values) { + const selectors: string[] = []; + postcss.parse(builtStyles).walkRules((rule) => { selectors.push(rule.selector); }); + expect(selectors.some((selector) => selector.includes(tag) && selector.includes(`[${attribute}="${value}"]`)), `${tag}[${attribute}="${value}"]`).toBe(true); + } + } + } + const builtComponents = await readFile(builds[version].componentsPath, "utf8"); const currentTags = new Set( [...builtComponents.matchAll(/customElements\.define\("([a-z][a-z0-9-]+)"/g)] diff --git a/packages/canonical/test/fixtures/canonical-public-api.json b/packages/canonical/test/fixtures/canonical-public-api.json index 4956a335..037e84fc 100644 --- a/packages/canonical/test/fixtures/canonical-public-api.json +++ b/packages/canonical/test/fixtures/canonical-public-api.json @@ -315,6 +315,7 @@ "--layer-panel", "--line-control", "--line-control-sm", + "--line-control-lg", "--neutral", "--neutral-100", "--neutral-200", @@ -774,5 +775,19 @@ "web" ] } + }, + "nativeControls": { + "canonical-v2": { + "button": { + "size": [ + "lg" + ] + }, + "input": { + "data-size": [ + "lg" + ] + } + } } } diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts index 0d0d4f9f..7ad33ff9 100644 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -45,6 +45,8 @@ import { TelevisionClient, ValidationError, buildConnectURL, type TelemetryStatu export type Writable = { write(chunk: string | Uint8Array): unknown; + /** True when the stream is an interactive terminal; decides link formatting. */ + isTTY?: boolean; }; export type CLIServer = Pick & { @@ -152,16 +154,16 @@ function writeLine(output: Writable, line: string): void { output.write(`${line}\n`); } -function terminalHyperlink(url: string, text = url): string { - return `\u001B]8;;${url}\u001B\\${text}\u001B]8;;\u001B\\`; +function formatConnectURL(output: Writable, serverURL: string, token?: string | null): string { + const url = buildConnectURL(serverURL, token); + return output.isTTY === true ? `\u001B]8;;${url}\u001B\\${url}\u001B]8;;\u001B\\` : url; } function writeConnectURLs(output: Writable, urls: string[], options: { token?: string | null; installed?: boolean } = {}): void { writeLine(output, options.installed ? "Television service installed." : "Television server running."); writeLine(output, "Open Television:"); for (const url of urls) { - const connectURL = buildConnectURL(url, options.token ?? null); - writeLine(output, ` ${terminalHyperlink(connectURL)}`); + writeLine(output, ` ${formatConnectURL(output, url, options.token)}`); } } @@ -1531,6 +1533,31 @@ function createProgram(env: CLIEnvironment, invocation: CLIInvocation = { argv: await uninstallPersistedService(resolveHome()); }); + program + .command("links") + .description("Print connect links for the running Television server") + .option("--port ", "Server port; required when the config file sets port 0", parseClientPortOption) + .action(async (opts: { port?: number }) => { + const serverURL = resolveServerURL(opts.port); + const token = readAuthToken(resolveHome()); + const client = env.createClient(serverURL, token); + const health = await client.health(); + await client.display.get(); + + // Authentication is a startup setting: the file may have changed since + // this server started. Only an unauthenticated 401 requires the token. + let requiresToken = false; + try { + await env.createClient(serverURL, undefined).display.get(); + } catch (error) { + if (typeof error !== "object" || error === null || !("status" in error) || error.status !== HTTP_UNAUTHORIZED_STATUS) throw error; + requiresToken = true; + } + for (const address of health.bindAddresses) { + writeLine(env.stdout, formatConnectURL(env.stdout, buildServerURL(address, health.port), requiresToken ? token : null)); + } + }); + program .command("status") .description("Print the selected home, server health, version, telemetry state, and service status as JSON") diff --git a/packages/cli/test/cli.test.ts b/packages/cli/test/cli.test.ts index 01596605..8ce8310e 100644 --- a/packages/cli/test/cli.test.ts +++ b/packages/cli/test/cli.test.ts @@ -152,6 +152,134 @@ function fakeEnvironment(overrides: Partial = {}): Partial { + // Contract peers for ^cli-links-contract and ^cli-osc8-visible. Real HTTP, + // process output and daemon installation are covered by the product walks. + function peers() { + const authenticated = fakeEnvironment().createClient!("http://localhost:43123"); + const unauthenticated = fakeEnvironment().createClient!("http://localhost:43123"); + const health = vi.spyOn(authenticated, "health").mockResolvedValue({ status: "ok", bindAddresses: ["127.0.0.1"], port: 43123 }); + const authenticatedGet = vi.spyOn(authenticated.display, "get"); + const unauthenticatedGet = vi.spyOn(unauthenticated.display, "get"); + const createClient = vi.fn() + .mockReturnValueOnce(authenticated) + .mockReturnValueOnce(unauthenticated); + return { createClient, health, authenticatedGet, unauthenticatedGet }; + } + + const unauthorized = () => new RequestError("Unauthorized", { serverURL: "http://localhost:43123", status: 401 }); + + // [[arch/cli/index.md#^cli-links-contract]] + it("exposes links in help and refuses --server before contacting a server", async () => { + expect(listVisibleCLICommandNames()).toContain("links"); + const stdout = new BufferOutput(); + expect(await runCLI(["--help"], fakeEnvironment({ stdout, stderr: new BufferOutput() }))).toBe(0); + expect(stdout.toString()).toMatch(/\n\s+links\b/); + const help = new BufferOutput(); + expect(await runCLI(["links", "--help"], fakeEnvironment({ stdout: help, stderr: new BufferOutput() }))).toBe(0); + expect(help.toString()).toContain("--port "); + expect(help.toString()).not.toContain("--server"); + const stderr = new BufferOutput(); + const env = fakeEnvironment({ stdout: new BufferOutput(), stderr }); + expect(await runCLI(["links", "--server", "https://example.com"], env)).toBe(1); + expect(stderr.toString()).toContain("does not support --server"); + expect(env.createClient).not.toHaveBeenCalled(); + }); + + it.each([ + { configAuth: false, requiresToken: true, homeToken: " live-token\n", addresses: ["127.0.0.1", "100.64.0.7"] }, + { configAuth: true, requiresToken: false, homeToken: "leftover-token\n", addresses: ["0.0.0.0"] }, + { configAuth: false, requiresToken: false, homeToken: "leftover-token\n", addresses: ["127.0.0.1"] }, + { configAuth: true, requiresToken: false, homeToken: undefined, addresses: ["127.0.0.1"] }, + ])("uses live auth=$requiresToken and health addresses despite config auth=$configAuth", async ({ configAuth, requiresToken, homeToken, addresses }) => { + const home = temporaryHome({ port: 43200, auth: configAuth }); + if (homeToken !== undefined) writeToken(home, homeToken); + const { createClient, health, authenticatedGet, unauthenticatedGet } = peers(); + health.mockResolvedValue({ status: "ok", bindAddresses: addresses, port: 43123 }); + if (requiresToken) unauthenticatedGet.mockRejectedValue(unauthorized()); + const stdout = new BufferOutput(); + const stderr = new BufferOutput(); + expect(await runCLI(["--home", home, "links"], fakeEnvironment({ stdout, stderr, createClient }))).toBe(0); + expect(createClient.mock.calls).toEqual([["http://localhost:43200", homeToken?.trim()], ["http://localhost:43200", undefined]]); + expect(health.mock.invocationCallOrder[0]).toBeLessThan(authenticatedGet.mock.invocationCallOrder[0]!); + expect(authenticatedGet.mock.invocationCallOrder[0]).toBeLessThan(unauthenticatedGet.mock.invocationCallOrder[0]!); + expect(stdout.toString()).toBe(addresses.map((address) => `http://${address}:43123${requiresToken ? "/?token=live-token" : ""}\n`).join("")); + expect(stderr.toString()).toBe(""); + }); + + it("uses the selected default home and the required port override", async () => { + const operatingSystemHome = temporaryDirectory("television-links-os-home-"); + const home = temporaryHome({ port: 0 }); + writeFileSync(path.join(operatingSystemHome, ".tv-home"), `${home}\n`); + writeToken(home, "trimmed-token\n"); + const { createClient } = peers(); + expect(await runCLI(["links", "--port", "43123"], fakeEnvironment({ + stdout: new BufferOutput(), stderr: new BufferOutput(), createClient, resolveHomeDir: () => operatingSystemHome, + }))).toBe(0); + expect(createClient.mock.calls).toEqual([["http://localhost:43123", "trimmed-token"], ["http://localhost:43123", undefined]]); + + writeConfig(home, { port: 43123 }); + const env = fakeEnvironment({ stdout: new BufferOutput(), stderr: new BufferOutput() }); + expect(await runCLI(["--home", home, "links", "--port", "43123"], env)).toBe(1); + expect(env.createClient).not.toHaveBeenCalled(); + }); + + it.each(["health", "authenticated", "unauthenticated"] as const)("prints no links when the %s request cannot reach the server", async (stage) => { + const { createClient, health, authenticatedGet, unauthenticatedGet } = peers(); + const request = { health, authenticated: authenticatedGet, unauthenticated: unauthenticatedGet }[stage]; + request.mockRejectedValue(new RequestError("fetch failed", { serverURL: "http://localhost:43123" })); + const stdout = new BufferOutput(); + const stderr = new BufferOutput(); + expect(await runCLI(["--home", temporaryHome({ port: 43123 }), "links"], fakeEnvironment({ stdout, stderr, createClient }))).toBe(1); + expect(stdout.toString()).toBe(""); + expect(stderr.toString()).toBe("Could not reach Television server at http://localhost:43123: fetch failed\n"); + if (stage === "health") expect(authenticatedGet).not.toHaveBeenCalled(); + if (stage !== "unauthenticated") expect(unauthenticatedGet).not.toHaveBeenCalled(); + }); + + it.each([undefined, "wrong-token"])("refuses a rejected home token %s before printing any links", async (token) => { + const home = temporaryHome({ port: 43123 }); + if (token !== undefined) writeToken(home, token); + const { createClient, authenticatedGet, unauthenticatedGet } = peers(); + authenticatedGet.mockRejectedValue(unauthorized()); + const stdout = new BufferOutput(); + const stderr = new BufferOutput(); + expect(await runCLI(["--home", home, "links"], fakeEnvironment({ stdout, stderr, createClient }))).toBe(1); + expect(stdout.toString()).toBe(""); + expect(stderr.toString()).toContain(`Television server at http://localhost:43123 rejected the request as unauthorized. Check the token in ${path.join(home, "state", "token")}.`); + expect(stderr.toString()).toContain(SKILL_POINTER); + expect(unauthenticatedGet).not.toHaveBeenCalled(); + }); + + // [[arch/cli/index.md#^cli-osc8-visible]] + it.each(["foreground", "persisted", "links"].flatMap((caller) => + [true, false, undefined].flatMap((isTTY) => [true, false].map((auth) => ({ caller, isTTY, auth }))), + ))("formats $caller output with isTTY=$isTTY and auth=$auth", async ({ caller, isTTY, auth }) => { + const home = temporaryHome({ port: 43123, auth }); + const token = "a+b/c?&="; + writeToken(home, token); + const stdout = Object.assign(new BufferOutput(), isTTY === undefined ? {} : { isTTY }); + const stderr = new BufferOutput(); + const { createClient, unauthenticatedGet } = peers(); + if (auth) unauthenticatedGet.mockRejectedValue(unauthorized()); + const env = fakeEnvironment({ stdout, stderr, createClient }); + const url = `http://127.0.0.1:43123${auth ? "/?token=a%2Bb%2Fc%3F%26%3D" : ""}`; + let exitCode: number; + if (caller === "foreground") { + const server = env.createServer!({} as any); + server.getAuthToken = () => token; + server.getBaseURLs = () => ["http://127.0.0.1:43123"]; + ({ exitCode } = await runForegroundServe(["--home", home, "serve"], { stdout, stderr, createServer: () => server })); + } else { + exitCode = await runCLI(["--home", home, ...(caller === "persisted" ? ["serve", "--persist"] : ["links"])], env); + } + expect(exitCode).toBe(0); + const formatted = isTTY === true ? `\u001B]8;;${url}\u001B\\${url}\u001B]8;;\u001B\\` : url; + const heading = caller === "persisted" ? "Television service installed." : "Television server running."; + expect(stdout.toString()).toBe(caller === "links" ? `${formatted}\n` : `${heading}\nOpen Television:\n ${formatted}\n`); + }); +}); + describe("CLI Slice 1 artifact command surface", () => { it("lists new pointer commands and omits removed lifecycle commands", () => { const names = listVisibleCLICommandNames(); @@ -273,6 +401,7 @@ describe("CLI Slice 1 artifact command surface", () => { "skills", "telemetry", "stop", + "links", "status", ]); }); @@ -1624,9 +1753,7 @@ describe("CLI Slice 1 artifact command surface", () => { expect(output).toContain("Television service installed."); expect(output).toContain("http://127.0.0.1:43123/?token="); expect(output).toContain("http://100.64.0.7:43123/?token="); - expect(output).toContain("\u001B]8;;http://127.0.0.1:43123/?token="); - expect(output).toContain("\u001B]8;;http://100.64.0.7:43123/?token="); - expect(output).toContain("\u001B]8;;\u001B\\"); + expect(output).not.toContain("\u001B"); expect(stderr.toString()).toBe(""); }); @@ -1719,7 +1846,7 @@ describe("CLI Slice 1 artifact command surface", () => { expect(() => JSON.parse(output)).toThrow(); expect(output).toContain("Television service installed."); expect(output).toContain("http://0.0.0.0:43123/?token="); - expect(output).toContain("\u001B]8;;http://0.0.0.0:43123/?token="); + expect(output).not.toContain("\u001B"); const installRecord = readLogRecords(home).find((record) => record.msg === "persisted service installed"); expect(installRecord?.execStart).toEqual([process.execPath, process.argv[1] ?? "tv", "--home", home, "serve"]); expect(installRecord?.env).toEqual({ @@ -2145,59 +2272,6 @@ describe("CLI Slice 1 artifact command surface", () => { } }); - // Spec: [[arch/cli/index.md#^cli-osc8-visible|OSC-8 visible URL]]. - it("prints a foreground startup URL as an OSC-8 link whose visible text is the URL", async () => { - const stdout = new BufferOutput(); - const signalHandlers = new Map void>(); - const url = "http://127.0.0.1:43124"; - const createServer = vi.fn(() => ({ - start: vi.fn(async function (this: any) { return this; }), - dispose: vi.fn(async () => {}), - getBaseURL: vi.fn(() => url), - getBaseURLs: vi.fn(() => [url]), - getAuthToken: vi.fn(() => "token-123"), - }) as any); - - const runPromise = runCLI(["--home", temporaryHome({ auth: false }), "serve"], fakeEnvironment({ - stdout, - stderr: new BufferOutput(), - createServer, - onSignal: vi.fn((signal, handler) => { signalHandlers.set(signal, handler); }), - })); - await vi.waitFor(() => { expect(createServer).toHaveBeenCalled(); }); - signalHandlers.get("SIGTERM")?.(); - await expect(runPromise).resolves.toBe(0); - - expect(stdout.toString()).toContain(`\u001B]8;;${url}\u001B\\${url}\u001B]8;;\u001B\\`); - }); - - // Spec: [[arch/cli/index.md#^cli-osc8-token|token-bearing OSC-8 URL]]. - it("includes the auth token in the OSC-8 startup link URL and visible text", async () => { - const stdout = new BufferOutput(); - const signalHandlers = new Map void>(); - const serverURL = "http://127.0.0.1:43124"; - const connectURL = `${serverURL}/?token=token-123`; - const createServer = vi.fn(() => ({ - start: vi.fn(async function (this: any) { return this; }), - dispose: vi.fn(async () => {}), - getBaseURL: vi.fn(() => serverURL), - getBaseURLs: vi.fn(() => [serverURL]), - getAuthToken: vi.fn(() => "token-123"), - }) as any); - - const runPromise = runCLI(["serve"], fakeEnvironment({ - stdout, - stderr: new BufferOutput(), - createServer, - onSignal: vi.fn((signal, handler) => { signalHandlers.set(signal, handler); }), - })); - await vi.waitFor(() => { expect(createServer).toHaveBeenCalled(); }); - signalHandlers.get("SIGTERM")?.(); - await expect(runPromise).resolves.toBe(0); - - expect(stdout.toString()).toContain(`\u001B]8;;${connectURL}\u001B\\${connectURL}\u001B]8;;\u001B\\`); - }); - // Spec: [[arch/cli/index.md#^cli-serve-config-options|serve settings come from the config file]]. it("serve passes the home and its config settings to createServer", async () => { const previousLaunchMode = process.env.TELEVISION_LAUNCH_MODE; @@ -2338,6 +2412,7 @@ const SERVER_COMMANDS: string[][] = [ ["telemetry", "enable"], ["telemetry", "disable"], ["status"], + ["links"], ]; function fakeDaemon() { diff --git a/packages/cli/test/contract-types.test.ts b/packages/cli/test/contract-types.test.ts index 14e45e36..335e5fc1 100644 --- a/packages/cli/test/contract-types.test.ts +++ b/packages/cli/test/contract-types.test.ts @@ -28,6 +28,7 @@ import type { // literal rather than deriving them from the source types: equality below is // intended to fail compilation when either side drifts. type SpecWritable = { + isTTY?: boolean; write(chunk: string | Uint8Array): unknown; }; diff --git a/packages/desktop/README.md b/packages/desktop/README.md index 7d8899cb..308ff279 100644 --- a/packages/desktop/README.md +++ b/packages/desktop/README.md @@ -6,7 +6,7 @@ The workspace, `@telepath-computer/television-desktop`, is private, so npm never ## Development -From the repository root, `npm run start:electron` builds the bundle and opens it in Electron; its connect window asks for a server URL and, when the server uses authentication, its token. `npm run build:desktop` builds the bundle into `packages/desktop/dist/` without starting it. The desktop tests run through the [canonical test runner](../../specs/arch/test-runner/test-runner.md). +From the repository root, `npm run start:electron` builds the bundle and opens it in Electron. With a saved connection it reconnects automatically; otherwise its setup screen asks for one connect link. `npm run build:desktop` builds the bundle into `packages/desktop/dist/` without starting it. The desktop tests run through the [canonical test runner](../../specs/arch/test-runner/test-runner.md). ## ToDesktop builds diff --git a/packages/desktop/build.mjs b/packages/desktop/build.mjs index ddd7659e..93c5a674 100644 --- a/packages/desktop/build.mjs +++ b/packages/desktop/build.mjs @@ -12,8 +12,9 @@ import path from "node:path"; import { fileURLToPath } from "node:url"; -import { copyFileSync, rmSync } from "node:fs"; +import { copyFileSync, cpSync, readFileSync, rmSync } from "node:fs"; import { build } from "esbuild"; +import { createRequire } from "node:module"; import { generateNoticesFromInventory } from "../../scripts/licenses/generate-notices.mjs"; import { mergeSurfaceInventories, @@ -58,6 +59,17 @@ const buildResults = await Promise.all([ target: "es2022", format: "iife", outfile: path.join(distDir, "connect-page.cjs"), + loader: { ".woff2": "file" }, + assetNames: "assets/[name]-[hash]", + // The shared UI modules use Vite's raw SVG and inline CSS imports. + plugins: [{ name: "inline-ui-assets", setup(builder) { + builder.onResolve({ filter: /\?(raw|inline)$/ }, args => ({ + path: createRequire(path.join(args.resolveDir, "entry.cjs")).resolve(args.path.replace(/\?(raw|inline)$/, "")), + suffix: args.path.slice(args.path.lastIndexOf("?")), + })); + builder.onLoad({ filter: /\.(svg|css)$/ }, args => /\?(raw|inline)$/.test(args.suffix) + ? { contents: readFileSync(args.path, "utf8"), loader: "text" } : undefined); + } }], metafile: true, }), build({ @@ -78,6 +90,8 @@ copyFileSync( path.join(distDir, "connect.html"), ); +cpSync(path.join(repoRoot, "packages/server/assets/themes/clouds"), path.join(distDir, "clouds"), { recursive: true }); + const bundleInventories = buildResults.map((result, index) => { if (result.metafile === undefined) throw new Error(`Desktop esbuild call ${index + 1} produced no metafile`); return inventoryFromEsbuildMetafile({ diff --git a/packages/desktop/package.json b/packages/desktop/package.json index b299d9e6..e26743a6 100644 --- a/packages/desktop/package.json +++ b/packages/desktop/package.json @@ -22,7 +22,9 @@ "devDependencies": { "@telepath-computer/television-artifact": "*", "@telepath-computer/television-shared": "*", + "@telepath-computer/utils": "*", "@todesktop/cli": "1.28.2", - "electron": "43.7.6" + "electron": "43.7.6", + "lit-html": "3.3.2" } } diff --git a/packages/desktop/src/assets.d.ts b/packages/desktop/src/assets.d.ts new file mode 100644 index 00000000..11f02fe2 --- /dev/null +++ b/packages/desktop/src/assets.d.ts @@ -0,0 +1 @@ +/// diff --git a/packages/desktop/src/connect-error.ts b/packages/desktop/src/connect-error.ts index 9e93a3fc..12130555 100644 --- a/packages/desktop/src/connect-error.ts +++ b/packages/desktop/src/connect-error.ts @@ -1,4 +1,4 @@ -export type ConnectResult = { ok: true } | { ok: false; message: string }; +export type ConnectResult = { ok: true; attempt: number } | { ok: false; message: string }; /** Strip Electron IPC wrapper text from invoke() rejections. */ export function connectErrorMessage(error: unknown): string { diff --git a/packages/desktop/src/connect-page.css b/packages/desktop/src/connect-page.css new file mode 100644 index 00000000..381f21a7 --- /dev/null +++ b/packages/desktop/src/connect-page.css @@ -0,0 +1,2 @@ +html, body { margin: 0; min-height: 100%; } +body { background: var(--color-surface); } diff --git a/packages/desktop/src/connect-page.ts b/packages/desktop/src/connect-page.ts index a25f4612..92583b05 100644 --- a/packages/desktop/src/connect-page.ts +++ b/packages/desktop/src/connect-page.ts @@ -1,142 +1,86 @@ +import "../../web/src/foundation/index.css"; +import "../../web/src/foundation/app.css"; +import "../../web/src/global.css"; +import { render } from "lit-html"; +import { SystemModalView } from "../../web/src/views/system-modal.ts"; import { connectErrorMessage, type ConnectResult } from "./connect-error.ts"; -import type { ConnectScreenIntent } from "./connect-screen.ts"; -import { parseDesktopConnectURL } from "./connect-url.ts"; +import type { ConnectScreenState } from "./connect-screen.ts"; +import { setupTemplate, type SetupState } from "./setup.ts"; +import "./connect-page.css"; export interface ConnectPageAPI { - getConnectScreenIntent(): Promise; - getConnection(): Promise<{ serverURL: string; token: string } | null>; - connect(serverURL: string, token: string): Promise; + getState(): Promise; + onState(callback: (state: ConnectScreenState) => void): () => void; + connect(link: string): Promise; + completeConnect(attempt: number): Promise; + disconnect(): Promise; } -export interface ConnectPageElements { - form: HTMLFormElement; - errorEl: HTMLElement; - submitButton: HTMLButtonElement; - submitLabel: HTMLElement; - serverInput: HTMLInputElement; - tokenInput: HTMLInputElement; -} - -export const MIN_CONNECT_MS = 500; - -export function bindConnectPage(api: ConnectPageAPI, elements: ConnectPageElements): () => void { - let busy = false; - - function setBusy(next: boolean): void { - busy = next; - if (next && document.activeElement instanceof HTMLElement) { - document.activeElement.blur(); - } - elements.submitButton.disabled = next; - elements.submitButton.dataset.connecting = next ? "true" : "false"; - elements.submitLabel.textContent = next ? "Connecting…" : "Connect"; - elements.serverInput.disabled = next; - elements.tokenInput.disabled = next; - } - - async function waitForMinConnect(started: number): Promise { - const remaining = MIN_CONNECT_MS - (Date.now() - started); - if (remaining > 0) { - await new Promise((resolve) => setTimeout(resolve, remaining)); - } - } - - async function runConnect(serverURL: string, token: string): Promise { - if (busy) return; - elements.errorEl.textContent = ""; - if (!serverURL.trim()) return; - setBusy(true); - const started = Date.now(); +export function initConnectPage(api: ConnectPageAPI, host: HTMLElement = document.getElementById("app")!): () => void { + let connection: ConnectScreenState | null = null; + let state: SetupState = "ready"; + let link = ""; + let error = ""; + let disposed = false; + const appearance = window.matchMedia("(prefers-color-scheme: dark)"); + const applyAppearance = () => document.documentElement.setAttribute("data-theme", appearance.matches ? "dark" : "light"); + applyAppearance(); + appearance.addEventListener("change", applyAppearance); + + const paint = () => { + render(connection?.kind === "setup" + ? setupTemplate(state, link, error, submit) + : connection ? SystemModalView(connection, { context: "local", dragStrip: true, onDisconnect: () => void api.disconnect() }) : null, host); + }; + const nextFrame = () => new Promise(resolve => requestAnimationFrame(() => resolve())); + async function submit(event: Event): Promise { + event.preventDefault(); + if (state === "connecting" || state === "connected") return; + link = host.querySelector(".setup-link input")!.value; + if (!link.trim()) return; + state = "connecting"; + error = ""; + paint(); try { - const result = await api.connect(serverURL, token); + const result = await api.connect(link); + if (disposed) return; if (!result.ok) { - await waitForMinConnect(started); - elements.errorEl.textContent = result.message; - setBusy(false); + state = "error"; + error = result.message; + paint(); + return; } - } catch (err) { - await waitForMinConnect(started); - elements.errorEl.textContent = connectErrorMessage(err); - setBusy(false); + state = "connected"; + paint(); + // Wait for the authored fade when it runs. Reduced motion has no + // animations; paint Connected once before handing navigation to main. + const done = host.querySelector(".setup-done")!; + await nextFrame(); + await Promise.all(done.getAnimations().map(animation => animation.finished.catch(() => {}))); + await nextFrame(); + if (!disposed) await api.completeConnect(result.attempt); + } catch (failure) { + if (disposed) return; + state = "error"; + error = connectErrorMessage(failure); + paint(); } } - - function applyConnectURL(input: string): void { - let parsed: ReturnType; - try { - parsed = parseDesktopConnectURL(input); - } catch { - return; - } - elements.serverInput.value = parsed.serverURL; - if (parsed.token !== null) { - elements.tokenInput.value = parsed.token; - } - } - - const onPasteServerURL = (event: ClipboardEvent): void => { - const text = event.clipboardData?.getData("text"); - if (!text) return; - try { - parseDesktopConnectURL(text); - } catch { - return; - } - event.preventDefault(); - applyConnectURL(text); - }; - - const onSubmit = (event: Event): void => { - event.preventDefault(); - applyConnectURL(elements.serverInput.value); - void runConnect(elements.serverInput.value, elements.tokenInput.value); - }; - - elements.serverInput.addEventListener("paste", onPasteServerURL); - elements.form.addEventListener("submit", onSubmit); - - void (async () => { - const intent = await api.getConnectScreenIntent(); - const saved = await api.getConnection(); - if (saved) { - elements.serverInput.value = saved.serverURL; - elements.tokenInput.value = saved.token; - } - if (intent === "bootstrap" && saved) { - await runConnect(saved.serverURL, saved.token); - } - })(); - + const unsubscribe = api.onState(next => { connection = next; paint(); }); + void api.getState().then(initial => { + if (disposed || connection !== null) return; + connection = initial; + paint(); + }); return () => { - elements.serverInput.removeEventListener("paste", onPasteServerURL); - elements.form.removeEventListener("submit", onSubmit); + disposed = true; + unsubscribe(); + appearance.removeEventListener("change", applyAppearance); + render(null, host); }; } -export function initConnectPage(api: ConnectPageAPI): void { - const form = document.getElementById("connect-form"); - const errorEl = document.getElementById("error"); - const submitButton = document.getElementById("submit"); - const serverInput = document.getElementById("serverURL"); - const tokenInput = document.getElementById("token"); - const submitLabel = submitButton?.querySelector(".button-label"); - - if (!(form instanceof HTMLFormElement)) throw new Error("connect form missing"); - if (!(errorEl instanceof HTMLElement)) throw new Error("connect error element missing"); - if (!(submitButton instanceof HTMLButtonElement)) throw new Error("connect submit button missing"); - if (!(serverInput instanceof HTMLInputElement)) throw new Error("connect server input missing"); - if (!(tokenInput instanceof HTMLInputElement)) throw new Error("connect token input missing"); - if (!(submitLabel instanceof HTMLElement)) throw new Error("connect submit label missing"); - - bindConnectPage(api, { form, errorEl, submitButton, submitLabel, serverInput, tokenInput }); -} - declare global { - interface Window { - television: ConnectPageAPI; - } -} - -if (typeof window !== "undefined" && window.television) { - initConnectPage(window.television); + interface Window { television: ConnectPageAPI; } } +if (typeof window !== "undefined" && window.television) initConnectPage(window.television); diff --git a/packages/desktop/src/connect-preflight.ts b/packages/desktop/src/connect-preflight.ts index b800419c..eed794f7 100644 --- a/packages/desktop/src/connect-preflight.ts +++ b/packages/desktop/src/connect-preflight.ts @@ -15,6 +15,7 @@ export interface PreflightOptions { desktopAppVersion: string; fetchImpl?: typeof fetch; timeoutMs?: number; + signal?: AbortSignal; } function unreachable(serverURL: string): PreflightResult { @@ -47,7 +48,9 @@ export async function preflightConnection(options: PreflightOptions): Promise => - ipcRenderer.invoke(GET_CONNECT_SCREEN_INTENT_CHANNEL), - getConnection: (): Promise<{ serverURL: string; token: string } | null> => - ipcRenderer.invoke("television:get-connection"), - connect: (serverURL: string, token: string): Promise => - ipcRenderer.invoke("television:connect", { serverURL, token }), + getState: (): Promise => ipcRenderer.invoke(GET_CONNECT_STATE_CHANNEL), + onState(callback: (state: ConnectScreenState) => void): () => void { + const listener = (_event: Electron.IpcRendererEvent, state: ConnectScreenState) => callback(state); + ipcRenderer.on(CONNECT_STATE_CHANNEL, listener); + return () => ipcRenderer.removeListener(CONNECT_STATE_CHANNEL, listener); + }, + connect: (link: string): Promise => ipcRenderer.invoke("television:connect", link), + completeConnect: (attempt: number): Promise => ipcRenderer.invoke(COMPLETE_CONNECT_CHANNEL, attempt), + disconnect: (): Promise => ipcRenderer.invoke(DISCONNECT_CHANNEL), }); } diff --git a/packages/desktop/src/connect-screen.ts b/packages/desktop/src/connect-screen.ts index f353aa13..d8dd1428 100644 --- a/packages/desktop/src/connect-screen.ts +++ b/packages/desktop/src/connect-screen.ts @@ -1,4 +1,11 @@ -/** Why the connect screen was opened — main process sets this before each loadFile. */ -export type ConnectScreenIntent = "bootstrap" | "manual"; +/** Presentation of the packaged local page; main owns saved-connection retries. */ +export type ConnectScreenState = + | { kind: "setup" } + | { kind: "connecting"; serverURL: string } + | { kind: "unauthorized"; serverURL: string } + | { kind: "error"; serverURL: string; nextRetryAt: number | null }; -export const GET_CONNECT_SCREEN_INTENT_CHANNEL = "television:get-connect-screen-intent"; +export const GET_CONNECT_STATE_CHANNEL = "television:get-connect-state"; +export const CONNECT_STATE_CHANNEL = "television:connect-state"; +export const COMPLETE_CONNECT_CHANNEL = "television:complete-connect"; +export const DISCONNECT_CHANNEL = "television:disconnect"; diff --git a/packages/desktop/src/connect-url.ts b/packages/desktop/src/connect-url.ts index 1a4a1c91..e05a589e 100644 --- a/packages/desktop/src/connect-url.ts +++ b/packages/desktop/src/connect-url.ts @@ -16,7 +16,9 @@ export function parseDesktopConnectURL(input: string): ParsedDesktopConnectURL { const trimmed = input.trim(); if (!trimmed) throw new ConnectURLError(); - if (/^[a-zA-Z][a-zA-Z0-9+.-]*:\/\//.test(trimmed) && !/^https?:\/\//i.test(trimmed)) { + if (/^[a-zA-Z][a-zA-Z0-9+.-]*:/.test(trimmed) + && !/^https?:\/\//i.test(trimmed) + && !/^[^:/?#]+:\d+(?:[/?#]|$)/.test(trimmed)) { throw new ConnectURLError(); } diff --git a/packages/desktop/src/connect.html b/packages/desktop/src/connect.html index 03cfb6f0..3db24653 100644 --- a/packages/desktop/src/connect.html +++ b/packages/desktop/src/connect.html @@ -1,148 +1,14 @@ - + - + Television - + + -
-
-
- - -
- -
-
+
diff --git a/packages/desktop/src/connection-store.ts b/packages/desktop/src/connection-store.ts index 84fcc1a7..94578980 100644 --- a/packages/desktop/src/connection-store.ts +++ b/packages/desktop/src/connection-store.ts @@ -1,6 +1,7 @@ import { app } from "electron"; -import { existsSync, readFileSync, renameSync, writeFileSync } from "node:fs"; +import { existsSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs"; import path from "node:path"; +import { parseDesktopConnectURL } from "./connect-url.ts"; export interface Connection { serverURL: string; @@ -17,7 +18,8 @@ export function loadConnection(): Connection | null { try { const parsed = JSON.parse(readFileSync(file, "utf8")) as Partial; if (typeof parsed.serverURL !== "string") return null; - return { serverURL: parsed.serverURL, token: typeof parsed.token === "string" ? parsed.token : "" }; + const { serverURL } = parseDesktopConnectURL(parsed.serverURL); + return { serverURL, token: typeof parsed.token === "string" ? parsed.token : "" }; } catch { return null; } @@ -29,3 +31,7 @@ export function saveConnection(connection: Connection): void { writeFileSync(tmp, JSON.stringify(connection, null, 2), "utf8"); renameSync(tmp, file); } + +export function deleteConnection(): void { + rmSync(storePath(), { force: true }); +} diff --git a/packages/desktop/src/index.ts b/packages/desktop/src/index.ts index 7c2ff649..47fca46c 100644 --- a/packages/desktop/src/index.ts +++ b/packages/desktop/src/index.ts @@ -12,11 +12,14 @@ import todesktop from "@todesktop/runtime"; import path from "node:path"; import { buildRemoteURL, ConnectURLError, parseDesktopConnectURL, resolveDesktopAppVersion } from "./connect-url.ts"; import { preflightConnection } from "./connect-preflight.ts"; -import { loadConnection, saveConnection, type Connection } from "./connection-store.ts"; +import { loadConnection, saveConnection, deleteConnection, type Connection } from "./connection-store.ts"; import type { ConnectResult } from "./connect-error.ts"; import { - GET_CONNECT_SCREEN_INTENT_CHANNEL, - type ConnectScreenIntent, + GET_CONNECT_STATE_CHANNEL, + CONNECT_STATE_CHANNEL, + COMPLETE_CONNECT_CHANNEL, + DISCONNECT_CHANNEL, + type ConnectScreenState, } from "./connect-screen.ts"; import { handleNativeNavigationKey, @@ -58,11 +61,12 @@ todesktop.autoUpdater?.on("update-downloaded", ({ updateInfo }) => { const WINDOW_WIDTH = 1400; const WINDOW_HEIGHT = 1000; const TRAFFIC_LIGHT_POSITION = { x: 15, y: 15 }; -const GET_CONNECTION_CHANNEL = "television:get-connection"; const CONNECT_CHANNEL = "television:connect"; const TEST_EXTERNAL_OPEN_CHANNEL = "television:test:external-open"; const TEST_FIXTURE_FLAG = "--test-fixture"; const ERR_ABORTED = -3; +const INITIAL_RETRY_MS = 1_000; +const MAX_RETRY_MS = 30_000; if (process.env.TV_ELECTRON_CHROMIUM_LOGS !== "1") { app.commandLine?.appendSwitch("log-level", "3"); @@ -122,7 +126,14 @@ function restartToInstallUpdate(): void { export class App { private window: BrowserWindow | null = null; - private connectScreenIntent: ConnectScreenIntent = "manual"; + private connection: Connection | null = null; + private connectState: ConnectScreenState = { kind: "setup" }; + private attempt = 0; + private checkAbort: AbortController | null = null; + private retryTimer: ReturnType | null = null; + private retryDelay = INITIAL_RETRY_MS; + private pendingNavigation: { attempt: number; connection: Connection; version: string } | null = null; + private localPage = false; async start(): Promise { await app.whenReady(); @@ -134,6 +145,7 @@ export class App { app.dock?.setIcon(path.join(__dirname, "..", "assets", "icon.png")); } nativeTheme.themeSource = "system"; + this.connection = loadConnection(); this.installMenu(); this.installWindowOpenHandler(); ipcMain.on(SET_APPEARANCE_MODE_CHANNEL, (_event, mode: unknown) => { @@ -141,9 +153,10 @@ export class App { }); ipcMain.handle(GET_DESKTOP_UPDATE_CHANNEL, async () => downloadedUpdateVersion); ipcMain.on(RESTART_TO_INSTALL_UPDATE_CHANNEL, () => restartToInstallUpdate()); - ipcMain.handle(GET_CONNECTION_CHANNEL, async () => loadConnection()); - ipcMain.handle(GET_CONNECT_SCREEN_INTENT_CHANNEL, async () => this.connectScreenIntent); - ipcMain.handle(CONNECT_CHANNEL, async (_event, connection: Connection) => this.tryConnect(connection)); + ipcMain.handle(GET_CONNECT_STATE_CHANNEL, async () => this.connectState); + ipcMain.handle(CONNECT_CHANNEL, async (_event, link: string) => this.tryConnect(link)); + ipcMain.handle(COMPLETE_CONNECT_CHANNEL, async (_event, attempt: number) => this.completeConnect(attempt)); + ipcMain.handle(DISCONNECT_CHANNEL, async () => this.disconnect()); await this.createWindow(); app.on("window-all-closed", () => { @@ -179,11 +192,12 @@ export class App { this.window.webContents.on("did-fail-load", (_event, code, _description, validatedURL, isMainFrame) => { if (!isMainFrame) return; if (code === ERR_ABORTED) return; - if (validatedURL.startsWith("file:")) return; - void this.loadConnectScreen("manual"); + if (validatedURL.startsWith("file:") || this.localPage) return; + void this.loadConnectScreen(); }); this.window.once("ready-to-show", () => this.window?.show()); this.window.on("closed", () => { + this.cancelConnectionWork(); this.window = null; }); @@ -193,49 +207,113 @@ export class App { return; } - await this.loadConnectScreen("bootstrap"); + await this.loadConnectScreen(); } - /** @param intent bootstrap = app open / retry saved connection; manual = user chose connect screen */ - async loadConnectScreen(intent: ConnectScreenIntent = "manual"): Promise { + private setConnectState(state: ConnectScreenState): void { + this.connectState = state; + this.window?.webContents.send(CONNECT_STATE_CHANNEL, state); + } + + private cancelConnectionWork(): number { + this.attempt += 1; + this.checkAbort?.abort(); + this.checkAbort = null; + if (this.retryTimer !== null) clearTimeout(this.retryTimer); + this.retryTimer = null; + this.pendingNavigation = null; + return this.attempt; + } + + async loadConnectScreen(): Promise { if (!this.window) return; - this.connectScreenIntent = intent; + const attempt = this.cancelConnectionWork(); + this.localPage = true; + this.retryDelay = INITIAL_RETRY_MS; + this.setConnectState(this.connection + ? { kind: "connecting", serverURL: this.connection.serverURL } + : { kind: "setup" }); await this.window.loadFile(path.join(__dirname, "connect.html")); + if (attempt === this.attempt && this.connection) void this.checkSavedConnection(attempt); } - private async tryConnect(raw: Connection): Promise { + private async disconnect(): Promise { + this.cancelConnectionWork(); + this.window?.webContents.stop(); + deleteConnection(); + this.connection = null; + this.installMenu(); + await this.loadConnectScreen(); + } + + private async check(connection: Connection) { + const version = resolveDesktopAppVersion(app.getVersion(), process.env); + this.checkAbort = new AbortController(); + const result = await preflightConnection({ + ...connection, desktopAppVersion: version, signal: this.checkAbort.signal, + }); + return { result, version }; + } + + private async checkSavedConnection(attempt: number): Promise { + const connection = this.connection; + if (!connection || attempt !== this.attempt) return; + if (this.connectState.kind === "error") { + this.setConnectState({ kind: "error", serverURL: connection.serverURL, nextRetryAt: null }); + } + const { result, version } = await this.check(connection); + if (attempt !== this.attempt) return; + if (result.ok) { + await this.loadRemote(connection, version); + } else if (result.code === "auth-required" || result.code === "auth-rejected") { + this.setConnectState({ kind: "unauthorized", serverURL: connection.serverURL }); + } else { + const delay = this.retryDelay; + this.retryDelay = Math.min(delay * 2, MAX_RETRY_MS); + this.setConnectState({ kind: "error", serverURL: connection.serverURL, nextRetryAt: Date.now() + delay }); + this.retryTimer = setTimeout(() => { + this.retryTimer = null; + void this.checkSavedConnection(attempt); + }, delay); + } + } + + private async tryConnect(link: string): Promise { + const attempt = this.cancelConnectionWork(); let connection: Connection; try { - const parsed = parseDesktopConnectURL(raw.serverURL); - connection = { serverURL: parsed.serverURL, token: parsed.token ?? raw.token ?? "" }; + const parsed = parseDesktopConnectURL(link); + connection = { serverURL: parsed.serverURL, token: parsed.token ?? "" }; } catch (error) { const message = error instanceof ConnectURLError ? error.message : new ConnectURLError().message; return { ok: false, message }; } - try { - const desktopAppVersion = resolveDesktopAppVersion(app.getVersion(), process.env); - const preflight = await preflightConnection({ - serverURL: connection.serverURL, - token: connection.token, - desktopAppVersion, - }); - if (!preflight.ok) { - return { ok: false, message: preflight.message }; - } - + const { result, version } = await this.check(connection); + if (attempt !== this.attempt) return { ok: false, message: "Connection cancelled" }; + if (!result.ok) return { ok: false, message: result.message }; saveConnection(connection); - await this.loadRemote(connection, desktopAppVersion); - return { ok: true }; + this.connection = connection; + this.installMenu(); + this.pendingNavigation = { attempt, connection, version }; + return { ok: true, attempt }; } catch (error) { - const message = error instanceof Error ? error.message : "Failed to connect"; - return { ok: false, message }; + return { ok: false, message: error instanceof Error ? error.message : "Failed to connect" }; } } + private async completeConnect(attempt: number): Promise { + const pending = this.pendingNavigation; + if (!pending || pending.attempt !== attempt || attempt !== this.attempt) return; + this.pendingNavigation = null; + await this.loadRemote(pending.connection, pending.version); + } + private async loadRemote(connection: Connection, desktopAppVersion: string): Promise { if (!this.window) return; - await this.window.loadURL(buildRemoteURL(connection.serverURL, connection.token, desktopAppVersion)); + this.localPage = false; + // did-fail-load owns recovery. Electron also rejects loadURL's promise. + await this.window.loadURL(buildRemoteURL(connection.serverURL, connection.token, desktopAppVersion)).catch(() => {}); } private installWindowOpenHandler(): void { @@ -276,9 +354,10 @@ export class App { private installMenu(): void { const isMac = process.platform === "darwin"; const connectItem: MenuItemConstructorOptions = { - label: "Connect to server…", + label: "Disconnect from Server", + enabled: this.connection !== null, accelerator: "CmdOrCtrl+,", - click: () => void this.loadConnectScreen("manual"), + click: () => void this.disconnect(), }; const template: MenuItemConstructorOptions[] = [ ...(isMac diff --git a/packages/desktop/src/setup.css b/packages/desktop/src/setup.css new file mode 100644 index 00000000..5e77c780 --- /dev/null +++ b/packages/desktop/src/setup.css @@ -0,0 +1,235 @@ +/* The screen: the wallpaper, blurred, filling the window, with the frame + centred on it. */ +.setup-screen { + position: relative; + isolation: isolate; + min-height: 100vh; + box-sizing: border-box; + display: grid; + place-items: center; + padding: var(--space-32); + overflow: hidden; + /* The desktop app's window has no title bar, so the window is dragged + by this background; the card itself is not a drag area. */ + -webkit-app-region: drag; +} + +/* Oversized past the window so the blur doesn't fade at the edges. */ +.setup-wallpaper { + position: absolute; + inset: calc(-1 * var(--space-48)); + z-index: -1; + background: var(--app-wallpaper); + filter: blur(24px); +} + +.setup-window { + width: min(640px, 100%); + -webkit-app-region: no-drag; +} + +/* The steps and the Connected confirmation share one cell, so the frame + keeps its size when the steps fade out. */ +.setup-body { + display: grid; +} + +.setup-body > * { + grid-area: 1 / 1; + transition: opacity 250ms ease; +} + +.setup-done { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: var(--space-16); + opacity: 0; + pointer-events: none; +} + +.setup-screen[data-state="connected"] .setup-document { + opacity: 0; + pointer-events: none; +} + +.setup-screen[data-state="connected"] .setup-done { + opacity: 1; +} + +.setup-done-mark { + display: grid; + place-items: center; + width: 56px; + height: 56px; + border-radius: 50%; + box-sizing: border-box; + border: 2px solid var(--color-success); + color: var(--color-success); +} + +.setup-done p { + margin: 0; + font-size: var(--text-xl); + font-weight: var(--font-weight-semibold); +} + +/* The document: reading text one step above the prose size. */ +.setup-document { + display: flex; + flex-direction: column; + gap: var(--space-32); + padding: var(--space-48); + font-size: var(--text-lg); +} + +.setup-document h1, +.setup-document h2, +.setup-document p, +.setup-steps { + margin: 0; +} + +.setup-header { + display: flex; + flex-direction: column; + gap: var(--space-10); +} + +.setup-document h1 { + font-size: var(--text-3xl); + font-weight: var(--font-weight-semibold); + line-height: 1.2; +} + +/* The Television logo, sized to the heading it sits in. */ +.setup-logo { + width: 1em; + height: 1em; + margin: 0 0.05em; + vertical-align: -0.14em; +} + +/* The steps: numbered markers joined by a line down the left. */ +.setup-steps { + display: flex; + flex-direction: column; + gap: var(--space-32); + padding: 0; + list-style: none; +} + +.setup-step { + position: relative; + display: grid; + grid-template-columns: 28px minmax(0, 1fr); + column-gap: var(--space-16); +} + +.setup-step:not(:last-child)::before { + content: ""; + position: absolute; + left: 13px; + top: 36px; + bottom: calc(-1 * var(--space-32) + var(--space-8)); + width: 2px; + border-radius: 1px; + background: var(--color-border); +} + +/* Both markers are always at full strength: the numbers show the usual + order, and nothing tracks a current step. */ +.setup-marker { + display: grid; + place-items: center; + width: 28px; + height: 28px; + border-radius: 50%; + box-sizing: border-box; + border: 1.5px solid var(--color-primary); + background: var(--color-primary); + color: var(--color-primary-text); + font-size: var(--text-sm); + font-weight: var(--font-weight-semibold); +} + +.setup-step-body { + display: flex; + flex-direction: column; + gap: var(--space-12); + padding-top: 3px; +} + +.setup-document h2 { + font-size: var(--text-lg); + font-weight: var(--font-weight-semibold); +} + +/* The prompt card, tilted slightly; one press selects the whole prompt. */ +.setup-prompt { + display: flex; + align-items: center; + gap: var(--space-16); + padding: var(--space-16); + border: 1px solid var(--color-border); + border-radius: 14px; + background: var(--color-surface-muted); + box-shadow: var(--shadow-md); + transform: rotate(-1deg); +} + +.setup-prompt p { + flex: 1; + min-width: 0; + font-family: var(--font-mono); + font-size: var(--text-md); + line-height: 1.5; + overflow-wrap: break-word; + user-select: all; +} + +.setup-prompt-actions { + flex: none; +} + +.setup-link { + display: flex; + gap: var(--space-8); +} + +.setup-link input { + flex: 1; + min-width: 0; +} + +/* Connect's idle and connecting faces share one grid cell, so the button + keeps the wider face's width. */ +.setup-submit { + display: inline-grid; +} + +.setup-submit > span { + grid-area: 1 / 1; + display: inline-flex; + align-items: center; + justify-content: center; + gap: var(--space-6); + visibility: hidden; +} + +.setup-screen:is([data-state="ready"], [data-state="error"]) .setup-submit-idle, +.setup-screen:is([data-state="connecting"], [data-state="connected"]) .setup-submit-busy { + visibility: visible; +} + +.setup-hint code { + font-family: var(--font-mono); + font-size: var(--text-md); +} + +@media (prefers-reduced-motion: reduce) { + .setup-body > * { + transition: none; + } +} diff --git a/packages/desktop/src/setup.ts b/packages/desktop/src/setup.ts new file mode 100644 index 00000000..b8f8b576 --- /dev/null +++ b/packages/desktop/src/setup.ts @@ -0,0 +1,63 @@ +import { html, nothing } from "lit-html"; +import { copyButtonTemplate } from "../../web/src/views/copy-button.ts"; +import "./setup.css"; +import "../../web/src/views/artifact-view.css"; + +export const SETUP_PROMPT = "Read the Television admin guide at https://television.run/install.md and help me get Television installed. I'm on the desktop app connect screen."; +export type SetupState = "ready" | "connecting" | "connected" | "error"; + +export function setupTemplate(state: SetupState, link: string, error: string, submit: (event: Event) => void) { + const busy = state === "connecting" || state === "connected"; + return html`
+ +
+
+
+
+
+

Let’s connect your Television

+

Television runs alongside your AI agent. Ask your agent to help you get connected, then paste the link it gives you.

+
+
    +
  1. + 1 +
    +

    Give your agent this prompt

    +
    +

    ${SETUP_PROMPT}

    +
    + ${copyButtonTemplate({ label: "Copy", prompt: SETUP_PROMPT, size: "default" })} +
    +
    +
    +
  2. +
  3. + 2 +
    +

    Paste the link from your agent

    + + ${state === "error" + ? html`` + : html`

    The link looks like http://…:32848/?token=…

    `} +
    +
  4. +
+
+
+ +

Connected

+
+
+
+
Connect to Television
+
+
`; +} diff --git a/packages/desktop/test/connect-flow.test.ts b/packages/desktop/test/connect-flow.test.ts index 909a7de2..64c7b6fc 100644 --- a/packages/desktop/test/connect-flow.test.ts +++ b/packages/desktop/test/connect-flow.test.ts @@ -1,6 +1,6 @@ /** - * End-to-end connect flow through main-process IPC + persistence + connect page API. - * Exercises the scenarios a human would hit without launching Electron UI. + * Main-process contracts with recording Electron/filesystem peers and controlled + * fetch/timers. Real IPC, disk and navigation are crossed by desktop acceptance. */ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; @@ -12,7 +12,7 @@ const mockState = vi.hoisted(() => { loadURL = vi.fn(async (_url: string) => {}); loadFile = vi.fn(async () => {}); show = vi.fn(); - webContents = { on: vi.fn(), send: vi.fn() }; + webContents = { on: vi.fn(), send: vi.fn(), stop: vi.fn() }; once = vi.fn(); on = vi.fn(); constructor() { @@ -72,6 +72,7 @@ vi.mock("node:fs", async (importOriginal) => { writeFileSync: (path: string, content: string) => { fsState.files.set(path, content); }, + rmSync: (path: string) => fsState.files.delete(path), renameSync: (oldPath: string, newPath: string) => { const content = fsState.files.get(oldPath); if (content === undefined) throw new Error(`ENOENT: ${oldPath}`); @@ -87,6 +88,7 @@ describe("connect flow", () => { const storePath = "/tmp/television-connect-flow/connection.json"; beforeEach(() => { + vi.useFakeTimers(); mockState.MockBrowserWindow.instances.length = 0; mockState.ipcHandlers.clear(); mockState.Menu.buildFromTemplate.mockClear(); @@ -98,6 +100,7 @@ describe("connect flow", () => { }); afterEach(() => { + vi.useRealTimers(); vi.unstubAllGlobals(); }); @@ -107,110 +110,142 @@ describe("connect flow", () => { return main; } - it("does not persist submitted fields when the connect check fails", async () => { - fetchMock.mockResolvedValue(new Response(JSON.stringify({ error: "Unauthorized" }), { status: 401 })); - fsState.files.set(storePath, JSON.stringify({ serverURL: "http://127.0.0.1:8", token: "old-token" })); - await startApp(); + function invoke(channel: string, ...args: unknown[]): Promise { + return Promise.resolve(mockState.ipcHandlers.get(`television:${channel}`)!({}, ...args)); + } - const connect = mockState.ipcHandlers.get("television:connect")!; - await expect(connect({}, { serverURL: "http://127.0.0.1:9", token: "my-token" })).resolves.toEqual({ - ok: false, - message: "Token rejected", - }); - - expect(JSON.parse(fsState.files.get(storePath)!)).toEqual({ - serverURL: "http://127.0.0.1:8", - token: "old-token", - }); - - const getConnection = mockState.ipcHandlers.get("television:get-connection")!; - await expect(getConnection({})).resolves.toEqual({ - serverURL: "http://127.0.0.1:8", - token: "old-token", - }); - }); + function disconnectItem() { + const template = mockState.Menu.buildFromTemplate.mock.calls.at(-1)![0] as Array<{ + submenu?: Array<{ label?: string; enabled?: boolean; accelerator?: string; click?: () => void }>; + }>; + return template.flatMap(item => item.submenu ?? []).find(item => item.label === "Disconnect from Server")!; + } - it("loads saved connections that do not have a token", async () => { - fsState.files.set(storePath, JSON.stringify({ serverURL: "http://127.0.0.1:32848" })); + // proofs/arch/desktop/connect-flow.md#^desktop-t-connect-entry + it("starts without a record in setup and saves a checked link before the Connected handoff", async () => { await startApp(); - - const getConnection = mockState.ipcHandlers.get("television:get-connection")!; - await expect(getConnection({})).resolves.toEqual({ - serverURL: "http://127.0.0.1:32848", - token: "", - }); + expect(await invoke("get-connect-state")).toEqual({ kind: "setup" }); + expect(fetchMock).not.toHaveBeenCalled(); + expect(disconnectItem()).toMatchObject({ enabled: false, accelerator: "CmdOrCtrl+," }); + const result = await invoke("connect", "https://example.test/path?token=a%2Bb&extra=1"); + expect(result).toMatchObject({ ok: true, attempt: expect.any(Number) }); + expect(JSON.parse(fsState.files.get(storePath)!)).toEqual({ serverURL: "https://example.test", token: "a+b" }); + expect(disconnectItem().enabled).toBe(true); + const win = mockState.MockBrowserWindow.instances[0]; + expect(win.loadURL).not.toHaveBeenCalled(); + await invoke("complete-connect", result.attempt); + expect(new URL(win.loadURL.mock.calls[0]![0]!).searchParams.get("token")).toBe("a+b"); }); - it("simulates app restart: saved token preloads via get-connection then bootstrap can reconnect", async () => { - fsState.files.set( - storePath, - JSON.stringify({ serverURL: "http://127.0.0.1:32848", token: "restart-token" }), - ); - + it("keeps rejected setup links inline and a tokenless retry carries no previous token", async () => { await startApp(); + fetchMock.mockResolvedValueOnce(new Response("", { status: 401 })); + expect(await invoke("connect", "http://example.test/?token=wrong")).toMatchObject({ ok: false, message: expect.stringMatching(/link/) }); + expect(await invoke("get-connect-state")).toEqual({ kind: "setup" }); + expect(fsState.files.has(storePath)).toBe(false); + await invoke("connect", "http://example.test/anything"); + expect(fetchMock.mock.calls.at(-1)![1]!.headers).toEqual({}); + expect(JSON.parse(fsState.files.get(storePath)!)).toEqual({ serverURL: "http://example.test", token: "" }); + }); - const intent = mockState.ipcHandlers.get("television:get-connect-screen-intent")!; - const getConnection = mockState.ipcHandlers.get("television:get-connection")!; - expect(await intent({})).toBe("bootstrap"); - await expect(getConnection({})).resolves.toEqual({ - serverURL: "http://127.0.0.1:32848", - token: "restart-token", - }); - - const connect = mockState.ipcHandlers.get("television:connect")!; - await expect(connect({}, { serverURL: "http://127.0.0.1:32848", token: "restart-token" })).resolves.toEqual({ - ok: true, - }); - - expect(fetchMock).toHaveBeenCalledWith( - "http://127.0.0.1:32848/desktop/connect-check?desktopAppVersion=0.1.170", - expect.objectContaining({ - method: "GET", - headers: { Authorization: "Bearer restart-token" }, - }), - ); - const win = mockState.MockBrowserWindow.instances[0]; - expect(win.loadURL).toHaveBeenCalledTimes(1); - const loaded = new URL(win.loadURL.mock.calls[0]![0]!); - expect(loaded.searchParams.get("token")).toBe("restart-token"); - expect(loaded.searchParams.get("mode")).toBe("electron"); + it("starts saved records with Connecting then retries failures with backoff and stops on 401", async () => { + fsState.files.set(storePath, JSON.stringify({ serverURL: "http://example.test", token: "saved" })); + let resolveCheck!: (value: Response) => void; + fetchMock.mockImplementationOnce(() => new Promise(resolve => { resolveCheck = resolve; })); + await startApp(); + expect(await invoke("get-connect-state")).toEqual({ kind: "connecting", serverURL: "http://example.test" }); + expect(disconnectItem().enabled).toBe(true); + resolveCheck(new Response("unavailable", { status: 503 })); + await vi.advanceTimersByTimeAsync(0); + const first = await invoke("get-connect-state"); + const firstDelay = first.nextRetryAt - Date.now(); + expect(first).toMatchObject({ kind: "error", nextRetryAt: expect.any(Number) }); + let retryCheck!: (value: Response) => void; + fetchMock.mockImplementationOnce(() => new Promise(resolve => { retryCheck = resolve; })); + await vi.advanceTimersByTimeAsync(first.nextRetryAt - Date.now()); + expect(await invoke("get-connect-state")).toMatchObject({ kind: "error", nextRetryAt: null }); + retryCheck(new Response("", { status: 503 })); + await vi.advanceTimersByTimeAsync(0); + const second = await invoke("get-connect-state"); + expect(second.nextRetryAt - Date.now()).toBeGreaterThan(firstDelay); + fetchMock.mockResolvedValueOnce(new Response("", { status: 401 })); + await vi.advanceTimersByTimeAsync(second.nextRetryAt - Date.now()); + expect(await invoke("get-connect-state")).toMatchObject({ kind: "unauthorized" }); + const calls = fetchMock.mock.calls.length; + await vi.advanceTimersByTimeAsync(60000); + expect(fetchMock).toHaveBeenCalledTimes(calls); }); - it("menu reopen uses manual intent so page layer must not auto-submit saved connection", async () => { - fsState.files.set(storePath, JSON.stringify({ serverURL: "http://127.0.0.1:1", token: "tok" })); + it("reconnects saved records automatically and treats malformed records as setup", async () => { + fsState.files.set(storePath, "not json"); + await startApp(); + expect(await invoke("get-connect-state")).toEqual({ kind: "setup" }); + fsState.files.set(storePath, JSON.stringify({ serverURL: "http://example.test", token: "" })); + fetchMock.mockResolvedValueOnce(new Response("", { status: 503 })); await startApp(); + await vi.advanceTimersByTimeAsync(0); + const retry = await invoke("get-connect-state"); + expect(retry.kind).toBe("error"); + await vi.advanceTimersByTimeAsync(retry.nextRetryAt - Date.now()); + expect(mockState.MockBrowserWindow.instances.at(-1)!.loadURL).toHaveBeenCalledWith("http://example.test/?mode=electron&desktopAppVersion=0.1.170"); + }); - const template = mockState.Menu.buildFromTemplate.mock.calls.at(-1)![0] as Array<{ - submenu?: Array<{ label?: string; click?: () => void }>; - }>; - const connectItem = template.flatMap((item) => item.submenu ?? []).find((s) => s.label === "Connect to server…"); - connectItem!.click!(); - - const intent = mockState.ipcHandlers.get("television:get-connect-screen-intent")!; - expect(await intent({})).toBe("manual"); - await expect(mockState.ipcHandlers.get("television:get-connection")!({})).resolves.toEqual({ - serverURL: "http://127.0.0.1:1", - token: "tok", - }); + // proofs/arch/desktop/connect-flow.md#^desktop-t-disconnect + it("disconnect cancels pending checks, retries and a successful setup handoff", async () => { + fsState.files.set(storePath, JSON.stringify({ serverURL: "http://example.test", token: "secret" })); + let resolveCheck!: (value: Response) => void; + fetchMock.mockImplementationOnce(() => new Promise(resolve => { resolveCheck = resolve; })); + await startApp(); + expect(disconnectItem().enabled).toBe(true); + disconnectItem().click!(); + await vi.advanceTimersByTimeAsync(0); + expect(fetchMock.mock.calls[0]![1]!.signal!.aborted).toBe(true); + resolveCheck(new Response(JSON.stringify(connectCheckBody))); + await vi.advanceTimersByTimeAsync(0); + expect(fsState.files.has(storePath)).toBe(false); + const win = mockState.MockBrowserWindow.instances[0]; + expect(win.loadURL).not.toHaveBeenCalled(); + const success = await invoke("connect", "http://example.test"); + disconnectItem().click!(); + await vi.advanceTimersByTimeAsync(0); + await invoke("complete-connect", success.attempt); + expect(win.loadURL).not.toHaveBeenCalled(); + expect(fsState.files.has(storePath)).toBe(false); + expect(disconnectItem().enabled).toBe(false); + expect(await invoke("get-connect-state")).toEqual({ kind: "setup" }); + + fsState.files.set(storePath, JSON.stringify({ serverURL: "http://example.test", token: "" })); + fetchMock.mockRejectedValue(new Error("unreachable")); + await startApp(); + await vi.advanceTimersByTimeAsync(0); + await invoke("disconnect"); + const calls = fetchMock.mock.calls.length; + await vi.advanceTimersByTimeAsync(60000); + expect(fetchMock).toHaveBeenCalledTimes(calls); }); - it("remote load failure returns to connect screen in manual mode", async () => { + // proofs/arch/desktop/connect-flow.md#^desktop-t-connect-load-recovery + it("only a genuine top-level remote load failure re-enters saved startup", async () => { + fsState.files.set(storePath, JSON.stringify({ serverURL: "http://example.test", token: "" })); await startApp(); + await vi.advanceTimersByTimeAsync(0); const win = mockState.MockBrowserWindow.instances[0]; - const failLoad = win.webContents.on.mock.calls.find(([event]) => event === "did-fail-load")?.[1] as ( - event: unknown, - code: number, - desc: string, - url: string, - isMainFrame: boolean, - ) => void; - - expect(failLoad).toBeDefined(); + const fail = win.webContents.on.mock.calls.find(([event]) => event === "did-fail-load")![1] as Function; win.loadFile.mockClear(); - failLoad({}, -1, "ERR_FAILED", "http://127.0.0.1:9/?mode=electron", true); - - expect(win.loadFile).toHaveBeenCalledWith(expect.stringMatching(/connect\.html$/)); - const intent = mockState.ipcHandlers.get("television:get-connect-screen-intent")!; - expect(await intent({})).toBe("manual"); + fail({}, -3, "aborted", "http://example.test", true); + fail({}, -2, "failed", "http://example.test", false); + fail({}, -2, "failed", "file:///connect.html", true); + expect(win.loadFile).not.toHaveBeenCalled(); + fetchMock.mockRejectedValue(new Error("offline")); + fail({}, -2, "failed", "http://example.test", true); + await vi.advanceTimersByTimeAsync(0); + expect(win.loadFile).toHaveBeenCalledTimes(1); + expect(await invoke("get-connect-state")).toMatchObject({ kind: "error", serverURL: "http://example.test" }); + expect(fsState.files.has(storePath)).toBe(true); + const close = win.on.mock.calls.find(([event]) => event === "closed")![1] as Function; + close(); + const calls = fetchMock.mock.calls.length; + await vi.advanceTimersByTimeAsync(60000); + expect(fetchMock).toHaveBeenCalledTimes(calls); }); }); diff --git a/packages/desktop/test/connect-html.test.ts b/packages/desktop/test/connect-html.test.ts index 183b6c5c..c10aa0e7 100644 --- a/packages/desktop/test/connect-html.test.ts +++ b/packages/desktop/test/connect-html.test.ts @@ -1,51 +1,13 @@ -// @vitest-environment jsdom import { describe, expect, it } from "vitest"; import { readFileSync } from "node:fs"; -import path from "node:path"; -const connectHtmlPath = path.join(__dirname, "../src/connect.html"); - -function loadConnect(): Document { - const html = readFileSync(connectHtmlPath, "utf8"); - return new DOMParser().parseFromString(html, "text/html"); -} - -describe("connect.html drag region structure", () => { - it("does not put -webkit-app-region: drag on body or any ancestor of the form", () => { - const document = loadConnect(); - const styles = Array.from(document.querySelectorAll("style")) - .map((node) => node.textContent ?? "") - .join("\n"); - - // The form must not be inside a drag region: that breaks click/focus on - // its inputs in Chromium. Drag must be applied to a sibling, not body or - // a wrapping container. - expect(styles).not.toMatch(/\bbody\s*\{[^}]*-webkit-app-region:\s*drag/); - }); - - it("declares a dedicated drag-region element distinct from the form", () => { - const document = loadConnect(); - const dragRegion = document.querySelector("[data-drag-region]"); - expect(dragRegion).not.toBeNull(); - const form = document.querySelector("form"); - expect(form).not.toBeNull(); - expect(dragRegion!.contains(form)).toBe(false); - }); -}); - -describe("connect.html connect flow UI", () => { - it("uses a text server URL field and a connect button with inline spinner", () => { - const document = loadConnect(); - const serverInput = document.querySelector("#serverURL"); - expect(serverInput?.getAttribute("type")).toBe("text"); - expect(document.querySelector("#submit .button-spinner")).not.toBeNull(); - expect(document.querySelector("#submit .button-label")?.textContent).toBe("Connect"); - }); - - it("prefills saved connection via bundled connect-page script", () => { - const html = readFileSync(connectHtmlPath, "utf8"); +describe("packaged local document", () => { + it("loads the renderer and local styles as an application document", () => { + const html = readFileSync(new URL("../src/connect.html", import.meta.url), "utf8"); + expect(html).toContain('data-television-document="app"'); expect(html).toContain('src="connect-page.cjs"'); - expect(html).not.toContain("window.television.getConnection"); - expect(html).toContain("script-src 'self' 'unsafe-inline'"); + expect(html).toContain('href="connect-page.css"'); + expect(html).toContain('href="clouds/theme.css"'); + expect(html).toContain("style-src 'self' 'unsafe-inline'"); }); }); diff --git a/packages/desktop/test/connect-page.test.ts b/packages/desktop/test/connect-page.test.ts index e3c65579..cfb91a74 100644 --- a/packages/desktop/test/connect-page.test.ts +++ b/packages/desktop/test/connect-page.test.ts @@ -1,273 +1,132 @@ // @vitest-environment jsdom +// Renderer contracts replace the local bridge, clipboard and animation scheduler. +// Real IPC, native input and CSS motion are crossed by connect-screen acceptance. import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { readFileSync } from "node:fs"; -import path from "node:path"; -import { - bindConnectPage, - initConnectPage, - MIN_CONNECT_MS, - type ConnectPageAPI, - type ConnectPageElements, -} from "../src/connect-page.ts"; - -const connectHtmlPath = path.join(__dirname, "../src/connect.html"); - -function mockElements(): ConnectPageElements { - const form = document.createElement("form"); - const errorEl = document.createElement("div"); - const submitButton = document.createElement("button"); - const submitLabel = document.createElement("span"); - submitLabel.className = "button-label"; - submitLabel.textContent = "Connect"; - submitButton.append(submitLabel); - const serverInput = document.createElement("input"); - const tokenInput = document.createElement("input"); - form.append(errorEl, submitButton, serverInput, tokenInput); - document.body.append(form); - return { form, errorEl, submitButton, submitLabel, serverInput, tokenInput }; -} +import type { ConnectPageAPI } from "../src/connect-page.ts"; +import type { ConnectScreenState } from "../src/connect-screen.ts"; + +let dispose: (() => void) | undefined; +let init: typeof import("../src/connect-page.ts").initConnectPage; +const frameCallbacks: FrameRequestCallback[] = []; + +beforeEach(async () => { + vi.stubGlobal("requestAnimationFrame", (callback: FrameRequestCallback) => { frameCallbacks.push(callback); return frameCallbacks.length; }); + vi.stubGlobal("matchMedia", () => ({ matches: false, addEventListener() {}, removeEventListener() {} })); + vi.stubGlobal("CSSStyleSheet", class { replaceSync() {} }); + HTMLDialogElement.prototype.showModal = function () { this.open = true; }; + HTMLDialogElement.prototype.close = function () { this.open = false; }; + Element.prototype.getAnimations = vi.fn(() => []); + init = (await import("../src/connect-page.ts")).initConnectPage; +}); +afterEach(() => { + dispose?.(); + document.body.replaceChildren(); + frameCallbacks.length = 0; + vi.unstubAllGlobals(); +}); -async function flushMicrotasks(): Promise { - await Promise.resolve(); +async function mount(state: ConnectScreenState = { kind: "setup" }) { + let listener!: (state: ConnectScreenState) => void; + const api: ConnectPageAPI = { + getState: vi.fn(async () => state), + onState: callback => { listener = callback; return () => {}; }, + connect: vi.fn(), completeConnect: vi.fn(async () => {}), disconnect: vi.fn(async () => {}), + }; + const host = document.createElement("div"); + document.body.append(host); + dispose = init(api, host); await Promise.resolve(); + return { api, publish: (next: ConnectScreenState) => listener(next) }; } - -describe("bindConnectPage", () => { - beforeEach(() => { - vi.useFakeTimers({ now: new Date(0) }); - }); - - afterEach(() => { - vi.useRealTimers(); - document.body.innerHTML = ""; - }); - - it("prefills saved connection but does not auto-connect in manual mode", async () => { - const elements = mockElements(); - const connect = vi.fn(async () => ({ ok: true as const })); - const api: ConnectPageAPI = { - getConnectScreenIntent: async () => "manual", - getConnection: async () => ({ serverURL: "http://localhost:1", token: "saved-token" }), - connect, - }; - - bindConnectPage(api, elements); - await vi.waitFor(() => { - expect(elements.serverInput.value).toBe("http://localhost:1"); - }); - expect(elements.tokenInput.value).toBe("saved-token"); - expect(connect).not.toHaveBeenCalled(); - }); - - it("auto-connects saved connection only in bootstrap mode", async () => { - const elements = mockElements(); - const connect = vi.fn(async () => ({ ok: true as const })); - const api: ConnectPageAPI = { - getConnectScreenIntent: async () => "bootstrap", - getConnection: async () => ({ serverURL: "http://localhost:1", token: "saved-token" }), - connect, - }; - - bindConnectPage(api, elements); - await vi.waitFor(() => { - expect(connect).toHaveBeenCalledWith("http://localhost:1", "saved-token"); - }); - }); - - it("bootstrap prefill survives a failed auto-connect", async () => { - const elements = mockElements(); - const connect = vi.fn(async () => ({ ok: false as const, message: "Token rejected" })); - bindConnectPage( - { - getConnectScreenIntent: async () => "bootstrap", - getConnection: async () => ({ serverURL: "http://localhost:9", token: "bad" }), - connect, - }, - elements, - ); - - await vi.waitFor(() => expect(connect).toHaveBeenCalled()); - await vi.advanceTimersByTimeAsync(MIN_CONNECT_MS); - - expect(elements.serverInput.value).toBe("http://localhost:9"); - expect(elements.tokenInput.value).toBe("bad"); - expect(elements.errorEl.textContent).toBe("Token rejected"); - expect(elements.submitButton.disabled).toBe(false); - }); - - it("form submit sends the entered url and token to connect", async () => { - const elements = mockElements(); - elements.serverInput.value = " http://localhost:1 "; - elements.tokenInput.value = " secret "; - const connect = vi.fn(async () => ({ ok: true as const })); - bindConnectPage( - { - getConnectScreenIntent: async () => "manual", - getConnection: async () => null, - connect, - }, - elements, - ); - await vi.waitFor(() => expect(connect).not.toHaveBeenCalled()); - - elements.form.requestSubmit(); - await vi.waitFor(() => expect(connect).toHaveBeenCalledWith("http://localhost:1", " secret ")); +const input = () => document.querySelector(".setup-link input")!; +const form = () => document.querySelector(".setup-link")!; +const state = () => document.querySelector(".setup-screen")?.getAttribute("data-state"); +const submit = () => form().dispatchEvent(new Event("submit", { bubbles: true, cancelable: true })); +const flush = async () => { await Promise.resolve(); await Promise.resolve(); }; + +// proofs/ui/setup/index.md#^setup-t-states +describe("setup renderer", () => { + it("renders ready, connecting, inline error and Connected through a single-link submission", async () => { + const { api } = await mount(); + expect(state()).toBe("ready"); + expect(input().value).toBe(""); + expect(input().required).toBe(true); + expect(input().getAttribute("aria-label")).toBe("Link from your agent"); + expect(input().dataset.size).toBe("lg"); + expect(document.querySelector(".setup-submit")?.getAttribute("size")).toBe("lg"); + expect(document.querySelector(".copy-button")?.hasAttribute("size")).toBe(false); + expect(document.querySelector(".artifact-title")?.textContent).toContain("Connect to Television"); + expect(document.querySelector("iframe, webview, .artifact-menu-trigger, .artifact-back")).toBeNull(); + submit(); + expect(api.connect).not.toHaveBeenCalled(); + let resolve!: (value: { ok: false; message: string }) => void; + vi.mocked(api.connect).mockImplementationOnce(() => new Promise(r => { resolve = r; })); + const link = "http://example.test/?token=wrong"; + input().value = link; + submit(); + expect(api.connect).toHaveBeenCalledWith(link); + expect(state()).toBe("connecting"); + expect(input().disabled).toBe(true); + expect(document.querySelector(".setup-submit")!.disabled).toBe(true); + expect(document.querySelector('.setup-submit tv-icon[name="spinner"][spinning]')).not.toBeNull(); + resolve({ ok: false, message: "Ask your agent for the current link." }); + await flush(); + expect(state()).toBe("error"); + expect(input().value).toBe(link); + expect(input().disabled).toBe(false); + expect(input().getAttribute("aria-invalid")).toBe("true"); + expect(input().getAttribute("aria-describedby")).toBe("setup-link-error"); + expect(document.getElementById("setup-link-error")?.textContent).toContain("current link"); + expect(document.querySelector(".setup-hint")).toBeNull(); + vi.mocked(api.connect).mockResolvedValueOnce({ ok: true, attempt: 7 }); + input().value = "http://example.test/?token=correct"; + submit(); + expect(state()).toBe("connecting"); + expect(input().hasAttribute("aria-invalid")).toBe(false); + await flush(); + expect(state()).toBe("connected"); + expect(document.querySelector(".setup-document")?.hasAttribute("inert")).toBe(true); + expect(document.querySelector(".setup-done")?.hasAttribute("aria-hidden")).toBe(false); + expect(api.completeConnect).not.toHaveBeenCalled(); }); - it("shows user-facing error after failed submit and keeps field values", async () => { - const elements = mockElements(); - elements.serverInput.value = "http://localhost:1"; - elements.tokenInput.value = "wrong"; - const connect = vi.fn(async () => ({ - ok: false as const, - message: "This server requires a token", - })); - bindConnectPage( - { - getConnectScreenIntent: async () => "manual", - getConnection: async () => null, - connect, - }, - elements, - ); - await vi.waitFor(() => expect(connect).not.toHaveBeenCalled()); - - elements.form.requestSubmit(); - await vi.waitFor(() => expect(connect).toHaveBeenCalled()); - expect(elements.submitButton.dataset.connecting).toBe("true"); - expect(elements.submitLabel.textContent).toBe("Connecting…"); - - await vi.advanceTimersByTimeAsync(MIN_CONNECT_MS); - - expect(elements.errorEl.textContent).toBe("This server requires a token"); - expect(elements.serverInput.value).toBe("http://localhost:1"); - expect(elements.tokenInput.value).toBe("wrong"); - expect(elements.submitButton.disabled).toBe(false); - expect(elements.submitButton.dataset.connecting).toBe("false"); - }); - - it("waits at least MIN_CONNECT_MS before showing a failed connect error", async () => { - const elements = mockElements(); - elements.serverInput.value = "http://localhost:1"; - const connect = vi.fn(async () => ({ ok: false as const, message: "Unreachable" })); - bindConnectPage( - { - getConnectScreenIntent: async () => "manual", - getConnection: async () => null, - connect, - }, - elements, - ); - await flushMicrotasks(); - - elements.form.requestSubmit(); - await flushMicrotasks(); - expect(connect).toHaveBeenCalled(); - - expect(elements.errorEl.textContent).toBe(""); - await vi.advanceTimersByTimeAsync(MIN_CONNECT_MS - 1); - expect(elements.errorEl.textContent).toBe(""); - await vi.advanceTimersByTimeAsync(1); - expect(elements.errorEl.textContent).toBe("Unreachable"); - }); - - it("stays in connecting state after successful submit until navigation replaces the page", async () => { - const elements = mockElements(); - elements.serverInput.value = "http://localhost:1"; - elements.tokenInput.value = "good"; - const connect = vi.fn(async () => ({ ok: true as const })); - bindConnectPage( - { - getConnectScreenIntent: async () => "manual", - getConnection: async () => null, - connect, - }, - elements, - ); - await vi.waitFor(() => expect(connect).not.toHaveBeenCalled()); - - elements.form.requestSubmit(); - await vi.waitFor(() => expect(connect).toHaveBeenCalled()); - - expect(elements.errorEl.textContent).toBe(""); - expect(elements.submitButton.disabled).toBe(true); - expect(elements.submitButton.dataset.connecting).toBe("true"); - expect(elements.submitLabel.textContent).toBe("Connecting…"); + // proofs/ui/setup/index.md#^setup-t-copy + it("Copy supplies the frame prompt and leaves both steps and the entered link available", async () => { + const clipboard = vi.fn(async () => {}); + Object.defineProperty(navigator, "clipboard", { configurable: true, value: { writeText: clipboard } }); + const { api } = await mount(); + input().value = "http://example.test/?token=keep"; + document.querySelector(".copy-button")!.click(); + expect(clipboard).toHaveBeenCalledWith("Read the Television admin guide at https://television.run/install.md and help me get Television installed. I'm on the desktop app connect screen."); + expect(state()).toBe("ready"); + expect(input().value).toBe("http://example.test/?token=keep"); + expect(input().disabled).toBe(false); + expect(document.querySelectorAll(".setup-step")).toHaveLength(2); + expect(document.querySelector(".setup-document")?.hasAttribute("inert")).toBe(false); + expect(api.connect).not.toHaveBeenCalled(); }); - it("clears prior error when submitting again", async () => { - const elements = mockElements(); - elements.errorEl.textContent = "Old error"; - elements.serverInput.value = "http://localhost:1"; - const connect = vi.fn(async () => ({ ok: true as const })); - bindConnectPage( - { - getConnectScreenIntent: async () => "manual", - getConnection: async () => null, - connect, - }, - elements, - ); - await vi.waitFor(() => expect(connect).not.toHaveBeenCalled()); - - elements.form.requestSubmit(); - await vi.waitFor(() => expect(connect).toHaveBeenCalled()); - expect(elements.errorEl.textContent).toBe(""); - }); - - it("unwraps thrown IPC errors and keeps typed fields sticky", async () => { - const elements = mockElements(); - elements.serverInput.value = "http://localhost:1"; - elements.tokenInput.value = "wrong"; - const connect = vi.fn(async () => { - throw new Error("Error invoking remote method 'television:connect': Error: Token rejected"); - }); - bindConnectPage( - { - getConnectScreenIntent: async () => "manual", - getConnection: async () => null, - connect, - }, - elements, - ); - await vi.waitFor(() => expect(connect).not.toHaveBeenCalled()); - - elements.form.requestSubmit(); - await vi.waitFor(() => expect(connect).toHaveBeenCalled()); - await vi.advanceTimersByTimeAsync(MIN_CONNECT_MS); - - expect(elements.errorEl.textContent).toBe("Token rejected"); - expect(elements.serverInput.value).toBe("http://localhost:1"); - expect(elements.tokenInput.value).toBe("wrong"); + it("completes the Connected handoff without transition events under reduced motion", async () => { + const { api } = await mount(); + vi.mocked(api.connect).mockResolvedValue({ ok: true, attempt: 9 }); + input().value = "http://example.test"; + submit(); + await flush(); + frameCallbacks.shift()!(0); + await flush(); + frameCallbacks.shift()!(16); + await flush(); + expect(api.completeConnect).toHaveBeenCalledWith(9); }); -}); - -describe("initConnectPage with connect.html", () => { - afterEach(() => { - document.body.innerHTML = ""; - document.head.innerHTML = ""; - }); - - it("wires the real connect.html form and allows external connect-page script under CSP", async () => { - const html = readFileSync(connectHtmlPath, "utf8"); - expect(html).toContain("script-src 'self' 'unsafe-inline'"); - expect(html).toContain('src="connect-page.cjs"'); - - const parsed = new DOMParser().parseFromString(html, "text/html"); - document.head.innerHTML = parsed.head.innerHTML; - document.body.innerHTML = parsed.body.innerHTML; - - const connect = vi.fn(async () => ({ ok: true as const })); - initConnectPage({ - getConnectScreenIntent: async () => "manual", - getConnection: async () => ({ serverURL: "http://localhost:32848", token: "persisted" }), - connect, - }); - await vi.waitFor(() => { - expect((document.getElementById("serverURL") as HTMLInputElement).value).toBe("http://localhost:32848"); - }); - expect((document.getElementById("token") as HTMLInputElement).value).toBe("persisted"); - expect(connect).not.toHaveBeenCalled(); + it("renders saved states without setup and disconnects through the shared local modal", async () => { + const { api, publish } = await mount({ kind: "connecting", serverURL: "http://example.test" }); + expect(document.querySelector(".setup-screen")).toBeNull(); + expect(document.body.textContent).toContain("Connecting"); + publish({ kind: "unauthorized", serverURL: "http://example.test" }); + await flush(); + expect(document.body.textContent).toContain("Access token required"); + document.querySelector(".system-modal-disconnect")!.click(); + expect(api.disconnect).toHaveBeenCalledOnce(); }); }); diff --git a/packages/desktop/test/connect-preflight.test.ts b/packages/desktop/test/connect-preflight.test.ts index 0434f6f9..8183e4d2 100644 --- a/packages/desktop/test/connect-preflight.test.ts +++ b/packages/desktop/test/connect-preflight.test.ts @@ -100,7 +100,7 @@ describe("preflightConnection desktop connect check", () => { await expect(check(fetchImpl)).resolves.toEqual({ ok: false, code: "auth-required", - message: "This server requires a token", + message: "This link is missing an access token. Ask your agent for the current link.", }); expect(fetchImpl).toHaveBeenCalledTimes(1); }); @@ -110,7 +110,7 @@ describe("preflightConnection desktop connect check", () => { await expect(check(fetchImpl, "bad")).resolves.toEqual({ ok: false, code: "auth-rejected", - message: "Token rejected", + message: "This link’s access token was rejected. Ask your agent for the current link.", }); expect(fetchImpl).toHaveBeenCalledTimes(1); }); diff --git a/packages/desktop/test/connect-url.test.ts b/packages/desktop/test/connect-url.test.ts index 57b9a2e2..c95c4a82 100644 --- a/packages/desktop/test/connect-url.test.ts +++ b/packages/desktop/test/connect-url.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest"; -import { buildRemoteURL, normalizeConnectURL, resolveDesktopAppVersion } from "../src/connect-url.ts"; +import { buildRemoteURL, normalizeConnectURL, parseDesktopConnectURL, resolveDesktopAppVersion } from "../src/connect-url.ts"; describe("normalizeConnectURL", () => { it("accepts explicit http URLs", () => { @@ -22,6 +22,20 @@ describe("normalizeConnectURL", () => { }); }); +// proofs/arch/desktop/connect-flow.md#^desktop-t-connect-link +// Pure parser boundary: authored links, no mechanism replacements. +it("reads one link as an HTTP origin and its decoded token", () => { + for (const [link, serverURL, token] of [ + ["http://example.test/path?extra=yes&token=a%2Bb%26c", "http://example.test", "a+b&c"], + ["https://example.test:8443/deep?token=secret#fragment", "https://example.test:8443", "secret"], + ["example.test", "http://example.test", null], + ["localhost:32848/?ignored=yes", "http://localhost:32848", null], + ]) expect(parseDesktopConnectURL(link!)).toEqual({ serverURL, token }); + for (const link of ["", "not a url!!!", "ftp://example.test", "file:///tmp/example", "mailto:person@example.test"]) { + expect(() => parseDesktopConnectURL(link)).toThrow(/valid http or https/i); + } +}); + // Contract tests for the shell's desktopAppVersion resolution // (specs/arch/updates/desktop-upgrade-gate.md ^hook-shell-version). The // TV_TEST_DESKTOP_APP_VERSION hook IS a declared mock of `app.getVersion()`: diff --git a/packages/desktop/test/connection-store.test.ts b/packages/desktop/test/connection-store.test.ts index b9e7e97f..037225aa 100644 --- a/packages/desktop/test/connection-store.test.ts +++ b/packages/desktop/test/connection-store.test.ts @@ -8,7 +8,7 @@ vi.mock("electron", () => ({ app: mockApp })); const fsState = vi.hoisted(() => { const files = new Map(); - return { files }; + return { files, operations: [] as string[] }; }); vi.mock("node:fs", async (importOriginal) => { @@ -22,9 +22,12 @@ vi.mock("node:fs", async (importOriginal) => { return content; }, writeFileSync: (path: string, content: string) => { + fsState.operations.push(`write:${path}`); fsState.files.set(path, content); }, + rmSync: (path: string) => { fsState.operations.push(`remove:${path}`); fsState.files.delete(path); }, renameSync: (oldPath: string, newPath: string) => { + fsState.operations.push(`rename:${oldPath}:${newPath}`); const content = fsState.files.get(oldPath); if (content === undefined) throw new Error(`ENOENT: ${oldPath}`); fsState.files.set(newPath, content); @@ -36,6 +39,7 @@ vi.mock("node:fs", async (importOriginal) => { describe("connection-store", () => { beforeEach(() => { fsState.files.clear(); + fsState.operations.length = 0; vi.resetModules(); }); @@ -60,28 +64,39 @@ describe("connection-store", () => { expect(loadConnection()).toEqual({ serverURL: "http://localhost:32848", token: "" }); }); - it("writes connection.json atomically via a temp file", async () => { - const { saveConnection } = await import("../src/connection-store.ts"); + // proofs/arch/desktop/connect-flow.md#^desktop-t-store-writes + it("writes connection.json atomically via a temp file and deletes the record", async () => { + const { saveConnection, deleteConnection } = await import("../src/connection-store.ts"); saveConnection({ serverURL: "http://localhost:32848", token: "abc" }); + expect(fsState.operations).toEqual([ + "write:/tmp/television-connection-test/connection.json.tmp", + "rename:/tmp/television-connection-test/connection.json.tmp:/tmp/television-connection-test/connection.json", + ]); expect(fsState.files.has("/tmp/television-connection-test/connection.json.tmp")).toBe(false); expect(JSON.parse(fsState.files.get("/tmp/television-connection-test/connection.json")!)).toEqual({ serverURL: "http://localhost:32848", token: "abc", }); + deleteConnection(); + expect(fsState.operations.at(-1)).toBe("remove:/tmp/television-connection-test/connection.json"); + expect(fsState.files.has("/tmp/television-connection-test/connection.json")).toBe(false); }); it("survives a simulated restart via loadConnection", async () => { const first = await import("../src/connection-store.ts"); first.saveConnection({ serverURL: "http://localhost:99", token: "persist-me" }); + fsState.operations.length = 0; vi.resetModules(); const second = await import("../src/connection-store.ts"); expect(second.loadConnection()).toEqual({ serverURL: "http://localhost:99", token: "persist-me" }); }); - it("loadConnection returns null for corrupt JSON", async () => { - fsState.files.set("/tmp/television-connection-test/connection.json", "{not json"); + it("loadConnection returns null for malformed records", async () => { const { loadConnection } = await import("../src/connection-store.ts"); - expect(loadConnection()).toBeNull(); + for (const record of ["{not json", "null", "{}", JSON.stringify({ serverURL: 42 }), JSON.stringify({ serverURL: "ftp://example.test", token: "" })]) { + fsState.files.set("/tmp/television-connection-test/connection.json", record); + expect(loadConnection()).toBeNull(); + } }); }); diff --git a/packages/desktop/test/e2e/connect-screen.01.test.ts b/packages/desktop/test/e2e/connect-screen.01.test.ts index f8da2a85..ceced52b 100644 --- a/packages/desktop/test/e2e/connect-screen.01.test.ts +++ b/packages/desktop/test/e2e/connect-screen.01.test.ts @@ -1,155 +1,121 @@ -import { expect, test, type Page } from "@playwright/test"; -import { readFileSync, rmSync } from "node:fs"; +import { expect, test } from "@playwright/test"; +import { existsSync, readFileSync, rmSync } from "node:fs"; import path from "node:path"; -import { startConnectTestServer, type ConnectTestServer } from "./connect-server.ts"; +import { startStableFrontProxy } from "../../../../test/helpers/stable-front-proxy.ts"; +import { startConnectTestServer } from "./connect-server.ts"; +import { configureTestMotion } from "../../../web/test/e2e/helpers.ts"; import { - createUserDataDir, - expectConnectedPage, - getBrowserWindowURL, - launchDesktopConnectScreen, - waitForConnectScreen, + createUserDataDir, expectConnectedPage, getBrowserWindowURL, + launchDesktopConnectScreen, waitForConnectScreen, disconnectFromServerMenu, disconnectEnabled, } from "./helpers.ts"; -let testServer: ConnectTestServer; - -async function pasteServerURL(page: Page, text: string): Promise { - await page.locator("#serverURL").focus(); - await page.locator("#serverURL").evaluate((input, pastedText) => { - const data = new DataTransfer(); - data.setData("text/plain", pastedText); - input.dispatchEvent(new ClipboardEvent("paste", { clipboardData: data, bubbles: true, cancelable: true })); - }, text); -} - - -test.describe(() => { -test.beforeEach(async () => { - testServer = await startConnectTestServer(); -}); - -test.afterEach(async () => { - await testServer.dispose(); -}); - -test("connect screen loads with working preload bridge and external connect-page script", async () => { +// ^desktop-ac-connect-link: real Electron, HTTP, disk and CSS motion. The +// ordinary shell-version hook is the only substituted production input. +test("a link connects, renders Connected, survives restart and is forgotten by the menu", async () => { + const server = await startConnectTestServer(); + let releaseCheck: (() => void) | undefined; + let checkBarrier: Promise | undefined; + const front = await startStableFrontProxy(server.serverURL, async request => { + if (request.url?.startsWith("/desktop/connect-check")) await checkBarrier; + }); + const serverURL = front.url; const userDataDir = createUserDataDir(); - const { app, page } = await launchDesktopConnectScreen({ userDataDir }); + let launch = await launchDesktopConnectScreen({ userDataDir }); try { + let { app, page } = launch; await waitForConnectScreen(page); - const hasBridge = await page.evaluate(() => { - return typeof window.television?.getConnectScreenIntent === "function" && - typeof window.television?.getConnection === "function" && - typeof window.television?.connect === "function"; + await configureTestMotion(page, { allowCSSMotion: true }); + expect(await disconnectEnabled(app)).toBe(false); + expect(await page.evaluate(() => typeof window.television.connect)).toBe("function"); + const prompt = page.locator(".setup-prompt p"); + await prompt.click(); + expect(await page.evaluate(() => getSelection()?.toString())).toBe(await prompt.textContent()); + await expect(page.locator(".setup-screen")).toHaveAttribute("data-state", "ready"); + + const events: string[] = []; + page.on("console", message => { if (message.text().startsWith("setup-observe:")) events.push(message.text().slice(14)); }); + page.on("framenavigated", frame => { if (frame === page.mainFrame() && frame.url().startsWith(serverURL)) events.push("remote"); }); + await page.evaluate(() => { + const screen = document.querySelector(".setup-screen")!; + new MutationObserver(() => console.log(`setup-observe:${screen.getAttribute("data-state")}`)) + .observe(screen, { attributes: true, attributeFilter: ["data-state"] }); + document.querySelector(".setup-done")!.addEventListener("transitionend", () => { + const done = document.querySelector(".setup-done")!; + if (getComputedStyle(done).opacity === "1") console.log("setup-observe:connected-painted"); + }); }); - expect(hasBridge).toBe(true); - await expect(page.locator("#serverURL")).toBeVisible(); - await expect(page.locator("#token")).toBeVisible(); - } finally { - await app.close(); - rmSync(userDataDir, { recursive: true, force: true }); - } -}); - -test.describe("successful connect navigation", () => { - -test("manual connect submits the form, checks the server, and navigates to the remote URL", async () => { - const userDataDir = createUserDataDir(); - const { app, page } = await launchDesktopConnectScreen({ userDataDir }); - try { - await waitForConnectScreen(page); - await page.locator("#serverURL").fill(testServer.serverURL); - await page.locator("#token").fill(testServer.token); - await page.locator("#serverURL").press("Enter"); - + const link = `${serverURL}/some/path?token=${encodeURIComponent(server.token)}&ignored=yes`; + const input = page.getByRole("textbox", { name: "Link from your agent" }); + await input.fill(link); + await input.press("Enter"); await expectConnectedPage(page); + expect(events).toEqual(expect.arrayContaining(["connecting", "connected", "connected-painted", "remote"])); + expect(events.indexOf("connected-painted")).toBeLessThan(events.indexOf("remote")); const url = new URL(await getBrowserWindowURL(app)); - expect(url.origin).toBe(testServer.serverURL); + expect(url.origin).toBe(serverURL); expect(url.searchParams.get("mode")).toBe("electron"); + expect(url.searchParams.get("desktopAppVersion")).toBe("9.9.9"); expect(url.searchParams.has("token")).toBe(false); - + expect(await page.evaluate(() => typeof window.television)).toBe("undefined"); + const record = path.join(userDataDir, "connection.json"); + expect(JSON.parse(readFileSync(record, "utf8"))).toEqual({ serverURL, token: server.token }); + expect(await disconnectEnabled(app)).toBe(true); await page.reload(); await expectConnectedPage(page); - const reloadedURL = new URL(await getBrowserWindowURL(app)); - expect(reloadedURL.origin).toBe(testServer.serverURL); - expect(reloadedURL.searchParams.get("mode")).toBe("electron"); - expect(reloadedURL.searchParams.has("token")).toBe(false); - - const saved = JSON.parse(readFileSync(path.join(userDataDir, "connection.json"), "utf8")) as { - serverURL: string; - token: string; - }; - expect(saved.serverURL).toBe(testServer.serverURL); - expect(saved.token).toBe(testServer.token); - } finally { await app.close(); - rmSync(userDataDir, { recursive: true, force: true }); - } -}); - -test("pasting a connect URL extracts the token and saves only the clean origin", async () => { - const userDataDir = createUserDataDir(); - const connectURL = `${testServer.serverURL}/?token=${encodeURIComponent(testServer.token)}`; - const { app, page } = await launchDesktopConnectScreen({ userDataDir }); - try { - await waitForConnectScreen(page); - await page.locator("#token").fill("stale-token"); - await pasteServerURL(page, connectURL); - - await expect(page.locator("#serverURL")).toHaveValue(testServer.serverURL); - await expect(page.locator("#token")).toHaveValue(testServer.token); - await page.locator("#serverURL").press("Enter"); + checkBarrier = new Promise(resolve => { releaseCheck = resolve; }); + launch = await launchDesktopConnectScreen({ userDataDir }); + ({ app, page } = launch); + await expect(page.getByRole("heading", { name: "Connecting", exact: true })).toBeVisible(); + await expect(page.locator(".setup-screen")).toHaveCount(0); + releaseCheck!(); await expectConnectedPage(page); - const saved = JSON.parse(readFileSync(path.join(userDataDir, "connection.json"), "utf8")) as { - serverURL: string; - token: string; - }; - expect(saved.serverURL).toBe(testServer.serverURL); - expect(saved.token).toBe(testServer.token); - } finally { - await app.close(); - rmSync(userDataDir, { recursive: true, force: true }); - } -}); - -test("submitting a connect URL extracts the token without relying on paste", async () => { - const userDataDir = createUserDataDir(); - const connectURL = `${testServer.serverURL}/?token=${encodeURIComponent(testServer.token)}`; - const { app, page } = await launchDesktopConnectScreen({ userDataDir }); - try { + await expect(page.locator(".setup-screen")).toHaveCount(0); + await disconnectFromServerMenu(app); await waitForConnectScreen(page); - await page.locator("#serverURL").fill(connectURL); - await page.locator("#token").fill(""); - await page.locator("#serverURL").press("Enter"); - - await expectConnectedPage(page); - const saved = JSON.parse(readFileSync(path.join(userDataDir, "connection.json"), "utf8")) as { - serverURL: string; - token: string; - }; - expect(saved.serverURL).toBe(testServer.serverURL); - expect(saved.token).toBe(testServer.token); - } finally { + expect(existsSync(record)).toBe(false); + expect(await disconnectEnabled(app)).toBe(false); await app.close(); + launch = await launchDesktopConnectScreen({ userDataDir }); + await waitForConnectScreen(launch.page); + await expect(launch.page.getByRole("textbox", { name: "Link from your agent" })).toHaveValue(""); + } finally { + await launch.app.close().catch(() => {}); + releaseCheck?.(); + await front.dispose(); + await server.dispose(); rmSync(userDataDir, { recursive: true, force: true }); } }); -}) - -test("pasting a URL without a token leaves the existing token field untouched", async () => { +// ^desktop-ac-setup-errors: malformed and real unauthorized responses stay +// inline; the corrected link succeeds through native button activation. +test("setup keeps rejected links editable and accepts the corrected current link", async () => { + const server = await startConnectTestServer(); const userDataDir = createUserDataDir(); const { app, page } = await launchDesktopConnectScreen({ userDataDir }); try { await waitForConnectScreen(page); - await page.locator("#token").fill(testServer.token); - await pasteServerURL(page, `${testServer.serverURL}/?channel=abc`); - - await expect(page.locator("#serverURL")).toHaveValue(testServer.serverURL); - await expect(page.locator("#token")).toHaveValue(testServer.token); + await configureTestMotion(page); + const input = page.getByRole("textbox", { name: "Link from your agent" }); + for (const link of ["not a url!!!", `${server.serverURL}/?token=wrong`]) { + await input.fill(link); + await input.press("Enter"); + await expect(page.locator("#setup-link-error")).toBeVisible(); + await expect(input).toHaveValue(link); + await expect(input).toBeEditable(); + await expect(input).toHaveAttribute("aria-invalid", "true"); + await expect(page.locator("dialog")).toHaveCount(0); + expect(existsSync(path.join(userDataDir, "connection.json"))).toBe(false); + } + await expect(page.locator("#setup-link-error")).toContainText("current link"); + await input.fill(`${server.serverURL}/?token=${encodeURIComponent(server.token)}`); + await page.getByRole("button", { name: "Connect", exact: true }).click(); + await expectConnectedPage(page); } finally { await app.close(); + await server.dispose(); rmSync(userDataDir, { recursive: true, force: true }); } }); -}); diff --git a/packages/desktop/test/e2e/connect-screen.02.test.ts b/packages/desktop/test/e2e/connect-screen.02.test.ts index a5327ac0..b2f38b27 100644 --- a/packages/desktop/test/e2e/connect-screen.02.test.ts +++ b/packages/desktop/test/e2e/connect-screen.02.test.ts @@ -1,155 +1,119 @@ import { expect, test } from "@playwright/test"; -import { readFileSync, rmSync, writeFileSync } from "node:fs"; +import { existsSync, readFileSync, rmSync, writeFileSync } from "node:fs"; import path from "node:path"; -import { startConnectTestServer, type ConnectTestServer } from "./connect-server.ts"; -import { - createUserDataDir, - expectConnectForm, - expectConnectedPage, - launchDesktopConnectScreen, - openConnectScreenFromMenu, -} from "./helpers.ts"; - -let testServer: ConnectTestServer; - - - -test.describe(() => { -test.beforeEach(async () => { - testServer = await startConnectTestServer(); -}); - -test.afterEach(async () => { - await testServer.dispose(); -}); - -test.describe("connected-screen transitions", () => { - -test("failed connect shows an error, keeps entered values on screen, and does not overwrite a saved connection", async () => { - const userDataDir = createUserDataDir(); - writeFileSync( - path.join(userDataDir, "connection.json"), - JSON.stringify({ serverURL: testServer.serverURL, token: testServer.token }, null, 2), - ); - const { app, page } = await launchDesktopConnectScreen({ userDataDir }); - try { - await expectConnectedPage(page); - await openConnectScreenFromMenu(app); - await expectConnectForm(page); - await page.locator("#serverURL").fill(testServer.serverURL); - await page.locator("#token").fill("wrong-token"); - await page.locator("#serverURL").press("Enter"); - - await expect(page.locator("#error")).toHaveText("Token rejected", { timeout: 5_000 }); - await expect(page.locator("#serverURL")).toHaveValue(testServer.serverURL); - await expect(page.locator("#token")).toHaveValue("wrong-token"); - await expect(page.locator("#connect-form")).toBeVisible(); - await expect(page.locator("#submit")).toBeEnabled(); - - const saved = JSON.parse(readFileSync(path.join(userDataDir, "connection.json"), "utf8")) as { - serverURL: string; - token: string; - }; - expect(saved.serverURL).toBe(testServer.serverURL); - expect(saved.token).toBe(testServer.token); - } finally { - await app.close(); - rmSync(userDataDir, { recursive: true, force: true }); - } -}); - -test("bootstrap reconnects automatically when a saved connection exists on app open", async () => { - const userDataDir = createUserDataDir(); - writeFileSync( - path.join(userDataDir, "connection.json"), - JSON.stringify({ serverURL: testServer.serverURL, token: testServer.token }, null, 2), - ); - - const { app, page } = await launchDesktopConnectScreen({ userDataDir }); - try { - await expectConnectedPage(page); - } finally { - await app.close(); - rmSync(userDataDir, { recursive: true, force: true }); - } -}); - -test("menu reopen shows prefilled connect screen without auto-submitting", async () => { +import { startConnectTestServer } from "./connect-server.ts"; +import { startStableFrontProxy } from "../../../../test/helpers/stable-front-proxy.ts"; +import { createUserDataDir, expectConnectedPage, launchDesktopConnectScreen, disconnectFromServerMenu, disconnectEnabled, waitForConnectScreen } from "./helpers.ts"; + +// ^desktop-ac-saved-recovery: the front keeps its real bound address while the +// actual Television backend stops and restarts on a new port. It generates a +// 502 when forwarding fails; it never supplies a Television identity or status. +test("saved startup retries an unavailable server and recovers without setup", async () => { + const server = await startConnectTestServer(); + let releaseCheck!: () => void; + const checkBarrier = new Promise(resolve => { releaseCheck = resolve; }); + const front = await startStableFrontProxy(server.serverURL, () => checkBarrier); const userDataDir = createUserDataDir(); - writeFileSync( - path.join(userDataDir, "connection.json"), - JSON.stringify({ serverURL: testServer.serverURL, token: testServer.token }, null, 2), - ); - + writeFileSync(path.join(userDataDir, "connection.json"), JSON.stringify({ serverURL: front.url, token: server.token })); + await server.stop(); const { app, page } = await launchDesktopConnectScreen({ userDataDir }); try { + await expect(page.getByRole("heading", { name: "Connecting", exact: true })).toBeVisible(); + await expect(page.locator(".setup-screen")).toHaveCount(0); + releaseCheck(); + await expect(page.getByRole("heading", { name: "Can’t connect with server" })).toBeVisible(); + await expect(page.locator(".server-url")).toHaveText(front.url); + await expect(page.locator(".setup-screen")).toHaveCount(0); + await page.evaluate(() => { + const observations: string[] = []; + (window as unknown as { retryObservations: string[] }).retryObservations = observations; + new MutationObserver(() => observations.push(document.querySelector(".system-modal")?.textContent ?? "")) + .observe(document.getElementById("app")!, { subtree: true, childList: true, characterData: true }); + }); + await expect.poll(() => page.evaluate(() => { + const entries = (window as unknown as { retryObservations: string[] }).retryObservations; + return entries.some(text => text.includes("Reconnecting in 2s")) && entries.some(text => text.includes("Reconnecting in 1s")) && entries.some(text => text.includes("Reconnecting now")); + })).toBe(true); + await server.restart(); + front.setTarget(server.serverURL); await expectConnectedPage(page); - - await openConnectScreenFromMenu(app); - await expectConnectForm(page); - - await expect(page.locator("#serverURL")).toHaveValue(testServer.serverURL); - await expect(page.locator("#token")).toHaveValue(testServer.token); - await expect(page.locator("#connect-form")).toBeVisible(); + expect(new URL(page.url()).origin).toBe(front.url); } finally { + releaseCheck(); await app.close(); + await front.dispose(); + await server.dispose(); rmSync(userDataDir, { recursive: true, force: true }); } }); -}) - -test("bootstrap connect screen tolerates a saved connection with a missing token", async () => { +// ^desktop-ac-saved-auth: observe actual server traffic through an interval +// longer than the initial retry delay, then cross the local disconnect IPC. +test("a rejected saved token stops checks and the local Disconnect button forgets it", async () => { + const server = await startConnectTestServer(); + const requests: number[] = []; + server.server.httpServer.on("request", req => { if (req.url?.startsWith("/desktop/connect-check")) requests.push(Date.now()); }); + let releaseCheck!: () => void; + const checkBarrier = new Promise(resolve => { releaseCheck = resolve; }); + const front = await startStableFrontProxy(server.serverURL, () => checkBarrier); const userDataDir = createUserDataDir(); - writeFileSync( - path.join(userDataDir, "connection.json"), - JSON.stringify({ serverURL: testServer.serverURL }, null, 2), - ); - + const record = path.join(userDataDir, "connection.json"); + writeFileSync(record, JSON.stringify({ serverURL: front.url, token: "wrong" })); const { app, page } = await launchDesktopConnectScreen({ userDataDir }); try { - await expectConnectForm(page); - await expect(page.locator("#serverURL")).toHaveValue(testServer.serverURL); - await expect(page.locator("#token")).toHaveValue(""); + await expect(page.getByRole("heading", { name: "Connecting", exact: true })).toBeVisible(); + await expect(page.locator(".setup-screen")).toHaveCount(0); + releaseCheck(); + await expect(page.getByRole("heading", { name: "Access token required" })).toBeVisible(); + await expect(page.locator(".setup-screen")).toHaveCount(0); + await expect.poll(() => Date.now() - requests[0]!, { timeout: 5000 }).toBeGreaterThan(1500); + expect(requests).toHaveLength(1); + await page.getByRole("button", { name: "Disconnect from Server", exact: true }).click(); + await waitForConnectScreen(page); + expect(existsSync(record)).toBe(false); + expect(await disconnectEnabled(app)).toBe(false); + await expect(page.getByRole("textbox", { name: "Link from your agent" })).toBeEditable(); } finally { + releaseCheck(); await app.close(); + await front.dispose(); + await server.dispose(); rmSync(userDataDir, { recursive: true, force: true }); } }); -test.describe("connected restart persistence", () => { - -test("app restart after failed connect keeps the previously persisted credentials", async () => { +// ^desktop-ac-gate-disconnect: real identity check and served gate; version +// fixtures select an older shell. Menu callback activation forfeits Mac input. +test("Disconnect from Server leaves the served upgrade gate", async () => { + const names = ["TV_TEST_VERSION", "TV_TEST_REQUIRED_DESKTOP_VERSION", "TV_UPDATE_CHANNEL_URL"]; + const before = names.map(name => process.env[name]); + process.env.TV_TEST_VERSION = "1.0.0"; + process.env.TV_TEST_REQUIRED_DESKTOP_VERSION = "2.0.0"; + process.env.TV_UPDATE_CHANNEL_URL = "http://127.0.0.1:9/update-channel.json"; + const server = await startConnectTestServer({ auth: false }); const userDataDir = createUserDataDir(); - writeFileSync( - path.join(userDataDir, "connection.json"), - JSON.stringify({ serverURL: testServer.serverURL, token: testServer.token }, null, 2), - ); - - { - const { app, page } = await launchDesktopConnectScreen({ userDataDir }); - try { - await expectConnectedPage(page); - await openConnectScreenFromMenu(app); - await expectConnectForm(page); - await page.locator("#serverURL").fill(testServer.serverURL); - await page.locator("#token").fill("wrong-token"); - await page.locator("#serverURL").press("Enter"); - await expect(page.locator("#error")).toHaveText("Token rejected", { timeout: 5_000 }); - await expect(page.locator("#serverURL")).toHaveValue(testServer.serverURL); - await expect(page.locator("#token")).toHaveValue("wrong-token"); - } finally { - await app.close(); - } - } - - const { app, page } = await launchDesktopConnectScreen({ userDataDir }); + const record = path.join(userDataDir, "connection.json"); + writeFileSync(record, JSON.stringify({ serverURL: server.serverURL, token: server.token })); + const { app, page } = await launchDesktopConnectScreen({ userDataDir, env: { TV_TEST_DESKTOP_APP_VERSION: "1.0.0" } }); try { - await expectConnectedPage(page); + await expect(page.locator(".desktop-upgrade-gate")).toBeVisible(); + expect(await disconnectEnabled(app)).toBe(true); + expect(await page.evaluate(() => typeof window.television)).toBe("undefined"); + await disconnectFromServerMenu(app); + await waitForConnectScreen(page); + expect(existsSync(record)).toBe(false); + expect(await disconnectEnabled(app)).toBe(false); + // Execute the staging runbook's replacement setup steps against the real + // authless server, with dynamic ports and an isolated profile. + await page.getByRole("textbox", { name: "Link from your agent" }).fill(server.serverURL); + await page.getByRole("button", { name: "Connect", exact: true }).click(); + await expect(page.locator(".desktop-upgrade-gate")).toBeVisible(); + await expect(page.locator(".update-popover, .update-bell")).toHaveCount(0); + expect(JSON.parse(readFileSync(record, "utf8"))).toEqual({ serverURL: server.serverURL, token: "" }); } finally { await app.close(); + await server.dispose(); rmSync(userDataDir, { recursive: true, force: true }); + names.forEach((name, index) => { if (before[index] === undefined) delete process.env[name]; else process.env[name] = before[index]; }); } }); -}) -}); diff --git a/packages/desktop/test/e2e/connect-server.ts b/packages/desktop/test/e2e/connect-server.ts index d6a57212..55e8b4c7 100644 --- a/packages/desktop/test/e2e/connect-server.ts +++ b/packages/desktop/test/e2e/connect-server.ts @@ -12,25 +12,32 @@ export interface ConnectTestServer { serverURL: string; token: string; storagePath: string; + readonly server: Server; + stop(): Promise; + restart(): Promise; dispose(): Promise; } -/** Real Television API server with a static index page at `/` for post-connect navigation. */ -export async function startConnectTestServer(): Promise { +/** Real Television server serving the built application, with retained storage across restart. */ +export async function startConnectTestServer(options: { auth?: boolean } = {}): Promise { const storagePath = mkdtempSync(path.join(os.tmpdir(), "television-connect-e2e-server-")); const staticDir = mkdtempSync(path.join(os.tmpdir(), "television-connect-e2e-static-")); cpSync(WEB_DIST, staticDir, { recursive: true }); - const store = createServingStore(storagePath); - const server = new Server({ store, host: "127.0.0.1", port: 0, auth: true, staticDir }); + const create = () => new Server({ store: createServingStore(storagePath), host: "127.0.0.1", port: 0, auth: options.auth ?? true, staticDir }); + let server = create(); + let stopped = false; await server.start(); return { - serverURL: server.getBaseURL(), + get serverURL() { return server.getBaseURL(); }, + get server() { return server; }, token: server.getAuthToken(), storagePath, + async stop() { if (!stopped) { stopped = true; await server.dispose(); } }, + async restart() { server = create(); stopped = false; await server.start(); }, async dispose() { - await server.dispose(); + if (!stopped) await server.dispose(); rmSync(storagePath, { recursive: true, force: true }); rmSync(staticDir, { recursive: true, force: true }); }, diff --git a/packages/desktop/test/e2e/helpers.ts b/packages/desktop/test/e2e/helpers.ts index 190f3a43..d05b7f7d 100644 --- a/packages/desktop/test/e2e/helpers.ts +++ b/packages/desktop/test/e2e/helpers.ts @@ -205,32 +205,32 @@ export async function waitForConnectScreen(page: Page): Promise { await page.waitForFunction(() => typeof (window as Window & { television?: unknown }).television !== "undefined"); } -export async function openConnectScreenFromMenu(app: ElectronApplication): Promise { +export async function disconnectFromServerMenu(app: ElectronApplication): Promise { await app.evaluate(({ Menu }) => { const menu = Menu.getApplicationMenu(); if (!menu) throw new Error("application menu missing"); const stack = [...menu.items]; while (stack.length > 0) { const item = stack.pop()!; - if (item.label === "Connect to server…") { + if (item.label === "Disconnect from Server") { item.click(); return; } if (item.submenu) stack.push(...item.submenu.items); } - throw new Error("Connect to server… menu item missing"); + throw new Error("Disconnect from Server menu item missing"); }); } export async function expectConnectForm(page: Page): Promise { - await page.locator("#connect-form").waitFor({ state: "visible", timeout: 15_000 }); + await page.locator(".setup-link").waitFor({ state: "visible", timeout: 15_000 }); } export async function expectConnectedPage(page: Page): Promise { try { await expect.poll(() => page.url(), { timeout: 15_000 }).toContain("mode=electron"); } catch (cause) { - const connectError = await page.locator("#error").textContent().catch(() => null); + const connectError = await page.locator("#setup-link-error").textContent().catch(() => null); throw new Error(`Desktop did not leave the connect screen${connectError ? `: ${connectError}` : ""}`, { cause }); } await waitForApplicationRender(page, APPLICATION_SHELL_STATES, 15_000); @@ -273,3 +273,15 @@ function requiredDesktopE2EURL(): string { } return url.origin; } + +export async function disconnectEnabled(app: ElectronApplication): Promise { + return app.evaluate(({ Menu }) => { + const pending = [...Menu.getApplicationMenu()!.items]; + while (pending.length) { + const item = pending.pop()!; + if (item.label === "Disconnect from Server") return item.enabled; + if (item.submenu) pending.push(...item.submenu.items); + } + throw new Error("Disconnect menu missing"); + }); +} diff --git a/packages/desktop/test/e2e/local-page.test.ts b/packages/desktop/test/e2e/local-page.test.ts new file mode 100644 index 00000000..be6e7c1d --- /dev/null +++ b/packages/desktop/test/e2e/local-page.test.ts @@ -0,0 +1,139 @@ +import { expect, test, type Page } from "@playwright/test"; +import { createServer } from "node:http"; +import { readFileSync, readdirSync, rmSync, writeFileSync } from "node:fs"; +import path from "node:path"; +import { TelevisionClient } from "@telepath-computer/television-shared"; +import { startConnectTestServer } from "./connect-server.ts"; +import { createUserDataDir, launchDesktopConnectScreen, expectConnectedPage, waitForConnectScreen, disconnectFromServerMenu } from "./helpers.ts"; + +const desktopDist = path.resolve(import.meta.dirname, "../../dist"); +const root = path.resolve(import.meta.dirname, "../../../.."); + +async function assertLocalResources(page: Page): Promise { + const resources = await page.evaluate(async () => { + const fonts = await document.fonts.load("400 18px Hind"); + await document.fonts.ready; + const wallpapers = await Promise.all(["wallpaper.webp", "wallpaper-dark.webp"].map(name => new Promise(resolve => { + const image = new Image(); + image.onload = () => resolve(image.naturalWidth > 0); + image.onerror = () => resolve(false); + image.src = new URL(`clouds/${name}`, location.href).href; + }))); + return { + marker: document.documentElement.getAttribute("data-television-document"), + sheets: [...document.styleSheets].map(sheet => ({ href: sheet.href, rules: sheet.cssRules.length })), + loadedFonts: fonts.map(font => ({ family: font.family, status: font.status })), + fontReady: document.fonts.check("400 18px Hind"), + family: getComputedStyle(document.body).fontFamily, + controlLine: getComputedStyle(document.documentElement).getPropertyValue("--line-control-lg"), + wallpapers, + resources: performance.getEntriesByType("resource").map(entry => entry.name), + }; + }); + expect(resources.marker).toBe("app"); + expect(resources.sheets).toEqual(expect.arrayContaining([ + { href: expect.stringMatching(/^file:.*connect-page\.css$/), rules: expect.any(Number) }, + { href: expect.stringMatching(/^file:.*clouds\/theme\.css$/), rules: expect.any(Number) }, + ])); + expect(resources.sheets.every(sheet => sheet.rules > 0)).toBe(true); + expect(resources.fontReady).toBe(true); + expect(resources.family).toContain("Hind"); + expect(resources.controlLine.trim()).not.toBe(""); + expect(resources.wallpapers).toEqual([true, true]); + expect(resources.loadedFonts).toEqual([{ family: "Hind", status: "loaded" }]); + expect(resources.resources.filter(url => /^https?:/.test(url))).toEqual([]); +} + +// ^desktop-t-local-assets: production build and Chromium file loader remain real. +// Seeding the second launch is a stored-data fixture; no server styles exist. +test("the built local page loads foundation, Clouds, fonts and icons for setup and saved dialogs", async () => { + const userDataDir = createUserDataDir(); + let launch = await launchDesktopConnectScreen({ userDataDir }); + try { + await waitForConnectScreen(launch.page); + await assertLocalResources(launch.page); + await test.info().attach("local-page", { body: await launch.page.screenshot(), contentType: "image/png" }); + expect(await launch.page.locator("tv-icon").first().evaluate(icon => Boolean(icon.shadowRoot?.querySelector("svg")))).toBe(true); + for (const name of ["theme.css", "wallpaper.webp", "wallpaper-dark.webp"]) { + expect(readFileSync(path.join(desktopDist, "clouds", name))).toEqual(readFileSync(path.join(root, "packages/server/assets/themes/clouds", name))); + } + const font = readdirSync(path.join(desktopDist, "assets")).find(name => name.endsWith(".woff2"))!; + expect(readFileSync(path.join(desktopDist, "assets", font))).toEqual(readFileSync(path.join(root, "specs/ui/foundation/fonts/Hind-Variable.woff2"))); + await launch.app.close(); + writeFileSync(path.join(userDataDir, "connection.json"), JSON.stringify({ serverURL: "http://127.0.0.1:9", token: "" })); + launch = await launchDesktopConnectScreen({ userDataDir }); + await expect(launch.page.getByRole("heading", { name: "Can’t connect with server" })).toBeVisible(); + await assertLocalResources(launch.page); + await test.info().attach("local-page", { body: await launch.page.screenshot(), contentType: "image/png" }); + } finally { + await launch.app.close().catch(() => {}); + rmSync(userDataDir, { recursive: true, force: true }); + } +}); + +// ^desktop-appearance-t-local: display API and native bridge are real. The final +// nativeTheme assignment substitutes the preference input, not its rendering. +test("the local page retains native appearance while always wearing Clouds", async () => { + const server = await startConnectTestServer(); + const client = new TelevisionClient(server.serverURL, { token: server.token }); + await client.display.patch({ activeThemeName: null, appearanceMode: "dark" }); + const userDataDir = createUserDataDir(); + const { app, page } = await launchDesktopConnectScreen({ userDataDir }); + try { + await waitForConnectScreen(page); + expect(await app.evaluate(({ nativeTheme }) => nativeTheme.themeSource)).toBe("system"); + const initialDark = await app.evaluate(({ nativeTheme }) => nativeTheme.shouldUseDarkColors); + await expect(page.locator("html")).toHaveAttribute("data-theme", initialDark ? "dark" : "light"); + await page.getByRole("textbox", { name: "Link from your agent" }).fill(`${server.serverURL}/?token=${server.token}`); + await page.getByRole("textbox", { name: "Link from your agent" }).press("Enter"); + await expectConnectedPage(page); + await expect.poll(() => app.evaluate(({ nativeTheme }) => nativeTheme.themeSource)).toBe("dark"); + await disconnectFromServerMenu(app); + await waitForConnectScreen(page); + expect(await app.evaluate(({ nativeTheme }) => nativeTheme.themeSource)).toBe("dark"); + await expect(page.locator("html")).toHaveAttribute("data-theme", "dark"); + expect(await page.locator('link[href="clouds/theme.css"]').count()).toBe(1); + expect(await page.evaluate(() => getComputedStyle(document.documentElement).getPropertyValue("--app-wallpaper-image"))).toContain("wallpaper-dark.webp"); + await test.info().attach("local-dark-setup", { body: await page.screenshot(), contentType: "image/png" }); + await app.evaluate(({ nativeTheme }) => { nativeTheme.themeSource = "light"; }); + await expect(page.locator("html")).toHaveAttribute("data-theme", "light"); + expect(await page.evaluate(() => getComputedStyle(document.documentElement).getPropertyValue("--app-wallpaper-image"))).toContain("wallpaper.webp"); + } finally { + await app.close(); + await server.dispose(); + rmSync(userDataDir, { recursive: true, force: true }); + } +}); + +// ^desktop-t-load-recovery-seam: the HTTP peer substitutes Television, passes +// the identity check, then breaks real top-level navigation and later checks. +test("a real failed remote page load returns to saved-connection recovery", async () => { + let checks = 0; + const peer = createServer((request, response) => { + if (request.url?.startsWith("/desktop/connect-check") && checks++ === 0) { + response.setHeader("content-type", "application/json"); + response.end(JSON.stringify({ product: "television" })); + } else request.socket.destroy(); + }); + await new Promise(resolve => peer.listen(0, "127.0.0.1", resolve)); + const address = peer.address(); + if (!address || typeof address === "string") throw new Error("HTTP peer missing port"); + const serverURL = `http://127.0.0.1:${address.port}`; + const userDataDir = createUserDataDir(); + const { app, page } = await launchDesktopConnectScreen({ userDataDir }); + try { + await waitForConnectScreen(page); + await page.getByRole("textbox", { name: "Link from your agent" }).fill(serverURL); + await page.getByRole("button", { name: "Connect", exact: true }).click(); + await expect(page.getByRole("heading", { name: "Can’t connect with server" })).toBeVisible(); + expect(page.url()).toMatch(/^file:.*connect\.html$/); + await expect(page.locator(".setup-screen, #setup-link-error")).toHaveCount(0); + expect(JSON.parse(readFileSync(path.join(userDataDir, "connection.json"), "utf8"))).toEqual({ serverURL, token: "" }); + expect(checks).toBeGreaterThan(1); + } finally { + await app.close(); + peer.closeAllConnections(); + await new Promise(resolve => peer.close(() => resolve())); + rmSync(userDataDir, { recursive: true, force: true }); + } +}); diff --git a/packages/desktop/test/e2e/telemetry-client.test.ts b/packages/desktop/test/e2e/telemetry-client.test.ts index 23c4f785..1f1fb450 100644 --- a/packages/desktop/test/e2e/telemetry-client.test.ts +++ b/packages/desktop/test/e2e/telemetry-client.test.ts @@ -78,9 +78,8 @@ async function clientId(page: Page): Promise { } async function connectElectron(page: Page, harness: Harness): Promise { - await page.locator("#serverURL").fill(harness.serverURL); - await page.locator("#token").fill(harness.token); - await page.locator("#serverURL").press("Enter"); + await page.getByRole("textbox", { name: "Link from your agent" }).fill(`${harness.serverURL}/?token=${encodeURIComponent(harness.token)}`); + await page.getByRole("textbox", { name: "Link from your agent" }).press("Enter"); await waitForApp(page); } diff --git a/packages/desktop/test/e2e/upgrade-gate.spec.ts b/packages/desktop/test/e2e/upgrade-gate.spec.ts index a930b4ee..f77f664f 100644 --- a/packages/desktop/test/e2e/upgrade-gate.spec.ts +++ b/packages/desktop/test/e2e/upgrade-gate.spec.ts @@ -238,8 +238,8 @@ async function expectExclusiveGate(page: Page): Promise { const root = page.locator("#app"); await expect(root).toHaveAttribute("data-app-state", "needs-upgrade"); await expect(root.locator(":scope > .app-sidebar, :scope > .app-main")).toHaveCount(0); - await expect(root.locator(":scope > .system-modal-host")).toHaveCount(1); - await expect(root.locator(":scope > .system-modal-host .desktop-upgrade-gate")).toHaveCount(1); + await expect(root.locator(":scope > .system-modal-host")).toHaveCount(0); + await expect(root.locator(":scope > .desktop-upgrade-gate")).toHaveCount(1); await expect(root.locator(".artifact-view")).toHaveCount(0); await settleApplicationPresentation(page); } @@ -392,7 +392,7 @@ test.describe("desktop upgrade gate", () => { sidebarCount: 0, mainCount: 0, modalHostCount: 1, - authFormCount: 0, + unauthorizedCount: 0, gateCount: 1, connectingCount: 0, disconnectedCount: 0, diff --git a/packages/desktop/test/e2e/window-drag-regions.test.ts b/packages/desktop/test/e2e/window-drag-regions.test.ts index cd1cfbc8..8d06ae9b 100644 --- a/packages/desktop/test/e2e/window-drag-regions.test.ts +++ b/packages/desktop/test/e2e/window-drag-regions.test.ts @@ -6,7 +6,7 @@ import { promisify } from "node:util"; import { fileURLToPath } from "node:url"; import { TelevisionClient } from "@telepath-computer/television-shared"; import { startConnectTestServer } from "./connect-server.ts"; -import { expectConnectedPage, launchDesktop } from "./helpers.ts"; +import { createUserDataDir, expectConnectedPage, launchDesktop, launchDesktopConnectScreen, waitForConnectScreen, SIMULATE_UPDATE_AVAILABLE } from "./helpers.ts"; import { configureTestMotion } from "../../../web/test/e2e/helpers.ts"; interface WindowBounds { @@ -73,19 +73,20 @@ async function movedWindowBounds(app: ElectronApplication): Promise { - return page.locator(selector).evaluate((element) => { +async function emptyGround(page: Page, selector: string, backgroundChild?: string): Promise { + return page.locator(selector).evaluate((element, { selector, backgroundChild }) => { const bounds = element.getBoundingClientRect(); const y = bounds.top + bounds.height / 2; const appRegion = getComputedStyle(element).getPropertyValue("-webkit-app-region"); const middle = bounds.left + bounds.width / 2; for (let offset = 0; offset < bounds.width / 2 - 2; offset += 2) { for (const x of offset === 0 ? [middle] : [middle - offset, middle + offset]) { - if (document.elementFromPoint(x, y) === element) return { x, y, appRegion }; + const hit = document.elementFromPoint(x, y); + if (hit === element || (backgroundChild && hit?.matches(backgroundChild) && element.contains(hit))) return { x, y, appRegion }; } } throw new Error(`No empty ground found in ${selector}`); - }); + }, { selector, backgroundChild }); } async function dragNativeWindow( @@ -182,3 +183,83 @@ test("dragging the empty sidebar titlebar and top-bar ground moves the native wi await server.dispose(); } }); + +// ^sm-ac-drag-strip, served route: real Electron top-layer hit testing and +// movement. The X11 driver substitutes the window manager; version and update +// hooks select the gate and its actionable restart control. +test("the served gate keeps a native drag strip and usable dialog controls", async () => { + test.skip(process.platform !== "linux", "Native drag driver requires Linux/X11"); + const savedEnv = new Map(["TV_TEST_REQUIRED_DESKTOP_VERSION", "TV_TEST_VERSION", "TV_UPDATE_CHANNEL_URL"].map((key) => [key, process.env[key]])); + process.env.TV_TEST_VERSION = "1.0.0"; + process.env.TV_UPDATE_CHANNEL_URL = "http://127.0.0.1:9/update-channel.json"; + process.env.TV_TEST_REQUIRED_DESKTOP_VERSION = "2.0.0"; + const server = await startConnectTestServer(); + let launched: Awaited> | undefined; + try { + launched = await launchDesktop({ + connectTo: { serverURL: server.serverURL, token: server.token }, + env: { TV_TEST_DESKTOP_APP_VERSION: "1.0.0" }, + args: [SIMULATE_UPDATE_AVAILABLE], + }); + const { app, page } = launched; + await expect(page.locator(".desktop-upgrade-gate")).toBeVisible(); + await configureTestMotion(page); + expect(await page.locator("dialog").evaluate((dialog) => dialog.matches(":modal"))).toBe(true); + const strip = await emptyGround(page, ".window-drag-strip"); + expect(strip.appRegion).toBe("drag"); + expectWindowMoved(await dragNativeWindow(app, strip)); + await expect(page.locator("dialog")).toBeVisible(); + const beforeClick = await windowBounds(app); + await page.getByRole("button", { name: "Restart to update", exact: true }).click(); + await expect(page.getByRole("button", { name: "Restarting…", exact: true })).toBeDisabled(); + expect(await windowBounds(app)).toEqual(beforeClick); + await expect(page.locator("dialog")).toBeVisible(); + } finally { + await launched?.app.close().catch(() => undefined); + if (launched?.userDataDir) rmSync(launched.userDataDir, { recursive: true, force: true }); + await server.dispose(); + for (const [key, value] of savedEnv) { + if (value === undefined) delete process.env[key]; + else process.env[key] = value; + } + } +}); + +// ^setup-t-drag and ^sm-ac-drag-strip local route: native Electron hit testing +// and bounds, with the existing X11 window-manager substitute. +test("packaged setup and saved-error dialogs move the window while controls stay usable", async () => { + test.skip(process.platform !== "linux", "Native drag driver requires Linux/X11"); + const userDataDir = createUserDataDir(); + let launched = await launchDesktopConnectScreen({ userDataDir }); + try { + await waitForConnectScreen(launched.page); + await configureTestMotion(launched.page); + await test.step("setup ground moves and the card edits without moving", async () => { + expectWindowMoved(await dragNativeWindow(launched.app, await emptyGround(launched.page, ".setup-screen", ".setup-wallpaper"))); + const before = await windowBounds(launched.app); + const input = launched.page.getByRole("textbox", { name: "Link from your agent" }); + await input.click(); + await input.pressSequentially("not a link"); + await expect(input).toHaveValue("not a link"); + expect(await windowBounds(launched.app)).toEqual(before); + }); + await launched.app.close(); + writeFileSync(path.join(userDataDir, "connection.json"), JSON.stringify({ serverURL: "http://127.0.0.1:9", token: "" })); + launched = await launchDesktopConnectScreen({ userDataDir }); + await expect(launched.page.getByRole("heading", { name: "Can’t connect with server" })).toBeVisible(); + await configureTestMotion(launched.page); + await test.step("the modal strip moves and Disconnect remains usable", async () => { + expect(await launched.page.locator("dialog").evaluate(dialog => dialog.matches(":modal"))).toBe(true); + const strip = await emptyGround(launched.page, ".window-drag-strip"); + expectWindowMoved(await dragNativeWindow(launched.app, strip)); + await expect(launched.page.locator("dialog")).toBeVisible(); + const before = await windowBounds(launched.app); + await launched.page.getByRole("button", { name: "Disconnect from Server", exact: true }).click(); + await waitForConnectScreen(launched.page); + expect(await windowBounds(launched.app)).toEqual(before); + }); + } finally { + await launched.app.close().catch(() => {}); + rmSync(userDataDir, { recursive: true, force: true }); + } +}); diff --git a/packages/desktop/test/main.test.ts b/packages/desktop/test/main.test.ts index 46868295..73b25809 100644 --- a/packages/desktop/test/main.test.ts +++ b/packages/desktop/test/main.test.ts @@ -538,59 +538,6 @@ describe("Electron main process", () => { expect(win.loadURL).not.toHaveBeenCalled(); }); - it("loads the connect screen on startup even when a connection is saved", async () => { - fsState.files.set( - "/tmp/television-test-userdata/connection.json", - JSON.stringify({ serverURL: "https://tv.example.com", token: "secret-token" }), - ); - - const main = await loadAppModule(); - await new main.App().start(); - - const win = mockState.MockBrowserWindow.instances[0]; - expect(win.loadFile).toHaveBeenCalledWith(expect.stringMatching(/connect\.html$/)); - expect(win.loadURL).not.toHaveBeenCalled(); - - const intentHandler = mockState.ipcHandlers.get("television:get-connect-screen-intent"); - expect(await intentHandler!({})).toBe("bootstrap"); - }); - - it("opens the connect screen in manual mode from the menu without bootstrap auto-connect", async () => { - const main = await loadAppModule(); - await new main.App().start(); - - const template = mockState.Menu.buildFromTemplate.mock.calls[0][0] as Array<{ - submenu?: Array<{ label?: string; click?: () => void }>; - }>; - const connectItem = template.flatMap((item) => item.submenu ?? []).find((sub) => sub.label === "Connect to server…"); - expect(connectItem?.click).toBeDefined(); - - const win = mockState.MockBrowserWindow.instances[0]; - win.loadFile.mockClear(); - connectItem!.click!(); - - expect(win.loadFile).toHaveBeenCalledWith(expect.stringMatching(/connect\.html$/)); - const intentHandler = mockState.ipcHandlers.get("television:get-connect-screen-intent"); - expect(await intentHandler!({})).toBe("manual"); - }); - - it("get-connection IPC returns saved connection", async () => { - fsState.files.set( - "/tmp/television-test-userdata/connection.json", - JSON.stringify({ serverURL: "http://localhost:32848", token: "abc" }), - ); - - const main = await loadAppModule(); - await new main.App().start(); - - const handler = mockState.ipcHandlers.get("television:get-connection"); - expect(handler).toBeDefined(); - await expect(handler!({})).resolves.toEqual({ - serverURL: "http://localhost:32848", - token: "abc", - }); - }); - // Spec: ^updates-t-preflight-desktop-contract it("connect IPC accepts additive connect-check JSON, saves normalized fields, and loads the versioned remote URL", async () => { const main = await loadAppModule(); @@ -604,8 +551,9 @@ describe("Electron main process", () => { const handler = mockState.ipcHandlers.get("television:connect"); expect(handler).toBeDefined(); - const result = await handler!({}, { serverURL: "localhost:32848", token: "abc123" }); - expect(result).toEqual({ ok: true }); + const result = await handler!({}, "localhost:32848/?token=abc123"); + expect(result).toMatchObject({ ok: true, attempt: expect.any(Number) }); + await mockState.ipcHandlers.get("television:complete-connect")!({}, (result as { attempt: number }).attempt); const saved = JSON.parse(fsState.files.get("/tmp/television-test-userdata/connection.json")!); expect(saved.serverURL).toBe("http://localhost:32848"); @@ -633,7 +581,7 @@ describe("Electron main process", () => { await new main.App().start(); const handler = mockState.ipcHandlers.get("television:connect"); - await expect(handler!({}, { serverURL: "not a url!!!", token: "typed-token" })).resolves.toEqual({ + await expect(handler!({}, "not a url!!!")).resolves.toEqual({ ok: false, message: "Enter a valid http or https URL", }); @@ -652,29 +600,15 @@ describe("Electron main process", () => { win.loadURL.mockClear(); const handler = mockState.ipcHandlers.get("television:connect"); - await expect(handler!({}, { serverURL: "http://127.0.0.1:9", token: "" })).resolves.toEqual({ + await expect(handler!({}, "http://127.0.0.1:9")).resolves.toEqual({ ok: false, - message: "This server requires a token", + message: "This link is missing an access token. Ask your agent for the current link.", }); expect(fsState.files.get("/tmp/television-test-userdata/connection.json")).toBeUndefined(); expect(win.loadURL).not.toHaveBeenCalled(); }); - it("connect IPC returns ok:false when loadURL rejects", async () => { - const main = await loadAppModule(); - await new main.App().start(); - - const win = mockState.MockBrowserWindow.instances[0]; - win.loadURL.mockRejectedValueOnce(new Error("Navigation failed")); - - const handler = mockState.ipcHandlers.get("television:connect"); - await expect(handler!({}, { serverURL: "https://tv.example.com", token: "" })).resolves.toEqual({ - ok: false, - message: "Navigation failed", - }); - }); - it("connect IPC loads remote URL without ?token= when token is empty", async () => { const main = await loadAppModule(); await new main.App().start(); @@ -683,7 +617,8 @@ describe("Electron main process", () => { win.loadURL.mockClear(); const handler = mockState.ipcHandlers.get("television:connect"); - await handler!({}, { serverURL: "https://tv.example.com", token: "" }); + const result = await handler!({}, "https://tv.example.com") as { attempt: number }; + await mockState.ipcHandlers.get("television:complete-connect")!({}, result.attempt); const loaded = new URL(win.loadURL.mock.calls[0][0]); expect(loaded.searchParams.get("mode")).toBe("electron"); @@ -722,7 +657,7 @@ describe("Electron main process", () => { ); }); - it("registers a Connect to server… menu item", async () => { + it("registers a Disconnect from Server menu item", async () => { const main = await loadAppModule(); await new main.App().start(); @@ -731,7 +666,7 @@ describe("Electron main process", () => { submenu?: Array<{ label?: string }>; }>; const labels = template.flatMap((item) => item.submenu?.map((sub) => sub.label) ?? []); - expect(labels).toContain("Connect to server…"); + expect(labels).toContain("Disconnect from Server"); }); it("routes webContents window-open URLs to shell.openExternal", async () => { @@ -798,47 +733,4 @@ describe("Electron main process", () => { expect(mockState.shell.openExternal).not.toHaveBeenCalled(); }); - it("returns to the connect screen in manual mode when remote loadURL fails", async () => { - const main = await loadAppModule(); - await new main.App().start(); - - const win = mockState.MockBrowserWindow.instances[0]; - const failLoad = win.webContents.on.mock.calls.find(([event]) => event === "did-fail-load")?.[1] as ( - event: unknown, - code: number, - desc: string, - url: string, - isMainFrame: boolean, - ) => void; - expect(failLoad).toBeDefined(); - - win.loadFile.mockClear(); - failLoad({}, -1, "ERR_FAILED", "http://127.0.0.1:9/?mode=electron", true); - - expect(win.loadFile).toHaveBeenCalledWith(expect.stringMatching(/connect\.html$/)); - const intentHandler = mockState.ipcHandlers.get("television:get-connect-screen-intent"); - expect(await intentHandler!({})).toBe("manual"); - }); - - it("does not return to the connect screen for subframe load failures or aborted navigations", async () => { - const main = await loadAppModule(); - await new main.App().start(); - - const win = mockState.MockBrowserWindow.instances[0]; - const failLoad = win.webContents.on.mock.calls.find(([event]) => event === "did-fail-load")?.[1] as ( - event: unknown, - code: number, - desc: string, - url: string, - isMainFrame: boolean, - ) => void; - expect(failLoad).toBeDefined(); - - win.loadFile.mockClear(); - failLoad({}, -1, "ERR_FAILED", "http://127.0.0.1:9/frame", false); - failLoad({}, -3, "ERR_ABORTED", "http://127.0.0.1:9/?mode=electron", true); - failLoad({}, -1, "ERR_FAILED", "file:///tmp/connect.html", true); - - expect(win.loadFile).not.toHaveBeenCalled(); - }); }); diff --git a/packages/skills/skills/television/src/app-shell-reference.md b/packages/skills/skills/television/src/app-shell-reference.md index 91a478be..2cfae4e3 100644 --- a/packages/skills/skills/television/src/app-shell-reference.md +++ b/packages/skills/skills/television/src/app-shell-reference.md @@ -19,6 +19,8 @@ An artifact frame belongs to the application document. The document inside its i `#app` owns the application layout and suppresses ordinary text selection in its contents; readable regions can explicitly restore selection. `.app-sidebar` holds the channel list. The sidebar defaults to 260 pixels wide, resizes between 160 and 350 pixels, and remembers the chosen width per browser; it can also collapse entirely, the navbar then opening with an expand control and — while a channel is open — a channel-switcher popover, with the collapsed state remembered per browser. `.app-main` holds the wallpaper, navbar and stage. `.app-main > .top-bar` supplies the horizontal control inset; stage padding is separate. Preserve the shell width constraints so overflowing tabs and pages scroll inside their regions without widening the document. `#app:focus` suppresses a ring on the container itself. The wallpaper region establishes a backdrop boundary so overlay blur samples the wallpaper rather than adjacent content, while fixed popovers retain viewport positioning. +While Television cannot use its server, one connection dialog is shown. Disconnected and unreachable states keep an established shell and its artifact documents mounted; first connection, authorization and upgrade-gate states have no shell behind them. Three failed unreachable reconnects change Disconnected to Can’t connect with server; the displayed upgrade gate stays until reload. + During collapse or expansion, `#app[data-sidebar-motion]` contains both resting layouts and one travelling control: ```html @@ -142,7 +144,7 @@ An empty channel adds `.stage-empty`, containing an artifact `tv-icon` and a par ``` -`.artifact-frame` owns the border, curve, surface and broad shadow. Its `::before` paints the sharp rim and its `::after` paints a pointer-transparent masked inner highlight over document and titlebar. Background pages suppress the broad shadow while retaining the exterior rim. `.artifact-frame-clip > :is(iframe, webview)` fills the document area through the one-pixel padding track. `.artifact-frame-clip` clips the document and titlebar while the outer frame lets both shadows extend outward. `.artifact-title-bar` paints the lower band, `.artifact-title-bar .artifact-title` takes the remaining width and truncates, and `.artifact-bar-divider` separates navigation from the menu trigger. The two direction buttons use `disabled` to show unavailable history. In browser demo mode, the frame of an external web page wraps its artifact `tv-icon` in `button.artifact-return` (`icon`, ghost, `sm`) and shows no direction controls. The document repeats the top clipping radius where required for composited iframe/webview rendering. +`.artifact-frame` owns the border, curve, surface and broad shadow. Its `::before` paints the sharp rim and its `::after` paints a pointer-transparent masked inner highlight over document and titlebar. Background pages suppress the broad shadow while retaining the exterior rim. `.artifact-frame-clip > :is(iframe, webview)` fills the document area through the one-pixel padding track. `.artifact-frame-clip` clips the document and titlebar while the outer frame lets both shadows extend outward. `.artifact-title-bar` paints the lower band, `.artifact-title-bar .artifact-title` takes the remaining width and truncates, and `.artifact-bar-divider` separates navigation from the menu trigger. The two direction buttons use `disabled` to show unavailable history. In browser demo mode, the frame of an external web page wraps its artifact `tv-icon` in `button.artifact-return` (`icon`, ghost, `sm`) and shows no direction controls. The document repeats the top clipping radius where required for composited iframe/webview rendering. A frame can also hold static `.artifact-frame-content` in place of a document and omit its menu when it has no artifact to act on. That content fills the clipping region above the title bar. The artifact menu uses the same shared menu elements and placement as channel menus. Deletion uses the shared dialog outline below. @@ -193,7 +195,7 @@ An executable active theme adds `.settings-javascript-consent`, with `.settings- The update control is `button.update-bell#update-bell[icon][intent="alert"]` paired with `tv-popover.update-popover[manual][trigger="update-bell"][role="status"]`. `.update-popover` contains readable notice text and `.update-actions` with `button.update-later` and an optional primary copy action. The desktop self-update notice has `button.update-restart[size="sm"][intent="primary"]` in place of the copy action; it carries `[disabled]` while the app restarts. The manual panel stays open until the composing surface closes it. -The reusable copy control is `button.copy-button[size="sm"][prompt]`, optionally carrying `intent`, containing `.copy-button-idle` and `.copy-button-done`. Both contain an icon and label. `.copy-button[copied] .copy-button-idle` hides the idle content without changing its occupied space; `.copy-button[copied] .copy-button-done` overlays the confirmation. `.copy-button-status` is a separate visually hidden live announcement. Preserve that status region and stable button size when restyling confirmation. +The reusable copy control is `button.copy-button[prompt]`, optionally carrying `intent` and `size`, containing `.copy-button-idle` and `.copy-button-done`. Both contain an icon and label. The default Copy size is `sm`; callers can omit the rendered size attribute for a standard button or request `size="lg"`. `.copy-button[copied] .copy-button-idle` hides the idle content without changing its occupied space; `.copy-button[copied] .copy-button-done` overlays the confirmation. `.copy-button-status` is a separate visually hidden live announcement. Preserve that status region and stable button size when restyling confirmation. ### Dialogs, connection states and upgrade gate @@ -212,19 +214,19 @@ The reusable copy control is `button.copy-button[size="sm"][prompt]`, optionally The scrolling `.dialog-content` wrapper may contain ordinary content instead of an alert. Ambient selectors `:where(.dialog-overlay)`, `:where(dialog)`, and `:where(dialog:focus-visible)` define dimming, centering, panel chrome and container focus treatment. `:where(.dialog-alert)`, `:where(.dialog-alert h2)`, `:where(.dialog-alert p)`, and `:where(.dialog-actions)` define the shared confirmation interior. Keep the modal input boundary and focus behavior intact. -Connection and authorization states reuse the dialog with `.system-modal`: an icon, heading and optional supporting paragraph. `.system-modal h2` and `.system-modal p:not(.tv-error)` set their hierarchy. Connecting and disconnected states use a spinning `tv-icon`; an error adds `.system-modal .server-url`. Authorization uses `form.system-modal.auth-form`. The field uses shared native-input styling; `.auth-form .auth-token` and `.auth-form .auth-submit` stretch the field and submit control. Rejection adds `aria-invalid="true"` and associates the shared error paragraph through `aria-describedby`; the alert communicates the failure. The rejected state is: +Connection dialogs use `.system-modal`, with a heading and supporting text where applicable. `.system-modal h2` and `.system-modal p:not(.tv-error)` set their hierarchy. Connecting has a spinning icon and title; Disconnected adds the reconnect countdown or in-flight line. Can’t connect with server has no icon, adds `.server-url` and recovery guidance, and keeps the reconnect line. Access token required uses the lock icon and guidance for obtaining a current link. The browser directs the person to its address bar; the desktop's served interface directs them to Disconnect from Server in the app menu. + +Only the local desktop page adds `button.system-modal-disconnect[intent="danger"]` to unauthorized and error dialogs: ```html -
+

…

…

- - - - + +
``` -The ordinary authorization state omits the invalid attribute, error association and error paragraph. +Connection dialogs leave only when their owner's state changes. Served dialogs wear the server's theme; local dialogs wear Clouds. In the desktop app a `.window-drag-strip[electron-draggable]` spans the top 36 pixels while no shell is rendered, including on the local page. It sits inside `dialog`, outside `.dialog-content`, so native modality leaves it interactive. A retained shell supplies its own drag areas, including with its sidebar collapsed. Upgrade instructions use `.desktop-upgrade-gate > .dialog-overlay > dialog > .dialog-content > .upgrade-gate-body`. The gate fills the halted page; the body scrolls within the panel. `.upgrade-gate-body h1`, `.upgrade-gate-body p`, `.upgrade-gate-body pre`, and `.upgrade-gate-body :last-child` restore local reading rhythm and command formatting. When the app has downloaded an update, `.upgrade-gate-actions` follows the body inside `.dialog-content` and places `button.upgrade-gate-restart[intent="primary"]` trailing below the message; the button carries `[disabled]` while the app restarts. @@ -239,17 +241,17 @@ Upgrade instructions use `.desktop-upgrade-gate > .dialog-overlay > dialog > .di ``` -`.artifact-error` paints the document ground; `.artifact-error-body` limits the reading column. `.artifact-error h1`, `.artifact-error p`, and `.artifact-error-block` arrange its text. A missing artifact can include a path chip. An unsupported URL can include a link or `.plain-address`, followed by a link to the admin guide. `.artifact-error a`, `.artifact-error .plain-address`, and `.artifact-error code` wrap long content; the path chip is selected whole for copying. Optional host-supplied content may carry `[hidden]`. +`.artifact-error` paints the document ground; `.artifact-error-body` limits the reading column. `.artifact-error h1`, `.artifact-error p`, and `.artifact-error-block` arrange its text. A missing artifact can include a path chip. An unsupported URL can include a link or `.plain-address`, followed by a link to the admin guide for installing the Mac desktop app. `.artifact-error a`, `.artifact-error .plain-address`, and `.artifact-error code` wrap long content; the path chip is selected whole for copying. Optional host-supplied content may carry `[hidden]`. - + - - - + + + @@ -260,10 +262,10 @@ Upgrade instructions use `.desktop-upgrade-gate > .dialog-overlay > dialog > .di - + - + diff --git a/packages/skills/skills/television/src/html-artifact-style.md b/packages/skills/skills/television/src/html-artifact-style.md index a34f9891..368be5e8 100644 --- a/packages/skills/skills/television/src/html-artifact-style.md +++ b/packages/skills/skills/television/src/html-artifact-style.md @@ -79,7 +79,7 @@ documented token with a literal fallback where appropriate. ### Type -`--control-font-size`, `--text-base`, `--text-sm`, `--text-md`, `--text-lg`, `--text-xl`, `--text-2xl`, `--text-3xl`, `--text-4xl`, `--line-control`, `--line-control-sm`. +`--control-font-size`, `--text-base`, `--text-sm`, `--text-md`, `--text-lg`, `--text-xl`, `--text-2xl`, `--text-3xl`, `--text-4xl`, `--line-control`, `--line-control-sm`, `--line-control-lg`. ### Spacing and radii @@ -95,6 +95,13 @@ documented token with a literal fallback where appropriate. ## Canonical components +### Large controls + +Use `` for a large button and +`` for a matching text field. +The same `data-size="lg"` applies to textareas. Their line height comes from +`--line-control-lg`; an ordinary control omits the size attribute. + ### Native inputs and errors Native text-entry inputs and textareas receive shared styling automatically. diff --git a/packages/web/src/foundation/button.css b/packages/web/src/foundation/button.css index 8d868117..ce4db4cb 100644 --- a/packages/web/src/foundation/button.css +++ b/packages/web/src/foundation/button.css @@ -212,6 +212,16 @@ padding: var(--space-2); } +:where(button[size="lg"]) { + font-size: var(--text-lg); + line-height: var(--line-control-lg); + padding: var(--space-8) var(--space-20); +} + +:where(button[size="lg"][icon]) { + padding: var(--space-8); +} + /* A sized control sizes the icon it holds: whatever size the composer put on the icon, the control's own wins — `tv-icon[size]` names a fixed pixel size, so left alone the icon would ignore the control shrinking around it. diff --git a/packages/web/src/foundation/input.css b/packages/web/src/foundation/input.css index 398d6625..c3f7efca 100644 --- a/packages/web/src/foundation/input.css +++ b/packages/web/src/foundation/input.css @@ -34,6 +34,15 @@ resize: vertical; } +/* The large size matches a large button beside the field + ([[ui/foundation/button/button.css]]): the same text step, line and + vertical padding step, so the two are the same height. */ +:where(:is(input:not([type]), input[type=""], input[type="text"], input[type="email"], input[type="url"], input[type="tel"], input[type="password"], input[type="number"], textarea)[data-size="lg"]) { + font-size: var(--text-lg); + line-height: var(--line-control-lg); + padding: var(--space-8) var(--space-12); +} + /* Shared error-message styling; validation and announcement belong to the surface. */ :where(.tv-error) { margin: 0; diff --git a/packages/web/src/foundation/text.css b/packages/web/src/foundation/text.css index 2ba1fc67..a10e0e69 100644 --- a/packages/web/src/foundation/text.css +++ b/packages/web/src/foundation/text.css @@ -16,11 +16,14 @@ --text-4xl: round(calc(var(--text-base) * 30 / 14), 1px); /* The control lines: the line-height a control's label uses, and the one - input its height derives from — the line, plus the space-2 padding step + input its height derives from — the line, plus the size's padding step top and bottom, plus a 1px border each side, gives every control the - same height: 18 + 4 + 2 = 24px (14 + 4 + 2 = 20px at the small step). + same height at each size: 18 + 4 + 2 = 24px at the default size (the + space-2 step), 14 + 4 + 2 = 20px at the small step (space-2), and + 22 + 16 + 2 = 40px at the large step (space-8). Stated in pixels, not a ratio: a control's cell is tighter than prose, immune to the leading around it, and lands on even pixels in any font. */ --line-control: 18px; --line-control-sm: 14px; + --line-control-lg: 22px; } diff --git a/packages/web/src/services/application-navigation.test.ts b/packages/web/src/services/application-navigation.test.ts index 3dae4dc1..bc3aad75 100644 --- a/packages/web/src/services/application-navigation.test.ts +++ b/packages/web/src/services/application-navigation.test.ts @@ -15,10 +15,10 @@ import { handleApplicationNavigationKey } from "./application-navigation.ts"; const EMPTY_CONNECTION: ApplicationSnapshot["connection"] = { authorizationRequired: false, - authorizationRejected: false, gateHalted: false, status: "connected", hasEverConnected: true, + failedReconnectAttempts: 0, firstConnectError: null, nextRetryAt: null, upgradeInstructions: null, diff --git a/packages/web/src/services/application-service.ts b/packages/web/src/services/application-service.ts index 82b6d905..9b2bb34c 100644 --- a/packages/web/src/services/application-service.ts +++ b/packages/web/src/services/application-service.ts @@ -53,10 +53,10 @@ export interface ApplicationDisplaySnapshot { export interface ApplicationConnectionSnapshot { readonly authorizationRequired: boolean; - readonly authorizationRejected: boolean; readonly gateHalted: boolean; readonly status: ServerStatus; readonly hasEverConnected: boolean; + readonly failedReconnectAttempts: number; readonly firstConnectError: string | null; readonly nextRetryAt: number | null; readonly upgradeInstructions: DesktopUpgradeInstructions | null; @@ -146,11 +146,6 @@ export class ApplicationService extends EventTarget< this.#navigationKeyHandler?.(key); } - authenticate(token: string): Promise { - this.#assertNotDisposed(); - return this.#connectionOwner.authenticate(token); - } - async createChannel(name: string): Promise { this.#assertNotDisposed(); const { channel } = await this.#writeWithAmbiguousRecovery( @@ -699,10 +694,10 @@ export class ApplicationService extends EventTarget< focusedChannel, connection: Object.freeze({ authorizationRequired: connection.hasAuthRejected, - authorizationRejected: connection.hasAuthTokenRejected, gateHalted: connection.bootState === "halted" || this.#retainedGateHalted(), status: connection.status, hasEverConnected: connection.hasEverConnected, + failedReconnectAttempts: connection.failedReconnectAttempts, firstConnectError: connection.hasEverConnected ? null : this.#connectionOwner.connectError, diff --git a/packages/web/src/services/server-connection-owner.ts b/packages/web/src/services/server-connection-owner.ts index 1c4368e8..4701d0db 100644 --- a/packages/web/src/services/server-connection-owner.ts +++ b/packages/web/src/services/server-connection-owner.ts @@ -113,12 +113,6 @@ export class ServerConnectionOwner extends EventTarget< await this.#connectWithToken(this.connection.token); } - /** Persist a user-supplied token and retry the same connection. */ - async authenticate(token: string): Promise { - this.#localStore.set(setAuthToken(this.#localStore.get(), this.connection.url, token)); - await this.#connectWithToken(token); - } - dispose(): void { if (this.#disposed) return; this.#disposed = true; diff --git a/packages/web/src/services/server-connection.ts b/packages/web/src/services/server-connection.ts index e6f3a3c0..4abafe1f 100644 --- a/packages/web/src/services/server-connection.ts +++ b/packages/web/src/services/server-connection.ts @@ -198,11 +198,11 @@ export class ServerConnection extends EventTarget< * `status` alone. */ hasEverConnected = false; + /** Completed unreachable reconnects since the last successful connection. */ + failedReconnectAttempts = 0; /** Browser demo mode as the server reported it when this page connected. */ browserDemoMode = false; hasAuthRejected = false; - /** True when the current auth rejection rejected a non-null token. */ - hasAuthTokenRejected = false; /** Boot-barrier phase of the current attempt; see `BootState`. */ bootState: BootState = "pending"; /** @@ -340,7 +340,6 @@ export class ServerConnection extends EventTarget< async connect(token: string | null): Promise { this._token = token; this.hasAuthRejected = false; - this.hasAuthTokenRejected = false; this.acpClient.setToken(token); this.cancelRetryTimer(); this.retryDelay = INITIAL_RETRY_DELAY_MS; @@ -476,12 +475,15 @@ export class ServerConnection extends EventTarget< return await new Promise((resolve, reject) => { let settled = false; + let initialized = false; const finishConnected = () => { if (settled || this.connectAttempt !== attempt) return; settled = true; + initialized = true; this.retryDelay = INITIAL_RETRY_DELAY_MS; this.hasEverConnected = true; + this.failedReconnectAttempts = 0; this.setStatus("connected"); this.telemetryActivityAgent?.start(); resolve(); @@ -644,6 +646,7 @@ export class ServerConnection extends EventTarget< // Post-success close. The attempt promise has already resolved, so // we can't reject through it — handle the disposition inline. + if (!initialized) return; if (closeCode === AUTH_FAILED_CLOSE_CODE) { this.handleAuthRejected(); return; @@ -656,6 +659,7 @@ export class ServerConnection extends EventTarget< return; } if (settled) { + if (!initialized) return; // Post-success error without a follow-up close — drive the // same transport-failure path so we schedule a retry instead // of relying on `finishError`, which is a no-op once settled. @@ -681,7 +685,6 @@ export class ServerConnection extends EventTarget< private handleAuthRejected(): void { this.hasAuthRejected = true; - this.hasAuthTokenRejected = this._token !== null; this.autoReconnect = false; this.cancelRetryTimer(); this.setStatus("unauthorized"); @@ -702,6 +705,7 @@ export class ServerConnection extends EventTarget< // leave status as "disconnected" (the caller will react). return; } + if (this.hasEverConnected) this.failedReconnectAttempts += 1; this.scheduleRetry(); } diff --git a/packages/web/src/views/application-state.ts b/packages/web/src/views/application-state.ts index 233634a6..6375465b 100644 --- a/packages/web/src/views/application-state.ts +++ b/packages/web/src/views/application-state.ts @@ -3,6 +3,8 @@ import type { } from "../services/application-service.ts"; import type { DesktopUpgradeInstructions } from "@telepath-computer/television-shared"; +const FAILED_RECONNECT_LIMIT = 3; + export type ConnectedApplicationState = | { readonly kind: "connected" } | { readonly kind: "no-channel" } @@ -11,8 +13,8 @@ export type ConnectedApplicationState = export type InterruptingApplicationState = | { readonly kind: "connecting" } | { readonly kind: "disconnected"; readonly nextRetryAt: number | null } - | { readonly kind: "unauthorized"; readonly invalid?: boolean } - | { readonly kind: "error"; readonly serverURL: string; readonly message: string } + | { readonly kind: "unauthorized" } + | { readonly kind: "error"; readonly serverURL: string; readonly nextRetryAt: number | null } | { readonly kind: "needs-upgrade"; readonly instructions: DesktopUpgradeInstructions | null; @@ -27,31 +29,26 @@ export function selectApplicationState( ): ApplicationState { const connection = snapshot.connection; - if (connection.authorizationRequired) { - return connection.authorizationRejected - ? { kind: "unauthorized", invalid: true } - : { kind: "unauthorized" }; - } if (connection.gateHalted) { return { kind: "needs-upgrade", instructions: connection.upgradeInstructions, }; } + if (connection.authorizationRequired) { + return { kind: "unauthorized" }; + } if (connection.status !== "connected") { + if (connection.failedReconnectAttempts >= FAILED_RECONNECT_LIMIT || + (!connection.hasEverConnected && connection.firstConnectError !== null)) { + return { kind: "error", serverURL, nextRetryAt: connection.nextRetryAt }; + } if (connection.hasEverConnected) { return { kind: "disconnected", nextRetryAt: connection.nextRetryAt, }; } - if (connection.firstConnectError !== null) { - return { - kind: "error", - serverURL, - message: connection.firstConnectError, - }; - } return { kind: "connecting" }; } if (!snapshot.ready) { diff --git a/packages/web/src/views/artifact-view.css b/packages/web/src/views/artifact-view.css index 0bdd8383..0a16b713 100644 --- a/packages/web/src/views/artifact-view.css +++ b/packages/web/src/views/artifact-view.css @@ -70,6 +70,14 @@ border: 0; } +/* Held content in place of a document takes the same room, and draws on + the frame's own ground. */ +.artifact-frame-clip > .artifact-frame-content { + flex: 1; + min-width: 0; + min-height: 0; +} + /* The title bar: a quiet band below the document, naming the artifact; its contents grow as they are designed. The band is a step off the frame's surface rather than the surface itself — the faintest step there is, enough that the diff --git a/packages/web/src/views/copy-button.ts b/packages/web/src/views/copy-button.ts index 57961be6..4c1f214c 100644 --- a/packages/web/src/views/copy-button.ts +++ b/packages/web/src/views/copy-button.ts @@ -6,6 +6,7 @@ export interface CopyButtonTemplateOptions { label: string; prompt?: string; intent?: string; + size?: "sm" | "default" | "lg"; copied?: boolean; onActivate?: () => void; } @@ -111,6 +112,7 @@ export function copyButtonTemplate( label, prompt = "", intent, + size = "sm", copied = false, onActivate = () => {}, }: CopyButtonTemplateOptions, @@ -119,7 +121,7 @@ export function copyButtonTemplate( return html` `}`, + topLayerContent, )} `; diff --git a/packages/web/src/views/dialog.ts b/packages/web/src/views/dialog.ts index 318eff72..de3fcde2 100644 --- a/packages/web/src/views/dialog.ts +++ b/packages/web/src/views/dialog.ts @@ -12,8 +12,9 @@ export interface DialogPresentation { /** Compose caller-authored contents into the shared native dialog markup. */ export function dialogTemplate( content: unknown, + topLayerContent: unknown = null, ): TemplateResult { - return html`
${content}
`; + return html`
${topLayerContent}
${content}
`; } /** diff --git a/packages/web/src/views/system-modal.css b/packages/web/src/views/system-modal.css index 2fe0a401..270250da 100644 --- a/packages/web/src/views/system-modal.css +++ b/packages/web/src/views/system-modal.css @@ -20,17 +20,20 @@ color: var(--color-text-muted); } -/* Shared input styling owns the field; this surface stretches it. */ -.auth-form .auth-token { - align-self: stretch; -} - -.auth-form .auth-submit { - align-self: stretch; -} - /* The error state's server line: the address as an address. */ .system-modal .server-url { font-family: var(--font-mono); font-size: var(--text-sm); } + +.system-modal .system-modal-disconnect { + margin-top: var(--space-8); +} + +/* The desktop window has no title bar: where nothing behind the dialog + offers a drag handle, this strip across the top moves the window. */ +.window-drag-strip { + position: fixed; + inset: 0 0 auto 0; + height: 36px; +} diff --git a/packages/web/src/views/system-modal.ts b/packages/web/src/views/system-modal.ts index c3029b0e..2cc2f735 100644 --- a/packages/web/src/views/system-modal.ts +++ b/packages/web/src/views/system-modal.ts @@ -4,10 +4,8 @@ import { type DialogPresentation, } from "./dialog.ts"; import { View, view } from "@telepath-computer/utils/lit-view"; -import { html, nothing, render as renderTemplate } from "lit-html"; +import { html, render as renderTemplate } from "lit-html"; import type { InterruptingApplicationState } from "./application-state.ts"; -import { DesktopUpgradeGateView } from "./desktop-upgrade-gate.ts"; -import type { DesktopUpdateState } from "../services/desktop-update.ts"; import "../elements/icon.ts"; import "./system-modal.css"; import "./system-modal.host.css"; @@ -15,27 +13,20 @@ import "./system-modal.host.css"; const COUNTDOWN_TICK_MS = 250; const ONE_SECOND_MS = 1000; -export type SystemModalState = InterruptingApplicationState; +export type SystemModalState = Exclude; -export interface SystemModalApplication { - authenticate(token: string): Promise; +export interface SystemModalOptions { + context: "browser" | "desktop" | "local"; + dragStrip?: boolean; + onDisconnect?: () => void; } -/** - * The app's one interrupting surface. It owns only view-local form and - * countdown state; connection and gate state remain application inputs. - */ -export class SystemModal extends View<[ - SystemModalState, - SystemModalApplication, - DesktopUpdateState?, -]> { +/** Connection owners supply state; this shared presentation owns its countdown. */ +export class SystemModal extends View<[SystemModalState, SystemModalOptions]> { readonly #host = createSystemModalHost(); #presentation: DialogPresentation | null = null; #presentScheduled = false; #ticker: ReturnType | null = null; - #token = ""; - #application: SystemModalApplication | null = null; disconnected(): void { this.#stopTicker(); @@ -47,24 +38,12 @@ export class SystemModal extends View<[ template( state: SystemModalState, - application: SystemModalApplication, - desktopUpdate?: DesktopUpdateState, + options: SystemModalOptions, ): unknown { - this.#application = application; this.#syncTicker(state); - if (state.kind === "needs-upgrade") { - this.#presentation?.withdraw(); - this.#presentation = null; - renderTemplate( - DesktopUpgradeGateView(state.instructions, desktopUpdate), - this.#host, - ); - return this.#host; - } - renderTemplate( - dialogTemplate(this.#standardInterior(state)), + dialogTemplate(this.#standardInterior(state, options), windowDragStripTemplate(options.dragStrip)), this.#host, ); this.#schedulePresentation(); @@ -72,7 +51,8 @@ export class SystemModal extends View<[ } #standardInterior( - state: Exclude, + state: SystemModalState, + options: SystemModalOptions, ): unknown { switch (state.kind) { case "connecting": @@ -87,65 +67,42 @@ export class SystemModal extends View<[

Disconnected

-

${formatReattempt(state.nextRetryAt)}

+

${formatReconnect(state.nextRetryAt)}

`; - case "unauthorized": { - const invalid = Boolean(state.invalid); - if (invalid) this.#token = ""; + case "unauthorized": return html` -
+
-

Enter access token

-

This server requires an access token to connect.

- - ${invalid - ? html`` - : null} - - +

Access token required

+

${{ + browser: "This server requires a valid access token to connect. Ask your agent for the current link, and paste the whole link into the address bar.", + desktop: "This server requires a valid access token to connect. Choose Television › Disconnect from Server, then try again.", + local: "This server requires a valid access token to connect. Disconnect from Server, then paste the current link from your agent.", + }[options.context]}

+ ${this.#disconnectButton(options)} +
`; - } case "error": return html`

Can’t connect with server

${state.serverURL}

-

${state.message}

+

Check your internet connection and that the server is running. ${formatReconnect(state.nextRetryAt)}

+ ${this.#disconnectButton(options)}
`; } } - readonly #handleInput = (event: Event): void => { - this.#token = (event.currentTarget as HTMLInputElement).value; - }; - - readonly #handleSubmit = (event: Event): void => { - event.preventDefault(); - const token = this.#token.trim(); - if (token.length === 0 || this.#application === null) return; - void this.#application.authenticate(token); - }; + #disconnectButton(options: SystemModalOptions): unknown { + return options.context === "local" + ? html`` + : null; + } #syncTicker(state: SystemModalState): void { - const running = state.kind === "disconnected" && state.nextRetryAt !== null; + const running = (state.kind === "disconnected" || state.kind === "error") && state.nextRetryAt !== null; if (running && this.#ticker === null) { this.#ticker = setInterval(() => this.render(), COUNTDOWN_TICK_MS); } else if (!running) { @@ -180,9 +137,14 @@ function createSystemModalHost(): HTMLElement { return host; } -function formatReattempt(nextRetryAt: number | null): string { - if (nextRetryAt === null) return "Reattempting now…"; +function formatReconnect(nextRetryAt: number | null): string { + if (nextRetryAt === null) return "Reconnecting now…"; const remainingMs = nextRetryAt - Date.now(); - if (remainingMs <= 0) return "Reattempting now…"; - return `Reattempting in ${Math.ceil(remainingMs / ONE_SECOND_MS)}s…`; + if (remainingMs <= 0) return "Reconnecting now…"; + return `Reconnecting in ${Math.ceil(remainingMs / ONE_SECOND_MS)}s…`; +} + +/** Inside the native dialog so modality leaves the drag region interactive. */ +export function windowDragStripTemplate(enabled = false): unknown { + return enabled ? html`` : null; } diff --git a/packages/web/src/views/television-app.ts b/packages/web/src/views/television-app.ts index b442dbc1..e9b8a591 100644 --- a/packages/web/src/views/television-app.ts +++ b/packages/web/src/views/television-app.ts @@ -24,7 +24,8 @@ import { ChannelSidebarView } from "./channel-sidebar.ts"; import { SidebarCollapseTransition } from "./sidebar-collapse-transition.ts"; import { sidebarPose, type SidebarGeometry } from "./sidebar-transition-pose.ts"; import { StageView } from "./stage.ts"; -import { SystemModalView } from "./system-modal.ts"; +import { SystemModalView, windowDragStripTemplate } from "./system-modal.ts"; +import { DesktopUpgradeGateView } from "./desktop-upgrade-gate.ts"; import type { TabReorderSnapshot } from "./tab-reorder.ts"; import { TopBarView } from "./top-bar.ts"; import "./television-app.css"; @@ -132,6 +133,7 @@ export class TelevisionApp extends View<[ApplicationService, TelevisionAppOption options.runtimeServerURL, ); const snapshot = application.snapshot; + const hasShell = hasApplicationShell(state, snapshot.connection.hasEverConnected); if (options.onRenderComplete) { queueMicrotask(() => { if (this.isConnected) options.onRenderComplete?.(state.kind); @@ -146,8 +148,13 @@ export class TelevisionApp extends View<[ApplicationService, TelevisionAppOption ?data-sidebar-motion=${this.#sidebarTransitioning} style=${this.#appStyle()} > - ${hasApplicationShell(state) ? this.#shell(application, snapshot, options) : null} - ${isInterruptingState(state) ? SystemModalView(state, application, options.desktopUpdate) : null} + ${hasShell ? this.#shell(application, snapshot, options) : null} + ${state.kind === "needs-upgrade" + ? DesktopUpgradeGateView(state.instructions, options.desktopUpdate, windowDragStripTemplate(options.electronMode && !hasShell)) + : isInterruptingState(state) ? SystemModalView(state, { + context: options.electronMode ? "desktop" : "browser", + dragStrip: options.electronMode && !hasShell, + }) : null} `; @@ -546,11 +553,12 @@ export class TelevisionApp extends View<[ApplicationService, TelevisionAppOption export const TelevisionAppView = view(TelevisionApp); -function hasApplicationShell(state: ApplicationState): boolean { +function hasApplicationShell(state: ApplicationState, hasEverConnected: boolean): boolean { return state.kind === "connected" || state.kind === "no-channel" || state.kind === "empty-channel" || - state.kind === "disconnected"; + state.kind === "disconnected" || + (state.kind === "error" && hasEverConnected); } function isInterruptingState( diff --git a/packages/web/src/views/top-bar.test.ts b/packages/web/src/views/top-bar.test.ts index 37e785f6..2867fce6 100644 --- a/packages/web/src/views/top-bar.test.ts +++ b/packages/web/src/views/top-bar.test.ts @@ -82,10 +82,10 @@ function snapshot( focusedChannel: channels.find(({ id }) => id === focusedChannelId) ?? null, connection: { authorizationRequired: false, - authorizationRejected: false, gateHalted: false, status: "connected", hasEverConnected: true, + failedReconnectAttempts: 0, firstConnectError: null, nextRetryAt: null, upgradeInstructions: null, diff --git a/packages/web/src/views/url-unsupported/index.html b/packages/web/src/views/url-unsupported/index.html index b44fa8b9..75e9d745 100644 --- a/packages/web/src/views/url-unsupported/index.html +++ b/packages/web/src/views/url-unsupported/index.html @@ -81,10 +81,10 @@

This is an external web page

-

Television can't display external web pages directly inside a web browser. You can view external web pages right inside Television with the desktop app.

+

Television can't display external web pages directly inside a web browser. The Television desktop app can show them right inside Television; it is currently available only for Macs.

diff --git a/packages/web/test/app-root.test.ts b/packages/web/test/app-root.test.ts index 193d7829..083ee2c8 100644 --- a/packages/web/test/app-root.test.ts +++ b/packages/web/test/app-root.test.ts @@ -1,5 +1,7 @@ // @vitest-environment jsdom +import { DesktopUpgradeGateView } from "../src/views/desktop-upgrade-gate.ts"; + import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from "vitest"; import { DesktopUpdateState } from "../src/services/desktop-update.ts"; import { StandInDesktopUpdateBridge } from "./helpers/desktop-update-bridge.ts"; @@ -19,7 +21,6 @@ import { import { ChannelSidebarWidthPreference } from "../src/services/channel-sidebar-width.ts"; import { SystemModalView, - type SystemModalApplication, type SystemModalState, } from "../src/views/system-modal.ts"; import { TelevisionAppView } from "../src/views/television-app.ts"; @@ -116,10 +117,10 @@ function applicationSnapshot( ...snapshotOverrides, connection: { authorizationRequired: false, - authorizationRejected: false, gateHalted: false, status: "connected", hasEverConnected: true, + failedReconnectAttempts: 0, firstConnectError: null, nextRetryAt: null, upgradeInstructions: null, @@ -128,9 +129,8 @@ function applicationSnapshot( }; } -class FakeApplication extends EventTarget implements SystemModalApplication { +class FakeApplication extends EventTarget { snapshot: ApplicationSnapshot; - authenticate = vi.fn(async (_token: string) => undefined); handleNavigationKey = vi.fn((_key: string) => undefined); getArtifactViewURL = vi.fn(() => "/artifact-view"); getArtifactContentURL = vi.fn(() => null); @@ -208,7 +208,6 @@ describe("application-state selection (^ap-ac-one-state)", () => { name: string; snapshot: ApplicationSnapshot; state: string; - rejectionAlert?: boolean; }> = [ { name: "connected populated channel", snapshot: applicationSnapshot(), state: "connected" }, { @@ -222,19 +221,17 @@ describe("application-state selection (^ap-ac-one-state)", () => { state: "empty-channel", }, { - name: "authorization outranks gate, prior session, and error", + name: "retained gate outranks authorization, prior session, and error", snapshot: applicationSnapshot({ connection: { authorizationRequired: true, - authorizationRejected: true, gateHalted: true, status: "disconnected", hasEverConnected: true, firstConnectError: "offline", }, }), - state: "unauthorized", - rejectionAlert: true, + state: "needs-upgrade", }, { name: "gate outranks a connection interstitial", @@ -259,6 +256,23 @@ describe("application-state selection (^ap-ac-one-state)", () => { }), state: "disconnected", }, + ...[1, 2, 3].map((failedReconnectAttempts) => ({ + name: `after ${failedReconnectAttempts} failed reconnects`, + snapshot: applicationSnapshot({ connection: { + status: "disconnected", failedReconnectAttempts, + } }), + state: failedReconnectAttempts < 3 ? "disconnected" : "error", + })), + ...[false, true].flatMap((hasEverConnected) => ["unauthorized", "needs-upgrade"].map((state) => ({ + name: `${state} answers immediately with prior session=${hasEverConnected}`, + snapshot: applicationSnapshot({ connection: { + status: "disconnected", hasEverConnected, failedReconnectAttempts: 3, + firstConnectError: "offline", + authorizationRequired: state === "unauthorized", + gateHalted: state === "needs-upgrade", + } }), + state, + }))), { name: "a failed first connection shows its error", snapshot: applicationSnapshot({ @@ -290,12 +304,10 @@ describe("application-state selection (^ap-ac-one-state)", () => { expect(await rendered, row.name).toBe(row.state); await flush(); expect(app.querySelector("#app")?.getAttribute("data-app-state"), row.name).toBe(row.state); - expect(app.querySelectorAll(":scope > #app > .system-modal-host"), row.name).toHaveLength( + expect(app.querySelectorAll(":scope > #app > .system-modal-host, :scope > #app > .desktop-upgrade-gate"), row.name).toHaveLength( ["connected", "no-channel", "empty-channel"].includes(row.state) ? 0 : 1, ); - if (row.rejectionAlert !== undefined) { - expect(app.querySelector('[role="alert"]') !== null, row.name).toBe(row.rejectionAlert); - } + } }); }); @@ -330,7 +342,7 @@ describe("root shell composition and readiness (^ap-ac-markup-smoke)", () => { expect([...app.children]).toEqual([applicationRoot, foreground]); expect(app.querySelectorAll(":scope > #app > .app-sidebar")).toHaveLength(shell ? 1 : 0); expect(app.querySelectorAll(":scope > #app > .app-main")).toHaveLength(shell ? 1 : 0); - expect(app.querySelectorAll(":scope > #app > .system-modal-host")).toHaveLength(modal ? 1 : 0); + expect(app.querySelectorAll(":scope > #app > .system-modal-host, :scope > #app > .desktop-upgrade-gate")).toHaveLength(modal ? 1 : 0); if (shell) { const main = app.querySelector(".app-main")!; expect(main.children).toHaveLength(2); @@ -375,6 +387,14 @@ describe("root shell composition and readiness (^ap-ac-markup-smoke)", () => { shell: true, modal: true, }, + { + snapshot: applicationSnapshot({ connection: { + status: "disconnected", hasEverConnected: true, failedReconnectAttempts: 3, + } }), + state: "error", + shell: true, + modal: true, + }, { snapshot: applicationSnapshot({ ready: false, @@ -641,173 +661,113 @@ describe("application navigation listener lifecycle", () => { }); describe("system modal (^sm-ac-markup-smoke)", () => { - it("renders each selected interrupting surface and no other", async () => { - const application = new FakeApplication(applicationSnapshot()); - const host = document.createElement("main"); - document.body.append(host); - const draw = (state: SystemModalState, desktopUpdate?: DesktopUpdateState) => - render(SystemModalView(state, application, desktopUpdate), host); + it("renders connection contents by state and place, with only local disconnect actions", async () => { + vi.useFakeTimers(); + vi.setSystemTime(10_000); + const host = createApplicationHost(); + const disconnect = vi.fn(); const downloadedBridge = new StandInDesktopUpdateBridge(); downloadedBridge.report("1.5.0"); - - const rows: Array<{ - name: string; - state: SystemModalState; - desktopUpdate?: DesktopUpdateState; - assert: () => void; - }> = [ - { - name: "connecting", - state: { kind: "connecting" }, - assert: () => { - expect(host.querySelector('tv-icon[name="spinner"][spinning]')).not.toBeNull(); - expect(host.querySelector("h2")?.textContent).toBe("Connecting"); - expect(host.querySelector(".system-modal > p")).toBeNull(); - }, - }, - { - name: "disconnected countdown", - state: { kind: "disconnected", nextRetryAt: Date.now() + 5_000 }, - assert: () => { - expect(host.querySelector('tv-icon[name="spinner"][spinning]')).not.toBeNull(); - expect(host.querySelector("h2")?.textContent).toBe("Disconnected"); - expect(host.querySelector(".system-modal > p")?.textContent).toMatch(/^Reattempting in \d+s…$/); - }, - }, - { - name: "disconnected attempt in flight", - state: { kind: "disconnected", nextRetryAt: null }, - assert: () => { - expect(host.querySelector(".system-modal > p")?.textContent).toBe("Reattempting now…"); - }, - }, - { - name: "authorization", - state: { kind: "unauthorized" }, - assert: () => { - expect(host.querySelector('tv-icon[name="locked"]')).not.toBeNull(); - expect(host.querySelector("h2")?.textContent).toBe("Enter access token"); - expect(host.textContent).toContain("This server requires an access token to connect."); - const input = host.querySelector('input[name="token"]'); - expect(input?.type).toBe("password"); - expect(input?.required).toBe(true); - expect(input?.getAttribute("aria-label")).toBe("Access token"); - expect(input?.hasAttribute("aria-invalid")).toBe(false); - expect(input?.hasAttribute("aria-describedby")).toBe(false); - expect(host.querySelector("button")?.textContent?.trim()).toBe("Connect"); - expect(host.querySelector('[role="alert"]')).toBeNull(); - }, - }, - { - name: "rejected authorization", - state: { kind: "unauthorized", invalid: true }, - assert: () => { - const input = host.querySelector('input[name="token"]'); - expect(input?.value).toBe(""); - expect(input?.getAttribute("aria-invalid")).toBe("true"); - expect(input?.getAttribute("aria-describedby")).toBe("auth-token-error"); - expect(host.querySelector("#auth-token-error")?.classList.contains("tv-error")).toBe(true); - expect(host.querySelector('[role="alert"]')?.textContent).toContain( - "The previous token was rejected. Try again.", - ); - }, - }, - { - name: "first-connect error", - state: { kind: "error", serverURL: SERVER_URL, message: "Connection refused" }, - assert: () => { - expect(host.querySelector("h2")?.textContent).toBe("Can’t connect with server"); - expect(host.querySelector(".server-url")?.textContent).toBe(SERVER_URL); - expect(host.textContent).toContain("Connection refused"); - }, - }, - { - name: "desktop upgrade", - state: { - kind: "needs-upgrade", - instructions: { upgradeMarkdown: "# Channel upgrade\n\nRun the channel command." }, - }, - assert: () => { - expect(host.querySelector(".desktop-upgrade-gate")).not.toBeNull(); - expect(host.querySelector(".system-modal")).toBeNull(); - expect(host.querySelector(".desktop-upgrade-gate")?.textContent).toContain("Channel upgrade"); - expect(host.querySelector(".upgrade-gate-restart")).toBeNull(); - }, - }, - { - name: "desktop upgrade with a downloaded update", - state: { - kind: "needs-upgrade", - instructions: { upgradeMarkdown: "# Channel upgrade\n\nRun the channel command." }, - }, - desktopUpdate: new DesktopUpdateState({ electron: true, bridge: downloadedBridge }), - assert: () => { - expect(host.querySelector(".system-modal")).toBeNull(); - expect(host.querySelector(".desktop-upgrade-gate")?.textContent).toContain("The new version has already downloaded"); - expect(host.querySelector(".desktop-upgrade-gate")?.textContent).not.toContain("Channel upgrade"); - expect(host.querySelector(".upgrade-gate-restart")?.textContent?.trim()).toBe("Restart to update"); - }, - }, + const desktopUpdate = new DesktopUpdateState({ electron: true, bridge: downloadedBridge }); + const states: SystemModalState[] = [ + { kind: "connecting" }, + { kind: "disconnected", nextRetryAt: 15_000 }, + { kind: "disconnected", nextRetryAt: null }, + { kind: "unauthorized" }, + { kind: "error", serverURL: SERVER_URL, nextRetryAt: 15_000 }, + { kind: "error", serverURL: SERVER_URL, nextRetryAt: null }, ]; - - for (const row of rows) { - draw(row.state, row.desktopUpdate); - await flush(); - expect(host.querySelectorAll(":scope > .system-modal-host"), row.name).toHaveLength(1); - expect(host.querySelectorAll("dialog"), row.name).toHaveLength(1); - row.assert(); + try { + for (const context of ["browser", "desktop", "local"] as const) { + for (const state of states) { + render(SystemModalView(state, { context, onDisconnect: disconnect }), host); + await flush(); + expect(host.querySelectorAll("dialog")).toHaveLength(1); + expect(host.querySelector(".desktop-upgrade-gate")).toBeNull(); + expect(host.querySelector("form, input")).toBeNull(); + expect(host.querySelector("h2")?.textContent).toBe({ + connecting: "Connecting", disconnected: "Disconnected", + unauthorized: "Access token required", error: "Can’t connect with server", + }[state.kind]); + const spinner = host.querySelector('tv-icon[name="spinner"][spinning]'); + expect(spinner !== null).toBe(state.kind === "connecting" || state.kind === "disconnected"); + if (state.kind === "connecting") expect(host.querySelector("p")).toBeNull(); + if (state.kind === "unauthorized") { + expect(host.querySelector('tv-icon[name="locked"]')).not.toBeNull(); + expect(host.textContent).toContain({ + browser: "paste the whole link into the address bar", + desktop: "Choose Television › Disconnect from Server", + local: "Disconnect from Server, then paste the current link", + }[context]); + } + if (state.kind === "error") { + expect(host.querySelector("tv-icon")).toBeNull(); + expect(host.querySelector(".server-url")?.textContent).toBe(SERVER_URL); + expect(host.textContent).toContain("Check your internet connection and that the server is running."); + } + if (state.kind === "error" || state.kind === "disconnected") { + expect(host.textContent).toContain(state.nextRetryAt === null ? "Reconnecting now…" : "Reconnecting in 5s…"); + } + const button = host.querySelector("button"); + const offersDisconnect = context === "local" && (state.kind === "unauthorized" || state.kind === "error"); + expect(button !== null).toBe(offersDisconnect); + disconnect.mockClear(); + if (offersDisconnect) { + expect(button?.getAttribute("intent")).toBe("danger"); + expect(button?.textContent?.trim()).toBe("Disconnect from Server"); + button!.click(); + expect(disconnect).toHaveBeenCalledOnce(); + } + } + } + render(SystemModalView({ kind: "disconnected", nextRetryAt: 15_000 }, { context: "browser" }), host); + await vi.advanceTimersByTimeAsync(1_000); + expect(host.textContent).toContain("Reconnecting in 4s…"); + for (const update of [undefined, desktopUpdate]) { + render(DesktopUpgradeGateView({ upgradeMarkdown: "# Channel upgrade" }, update), host); + await flush(); + expect(host.querySelectorAll("dialog")).toHaveLength(1); + expect(host.querySelector(".system-modal")).toBeNull(); + expect(host.textContent).toContain(update ? "The new version has already downloaded" : "Channel upgrade"); + expect(host.querySelector(".upgrade-gate-restart") !== null).toBe(Boolean(update)); + } + } finally { + render(null, host); + vi.useRealTimers(); } }); }); -describe("system-modal token submission (^sm-ac-auth-submit)", () => { - it("refuses blank values, authenticates entered tokens, and clears a rejected token", async () => { - const application = new FakeApplication(applicationSnapshot()); - const host = document.createElement("main"); - document.body.append(host); - const draw = (state: SystemModalState) => render(SystemModalView(state, application), host); - - draw({ kind: "unauthorized" }); - await flush(); - const input = host.querySelector('input[name="token"]')!; - input.value = " "; - input.dispatchEvent(new Event("input", { bubbles: true })); - input.form!.dispatchEvent(new Event("submit", { bubbles: true, cancelable: true })); - expect(application.authenticate).not.toHaveBeenCalled(); - - input.value = " first-token "; - input.dispatchEvent(new Event("input", { bubbles: true })); - input.form!.dispatchEvent(new Event("submit", { bubbles: true, cancelable: true })); - expect(application.authenticate).toHaveBeenCalledTimes(1); - expect(application.authenticate).toHaveBeenLastCalledWith("first-token"); - expect(host.querySelector('[role="alert"]')).toBeNull(); - - // Submission and an in-flight reconnect do not imply rejection. - draw({ kind: "connecting" }); - draw({ kind: "unauthorized" }); - await flush(); - expect(host.querySelector('input[name="token"]')?.value).toBe(" first-token "); - expect(host.querySelector('[role="alert"]')).toBeNull(); - - // The application state explicitly reports rejection before the view - // clears the rejected token and offers a replacement. - draw({ kind: "unauthorized", invalid: true }); - await flush(); - const replacement = host.querySelector('input[name="token"]')!; - expect(replacement.value).toBe(""); - expect(host.querySelector('[role="alert"]')).not.toBeNull(); - - replacement.value = "replacement-token"; - replacement.dispatchEvent(new Event("input", { bubbles: true })); - replacement.form!.dispatchEvent(new Event("submit", { bubbles: true, cancelable: true })); - expect(application.authenticate).toHaveBeenCalledTimes(2); - expect(application.authenticate).toHaveBeenLastCalledWith("replacement-token"); - - draw({ kind: "unauthorized" }); - await flush(); - const fresh = host.querySelector('input[name="token"]')!; - expect(fresh.hasAttribute("aria-invalid")).toBe(false); - expect(fresh.hasAttribute("aria-describedby")).toBe(false); - expect(host.querySelector("#auth-token-error")).toBeNull(); - }); +// Composition contract: posed prior-session and platform facts; native drag +// hit testing is covered by the Electron seam. +it("requests a desktop drag strip only over a bare background", async () => { + const rows: Array<{ connection: Partial; hasShell: boolean }> = [ + { connection: { status: "disconnected", hasEverConnected: true }, hasShell: true }, + { connection: { status: "disconnected", hasEverConnected: true, failedReconnectAttempts: 3 }, hasShell: true }, + { connection: { status: "disconnected", hasEverConnected: false }, hasShell: false }, + { connection: { status: "disconnected", hasEverConnected: false, firstConnectError: "offline" }, hasShell: false }, + { connection: { authorizationRequired: true }, hasShell: false }, + { connection: { gateHalted: true }, hasShell: false }, + ]; + for (const electronMode of [false, true]) { + for (const collapsed of [false, true]) { + const host = createApplicationHost(); + const application = new FakeApplication(applicationSnapshot()); + render(TelevisionAppView(application as never, { + runtimeServerURL: SERVER_URL, primaryServerURL: SERVER_URL, electronMode, + sidebarWidthPreference: SIDEBAR_WIDTH_PREFERENCE, + sidebarCollapsedPreference: collapsedPreference(collapsed), + }), host); + for (const { connection, hasShell } of rows) { + application.setSnapshot(applicationSnapshot({ connection })); + await flush(); + expect(host.querySelectorAll(".app-main")).toHaveLength(hasShell ? 1 : 0); + expect(host.querySelectorAll(".window-drag-strip")).toHaveLength(electronMode && !hasShell ? 1 : 0); + expect(host.querySelectorAll("dialog")).toHaveLength(1); + if (connection.authorizationRequired) { + expect(host.textContent).toContain(electronMode ? "Choose Television › Disconnect from Server" : "paste the whole link into the address bar"); + } + } + } + } }); diff --git a/packages/web/test/application-service.test.ts b/packages/web/test/application-service.test.ts index 320ae5fd..24f48eaf 100644 --- a/packages/web/test/application-service.test.ts +++ b/packages/web/test/application-service.test.ts @@ -153,8 +153,8 @@ class FakeConnection extends EventTarget { bootState: "pending" | "halted" | "booted" = "pending"; hasEverConnected = false; hasAuthRejected = false; - hasAuthTokenRejected = false; nextRetryAt: number | null = null; + failedReconnectAttempts = 0; updateState: UpdateState | null = null; constructor(client: TelevisionClient) { @@ -174,7 +174,6 @@ class FakeConnection extends EventTarget { class FakeOwner extends EventTarget { readonly connection: FakeConnection; connectError: string | null = null; - authenticate = vi.fn(async () => undefined); constructor(client: TelevisionClient) { super(); @@ -294,10 +293,10 @@ describe("ApplicationService snapshot", () => { expect(service.snapshot.connection).toEqual({ authorizationRequired: false, - authorizationRejected: false, gateHalted: false, status: "disconnected", hasEverConnected: false, + failedReconnectAttempts: 0, firstConnectError: null, nextRetryAt: null, upgradeInstructions: null, @@ -312,11 +311,9 @@ describe("ApplicationService snapshot", () => { owner.emitChange(); expect(service.snapshot.connection).toMatchObject({ authorizationRequired: true, - authorizationRejected: false, status: "unauthorized", }); - owner.connection.hasAuthTokenRejected = true; owner.connection.bootState = "halted"; owner.connection.nextRetryAt = 12_345; owner.connection.updateState = { @@ -326,7 +323,6 @@ describe("ApplicationService snapshot", () => { owner.emitChange(); expect(service.snapshot.connection).toMatchObject({ authorizationRequired: true, - authorizationRejected: true, gateHalted: true, status: "unauthorized", hasEverConnected: false, @@ -341,7 +337,9 @@ describe("ApplicationService snapshot", () => { }).toThrow(); owner.connection.hasEverConnected = true; + owner.connection.failedReconnectAttempts = 3; owner.emitChange(); + expect(service.snapshot.connection.failedReconnectAttempts).toBe(3); expect(service.snapshot.connection.firstConnectError).toBeNull(); expect(service.snapshot.display).not.toHaveProperty("status"); }); @@ -1956,7 +1954,6 @@ describe("ApplicationService operations and disposal", () => { owner.connect(); await settle(); - await service.authenticate("token"); await service.createChannel("New"); await service.focusChannel(value.id); await service.setPinnedChannelIds([value.id]); @@ -1966,7 +1963,6 @@ describe("ApplicationService operations and disposal", () => { expect(await service.readMarkdown("artifact-a")).toBe("content:artifact-a"); await service.writeMarkdown("artifact-a", "changed"); - expect(owner.authenticate).toHaveBeenCalledWith("token"); expect(client.channels.create).toHaveBeenCalledWith({ name: "New" }); expect(client.display.patch).toHaveBeenCalledWith({ focusedChannelId: value.id }); expect(client.display.patch).toHaveBeenCalledWith({ pinnedChannelIds: [value.id] }); diff --git a/packages/web/test/channel-sidebar.test.ts b/packages/web/test/channel-sidebar.test.ts index 0d9a570e..3f447706 100644 --- a/packages/web/test/channel-sidebar.test.ts +++ b/packages/web/test/channel-sidebar.test.ts @@ -41,10 +41,10 @@ function snapshot( focusedChannel: channels.find(({ id }) => id === focusedChannelId) ?? null, connection: { authorizationRequired: false, - authorizationRejected: false, gateHalted: false, status: "connected", hasEverConnected: true, + failedReconnectAttempts: 0, firstConnectError: null, nextRetryAt: null, upgradeInstructions: null, diff --git a/packages/web/test/copy-button.test.ts b/packages/web/test/copy-button.test.ts index 5ad20969..518a1fdd 100644 --- a/packages/web/test/copy-button.test.ts +++ b/packages/web/test/copy-button.test.ts @@ -33,7 +33,7 @@ const fixtures: CopyButtonFixture[] = [ ]; describe("copyButtonTemplate (^cb-ac-markup-smoke)", () => { - it.each(fixtures)("renders the $name state", (fixture) => { + it.each(fixtures.flatMap((fixture) => [undefined, "sm", "default", "lg"].map((size) => ({ ...fixture, size: size as CopyButtonTemplateOptions["size"] }))))("renders the $name state at size=$size", (fixture) => { const host = document.createElement("main"); document.body.append(host); @@ -42,7 +42,7 @@ describe("copyButtonTemplate (^cb-ac-markup-smoke)", () => { expect(host.children).toHaveLength(2); const button = host.querySelector("button.copy-button"); expect(button).toBe(host.firstElementChild); - expect(button?.getAttribute("size")).toBe("sm"); + expect(button?.getAttribute("size")).toBe(fixture.size === "default" ? null : fixture.size ?? "sm"); expect(button?.getAttribute("aria-label")).toBe(fixture.label); expect(button?.getAttribute("prompt")).toBe(fixture.prompt); expect(button?.getAttribute("intent")).toBe(fixture.expectedIntent); diff --git a/packages/web/test/e2e/application-presentation.helpers.ts b/packages/web/test/e2e/application-presentation.helpers.ts index 30c84869..dd62ee5f 100644 --- a/packages/web/test/e2e/application-presentation.helpers.ts +++ b/packages/web/test/e2e/application-presentation.helpers.ts @@ -12,8 +12,11 @@ export interface ApplicationPresentationRecord { readonly sidebarCount: number; readonly mainCount: number; readonly modalHostCount: number; - readonly authFormCount: number; readonly gateCount: number; + readonly unauthorizedCount: number; + readonly failedReconnectAttempts: number | null; + readonly nextRetryAt: number | null; + readonly reconnectLine: string; readonly connectingCount: number; readonly disconnectedCount: number; readonly errorCount: number; @@ -58,7 +61,7 @@ function installRecorderInDocument(): void { const owner = window as unknown as { __tvApp3PresentationRecorder?: BrowserPresentationRecorder; __tvRecordApplicationPresentation?: (record: ApplicationPresentationRecord) => Promise; - __telepath?: { renderCompleteCallbacks?: Set<(state: string) => void> }; + __telepath?: { renderCompleteCallbacks?: Set<(state: string) => void>; connectionOwner?: { connection: { failedReconnectAttempts: number; nextRetryAt: number | null } } }; }; if (owner.__tvApp3PresentationRecorder) return; const publish = owner.__tvRecordApplicationPresentation; @@ -76,9 +79,12 @@ function installRecorderInDocument(): void { shellRegionCount: root?.querySelectorAll(":scope > .app-sidebar, :scope > .app-main").length ?? 0, sidebarCount: root?.querySelectorAll(":scope > .app-sidebar").length ?? 0, mainCount: root?.querySelectorAll(":scope > .app-main").length ?? 0, - modalHostCount: root?.querySelectorAll(":scope > .system-modal-host").length ?? 0, - authFormCount: root?.querySelectorAll(".system-modal-host .auth-form").length ?? 0, - gateCount: root?.querySelectorAll(".system-modal-host .desktop-upgrade-gate").length ?? 0, + modalHostCount: root?.querySelectorAll(":scope > .system-modal-host, :scope > .desktop-upgrade-gate").length ?? 0, + unauthorizedCount: [...root?.querySelectorAll(".system-modal h2") ?? []].filter((heading) => heading.textContent === "Access token required").length, + failedReconnectAttempts: owner.__telepath?.connectionOwner?.connection.failedReconnectAttempts ?? null, + nextRetryAt: owner.__telepath?.connectionOwner?.connection.nextRetryAt ?? null, + reconnectLine: root?.querySelector(".system-modal")?.textContent ?? "", + gateCount: root?.querySelectorAll(":scope > .desktop-upgrade-gate").length ?? 0, connectingCount: root?.querySelectorAll(".system-modal-host .system-modal h2").length ? [...root.querySelectorAll(".system-modal-host .system-modal h2")] .filter((heading) => heading.textContent?.trim() === "Connecting").length @@ -141,7 +147,7 @@ async function stopApplicationPresentationRecorder(page: Page): Promise { await page.evaluate(async () => { const owner = window as unknown as { __tvApp3PresentationRecorder?: BrowserPresentationRecorder; - __telepath?: { renderCompleteCallbacks?: Set<(state: string) => void> }; + __telepath?: { renderCompleteCallbacks?: Set<(state: string) => void>; connectionOwner?: { connection: { failedReconnectAttempts: number; nextRetryAt: number | null } } }; }; const recorder = owner.__tvApp3PresentationRecorder; if (!recorder) return; diff --git a/packages/web/test/e2e/auth.01.test.ts b/packages/web/test/e2e/auth.01.test.ts index beb4d34c..e9d3fdc1 100644 --- a/packages/web/test/e2e/auth.01.test.ts +++ b/packages/web/test/e2e/auth.01.test.ts @@ -11,7 +11,6 @@ import { } from "./helpers.ts"; import { observeApplicationPresentations, - type ApplicationPresentationRecord, } from "./application-presentation.helpers.ts"; function createDataDir(): string { @@ -88,19 +87,16 @@ test.describe("product browser token flow", () => { const appURL = await product.appURL(baseURL!); await page.goto(productAppIndexURL(appURL, product.token)); - await expect(page.locator(".auth-form")).toHaveCount(0); await waitForApplicationShell(page); await expect(page).toHaveURL(productAppCleanURL(appURL)); await page.reload(); - await expect(page.locator(".auth-form")).toHaveCount(0); await waitForApplicationShell(page); await expect(page).toHaveURL(productAppCleanURL(appURL)); await page.close(); const reopened = await context.newPage(); await reopened.goto(productAppCleanURL(appURL)); - await expect(reopened.locator(".auth-form")).toHaveCount(0); await waitForApplicationShell(reopened); await expect(reopened).toHaveURL(productAppCleanURL(appURL)); } finally { @@ -109,186 +105,63 @@ test.describe("product browser token flow", () => { }); }) -const SHELL_STATES = new Set(["connected", "no-channel", "empty-channel"]); - -function applicationRecords( - records: readonly ApplicationPresentationRecord[], -): readonly ApplicationPresentationRecord[] { - return records.filter((record) => record.appState !== null); -} - -function expectSoleUnauthorizedSettlement( - records: readonly ApplicationPresentationRecord[], - allowedBeforeUnauthorized: ReadonlySet, -): void { - const presentations = applicationRecords(records); - const firstUnauthorized = presentations.findIndex((record) => - record.appState === "unauthorized" - ); - expect(firstUnauthorized).toBeGreaterThanOrEqual(0); - - for (const record of presentations.slice(0, firstUnauthorized)) { - expect(allowedBeforeUnauthorized.has(record.appState!)).toBe(true); - } - for (const record of presentations.slice(firstUnauthorized)) { - expect(record).toMatchObject({ - appState: "unauthorized", - shellRegionCount: 0, - sidebarCount: 0, - mainCount: 0, - modalHostCount: 1, - authFormCount: 1, - gateCount: 0, - connectingCount: 0, - disconnectedCount: 0, - errorCount: 0, - }); - } -} - -function expectSuccessfulAuthenticationHandoff( - records: readonly ApplicationPresentationRecord[], -): void { - const presentations = applicationRecords(records); - expect(presentations[0]).toMatchObject({ - appState: "unauthorized", - shellRegionCount: 0, - modalHostCount: 1, - authFormCount: 1, - }); - expect(SHELL_STATES.has(presentations.at(-1)?.appState ?? "")).toBe(true); - - for (const record of presentations) { - expect( - record.appState === "unauthorized" || - record.appState === "connecting" || - SHELL_STATES.has(record.appState!), - ).toBe(true); - if (record.appState === "unauthorized") { - expect(record).toMatchObject({ - shellRegionCount: 0, - modalHostCount: 1, - authFormCount: 1, - gateCount: 0, - connectingCount: 0, - disconnectedCount: 0, - errorCount: 0, - }); - } - if (SHELL_STATES.has(record.appState!)) { - expect(record).toMatchObject({ - shellRegionCount: 2, - sidebarCount: 1, - mainCount: 1, - modalHostCount: 0, - authFormCount: 0, - gateCount: 0, - }); - } - } -} - -test.describe("product browser token flow", () => { - test("auth modal stores the token without writing it to the URL", async ({ page, baseURL }) => { - const product = await launchProductServer({ auth: true }); - try { - const appURL = await product.appURL(baseURL!); - await page.goto(productAppCleanURL(appURL)); - - const tokenInput = page.getByLabel("Access token"); - await expect(tokenInput).toBeVisible(); - await configureTestMotion(page); - const presentation = await observeApplicationPresentations(page); - await tokenInput.fill(product.token); - await page.locator(".auth-form button", { hasText: "Connect" }).click(); - - await expect(page.locator(".auth-form")).toHaveCount(0); - await waitForApplicationShell(page); +// Acceptance (^ap-ac-signin-first): real token rejection and current-link +// recovery. Stored tokens and the restarted server's token file are fixtures; +// no authentication response or request header is replaced. +test("rejected tokens at boot and after a session clear credentials and recover with a current link", async ({ page, baseURL }) => { + if (!baseURL) throw new Error("Expected Playwright baseURL"); + const product = await launchProductServer({ auth: true }); + try { + const appURL = await product.appURL(baseURL); + await page.addInitScript((origin) => { + if (location.origin !== origin || localStorage.getItem("seeded-auth-fixture")) return; + localStorage.setItem("store-television-browser", JSON.stringify({ authTokens: { [origin]: "wrong-token" } })); + localStorage.setItem("seeded-auth-fixture", "true"); + }, appURL); + const presentation = await observeApplicationPresentations(page); + const socketURLs: string[] = []; + page.on("websocket", (socket) => socketURLs.push(socket.url())); + await page.goto(productAppCleanURL(appURL)); + await configureTestMotion(page); + + const expectRejected = async (): Promise => { + await expect(page.getByRole("heading", { name: "Access token required" })).toBeVisible(); + await expect(page.locator(".app-sidebar, .app-main, .desktop-upgrade-gate")).toHaveCount(0); + await expect(page.locator("dialog")).toHaveCount(1); + await expect(page.locator(".system-modal")).toContainText("paste the whole link into the address bar"); + expect(await page.evaluate((origin) => { + const state = JSON.parse(localStorage.getItem("store-television-browser") ?? "{}"); + return state.authTokens?.[origin] ?? null; + }, appURL)).toBeNull(); + // Observe the real owner; the scheduler contract proves the indefinite + // no-retry promise without an arbitrary quiet-period sleep here. + expect(await page.evaluate(() => { + const { connection } = (window as unknown as { __telepath: { connectionOwner: { connection: { status: string; nextRetryAt: number | null; attempting: boolean } } } }).__telepath.connectionOwner; + return { status: connection.status, nextRetryAt: connection.nextRetryAt, attempting: connection.attempting }; + })).toEqual({ status: "unauthorized", nextRetryAt: null, attempting: false }); await presentation.settle(); - await presentation.stop(); - expectSuccessfulAuthenticationHandoff(presentation.records()); - await expect(page).toHaveURL(productAppCleanURL(appURL)); - const stored = await page.evaluate((origin) => { - const raw = window.localStorage.getItem("store-television-browser"); - return raw ? JSON.parse(raw).authTokens?.[origin] : null; - }, appURL); - expect(stored).toBe(product.token); - } finally { - await product.dispose(); - } - }); - - test("stored-token HTTP 401 clears localStorage and returns to the auth modal", async ({ page, baseURL }) => { - const product = await launchProductServer({ auth: true }); - try { - const appURL = await product.appURL(baseURL!); - await page.goto(productAppIndexURL(appURL, product.token)); - await waitForApplicationShell(page); - await configureTestMotion(page); - const presentation = await observeApplicationPresentations(page); - - await page.route(`${appURL}/channels`, async (route) => { - const request = route.request(); - if (request.method() !== "POST") { - await route.continue(); - return; - } - await route.continue({ - headers: { - ...request.headers(), - authorization: "Bearer wrong-token", - }, - }); - }); - await page.evaluate(async () => { - const application = (window as unknown as { - __telepath: { applicationService: { createChannel(name: string): Promise } }; - }).__telepath.applicationService; - await application.createChannel("Rejected write").catch(() => undefined); + const records = presentation.records().filter((record) => record.appState === "unauthorized"); + expect(records.length).toBeGreaterThan(0); + for (const record of records) expect(record).toMatchObject({ + shellRegionCount: 0, modalHostCount: 1, unauthorizedCount: 1, + gateCount: 0, connectingCount: 0, disconnectedCount: 0, errorCount: 0, }); - - await expect(page.getByLabel("Access token")).toBeVisible(); - await presentation.settle(); - await presentation.stop(); - expectSoleUnauthorizedSettlement(presentation.records(), SHELL_STATES); - const stored = await page.evaluate((origin) => { - const raw = window.localStorage.getItem("store-television-browser"); - return raw ? JSON.parse(raw).authTokens?.[origin] ?? null : null; - }, appURL); - expect(stored).toBeNull(); - } finally { - await product.dispose(); - } - }); - - test("stored-token WebSocket 4401 clears localStorage and returns to the auth modal", async ({ page, baseURL }) => { - const product = await launchProductServer({ auth: true }); - try { - const appURL = await product.appURL(baseURL!); - const presentation = await observeApplicationPresentations(page); - await page.addInitScript(({ origin, token }) => { - if (window.location.origin !== origin) return; - const key = "store-television-browser"; - const state = JSON.parse(window.localStorage.getItem(key) ?? "{}"); - state.authTokens = { ...(state.authTokens ?? {}), [origin]: token }; - window.localStorage.setItem(key, JSON.stringify(state)); - }, { origin: appURL, token: "wrong-token" }); - - await page.goto(productAppCleanURL(appURL)); - await expect(page.getByLabel("Access token")).toBeVisible(); - await presentation.settle(); - await presentation.stop(); - expectSoleUnauthorizedSettlement( - presentation.records(), - new Set(["connecting"]), - ); - const stored = await page.evaluate((origin) => { - const raw = window.localStorage.getItem("store-television-browser"); - return raw ? JSON.parse(raw).authTokens?.[origin] ?? null : null; - }, appURL); - expect(stored).toBeNull(); - } finally { - await product.dispose(); - } - }); + }; + await expectRejected(); + expect(socketURLs.some((url) => new URL(url).searchParams.get("token") === "wrong-token")).toBe(true); + await page.goto(productAppIndexURL(appURL, product.token)); + await waitForApplicationShell(page); + const replacementToken = "a".repeat(64); + writeFileSync(path.join(product.home, "state", "token"), replacementToken); + await product.restart(); + await expectRejected(); + expect(socketURLs.filter((url) => new URL(url).searchParams.get("token") === product.token).length).toBeGreaterThanOrEqual(2); + await page.goto(productAppIndexURL(appURL, replacementToken)); + await waitForApplicationShell(page); + await expect(page.locator("dialog")).toHaveCount(0); + await expect(page).toHaveURL(productAppCleanURL(appURL)); + await presentation.stop(); + } finally { + await product.dispose(); + } }); diff --git a/packages/web/test/e2e/auth.02.test.ts b/packages/web/test/e2e/auth.02.test.ts index bb368821..a06bb5f7 100644 --- a/packages/web/test/e2e/auth.02.test.ts +++ b/packages/web/test/e2e/auth.02.test.ts @@ -8,10 +8,7 @@ import { configureTestMotion, waitForApplicationShell, } from "./helpers.ts"; -import { - observeApplicationPresentations, - type ApplicationPresentationRecord, -} from "./application-presentation.helpers.ts"; + function createDataDir(): string { return mkdtempSync(path.join(os.tmpdir(), "television-auth-e2e-")); @@ -36,7 +33,6 @@ test.describe("web auth modal integration", () => { `${baseURL}/packages/web/src/index.html?serverURL=${encodeURIComponent(serverURL)}&mode=electron&token=${encodeURIComponent(token)}`, ); - await expect(page.locator(".auth-form")).toHaveCount(0); await waitForApplicationShell(page); await expect(page).toHaveURL(`${baseURL}/packages/web/src/index.html?serverURL=${encodeURIComponent(serverURL)}&mode=electron`); } finally { @@ -46,170 +42,7 @@ test.describe("web auth modal integration", () => { }); }) -const SHELL_STATES = new Set(["connected", "no-channel", "empty-channel"]); - -function expectSuccessfulAuthenticationHandoff( - records: readonly ApplicationPresentationRecord[], -): void { - const presentations = records.filter((record) => record.appState !== null); - const firstUnauthorized = presentations.findIndex((record) => - record.appState === "unauthorized" - ); - expect(firstUnauthorized).toBeGreaterThanOrEqual(0); - expect(SHELL_STATES.has(presentations.at(-1)?.appState ?? "")).toBe(true); - - for (const record of presentations) { - expect( - record.appState === "connecting" || - record.appState === "unauthorized" || - SHELL_STATES.has(record.appState!), - ).toBe(true); - if (record.appState === "unauthorized") { - expect(record).toMatchObject({ - shellRegionCount: 0, - sidebarCount: 0, - mainCount: 0, - modalHostCount: 1, - authFormCount: 1, - gateCount: 0, - connectingCount: 0, - disconnectedCount: 0, - errorCount: 0, - }); - } - if (SHELL_STATES.has(record.appState!)) { - expect(record).toMatchObject({ - shellRegionCount: 2, - sidebarCount: 1, - mainCount: 1, - modalHostCount: 0, - authFormCount: 0, - gateCount: 0, - }); - } - } -} - -test.describe("web auth modal integration", () => { - test("connecting with the auth modal token exits the auth gate", async ({ page, baseURL }) => { - const storagePath = createDataDir(); - const server = new Server({ - store: createServingStore(storagePath), - port: 0, - auth: true, - }); - - try { - await server.start(); - const serverURL = server.getBaseURL(); - const token = server.getAuthToken(); - const presentation = await observeApplicationPresentations(page); - - await page.goto(`${baseURL}/packages/web/src/index.html?serverURL=${encodeURIComponent(serverURL)}`); - - const tokenInput = page.getByLabel("Access token"); - await expect(tokenInput).toBeVisible(); - await expect(page.locator("#app[data-app-state='unauthorized'] > .app-sidebar")).toHaveCount(0); - await expect(page.locator("#app[data-app-state='unauthorized'] > .app-main")).toHaveCount(0); - await expect(page.locator(".desktop-upgrade-gate")).toHaveCount(0); - await configureTestMotion(page); - - await tokenInput.fill(token); - await page.locator(".auth-form button", { hasText: "Connect" }).click(); - - await expect(page.locator(".auth-form")).toHaveCount(0); - await waitForApplicationShell(page); - await expect(page.locator("#app > .app-sidebar")).toHaveCount(1); - await expect(page.locator("#app > .app-main > .top-bar")).toHaveCount(1); - await expect(page.locator("#app > .app-main > .stage")).toHaveCount(1); - await presentation.settle(); - await presentation.stop(); - expectSuccessfulAuthenticationHandoff(presentation.records()); - } finally { - await server.dispose(); - rmSync(storagePath, { recursive: true, force: true }); - } - }); - - test("a server-rejected auth modal token shows rejection", async ({ page, baseURL }) => { - const storagePath = createDataDir(); - const server = new Server({ - store: createServingStore(storagePath), - port: 0, - auth: true, - }); - - try { - await server.start(); - const serverURL = server.getBaseURL(); - await page.goto(`${baseURL}/packages/web/src/index.html?serverURL=${encodeURIComponent(serverURL)}`); - - const tokenInput = page.getByLabel("Access token"); - await expect(tokenInput).toBeVisible(); - await configureTestMotion(page); - await tokenInput.fill(`${server.getAuthToken()}-rejected`); - await page.locator(".auth-form button", { hasText: "Connect" }).click(); - - const alert = page.locator('[role="alert"]'); - await expect(alert).toHaveText("The previous token was rejected. Try again."); - await expect(tokenInput).toHaveAttribute("aria-invalid", "true"); - await expect(alert).toHaveAttribute("id", "auth-token-error"); - await expect(tokenInput).toHaveAttribute("aria-describedby", "auth-token-error"); - await expect(tokenInput).toHaveValue(""); - } finally { - await server.dispose(); - rmSync(storagePath, { recursive: true, force: true }); - } - }); - - test("a server-accepted auth modal token never shows rejection", async ({ page, baseURL }) => { - const storagePath = createDataDir(); - const server = new Server({ - store: createServingStore(storagePath), - port: 0, - auth: true, - }); - - try { - await server.start(); - const serverURL = server.getBaseURL(); - await page.goto(`${baseURL}/packages/web/src/index.html?serverURL=${encodeURIComponent(serverURL)}`); - - const tokenInput = page.getByLabel("Access token"); - await expect(tokenInput).toBeVisible(); - await configureTestMotion(page); - await page.evaluate(() => { - document.documentElement.dataset.authRejectionSeen = "false"; - const observer = new MutationObserver((records) => { - for (const record of records) { - for (const node of record.addedNodes) { - if ( - node instanceof Element && - (node.matches('[role="alert"]') || node.querySelector('[role="alert"]')) - ) { - document.documentElement.dataset.authRejectionSeen = "true"; - } - } - } - }); - observer.observe(document.body, { childList: true, subtree: true }); - }); - - await tokenInput.fill(server.getAuthToken()); - await page.locator(".auth-form button", { hasText: "Connect" }).click(); - - expect(await page.evaluate(() => - document.querySelector('[role="alert"]')?.textContent ?? null - )).toBeNull(); - await expect(page.locator(".auth-form")).toHaveCount(0); - await waitForApplicationShell(page); - await expect(page.locator("html")).toHaveAttribute("data-auth-rejection-seen", "false"); - } finally { - await server.dispose(); - rmSync(storagePath, { recursive: true, force: true }); - } - }); - +test.describe("web authentication integration", () => { test("artifacts remain visible after reload without switching channels", async ({ page, baseURL }) => { const storagePath = createDataDir(); const artifactPath = path.join(storagePath, "startup-artifact.html"); @@ -235,18 +68,9 @@ test.describe("web auth modal integration", () => { try { await server.start(); - const presentation = await observeApplicationPresentations(page); - await page.goto(`${baseURL}/packages/web/src/index.html?serverURL=${encodeURIComponent(server.getBaseURL())}`); - const tokenInput = page.getByLabel("Access token"); - await expect(tokenInput).toBeVisible(); - await configureTestMotion(page); - await tokenInput.fill(server.getAuthToken()); - await page.locator(".auth-form button", { hasText: "Connect" }).click(); - + await page.goto(`${baseURL}/packages/web/src/index.html?serverURL=${encodeURIComponent(server.getBaseURL())}&token=${encodeURIComponent(server.getAuthToken())}`); await waitForApplicationShell(page); - await presentation.settle(); - await presentation.stop(); - expectSuccessfulAuthenticationHandoff(presentation.records()); + await configureTestMotion(page); await expect( page.locator(`.channel-row[data-channel-id="${channel.id}"] .channel`), ).toHaveAttribute("aria-selected", "true"); diff --git a/packages/web/test/e2e/fixtures/app-shell.html b/packages/web/test/e2e/fixtures/app-shell.html index 02ea62be..8dea1316 100644 --- a/packages/web/test/e2e/fixtures/app-shell.html +++ b/packages/web/test/e2e/fixtures/app-shell.html @@ -52,7 +52,6 @@ }, }; - authenticate() { return Promise.resolve(); } createChannel() { return Promise.reject(new Error("not used")); } focusChannel() { return Promise.resolve(); } setPinnedChannelIds() { return Promise.resolve(); } diff --git a/packages/web/test/e2e/fixtures/channel-sidebar.html b/packages/web/test/e2e/fixtures/channel-sidebar.html index e025ad68..387d819a 100644 --- a/packages/web/test/e2e/fixtures/channel-sidebar.html +++ b/packages/web/test/e2e/fixtures/channel-sidebar.html @@ -196,7 +196,6 @@ return Promise.resolve(); } - authenticate() { return Promise.resolve(); } getArtifactViewURL() { return "/packages/web/test/e2e/fixtures/channel-sidebar-document.html"; } diff --git a/packages/web/test/e2e/fixtures/keyboard-navigation.html b/packages/web/test/e2e/fixtures/keyboard-navigation.html index 7dd15b44..4fba1961 100644 --- a/packages/web/test/e2e/fixtures/keyboard-navigation.html +++ b/packages/web/test/e2e/fixtures/keyboard-navigation.html @@ -138,7 +138,6 @@ class FixtureOwner extends EventTarget { connection = new FixtureConnection(); connectError = null; - authenticate() { return Promise.resolve(); } connect() { this.connection.status = "connected"; diff --git a/packages/web/test/e2e/fixtures/system-modal.html b/packages/web/test/e2e/fixtures/system-modal.html index d5839be0..a202dcd2 100644 --- a/packages/web/test/e2e/fixtures/system-modal.html +++ b/packages/web/test/e2e/fixtures/system-modal.html @@ -15,22 +15,16 @@ import { SystemModalView } from "/packages/web/src/views/system-modal.ts"; const host = document.querySelector("#fixture"); - const authenticationCalls = []; - const application = { - async authenticate(token) { - authenticationCalls.push(token); - }, - }; - - window.__authenticationCalls = authenticationCalls; + window.__disconnectCalls = 0; + const options = { context: "local", onDisconnect() { window.__disconnectCalls += 1; } }; window.__poseSystemModal = async (state) => { - render(SystemModalView(state, application), host); + render(state === null ? null : SystemModalView(state, options), host); await Promise.resolve(); await new Promise((resolve) => requestAnimationFrame(resolve)); }; await customElements.whenDefined("tv-icon"); - await window.__poseSystemModal({ kind: "unauthorized", invalid: false }); + await window.__poseSystemModal({ kind: "unauthorized" }); window.__fixtureReady = true; diff --git a/packages/web/test/e2e/fixtures/top-bar.html b/packages/web/test/e2e/fixtures/top-bar.html index f2c8d0d1..8e988961 100644 --- a/packages/web/test/e2e/fixtures/top-bar.html +++ b/packages/web/test/e2e/fixtures/top-bar.html @@ -86,7 +86,6 @@ focusedChannel: channels.find(({ id }) => id === focusedChannelId) ?? null, connection: { authorizationRequired: false, - authorizationRejected: false, gateHalted: false, status: "connected", hasEverConnected: true, diff --git a/packages/web/test/e2e/gate-boot-barrier.spec.ts b/packages/web/test/e2e/gate-boot-barrier.spec.ts index e6712a0e..f761a8db 100644 --- a/packages/web/test/e2e/gate-boot-barrier.spec.ts +++ b/packages/web/test/e2e/gate-boot-barrier.spec.ts @@ -3,6 +3,7 @@ import { mkdtempSync, rmSync } from "node:fs"; import os from "node:os"; import path from "node:path"; import { Server } from "@telepath-computer/television-server"; +import { startStableFrontProxy } from "../../../../test/helpers/stable-front-proxy.ts"; import { buildVersionedWebBundle } from "../../../../test/helpers/versioned-web-bundle.ts"; import { createServingStore } from "../../../../test/helpers/serving-store.ts"; import { @@ -146,7 +147,7 @@ test.describe("boot barrier (^t-gate-boot-barrier)", () => { sidebarCount: 0, mainCount: 0, modalHostCount: 1, - authFormCount: 0, + unauthorizedCount: 0, gateCount: 1, connectingCount: 0, disconnectedCount: 0, @@ -187,3 +188,35 @@ test.describe("browser clients (^ac-gate-desktop-only)", () => { expect(await gate(page).count()).toBe(0); }); }) + +// ^ap-ac-gate-uncovered: real reconnect status into the production view. +// Version inputs pose Electron eligibility; the stable front holds the origin +// while each real backend binds port 0. No state/transport mechanism is mocked. +test("a reconnect requirement replaces the outage with the lone retained gate", async ({ page }) => { + const dist = await buildVersionedWebBundle(SERVER_VERSION); + const initial = await startServer({ staticDir: dist }); + const front = await startStableFrontProxy(initial.getBaseURL()); + try { + await page.goto(`${front.url}/?mode=electron&desktopAppVersion=1.3.1`); + await waitForApplicationShell(page); + await configureTestMotion(page); + const presentation = await observeApplicationPresentations(page); + await initial.dispose(); + await expect(page.locator("#app")).toHaveAttribute("data-app-state", "disconnected"); + const replacement = await startServer({ staticDir: dist, requiredDesktopVersion: "2.0.0" }); + front.setTarget(replacement.getBaseURL()); + await expect(gate(page)).toBeVisible({ timeout: 15_000 }); + await presentation.settle(); + await presentation.stop(); + const records = presentation.records().filter((record) => record.appState !== null); + const firstGate = records.findIndex((record) => record.gateCount > 0); + expect(firstGate).toBeGreaterThan(0); + expect(records.slice(0, firstGate).some((record) => record.appState === "disconnected")).toBe(true); + for (const record of records.slice(firstGate)) expect(record).toMatchObject({ + appState: "needs-upgrade", shellRegionCount: 0, modalHostCount: 1, + gateCount: 1, unauthorizedCount: 0, connectingCount: 0, disconnectedCount: 0, errorCount: 0, + }); + } finally { + await front.dispose(); + } +}); diff --git a/packages/web/test/e2e/reload-after-reconnect.02.test.ts b/packages/web/test/e2e/reload-after-reconnect.02.test.ts index 9e04870f..49010a21 100644 --- a/packages/web/test/e2e/reload-after-reconnect.02.test.ts +++ b/packages/web/test/e2e/reload-after-reconnect.02.test.ts @@ -2,6 +2,7 @@ import { expect, test, type Page } from "@playwright/test"; import { mkdtempSync, rmSync } from "node:fs"; import os from "node:os"; import path from "node:path"; +import { TelevisionClient } from "@telepath-computer/television-shared"; import { Server } from "@telepath-computer/television-server"; import { configureTestMotion, @@ -353,7 +354,7 @@ test.describe("reload after reconnect (real server)", () => { } }); - test("a disconnected modal preserves the live shell and artifact document through automatic reconnect", async ({ + test("outage escalation preserves the live shell and artifact document through automatic reconnect", async ({ page, baseURL, }) => { @@ -378,18 +379,20 @@ test.describe("reload after reconnect (real server)", () => { `, "html", ); - const sourceArtifact = store.createArtifact({ - channelID: sourceChannel.id, - title: "Continuity source", - kind: "path", - path: sourcePath, - }); store.patchDisplay({ focusedChannelId: visibleChannel.id }); - const server = new Server({ store, port: 0 }); + let server = new Server({ store, port: 0 }); let proxy: Awaited> | undefined; try { await server.start(); + const client = new TelevisionClient(server.getBaseURL(), { token: server.getAuthToken() }); + const { artifact: sourceArtifact } = await client.artifacts.create({ + channelID: sourceChannel.id, + title: "Continuity source", + kind: "path", + path: sourcePath, + }); + proxy = await startDevelopmentProxy({ productServerURL: server.getBaseURL(), viteBaseURL: baseURL, @@ -399,7 +402,7 @@ test.describe("reload after reconnect (real server)", () => { // reconnect while correctly avoiding the intentional path-document // refresh; APP-3's subject is identity of the already-loaded document. const sharedURL = `${proxy.url}/artifact/${sourceArtifact.id}/${encodeURIComponent(path.basename(sourcePath))}`; - const visibleArtifact = store.createArtifact({ + const { artifact: visibleArtifact } = await client.artifacts.create({ channelID: visibleChannel.id, title: "Continuity document", kind: "url", @@ -426,8 +429,7 @@ test.describe("reload after reconnect (real server)", () => { const presentation = await observeApplicationPresentations(page); expect(eventStream(server).getConnectedClientCount()).toBeGreaterThan(0); - terminateEventSockets(server); - await expectEventClientsGone(server, 5_000); + await server.dispose(); await expect( page.locator("#app[data-app-state='disconnected'] .system-modal-host .system-modal h2"), ).toHaveText("Disconnected"); @@ -438,6 +440,13 @@ test.describe("reload after reconnect (real server)", () => { expectContinuity(interrupted); expect(interrupted.animationFrames).toBeGreaterThan(before.animationFrames); + await expect(page.locator("#app")).toHaveAttribute("data-app-state", "error", { timeout: 20_000 }); + await expect(page.locator(".system-modal")).toContainText("Reconnecting in"); + await presentation.settle(); + expectContinuity(await readContinuityObservation(page)); + server = new Server({ store: createServingStore(storagePath), port: 0 }); + await server.start(); + proxy.setProductServerURL(server.getBaseURL()); await expectEventClientsConnected(server, 15_000); await expect(page.locator("#app")).toHaveAttribute( "data-app-state", @@ -455,8 +464,17 @@ test.describe("reload after reconnect (real server)", () => { record.appState !== null ); const disconnectedRecords = records.filter((record) => - record.appState === "disconnected" + record.appState === "disconnected" || record.appState === "error" ); + for (const count of [0, 1, 2, 3]) { + const attempts = disconnectedRecords.filter((record) => record.failedReconnectAttempts === count); + expect(attempts.length, `observed ${count} completed failures`).toBeGreaterThan(0); + for (const record of attempts) expect(record.appState).toBe(count < 3 ? "disconnected" : "error"); + } + for (const state of ["disconnected", "error"]) { + expect(disconnectedRecords.some((record) => record.appState === state && /Reconnecting in \d+s…/.test(record.reconnectLine))).toBe(true); + expect(disconnectedRecords.some((record) => record.appState === state && record.reconnectLine.includes("Reconnecting now…"))).toBe(true); + } expect(disconnectedRecords.length).toBeGreaterThan(0); for (const record of disconnectedRecords) { expect(record).toMatchObject({ @@ -464,11 +482,11 @@ test.describe("reload after reconnect (real server)", () => { sidebarCount: 1, mainCount: 1, modalHostCount: 1, - authFormCount: 0, + unauthorizedCount: 0, gateCount: 0, connectingCount: 0, - disconnectedCount: 1, - errorCount: 0, + disconnectedCount: record.appState === "disconnected" ? 1 : 0, + errorCount: record.appState === "error" ? 1 : 0, }); } expect( diff --git a/packages/web/test/e2e/system-modal.test.ts b/packages/web/test/e2e/system-modal.test.ts index d3c1d5e9..7e871e6f 100644 --- a/packages/web/test/e2e/system-modal.test.ts +++ b/packages/web/test/e2e/system-modal.test.ts @@ -4,7 +4,7 @@ const FIXTURE = "/packages/web/test/e2e/fixtures/system-modal.html"; interface SystemModalFixtureWindow { __fixtureReady?: boolean; - __authenticationCalls: string[]; + __disconnectCalls: number; __poseSystemModal(state: unknown): Promise; } @@ -35,42 +35,23 @@ test.describe("application system modal", () => { await setup(page); }); - test("presents the production authorization form through a native modal dialog", async ({ page }) => { - await expectNativeModal(page); - const input = page.getByLabel("Access token"); - await expect(input).toBeFocused(); - - await input.fill(" "); - await page.getByRole("button", { name: "Connect" }).click(); - expect( - await page.evaluate( - () => (window as unknown as SystemModalFixtureWindow).__authenticationCalls, - ), - ).toEqual([]); - - await input.fill(" browser-token "); - await page.getByRole("button", { name: "Connect" }).click(); - expect( - await page.evaluate( - () => (window as unknown as SystemModalFixtureWindow).__authenticationCalls, - ), - ).toEqual(["browser-token"]); + test("connection dialogs resist Escape and backdrop input until the owner withdraws them (^sm-ac-blocking)", async ({ page }) => { + for (const state of [ + { kind: "connecting" }, + { kind: "disconnected", nextRetryAt: null }, + { kind: "unauthorized" }, + { kind: "error", serverURL: "https://server.example", nextRetryAt: null }, + ]) { + await pose(page, state); + await expectNativeModal(page); + await page.keyboard.press("Escape"); + await expectNativeModal(page); + await page.mouse.click(5, 5); + await expectNativeModal(page); + expect(await page.evaluate(() => (window as unknown as SystemModalFixtureWindow).__disconnectCalls)).toBe(0); + await pose(page, null); + await expect(page.locator("dialog")).toHaveCount(0); + } }); - test("switches between the routed upgrade gate and the standard dialog without stacking", async ({ page }) => { - await pose(page, { - kind: "needs-upgrade", - instructions: { upgradeMarkdown: "# Upgrade this desktop\n\nUse the channel instructions." }, - }); - - await expect(page.locator(".desktop-upgrade-gate")).toContainText("Upgrade this desktop"); - await expect(page.locator(".system-modal")).toHaveCount(0); - await expectNativeModal(page); - - await pose(page, { kind: "connecting" }); - - await expect(page.locator(".desktop-upgrade-gate")).toHaveCount(0); - await expect(page.locator(".system-modal h2")).toHaveText("Connecting"); - await expectNativeModal(page); - }); }); diff --git a/packages/web/test/e2e/theme-product-acceptance.test.ts b/packages/web/test/e2e/theme-product-acceptance.test.ts index d09b8eb6..43d9953c 100644 --- a/packages/web/test/e2e/theme-product-acceptance.test.ts +++ b/packages/web/test/e2e/theme-product-acceptance.test.ts @@ -1855,12 +1855,10 @@ document.querySelector("#artifact-probe").addEventListener("click", () => { // proofs/arch/themes/delivery.md#^theme-delivery-t-settings-reopen test("executable theme reset reloads connected Chromium documents under confirmed state", async ({ page, - baseURL, browserName, }) => { test.skip(browserName !== "chromium", "Chromium owns executable-theme reset acceptance"); test.slow(); - if (!baseURL) throw new Error("Expected Playwright baseURL"); const product = await launchProductServer(); const secondPage = await page.context().newPage(); @@ -1976,11 +1974,8 @@ test("executable theme reset reloads connected Chromium documents under confirme name: "Other theme", }); - const appURL = await product.appURL(baseURL); await Promise.all(applicationPages.map(async (applicationPage) => { - await applicationPage.goto( - `${appURL}/packages/web/src/index.html?serverURL=${encodeURIComponent(appURL)}`, - ); + await applicationPage.goto(product.serverURL); await waitForApplicationShell(applicationPage); })); await openSettings(page); diff --git a/packages/web/test/foundation-copy.test.ts b/packages/web/test/foundation-copy.test.ts index bcb33052..0fb0a976 100644 --- a/packages/web/test/foundation-copy.test.ts +++ b/packages/web/test/foundation-copy.test.ts @@ -167,7 +167,7 @@ describe("application foundation production crossing (^ui-t-foundation-copy)", ( ); const forbiddenImports = TRACKED_PATHS - .filter((file) => file.startsWith("packages/web/src/")) + .filter((file) => (file.startsWith("packages/web/src/") || file.startsWith("packages/desktop/src/"))) .filter((file) => /\.(?:css|html|ts)$/.test(file)) .flatMap((file) => readFileSync(path.join(REPO_ROOT, file), "utf8") @@ -230,6 +230,7 @@ describe("application foundation production crossing (^ui-t-foundation-copy)", ( file.startsWith("specs/ui/foundation/") || file.startsWith("specs/ui/app/") || file.startsWith("specs/ui/themes/") || + file.startsWith("specs/ui/setup/") || file.startsWith("specs/ui/markdown-editor/") ) .filter((file) => file.endsWith(".css") || file.endsWith(".frame")) diff --git a/packages/web/test/server-connection-owner.test.ts b/packages/web/test/server-connection-owner.test.ts index 66dd01b0..3780ded9 100644 --- a/packages/web/test/server-connection-owner.test.ts +++ b/packages/web/test/server-connection-owner.test.ts @@ -137,7 +137,7 @@ describe("ServerConnectionOwner", () => { expect(owner.storedAuthToken).toBe("stored-token"); }); - it("starts and authenticates the same connection while retaining only non-auth connect errors", async () => { + it("starts the connection while retaining only non-auth connect errors", async () => { const localStore = createLocalStore(); const { owner, server } = createOwner({ localStore }); @@ -146,11 +146,10 @@ describe("ServerConnectionOwner", () => { expect(owner.connectError).toBe("offline"); server.nextConnectError = new AuthError(); - await owner.authenticate("new-token"); + await owner.connect(); expect(owner.connection).toBe(server); - expect(server.connectCalls).toEqual([null, "new-token"]); + expect(server.connectCalls).toEqual([null, null]); expect(owner.connectError).toBeNull(); - expect(localStore.get().authTokens).toEqual({ [SERVER_URL]: "new-token" }); }); it("clears a token only after the connection rejects authentication", () => { diff --git a/packages/web/test/server-connection.test.ts b/packages/web/test/server-connection.test.ts index 3e24d27f..642e725b 100644 --- a/packages/web/test/server-connection.test.ts +++ b/packages/web/test/server-connection.test.ts @@ -94,6 +94,76 @@ describe("ServerConnection lifecycle", () => { vi.useFakeTimers(); }); + // Contract (^ap-ac-retry-count): socket/client peers and the scheduler are + // substitutes; real failure, timing and recovery are crossed by the app walks. + it("counts completed unreachable reconnects once and resets on success", async () => { + const sockets: FakeSocket[] = []; + let halt = false; + const emitStatus = () => sockets.at(-1)!.emit("message", { data: JSON.stringify({ + type: "server-status", version: "1.0.0", requiredDesktopVersion: null, update: null, + }) }); + const c = new ServerConnection({ + url: "http://example.test", + name: "test", + decideBoot: () => halt ? "halt" : "boot", + createSocket: () => { + const socket = new FakeSocket(); + sockets.push(socket); + return socket; + }, + createClient: () => createClient(), + visibilityEventTarget: null, + networkEventTarget: null, + }); + try { + const connected = c.connect(null); + sockets[0]!.emit("open"); + emitStatus(); + await connected; + sockets[0]!.emit("error"); + sockets[0]!.emit("close"); + expect(c.failedReconnectAttempts).toBe(0); + for (let count = 1; count <= 3; count += 1) { + await vi.advanceTimersToNextTimerAsync(); + expect(sockets).toHaveLength(count + 1); + expect(c.attempting).toBe(true); + expect(c.failedReconnectAttempts).toBe(count - 1); + sockets.at(-1)!.emit("error"); + sockets.at(-1)!.emit("close"); + await vi.advanceTimersByTimeAsync(0); + expect(c.failedReconnectAttempts).toBe(count); + expect(c.attempting).toBe(false); + expect(c.nextRetryAt).not.toBeNull(); + } + halt = true; + await vi.advanceTimersToNextTimerAsync(); + sockets.at(-1)!.emit("open"); + emitStatus(); + expect(c.bootState).toBe("halted"); + expect(c.failedReconnectAttempts).toBe(3); + expect(c.nextRetryAt).toBeNull(); + halt = false; + const recovered = c.connect(null); + sockets.at(-1)!.emit("open"); + emitStatus(); + await recovered; + expect(c.status).toBe("connected"); + expect(c.failedReconnectAttempts).toBe(0); + sockets.at(-1)!.emit("close"); + expect(c.failedReconnectAttempts).toBe(0); + await vi.advanceTimersToNextTimerAsync(); + sockets.at(-1)!.emit("close", { code: 4401 }); + await vi.advanceTimersByTimeAsync(0); + expect(c.status).toBe("unauthorized"); + expect(c.failedReconnectAttempts).toBe(0); + expect(c.nextRetryAt).toBeNull(); + expect(vi.getTimerCount()).toBe(0); + } finally { + c.dispose(); + vi.useRealTimers(); + } + }); + it("connects, bootstraps channels/display, emits status changes, and records hasEverConnected", async () => { const sockets: FakeSocket[] = []; const changes: string[] = []; @@ -461,8 +531,8 @@ describe("ServerConnection lifecycle", () => { }); // Contract: injected socket and HTTP-client sources replace transport. The - // real 401/4401 browser-server crossings remain in e2e/auth.01.test.ts. - it("distinguishes an initial authorization requirement from token rejection and clears rejection on success", async () => { + // real rejection and recovery remain in e2e/auth.01.test.ts. + it("clears authorization rejection when starting a new connection", async () => { const sockets: FakeSocket[] = []; const c = new ServerConnection({ url: "http://example.test", @@ -481,20 +551,19 @@ describe("ServerConnection lifecycle", () => { sockets[0]!.emit("close", { code: 4401 }); await expect(initial).rejects.toMatchObject({ name: "AuthError" }); expect(c.hasAuthRejected).toBe(true); - expect(c.hasAuthTokenRejected).toBe(false); const rejected = c.connect("bad-token"); - expect(c.hasAuthTokenRejected).toBe(false); + expect(c.hasAuthRejected).toBe(false); sockets[1]!.emit("close", { code: 4401 }); await expect(rejected).rejects.toMatchObject({ name: "AuthError" }); - expect(c.hasAuthTokenRejected).toBe(true); + expect(c.hasAuthRejected).toBe(true); const accepted = c.connect("good-token"); - expect(c.hasAuthTokenRejected).toBe(false); + expect(c.hasAuthRejected).toBe(false); sockets[2]!.emit("open"); await accepted; expect(c.status).toBe("connected"); - expect(c.hasAuthTokenRejected).toBe(false); + expect(c.hasAuthRejected).toBe(false); c.dispose(); }); @@ -534,7 +603,7 @@ describe("ServerConnection lifecycle", () => { socket.emit("open"); await expect(connecting).rejects.toMatchObject({ name: "AuthError" }); expect(c.status).toBe("unauthorized"); - expect(c.hasAuthTokenRejected).toBe(true); + expect(c.hasAuthRejected).toBe(true); c.dispose(); }); diff --git a/packages/web/test/settings.test.ts b/packages/web/test/settings.test.ts index 70a6e3dd..23d9cd67 100644 --- a/packages/web/test/settings.test.ts +++ b/packages/web/test/settings.test.ts @@ -72,10 +72,10 @@ describe("settings markup", () => { get connection() { return { authorizationRequired: false, - authorizationRejected: false, gateHalted: false, status: connectionStatus, hasEverConnected: true, + failedReconnectAttempts: 0, firstConnectError: null, nextRetryAt: null, upgradeInstructions: null, @@ -225,10 +225,10 @@ describe("settings markup", () => { snapshot: { connection: { authorizationRequired: false, - authorizationRejected: false, gateHalted: false, status: "connected" as const, hasEverConnected: true, + failedReconnectAttempts: 0, firstConnectError: null, nextRetryAt: null, upgradeInstructions: null, diff --git a/proofs/arch/canonical.md b/proofs/arch/canonical.md index f1554077..c3d2908f 100644 --- a/proofs/arch/canonical.md +++ b/proofs/arch/canonical.md @@ -12,6 +12,8 @@ The independent public-name fixture proves released v1's additive vocabulary and The icon-extraction contract cases exercise mixed top-level selector lists and nested rules through the production extractor. They verify that only matching placeholder selectors survive, their declarations remain unchanged, and nested rules are not lifted. The existing fresh-build icon seam then compares the authoritative placeholder declarations with real canonical output. +The live-v2 foundation crossing includes the large button/input rules and `--line-control-lg`; [the public-name contract](#^cn-t-v1-additive) and [skill-guidance seam](#^cn-t-skill-guidance) cover their public vocabulary with `--line-control-lg`, button `size="lg"` and input `data-size="lg"` included in the live fixture and guidance. The assertions below cover these additions at their existing test boundaries. This adds no size/colour assertions and does not alter frozen v1's committed vocabulary or bytes. + ## Assertions ### Test assertions @@ -21,11 +23,11 @@ The public-name fixture is separate and committed: it records only names an arti - **Seam** (the handoff is the complete committed `packages/canonical/frozen/v1/` directory to a fresh canonical output root, crossed through the real repository filesystem and production builder; no mocks or test hooks): the builder discovers v1 as frozen, emits exactly its recursive file set including `frozen.json`, and copies every file byte-for-byte without resolving CSS, renaming its font, or bundling its components; a version present in both the live and frozen roots is rejected rather than overwritten — *(covered by tests: `packages/canonical/test/build-canonical.test.ts` "copies every frozen canonical version byte-for-byte into fresh production output" and "rejects a version present in both live and frozen roots")*. ^cn-t-frozen-copy - **Seam** (the handoff is the complete live foundation and committed v2 production copies to fresh canonical output, crossed through the real repository filesystem and production builder; no mocks or test hooks): every complete-sheet production input conforms to the foundation authority in the same order; the sole partial input is the icon placeholder set selected from the live icon sheet; `styles.css` resolves to the complete foundation with only the builder's URL transformations; every referenced font is copied byte-for-byte under the content-addressed name defined by [#^cn-cache-policy](../../specs/arch/canonical.md#^cn-cache-policy), and changing font bytes changes that name; `components.js` carries the live public elements and complete icon stylesheet; and no production input imports from `specs/` — *(covered by tests: `packages/canonical/test/build-canonical.test.ts` "builds live canonical v2 from its authoritative sources into fresh production output", "changes the published font name when the font bytes change", and "extracts exactly the authoritative pre-upgrade icon rules into canonical document scope")*. ^cn-t-authored-build -- **Contract** (the public-name checker over the committed per-version fixture, frozen v1 CSS/JavaScript, and real live-v2 sources and fresh output; no mocks or test hooks): public token names, element tag names, attributes and public values, documented custom properties, and icon names include every fixture entry for the corresponding version; v1 checks use precise extraction from the built payload, while v2 checks use the live owners and output. Removing or renaming a recorded name fails; for v1 that enforces its released compatibility contract, while v2 checks the current live inventory. The fixture records no CSS value, class, shadow part, or internal markup — *(covered by test: `packages/canonical/test/build-canonical.test.ts` "keeps every recorded canonical public name per version and rejects a recorded removal")*. ^cn-t-v1-additive +- **Contract** (the public-name checker over the committed per-version fixture, frozen v1 CSS/JavaScript, and real live-v2 sources and fresh output; no mocks or test hooks): public token names, element tag names, attributes and public values, documented custom properties, and icon names include every fixture entry for the corresponding version; v1 checks use precise extraction from the built payload, while v2 checks use the live owners and output. Removing or renaming a recorded name fails; for v1 that enforces its released compatibility contract, while v2 checks the current live inventory. The fixture records no CSS value, class, shadow part, or internal markup — *(covered by `packages/canonical/test/build-canonical.test.ts` "keeps every recorded canonical public name per version and rejects a recorded removal")*. ^cn-t-v1-additive - **Seam** (production canonical build output → canonical HTTP response caching, crossed over real HTTP against a running production `Server`; the fresh canonical tree supplies marked frozen v1 and unmarked live v2 fixtures; no mocks or test hooks): for each class, a font response under `/canonical/v/fonts/` carries `Cache-Control: public, max-age=31536000, immutable`; `base.css`, the server-owned `styles.css` wrapper, and `components.js` each carry `Cache-Control: no-cache` and an `ETag`; and a matching `If-None-Match` for `components.js` returns `304` — *(covered by test: `packages/server/test/canonical-cache.test.ts` “serves both canonical cache classes over real HTTP”)*. ^cn-t-cache-headers - **Acceptance** (outer boundary: for each built version, a real browser loads two authored artifact documents from a really running production `Server` supplied the canonical tree from a fresh production server build; temporary storage and the documents are fixtures; no mocks; synchronization waits for stylesheet load, `document.fonts.ready`, and `customElements.whenDefined()` for every public tag): each document receives the public `styles.css` wrapper, canonical-built `base.css`, and `components.js` from its `/canonical/v` mount; the first receives every referenced font; the components register exactly the public elements; and the served base, font, and component bytes equal the fresh build's files. Frozen v1's wrapper is its base import alone and requests no theme; live v2's wrapper imports and requests the theme. While the second document loads, the running server receives no request for any font, and that document renders in Hind — *(covered by tests: `packages/server/test/e2e/canonical.spec.ts` “an artifact loads the fresh canonical v1 tree through the production server” and “an artifact loads the fresh canonical v2 tree through the production server”)*. ^cn-t-server-mount - **Seam** (canonical-owned version base and frozen marker → server-owned wrapper class, crossed through a really running production `Server` and real HTTP; marked v1 and unmarked v2 directories plus an installed theme are authored filesystem fixtures; no mocks or test hooks): frozen v1 receives the exact base-only wrapper, while live v2 receives its base and the stable active theme without a wrapper-owned `color-scheme` declaration; the theme remains a separate resource — *(covered by tests: `packages/server/test/canonical-styles.test.ts` “serves frozen versions with only their base import”, “serves live versions with base then theme and nothing after”, and “keeps each base separate from the live version's active theme resource”)*. ^cn-t-theme-layer -- **Seam** (the handoff is this spec's public live-v2 vocabulary to the built Television and artifact-authoring skills, crossed through the real skill build and their real sources; the canonical manifests and source guidance are repository inputs, not mocks; no test hooks): the built HTML guidance carries canonical v2's mount addresses, every public token and icon name, each public element's author markup, attributes, and documented custom properties, while exposing no class, shadow part, internal structure, numeric icon-size API, or unknown-name fallback; each built artifact-authoring skill's guidance links canonical v2 and names no retired token, each stylesheet those skills ship executes no retired token, and the unbundled sidebar's source guidance and carried stylesheet are checked on the same terms, with historical token names in comments outside the executable vocabulary. The check samples these salient contract facts rather than comparing the guidance character for character — *(covered by test: `test/repo/skills-build.test.ts` “builds one bundle per renamed skill without stamping shared house style into per-artifact bundles”)*. ^cn-t-skill-guidance +- **Seam** (the handoff is this spec's public live-v2 vocabulary to the built Television and artifact-authoring skills, crossed through the real skill build and their real sources; the canonical manifests and source guidance are repository inputs, not mocks; no test hooks): the built HTML guidance carries canonical v2's mount addresses, every public token and icon name, each public element's author markup, attributes, and documented custom properties, while exposing no class, shadow part, internal structure, numeric icon-size API, or unknown-name fallback; each built artifact-authoring skill's guidance links canonical v2 and names no retired token, each stylesheet those skills ship executes no retired token, and the unbundled sidebar's source guidance and carried stylesheet are checked on the same terms, with historical token names in comments outside the executable vocabulary. The check samples these salient contract facts rather than comparing the guidance character for character — *(covered by `test/repo/skills-build.test.ts` “builds the manifest collection with television theming guidance and specialist artifacts”)*. ^cn-t-skill-guidance Coverage relationships: [#^cn-t-cache-headers](#^cn-t-cache-headers) is the regression proof for the canonical header policy. [#^cn-t-server-mount](#^cn-t-server-mount) separately proves warm cross-document browser behavior without treating Chromium's reuse decision as evidence of a particular response policy. diff --git a/proofs/arch/channel-state/index.md b/proofs/arch/channel-state/index.md index d33a32c7..17fa9976 100644 --- a/proofs/arch/channel-state/index.md +++ b/proofs/arch/channel-state/index.md @@ -12,6 +12,8 @@ The inventory's active-theme row delegates field and selection semantics to [the The channel-sidebar width and collapsed state are client-only preferences and order no server-state assertions. Separate contract tests exercise each production preference owner against a recording storage substitute. This mock makes key use, encoding, malformed and absent reads, separation between the two preferences, and failed writes cheap and deterministic, but forfeits the real browser-storage handoff. [Resize acceptance](../../ui/app/index.md#^ap-ac-sidebar-resize) crosses the width handoff with real `localStorage`; [collapse acceptance](../../ui/app/index.md#^ap-ac-sidebar-collapse) crosses the collapsed-state handoff and proves the reload landing and preserved width. The width spec resolves an exact half-pixel tie upward; the storage assertion covers values strictly below and above the tie, and an exact-tie assertion is not ordered under the coverage-returns rule — the rounding path is proven around the tie, and the tie itself is neither plausible nor consequential enough to buy its own permanent test. +The [connection carve-out](../../../specs/arch/channel-state/index.md#^cs-connection-carve-out) leaves socket management and backoff under code authority. The [app proof](../../ui/app/index.md#^ap-ac-retry-count) owns visible retry escalation, auth/gate priority and shell continuity; its real-browser outage walk composes with this proof's reconnect-convergence coverage. Renaming the auth presentation orders no channel-state test. + ## Test hooks diff --git a/proofs/arch/cli/index.md b/proofs/arch/cli/index.md index 070ea096..eac031e9 100644 --- a/proofs/arch/cli/index.md +++ b/proofs/arch/cli/index.md @@ -22,6 +22,8 @@ The product spine and its spawned-process acceptance tests are owned by [product - The **validated source assets ↔ packaged CLI assets** seams cross the production build and real filesystem output. Full-build packaging tests prove every declared copy byte-for-byte, and resolver tests prove the supported built and development lookup layouts. - The **served home and port ↔ ACP agent prompt** handoff is the [home-context buffer](../../../specs/arch/cli/index.md#^cli-acp-home-context). [Its seam](#^cli-acp-home-context-seam) runs the web package's real `ACPClient` against a spawned `tv serve` whose ACP agent is a stub executable, and reads the prompt the stub receives. +Connect-link contracts exercise `runCLI` with recording output streams and client/server/daemon peers. They forfeit real process streams and HTTP to [product links acceptance](../../product/cli.md#^cli-ac-links). Link inclusion follows the running server's responses, including when its home's configuration has since changed. The default environment passes `process.stdout` directly; spawned acceptance crosses that handoff with a pipe, and the formatter contract covers true, false and absent `isTTY` values. Native terminal detection and hyperlink activation are not observed; they are Node and terminal behavior, so this change adds no pseudo-terminal harness. The existing [persisted-install acceptance](../../product/cli.md#^cli-ac-persist-home-install) crosses plain piped output in its designated-host suite; no second service-manager journey is needed. + Each assertion below declares whether it is a contract or seam test. A contract that replaces a peer states what that replacement leaves unproved and points to the real crossing or explicit exception that owns the gap. ### Remaining coverage decisions and owned exception @@ -48,7 +50,7 @@ The CLI claims no real crossing of the `tv skills install -i` seam into the [ext ## Test hooks -`runCLI` accepts a partial `CLIEnvironment`, the spec's dependency boundary; a test may supply any subset, and production fills the rest with the defaults listed under [contract types](#Architecture test assertion: contract types). In particular, `resolveHomeDir` lets a test supply a temporary operating-system home directory, which holds `.tv-home` and the built-in default `.television`; production returns `os.homedir()`. +`runCLI` accepts a partial `CLIEnvironment`, the spec's dependency boundary; a test may supply any subset, and production fills the rest with the defaults listed under [contract types](#Architecture test assertion: contract types). Recording `stdout` and `stderr` writers may set `isTTY` to true, false, or leave it absent; production uses the real process streams. In particular, `resolveHomeDir` lets a test supply a temporary operating-system home directory, which holds `.tv-home` and the built-in default `.television`; production returns `os.homedir()`. The web package's `ACPClient` accepts `createSocket`, which lets a Node test give it a real `ws` WebSocket; production uses the browser `WebSocket`. Only [the ACP home-context seam](#^cli-acp-home-context-seam) uses it in this proof. @@ -70,7 +72,7 @@ The contract boundary in this block is the exported source module called in proc The contract boundary is the exported TypeScript declaration surface. The fixture is an independently written literal copy of the spec-owned types; no declaration is derived from production, and no mock or test hook is used. -- **Contract.** The compile-time fixture declares the spec-owned `Writable`, server adapter, daemon adapter, option, and `CLIEnvironment` shapes independently, then requires exact type equality with every exported source declaration. It likewise declares the [home and config types and functions](../../../specs/arch/cli/index.md#Home and config resolution) that `@telepath-computer/television-server` exports for the CLI — `TelevisionConfigFile`, `TelevisionSettings`, `TelevisionConfig`, `TelevisionConfigError`, `resolveTelevisionHome`, `readTelevisionConfig`, and `updateTelevisionConfig` — and requires exact type equality with those exports. It lives on the normal `unit:cli` surface; `npm run type-check` fails if either side drifts — *(policy-grade tests: `packages/cli/test/contract-types.test.ts` "keeps exported CLIEnvironment and adapter declarations exact" and "keeps the server package's home and config declarations exact")*. ^cli-contract-type-drift +- **Contract.** The compile-time fixture declares the spec-owned `Writable` including optional `isTTY?: boolean`, server adapter, daemon adapter, option, and `CLIEnvironment` shapes independently, then requires exact type equality with every exported source declaration. It likewise declares the [home and config types and functions](../../../specs/arch/cli/index.md#Home and config resolution) that `@telepath-computer/television-server` exports for the CLI — `TelevisionConfigFile`, `TelevisionSettings`, `TelevisionConfig`, `TelevisionConfigError`, `resolveTelevisionHome`, `readTelevisionConfig`, and `updateTelevisionConfig` — and requires exact type equality with those exports. It lives on the normal `unit:cli` surface; `npm run type-check` fails if either side drifts — *(policy-grade tests: `packages/cli/test/contract-types.test.ts` “keeps exported CLIEnvironment and adapter declarations exact” and “keeps the server package's home and config declarations exact”)*. ^cli-contract-type-drift The production default environment is: @@ -158,12 +160,11 @@ The contract boundary in this block is authored serve argv entering the in-proce - **Acceptance.** A spawned source CLI with an ACP profile and an empty `PATH` exits nonzero with the missing-command message before server startup. A temporary home and an empty directory used as `PATH` are fixtures; no mocks or test hooks are used — *(covered by inherited test: `test/node/dev-server-script.test.ts` "fails via CLI serve before startup when the ACP binary is missing from PATH")*. ^cli-acp-missing-command - **Seam.** The handoff is the foreground serve action to the real Television server lifecycle through production server construction, a real TCP listener, `/health`, and signal disposal. A spawned source CLI uses a temporary home whose config file sets port `0`, with no mocks or test hooks, prints startup output, and records lifecycle start/stop — *(covered by inherited test: `test/node/cli-integration.test.ts` "tv serve records lifecycle start and stop when terminated")*. ^fd17f122 - **Coverage relationship.** The product [multi-listener Acceptance assertion](../../product/cli.md#^cli-ac-multi-listener-success) carries the production foreground-listener handoff for addresses from the config file's `listen`, written by `tv config set`. It spawns the built CLI, binds both loopback and an assigned non-loopback IPv4 to one dynamic port, and reaches `/health` through each real listener. -- **Seam.** The handoff is the authenticated production server adapter's generated token to CLI startup output through `getAuthToken()`. A spawned source CLI over a temporary home whose config file sets port `0` and leaves authentication on, with no mocks or test hooks, prints a token-bearing URL and no tokenless warning — *(covered by inherited test: `test/node/cli-integration.test.ts` "tv serve with auth does not print the no-auth startup warning")*. ^193e8638 +- **Seam.** The handoff is the authenticated production server adapter's generated token to CLI startup output through `getAuthToken()`. A spawned source CLI over a temporary home whose config file sets port `0` and leaves authentication on, with no mocks or test hooks, prints a plain token-bearing URL on piped stdout, without OSC-8 escapes, under [link output](../../../specs/product/cli.md#^cli-link-output), and no tokenless warning — *(covered by inherited test: `test/node/cli-integration.test.ts` "tv serve with auth does not print the no-auth startup warning", minimally extended for plain piped output)*. ^193e8638 - **Contract.** At the fake server-adapter boundary, a home whose config file sets port `0` makes foreground `tv serve` print the port-`0` warning and supply numeric port `0` to `createServer`; the fake forfeits real binding to [#^fd17f122](#^fd17f122) — *(policy-grade test: `packages/cli/test/cli.test.ts` "serve warns but supports explicit port 0 in the foreground")*. ^0ffa0d0d - **Contract.** At the same boundary, that warning is exactly `PORT_ZERO_WARNING`, the product's port-`0` warning text, written to stderr before `createServer` is called; a nonzero config port prints no such warning — *(policy-grade test: `packages/cli/test/cli.test.ts` "serve warns but supports explicit port 0 in the foreground")*. ^cli-port-zero-warning-text - **Contract.** At the fake server-adapter boundary, a config file with `"auth": false` makes every foreground start print exactly the product's tokenless warning to stderr, and adds `Non-loopback listeners without auth: ` naming the resolved non-loopback addresses when `listen` holds one. With authentication on, neither line is printed — *(policy-grade test: `packages/cli/test/cli.test.ts` "prints the tokenless warnings only when the config file turns authentication off")*. ^cli-tokenless-warnings -- **Contract.** A fake server adapter returning one authored URL proves that foreground startup formats an OSC-8 hyperlink whose visible text is the same URL. The fake forfeits the real server URL crossing to [#^fd17f122](#^fd17f122) — *(policy-grade test: `packages/cli/test/cli.test.ts` "prints a foreground startup URL as an OSC-8 link whose visible text is the URL")*. ^cli-osc8-visible -- **Contract.** A fake authenticated server adapter returning an authored URL and token proves that the OSC-8 target and visible text both include `?token=`. The fake forfeits real token generation to [#^193e8638](#^193e8638) — *(policy-grade test: `packages/cli/test/cli.test.ts` "includes the auth token in the OSC-8 startup link URL and visible text")*. ^cli-osc8-token +- **Contract — stream-dependent link formatting.** Exercise `runCLI` for foreground serve, persisted serve and links with the recording writers and server, daemon and client peers declared above. These mocks forfeit real construction, HTTP, piped output and service management to the standing product walks; native terminal detection and hyperlink activation remain outside the coverage described above. Under [the shared formatter rule](../../../specs/arch/cli/index.md#client-boundary), each caller produces OSC-8 with identical target and visible URL only for `isTTY === true`; false or absent produces the plain URL with no escape sequences. Authenticated URLs carry the encoded token in both target and visible text, and tokenless URLs stay origins. Startup retains its surrounding output; links writes only one URL per line. *(policy-grade tests: `packages/cli/test/cli.test.ts`, the “formats $caller output with isTTY=$isTTY and auth=$auth” rows in “CLI connect links”)* ^cli-osc8-visible ### Architecture test assertions: ACP agent home context @@ -246,3 +247,7 @@ The CLI/server seam tests call the source dispatcher in process against a really Fresh-install, upgrade, deletion, focus, and missing-bundle onboarding outcomes through the built CLI are owned and acceptance-tested by [product/onboarding/onboarding-channels.md#Acceptance test criteria](../../product/onboarding/onboarding-channels.md#Acceptance test criteria). The compiled telemetry diagnostic follows [the ordinary/release build seam](../telemetry/sink.md#^t-build-config-integrity). Full version display remains governed here; analytics classification is proven at [the telemetry chokepoint](../telemetry/index.md#^t-version-payload). + +### Connect links + +- **Contract — links command and live-server facts.** Exercise `runCLI` with real temporary home/config/token files, authored health replies and recording authenticated/unauthenticated `TelevisionClient` peers; client construction and HTTP are mocked, forfeiting them to [product acceptance](../../product/cli.md#^cli-ac-links). Under [the client boundary](../../../specs/arch/cli/index.md#client-boundary), help exposes `links`; it obeys the shared home/config-port rule and refuses `--server`. Health precedes the authenticated and unauthenticated checks. Output uses every reported bind address and the reported bound port, with the trimmed home token only when the running server requires it. Cover both directions of config/runtime auth disagreement, an authless server with a leftover token file, and a requiring server rejecting a missing or wrong home token. Failed health/auth validation writes no links, returns `1` and uses the specified reachability/unauthorized stderr. Multi-address and wildcard fixtures prove formatting without another process-per-address matrix. *(policy-grade tests: `packages/cli/test/cli.test.ts`, “CLI connect links”: “exposes links in help and refuses --server before contacting a server”, the “uses live auth=$requiresToken and health addresses despite config auth=$configAuth” rows, “uses the selected default home and the required port override”, the “prints no links when the %s request cannot reach the server” rows, and the “refuses a rejected home token %s before printing any links” rows; the shared client-port contract also includes `links`)* ^cli-links-contract diff --git a/proofs/arch/desktop/appearance.md b/proofs/arch/desktop/appearance.md index 217d7bf9..3b02f9b8 100644 --- a/proofs/arch/desktop/appearance.md +++ b/proofs/arch/desktop/appearance.md @@ -12,6 +12,12 @@ A web-composition contract supplies confirmed display snapshots and events and r The real Electron/server/webview acceptance path proves that adaptive and fixed theme inputs reach `nativeTheme`, existing webviews, their root markers, and rendered styling. It also proves that a stored preference can change under a fixed theme without changing presentation and becomes effective after returning to an adaptive theme. The theming release blocks incompatible shells before connected rendering; that requirement is covered by [desktop upgrade acceptance](../../product/update-notifications.md#^ac-themes-desktop-required), not by weakening the defensive missing-operation test. +The local page keeps the last native appearance input and uses Clouds independently of the server theme. Its seam below observes native appearance and the local document together; [local asset delivery](./connect-flow.md#^desktop-t-local-assets) proves packaged resources. This extends lifecycle evidence without duplicating the existing webview/theme matrix. + +## Test hooks + +The local-page seam uses the [desktop harness hooks](./connect-flow.md#test-hooks). Appearance input reaches Electron through the real server display API and existing native bridge for the retention check. The live-adaptation leg may assign `nativeTheme.themeSource` directly as declared below; the renderer marker is always production-owned. + ## Assertions ### Test assertions @@ -19,3 +25,5 @@ The real Electron/server/webview acceptance path proves that adaptive and fixed - **Contract** (preload and main sides of the IPC boundary with Electron APIs replaced by recording mocks, forfeiting real IPC and native-theme behavior to the Electron assertion below): the shared predicate accepts exactly the three appearance inputs; preload sends each valid value and rejects unknown input; main starts at `system`, assigns each valid message, and ignores unknown input — *(covered by tests: `packages/desktop/test/connect-preload.test.ts` and `packages/desktop/test/main.test.ts` “starts native appearance at system and accepts only valid IPC values”)*. ^desktop-appearance-t-contract - **Contract** (web composition with confirmed display snapshots and events as fixtures and the native bridge supplied as a recording boundary mock or as an older-shell fixture lacking the appearance operation; this forfeits real server transport to [theme architecture](../themes/index.md#^themes-t-active-color-scheme-state) and IPC to [the IPC contract](#^desktop-appearance-t-contract)): the null and adaptive schemes send the stored `system`, `light`, or `dark` preference, while fixed-light and fixed-dark schemes send their fixed value. Electron sends the initial appearance input before connected content mounts, sends only changed inputs after confirmed theme or preference events, retains the last input across disconnect, and replaces it from the next server's display state on reconnect. Browser mode never calls the bridge. When the bridge operation is absent, confirmed state still reaches the document and startup continues — *(covered by `packages/web/test/renderer.test.ts` “sends resolved Electron appearance before mount and only when effective input changes”, “keeps Electron startup running when the shell lacks the appearance bridge operation”, and “never calls the native appearance bridge in browser mode”)*. ^desktop-appearance-t-renderer - **Acceptance** (outer boundary: the real Electron app at the [declared runtime version](../../../specs/arch/desktop/runtime.md#^desktop-runtime-version) connected to a really-running server with adaptive and fixed installed themes and media-query, `[data-theme]`, and computed-style probe artifacts; the harness fixture URL bypasses the saved-connection flow, and `TV_TEST_MODE=true` with the default shell-version override `9.9.9` substitutes version provenance. Those forfeits are covered by [gate acceptance](../../product/update-notifications.md#^ac-themes-desktop-required) and the [unhooked version seam](../updates/desktop-upgrade-gate.md#^t-shell-version-param); native appearance IPC, media queries, webview retention, and rendering remain real): main applies the initial appearance input before the first artifact webview attaches. Under the adaptive theme, `light`, `dark`, and `system` update `nativeTheme.themeSource`, the same loaded webview's media-query result, resolver-owned root marker, and exact computed probe color without replacing its document. Under the fixed theme, an opposing stored preference persists without changing those results; returning to the adaptive theme applies that latest preference — *(covered by `packages/desktop/test/e2e/webview-event-parity.test.ts` “applies manifest-resolved appearance to Electron and retained webviews”)*. ^desktop-appearance-t-electron + +- **Seam — retained appearance to local page.** Cross real Electron `nativeTheme` into the packaged local renderer, using a running Television server and the hooks above. On empty startup, observe `system` and the local root marker matching Electron's effective appearance. Set one non-system input through the server's production display API, connect and observe its delivery, then disconnect through the installed menu. Native appearance retains that input while setup uses Clouds and the matching application root marker. For live adaptation, assign a contrasting `nativeTheme.themeSource` while setup remains open and observe the marker follow the changed effective value without changing Clouds. This last assignment is a mock of the production source of the appearance input; it does not simulate an operating-system preference change. Production selection and IPC are covered by [connected appearance acceptance](#^desktop-appearance-t-electron); native notification, media queries and local adaptation stay real here. Changes to the device preference while `themeSource` is `system` are not observed by these assertions or the listed Mac checks. This proves [appearance lifecycle](../../../specs/arch/desktop/appearance.md#^desktop-appearance-lifecycle) together with [the local-page rule](../../../specs/arch/desktop/connect-flow.md#^desktop-local-page). *(covered by `packages/desktop/test/e2e/local-page.test.ts` “the local page retains native appearance while always wearing Clouds”)* ^desktop-appearance-t-local diff --git a/proofs/arch/desktop/connect-flow.md b/proofs/arch/desktop/connect-flow.md index 9ebd8296..66c31b4d 100644 --- a/proofs/arch/desktop/connect-flow.md +++ b/proofs/arch/desktop/connect-flow.md @@ -1,4 +1,4 @@ -*How the promises in Desktop connection flow are proven.* +*Desktop connection coverage across link parsing, saved state, the packaged local page, native IPC, and server navigation.* # Desktop connection flow — proof @@ -6,16 +6,27 @@ Proves [specs/arch/desktop/connect-flow.md](../../../specs/arch/desktop/connect- ## Coverage model -Coverage declarations are carried inside the migrated assertion blocks below. +The [desktop product proof](../../product/desktop-app.md#^desktop-ac-connect-link) carries the real-Electron walk from setup through a token-requiring Television server to its served page, honoring this spec's Testing directive. It also carries saved startup, the principal failure outcomes, and Disconnect from Server. This proof composes that evidence with cheaper contracts for parsing, persistence, retries and cancellation. -## Assertions +Main-process contracts replace Electron, the connect-check peer, and scheduling. They prove decisions and side effects, not real HTTP, IPC, rendering, or native menus. The product walks cross HTTP, IPC, disk and navigation, invoking installed menu callbacks; native Mac menu and accelerator input remain unobserved. The navigation-failure seam below covers Electron's actual failed-load event. Identity-response permutations and success-only save/navigation remain with the [connect-check proof](../updates/desktop-upgrade-gate.md#^updates-t-preflight-desktop-contract), rather than being repeated here. + +The [setup proof](../../ui/setup/index.md) owns link entry, inline errors, Copy composition, and the Connected presentation. The [system-modal proof](../../ui/app/system-modal/index.md) owns dialog contents, non-dismissal, and dragging. Local assets cross the real desktop build and `file:` loader below; that does not prove visual conformance. The [appearance proof](./appearance.md#^desktop-appearance-t-local) crosses retained native appearance into the local page. Exact styling stays with its UI owners and active stylesheet-copy checks. + +Under [the local bridge](../../../specs/arch/desktop/connect-flow.md#^desktop-connect-local-bridge), [product connection acceptance](../../product/desktop-app.md#^desktop-ac-connect-link) crosses the packaged local page's connect operation through real preload and IPC, then observes that the served page has neither connection control. [Local-button acceptance](../../product/desktop-app.md#^desktop-ac-saved-auth) crosses disconnect through the same local bridge. These walks also prove [server page entry](../../../specs/arch/desktop/connect-flow.md#^shell-page-entry); no separate bridge launch is needed. -### Test assertions +## Test hooks -- **Contract — local entry and bridge** (saved connection, mode, form, and IPC peers authored in memory; Electron IPC is mocked, forfeiting the real bridge to the seam below): bootstrap auto-submits saved values, manual entry only pre-fills them, failed attempts keep editable values, and the packaged page and preload expose the expected local connection surface — *(covered by inherited tests: all cases under `packages/desktop/test/connect-page.test.ts` “bindConnectPage” and “initConnectPage with connect.html”)*. ^desktop-t-connect-entry -- **Contract — normalization and persistence** (authored URL strings and a real temporary filesystem; no network peer): supported inputs normalize to HTTP(S), unsupported inputs fail, and saved records round-trip through an atomic write while absent or malformed records load as no connection — *(covered by inherited tests: all cases under `packages/desktop/test/connect-url.test.ts` “normalizeConnectURL” and `packages/desktop/test/connection-store.test.ts` “connection-store”)*. ^desktop-t-connect-storage -- **Seam — authenticated page entry** (real Electron app and a really-running token-requiring Television server; isolated real user-data directory; the standing `TV_TEST_MODE` harness gate and declared `TV_TEST_DESKTOP_APP_VERSION` mock of `app.getVersion()`, forfeiting the real version path to [the unhooked shell-version seam](../updates/desktop-upgrade-gate.md#^t-shell-version-param); no other mocks): manual submission crosses the local preload and main-process IPC, runs the connect check, stores the normalized connection, loads the server root in Electron mode, consumes the page token into the live session, and leaves no token in the visible URL — *(covered by inherited tests: `packages/desktop/test/e2e/connect-screen.01.test.ts` “manual connect submits the form, checks the server, and navigates to the remote URL”)*. ^desktop-t-page-entry -- **Contract — load recovery** (Electron's `BrowserWindow` is a recording peer): a top-level remote failure returns to manual connection entry, while subframe, aborted-navigation, and local-page failures do not — *(covered by inherited tests: `packages/desktop/test/main.test.ts` “returns to the connect screen in manual mode when remote loadURL fails” and “does not return to the connect screen for subframe load failures or aborted navigations”)*. ^desktop-t-connect-load-recovery +The desktop harness supplies an isolated Electron `userData` directory and may seed a saved `connection.json`; production uses the ordinary application data directory and the record saved by a successful connection. Seeding is a stored-data fixture, not evidence of the save path. -Coverage relationship: the desktop check's [reader](../updates/desktop-upgrade-gate.md#^updates-t-connect-check-reader-contract) and [main-process](../updates/desktop-upgrade-gate.md#^updates-t-preflight-desktop-contract) assertions own identity acceptance and the success-only save/navigation boundary. The page-entry seam above crosses the surrounding local-screen-to-served-page path once without duplicating those response permutations. +The [shell-version hook](../updates/desktop-upgrade-gate.md#^hook-shell-version) may represent a compatible shell in the Electron assertions; it forfeits the real version source to the [unhooked version seam](../updates/desktop-upgrade-gate.md#^t-shell-version-param). Tests enter through the packaged local page, not the harness's direct remote-URL shortcut. + +## Assertions +- **Contract — entry and retries.** Exercise the production main-process connection owner with recording Electron/window and connect-check peers and a controlled scheduler. Authored fixtures are absent, valid, and malformed saved records and successful, `401`, and other failed check results. These mocks forfeit real startup, HTTP and timers to [saved-startup acceptance](../../product/desktop-app.md#^desktop-ac-saved-recovery). Under [local entry](../../../specs/arch/desktop/connect-flow.md#^desktop-connect-entry), no record selects setup; a valid saved record selects Connecting without selecting setup. A tokenless submission uses no token from an earlier input or saved connection. Failed setup submission returns its message to setup, including link-oriented missing/rejected-token guidance, without selecting a system modal. Saved `401` selects Access token required and schedules nothing further; other failures select Can't connect with server and retry with backoff, supplying a deadline between attempts and an in-flight state during a check. Later success navigates; later `401` stops. A gate-requiring but valid server identity still passes to the served page. Identity-body breadth remains with the connect-check owner. *(covered by `packages/desktop/test/connect-flow.test.ts` “starts without a record in setup and saves a checked link before the Connected handoff”, “keeps rejected setup links inline and a tokenless retry carries no previous token”, “starts saved records with Connecting then retries failures with backoff and stops on 401”, and “reconnects saved records automatically and treats malformed records as setup”)* ^desktop-t-connect-entry +- **Contract — link normalization.** At the link-reader boundary, authored HTTP, HTTPS, bare-host, host-and-port, path/query, encoded-token, tokenless, invalid and unsupported-scheme strings are fixtures; no mechanisms are mocked. Under [normalization](../../../specs/arch/desktop/connect-flow.md#^desktop-connect-normalization), the result is the normalized origin and the link's token, discarding path and other query parameters. A tokenless link yields no token. Invalid inputs fail. Real consumption is covered by [product connection acceptance](../../product/desktop-app.md#^desktop-ac-connect-link). *(covered by `packages/desktop/test/connect-url.test.ts` “reads one link as an HTTP origin and its decoded token”)* ^desktop-t-connect-link +- **Contract — stored records.** The storage owner runs with recording Electron data-directory and filesystem peers and authored records. The filesystem mock forfeits real disk operations to [product connection acceptance](../../product/desktop-app.md#^desktop-ac-connect-link). Under [persistence](../../../specs/arch/desktop/connect-flow.md#^desktop-connect-persistence), supported records round-trip with normalized `serverURL` and token, including an empty token; a missing or malformed record means no connection. *(covered by inherited tests: `packages/desktop/test/connection-store.test.ts`, all cases under “connection-store”; these use a filesystem mock, not a real temporary filesystem)* ^desktop-t-connect-storage +- **Contract — atomic save and deletion.** Exercise the production storage owner with recording filesystem and Electron data-directory peers. Under [persistence](../../../specs/arch/desktop/connect-flow.md#^desktop-connect-persistence), saving writes the complete record to a temporary sibling before renaming it over `connection.json`; deleting removes the saved record. Record operations, not just the final in-memory map. These mocks forfeit actual filesystem behavior to [product connection acceptance](../../product/desktop-app.md#^desktop-ac-connect-link). *(covered by `packages/desktop/test/connection-store.test.ts` “writes connection.json atomically via a temp file and deletes the record”)* ^desktop-t-store-writes +- **Contract — disconnect and pending work.** Exercise the production main process with recording Electron/menu, storage and check peers, a controlled scheduler, and deferred check/navigation-completion results. These mocks forfeit installed-menu wiring, real IPC, disk and navigation to [product acceptance](../../product/desktop-app.md#^desktop-ac-connect-link) and [local-button acceptance](../../product/desktop-app.md#^desktop-ac-saved-auth). Under [Disconnect from Server](../../../specs/arch/desktop/connect-flow.md#^desktop-disconnect-server), the application menu always contains that item with `CmdOrCtrl+,`, enabled exactly while a valid record is saved, including immediately after setup success and before remote navigation. Menu and local-button requests stop pending attempts and retries, delete the record, and select setup without confirmation. Completing a check or setup-success handoff after disconnect cannot save again or navigate; advancing a previously scheduled retry makes no request. Cover disconnect during a pending check and after success while navigation is pending, while local or served pages are displayed. The served gate uses the same main-process operation; its real crossing is [product-owned](../../product/desktop-app.md#^desktop-ac-gate-disconnect). *(covered by `packages/desktop/test/connect-flow.test.ts` “disconnect cancels pending checks, retries and a successful setup handoff” and “starts without a record in setup and saves a checked link before the Connected handoff”)* ^desktop-t-disconnect +- **Contract — load recovery.** The production main process receives failed-navigation event fixtures through a recording `BrowserWindow`, forfeiting native event delivery to the next seam. Under [load recovery](../../../specs/arch/desktop/connect-flow.md#^desktop-connect-load-recovery), a genuine top-level remote failure re-enters saved startup; aborted navigation, subframe failure, and local-page failure do not. *(covered by `packages/desktop/test/connect-flow.test.ts` “only a genuine top-level remote load failure re-enters saved startup”)* ^desktop-t-connect-load-recovery +- **Seam — failed remote navigation.** Cross Electron's real failed top-level HTTP load into the production recovery handler. A controlled HTTP peer passes the identity check and then breaks the page load; this peer mocks Television's response, forfeiting real server behavior to the [connect-check contracts](../updates/desktop-upgrade-gate.md#^updates-t-preflight-server-contract) and [product success](../../product/desktop-app.md#^desktop-ac-connect-link). Electron, local assets, preload, main process and native navigation events remain real; use the hooks above. Under [load recovery](../../../specs/arch/desktop/connect-flow.md#^desktop-connect-load-recovery), failure returns to the local page with the saved connection and its retry presentation, without setup or a setup-field error. *(covered by `packages/desktop/test/e2e/local-page.test.ts` “a real failed remote page load returns to saved-connection recovery”)* ^desktop-t-load-recovery-seam +- **Seam — packaged local assets.** Cross the real desktop build's packaged files into Electron's `file:` loader, with isolated user data and the version hook above; no builder, filesystem, asset-response or renderer mocks. Under [the local-page contract](../../../specs/arch/desktop/connect-flow.md#^desktop-local-page), setup and a saved-startup dialog run from the built package without a server stylesheet available. Their application-document marker, foundation, surface styles, icons, fonts, Clouds stylesheet and both referenced wallpapers resolve from packaged files. Observe actual resource loading and font readiness. The [foundation crossing](../ui/foundation.md#^ui-t-foundation-copy) owns byte equality; [local appearance](./appearance.md#^desktop-appearance-t-local) owns mode selection. *(covered by `packages/desktop/test/e2e/local-page.test.ts` “the built local page loads foundation, Clouds, fonts and icons for setup and saved dialogs”)* ^desktop-t-local-assets diff --git a/proofs/arch/desktop/index.md b/proofs/arch/desktop/index.md index a642a558..eef56126 100644 --- a/proofs/arch/desktop/index.md +++ b/proofs/arch/desktop/index.md @@ -8,7 +8,7 @@ Proves [specs/arch/desktop/index.md](../../../specs/arch/desktop/index.md). The [product proof](../../product/desktop-app.md) owns platform evidence and the real-host checks. Each module proof carries its own contract: -- [connect-flow.md](./connect-flow.md) and [appearance.md](./appearance.md) cover the connection flow and native appearance; +- [connect-flow.md](./connect-flow.md) and [appearance.md](./appearance.md) cover setup, saved reconnection, Disconnect from Server, the local-only connection bridge, and native appearance; the product connection walks cross them together; - [distribution.md](./distribution.md) covers the desktop workspace, the ToDesktop configuration, the upload directory and the update runtime's start, and leaves everything ToDesktop does to the product's real-host checks; - [runtime.md](./runtime.md) covers Electron's exact version and declarations, runtime validity, and a real installation; - [e2e-harness.md](./e2e-harness.md) crosses the installed Electron executable into Playwright and the real desktop app, including the harness smoke seam. diff --git a/proofs/arch/licensing.md b/proofs/arch/licensing.md index 1df23e12..56bfd2c8 100644 --- a/proofs/arch/licensing.md +++ b/proofs/arch/licensing.md @@ -16,10 +16,10 @@ Shapes per the [testing-policy.md](../../specs/arch/testing-policy.md) declarati - **Contract — generator.** Fixture metafiles, bundle-module lists, and package trees in, notices text out. Covers: input-path → containing-package mapping (including scoped and nested packages); [the ignored filter](../../specs/arch/licensing.md#^licensing-ignored-filter) — a fixture package matched by an exact pattern and one matched by a `@scope/*` pattern each leave no inventory record and no notices text, and a fixture whose every package is ignored yields no file at all ([existence rule](../../specs/arch/licensing.md#^licensing-notices-existence)), while one retaining a single handled package still yields a well-formed file; the [notice-resolution rules](../../specs/arch/licensing.md#^licensing-notice-resolution) — license-file case, config-`notices` case (a `cookie-signature`-shaped fixture: MIT declared, no license file, config-supplied text), and the **failure** when neither resolves; reproduction of a synthetic Apache package's `NOTICE` file; election application; vendored-asset folding per surface (including `skill:*` and `source`), including a component missing its notice text, one missing its license, and one naming an ignored package each failing the build; the [content rule](../../specs/arch/licensing.md#^licensing-notices-header) — the rendered file names the product and what the file is, contains no config `id` and none of this spec's field names as labels, and renders an asset-sourced and a bundler-sourced entry in the same shape, so neither betrays its origin; entry naming — a package-identity entry carries its name and version, a component without package identity carries its own `name`, and one with neither package identity nor a name is rejected; an asset naming an undeclared surface is rejected, and a plugin is rejected when constructed with an undeclared or non-inventory surface; [one entry per item](../../specs/arch/licensing.md#^licensing-entry-once) — the same name and version arriving from both an inventory and the asset manifest renders once, and a disagreement between them about license or notice text fails the build; [record dedupe](../../specs/arch/licensing.md#^licensing-record-dedupe) — combining inventories where one record carries a license file and the other does not keeps the one that does; deterministic output ordering; [folder notices](../../specs/arch/licensing.md#^licensing-folder-notices) — an asset naming a `noticesFolder` yields a `THIRD-PARTY-NOTICES.txt` in that folder rendered from its components under the same content rule, two assets naming one folder share one file, an asset without the field yields no folder file, and the loader rejects a `noticesFolder` that is absolute, escapes the repository, is missing, or is not a folder. The bundlers are absent — the fixtures are authored data, declared here. ^licensing-t-generator-contract - **Contract — gate.** Fixture inventories and configs in, exit results out. Covers each failure rule in [gate behavior](../../specs/arch/licensing.md#^licensing-gate-behavior): disallowed license, unresolvable notice, election drift (via the recorded `offered` value), dead config entries per category ([dead-entry rules](../../specs/arch/licensing.md#^licensing-dead-entries) — including an entry whose package survives only in `node_modules` as dev tooling), an inventory-producing declaration whose inventory is absent from the root ([forward direction](../../specs/arch/licensing.md#^licensing-declared-forward) — the writer and the plugin refuse the opposite case at its source, so the gate has none to check), stale root notices, a [folder notices file](../../specs/arch/licensing.md#^licensing-folder-notices) that is stale and one that is missing, plus the passing case with elections applied. The [declared-dependency scan](../../specs/arch/licensing.md#^licensing-declared-scan) reads the `dependencies` of both the public CLI package and the private desktop workspace: a declared dependency under a disallowed license fails by name, one that is not installed fails, and neither a notice nor its own dependency tree is required or scanned. An ignored package cannot reach the gate, so no ignored package is gated; what the gate contract does cover for that config category is that an ignored pattern matching nothing in the current tree is **not** reported dead ([dead-entry rules](../../specs/arch/licensing.md#^licensing-dead-entries)), since the list is a standing scoping decision rather than a correction to an observed fact. A companion config-loader case proves the pattern grammar: exact and `@scope/*` forms load, and a bare `*`, a partial wildcard, a trailing-slash form, and an empty pattern are each rejected by name — *(covered by tests: the “license gate” cases in `test/repo/licensing-gate.test.ts` and the “licensing configuration” cases in `test/repo/licensing-config.test.ts`)*. ^licensing-t-gate-contract -- **Seam — esbuild wiring.** Parameterized over **both esbuild products**: a real full CLI build and a real desktop build each persist their surface inventory, proving the metafiles really flow from each `build.mjs` into the generator on the production build path, and each write a notices file where that surface has entries. The CLI's file carries entries for genuinely bundled packages (`express`); the desktop's carries the single package its bundles actually compile in (`@rupertsworld/event-target`), since its sources are otherwise workspace code, and `electron` and the workspace's declared `dependencies` load from outside the bundle — a one-entry file that exercises the same [existence rule](../../specs/arch/licensing.md#^licensing-notices-existence) from the populated side. Crossed once for real per product; package-level permutations stay in the generator contract tests. ^licensing-t-build-seam +- **Seam — esbuild wiring.** Parameterized over **both esbuild products**: a real full CLI build and a real desktop build each persist their surface inventory, proving the metafiles really flow from each `build.mjs` into the generator on the production build path, and each write a notices file where that surface has entries. The CLI's file carries entries for genuinely bundled packages (`express`); the desktop's carries its actual bundled packages, including `lit-html` from the local renderer, and the shipped Hind, Phosphor, Tailwind and Clouds photo asset records. `electron` and the workspace's declared `dependencies` load from outside the bundle. This exercises the same [existence rule](../../specs/arch/licensing.md#^licensing-notices-existence) from the populated side. Crossed once for real per product; package-level permutations stay in the generator contract tests. ^licensing-t-build-seam - **Seam — Vite inventories.** Parameterized over **every shipping Vite build** (`web`, `view-markdown`, `tv-calendar`, `tv-tasks`): a real `vite build` persists its surface inventory and yields a final per-dist notices file exactly where the surface has entries. The web, markdown, and `tv-tasks` inventories each list a genuinely bundled package; `tv-calendar`'s framework-free bundle records no packages and emits no notices file. Every dist contains no raw inventory artifact ([no in-dist inventory](../../specs/arch/licensing.md#^licensing-vite-intermediate)), and inline `/*! @license` banners survive minification where present ([legal comments](../../specs/arch/licensing.md#^licensing-legal-comments)). ^licensing-t-vite-seam - **Seam — aggregation.** The full CLI build's umbrella notices include entries sourced from the copied-in web inventory and the `tv-tasks` skill inventory, the copied `tv-tasks` directory retains its notices file, and the dependency-free `tv-calendar` directory has none; no copied skill carries a raw inventory artifact. This proves the [aggregation](../../specs/arch/licensing.md#^licensing-notices-placement) and [per-skill placement](../../specs/arch/licensing.md#^licensing-skill-notices) handoffs are wired. ^licensing-t-aggregation-seam - **Seam — suite inventory handoff.** On the suite path (the [real-tree acceptance](../product/licensing.md#^licensing-ac-gate-passes)'s boundary), the `e2e:node` pre-build's two real product builds populate an inventory for every [declaration with `producesInventory: true`](../../specs/arch/licensing.md#^licensing-declared-surfaces) in the shared root, and the in-suite gate consumes them, asserted through the gate enumerating the surfaces it checked. This is the crossing the per-build seams cannot prove: the inventories survive their child build processes and reach the gate that enforces them. ^licensing-t-suite-seam -- **Seam — real asset records.** Reads the **real committed `assets.json`** (not a fixture) and asserts that every declared path exists; the Clouds record includes its CSS and wallpaper paths, Tailwind and both photo components, and `cli`/`source`/folder delivery; the Tailwind foundation record covers all live copies and frozen v1 with `web`, `view:markdown` and `source` delivery; the retained token demo has a source-only Tailwind record; Tailwind attribution comments open the Clouds and live/staging stylesheets, and its complete MIT text renders once in root and Clouds folder notices; the Hind record names the frozen v1 font, live v2 canonical font, web font, and foundation-spec font with no deleted v1 source/spec path; the Phosphor record names both the live icon source and frozen v1 component bundle; and the Nord record names the UI-spec stylesheet and its production copy, the `cli` and `source` surfaces, and the Nord theme folder as its `noticesFolder`, with both stylesheets opening with the [attribution comment](../../specs/arch/licensing.md#^licensing-theme-records). The Hind and Phosphor records appear in the root notices, CLI umbrella notices, and web dist notices, and the Nord record in the root notices, CLI umbrella notices, and its committed folder file, proving that the authoritative records exist and flow to every surface they declare — *(covered by `test/repo/licensing-config.test.ts` and the real build/gate cases in `test/node/licensing.test.ts`)*. ^licensing-t-real-assets-seam +- **Seam — real asset records.** Reads the **real committed `assets.json`** (not a fixture) and asserts that every declared path exists; the Clouds record includes its CSS and wallpaper paths, Tailwind and both photo components, and `cli`/`desktop`/`source`/folder delivery; the Tailwind foundation record covers all live copies and frozen v1 with `web`, `view:markdown`, `desktop` and `source` delivery; the retained token demo has a source-only Tailwind record; Tailwind attribution comments open the Clouds and live/staging stylesheets, and its complete MIT text renders once in root and Clouds folder notices; the Hind record names the frozen v1 font, live v2 canonical font, web font, and foundation-spec font with no deleted v1 source/spec path; the Phosphor record names both the live icon source and frozen v1 component bundle; and the Nord record names the UI-spec stylesheet and its production copy, the `cli` and `source` surfaces, and the Nord theme folder as its `noticesFolder`, with both stylesheets opening with the [attribution comment](../../specs/arch/licensing.md#^licensing-theme-records). The Hind and Phosphor records appear in the root notices, CLI umbrella notices, web dist notices and desktop notices; the desktop also attributes the Tailwind foundation and Clouds material it ships, and the Nord record in the root notices, CLI umbrella notices, and its committed folder file, proving that the authoritative records exist and flow to every surface they declare — *(covered by `test/repo/licensing-config.test.ts` and the real build/gate cases in `test/node/licensing.test.ts`)*. ^licensing-t-real-assets-seam - **Contract — manifest lint.** The extended `check-publishable` manifest rule fails on a fixture workspace manifest lacking `"license": "MIT"` and passes the real tree. ^licensing-t-manifest-contract diff --git a/proofs/arch/ui/foundation.md b/proofs/arch/ui/foundation.md index 45dc0693..00523d95 100644 --- a/proofs/arch/ui/foundation.md +++ b/proofs/arch/ui/foundation.md @@ -6,11 +6,11 @@ Proves [specs/arch/ui/foundation.md](../../../specs/arch/ui/foundation.md). ## Coverage model -The distribution seams include the shared native input, error-message, and standalone Markdown-editor stylesheets, inspect authoritative and production repository files, then run fresh live-v2 canonical and web builds. No filesystem, builder, or stylesheet mechanism is mocked. These assertions prove live byte crossings, import order, standalone-document delivery, and the canonical v2 Hind preload URL; they do not claim visual styling adherence. The Markdown editor's own proof adds its fresh-view-build leg to the shared registry and copy checks here. Frozen v1 has no foundation-source crossing and is owned by canonical's committed-payload assertions. +The distribution seams include the shared native input, error-message, setup and system-modal frame styles, and standalone Markdown-editor stylesheets, inspect authoritative and production repository files, then run fresh live-v2 canonical and web builds. No filesystem, builder, or stylesheet mechanism is mocked. These assertions prove live byte crossings, import order, standalone-document delivery, and the canonical v2 Hind preload URL; they do not claim visual styling adherence. The Markdown editor's own proof adds its fresh-view-build leg to the shared registry and copy checks here. Frozen v1 has no foundation-source crossing and is owned by canonical's committed-payload assertions. The production token sheets cross through the ordinary `television` builder into `theming.md`. [Theme authoring](../themes/authoring.md#^theme-authoring-t-shipped-guidance) owns the manifest-built document, including the authored reference and token and styling guidance. -The drag-region contract uses jsdom for selector matching and therefore forfeits native-window movement to the sidebar's Electron acceptance path. Canonical's own proof carries artifact-foundation delivery through the server, while this proof owns the application's copied foundation and build output. +The drag-region contract uses jsdom for selector matching and therefore forfeits native-window movement to the sidebar's Electron acceptance path. Canonical's own proof carries artifact-foundation delivery through the server, while this proof owns the application's copied foundation and build output. Setup discovery and its committed production stylesheet crossing are included here; [the local-page seam](../desktop/connect-flow.md#^desktop-t-local-assets) proves those assets actually load from the desktop package over `file:`. ## Test hooks @@ -22,7 +22,7 @@ The drag-region contract uses jsdom for selector matching and therefore forfeits The crossing assertion covers the real repository copies and production build. The complete document route set, including the runtime theme sheet, remains [arch/ui/index.md#^ui-t-style-delivery](./index.md#^ui-t-style-delivery)'s. -- **Seam** (the authoritative foundation, element, app-surface, bundled-theme, Markdown-editor, and standalone view-specific assets crossed to their production styles through the real repository filesystem, with app-delivered assets additionally exercised by a fresh production web build; no mocks or test hooks): each CSS and font source in the app-delivered foundation has a byte-identical committed copy under `packages/web/src/foundation/`; every implemented stylesheet or frame style block discovered under `specs/ui/foundation/`, `specs/ui/elements/`, `specs/ui/app/`, `specs/ui/themes/`, and `specs/ui/markdown-editor/` has at least one declared production crossing, while each intentionally uncrossed stylesheet belongs to a spec explicitly marked **specified, not implemented**; Clouds stylesheet and wallpaper bytes cross into `packages/server/assets/themes/clouds/`, not the web bundle; module-sibling stylesheets contain their ordered byte-identical CSS sources or YAML-decoded frame style blocks, bundled-package entries and standalone view-specific style blocks preserve their source bytes, and the complete production foundation imports a copy of every sheet in the authority's order; the app imports only production copies; and the built stylesheet contains the app-document delivery set without any production import from `specs/ui/` — *(covered by test: `packages/web/test/foundation-copy.test.ts` “application foundation production crossing (^ui-t-foundation-copy)”)*. ^ui-t-foundation-copy +- **Seam** (the authoritative foundation, element, app-surface, bundled-theme, Markdown-editor, and standalone view-specific assets crossed to their production styles through the real repository filesystem, with app-delivered assets additionally exercised by a fresh production web build; no mocks or test hooks): each CSS and font source in the app-delivered foundation has a byte-identical committed copy under `packages/web/src/foundation/`; every implemented stylesheet or frame style block discovered under `specs/ui/foundation/`, `specs/ui/elements/`, `specs/ui/app/`, `specs/ui/themes/`, `specs/ui/setup/`, and `specs/ui/markdown-editor/` has at least one declared production crossing, while each intentionally uncrossed stylesheet belongs to a spec explicitly marked **specified, not implemented**; Clouds stylesheet and wallpaper bytes cross into `packages/server/assets/themes/clouds/` and the desktop local-page assets, without entering the served web bundle; module-sibling stylesheets contain their ordered byte-identical CSS sources or YAML-decoded frame style blocks, bundled-package entries and standalone view-specific style blocks preserve their source bytes, and the complete production foundation imports a copy of every sheet in the authority's order; the app imports only production copies; and the built stylesheet contains the app-document delivery set without any production import from `specs/ui/` — *(covered by `packages/web/test/foundation-copy.test.ts` “application foundation production crossing (^ui-t-foundation-copy)” and `packages/desktop/test/e2e/local-page.test.ts` “the built local page loads foundation, Clouds, fonts and icons for setup and saved dialogs”)*. ^ui-t-foundation-copy - **Contract** (the style-source reader used by the repository crossing seam; literal CSS and frame fixtures, no mocks): standalone CSS keeps its bytes; frame extraction returns only the YAML-decoded `style` value, preserving YAML chomping; frames without a style block contribute no stylesheet; malformed front matter and non-string styles fail rather than disappearing from discovery. This does not establish production conformance; the repository seam above owns that claim — *(covered by `test/repo/ui-style-source.test.ts`)*. ^ui-t-frame-style-source - **Seam** (production token sheets → ordinary `television` builder → temporary `theming.md`, through real files; no mocks): complete bytes and source labels appear in foundation order, including font and layer tokens and the application catalog — *(covered by `packages/skills/skills/television/src/build.test.ts` “inserts the complete production token sheets in foundation order in the ordinary build”)*. ^ui-t-foundation-theme-guidance-copy - **Seam** (authoritative Hind bytes → fresh canonical and web production builds, crossed through the real repository filesystem and builders; the canonical stylesheet, byte-identical web font copy, and built shell entry document are real outputs; no mocks or test hooks): the built entry document contains exactly one `link` with `rel="preload"`, `as="font"`, and `crossorigin`; the web and canonical v2 builds derive the same eight-character SHA-256 digest from their byte-identical font copies; and the link's `href` is the exact v2 font URL referenced by the built canonical `styles.css` — *(covered by test: `test/repo/build-config-integrity.test.ts` “the production shell preloads the exact canonical Hind URL”)*. ^ui-t-canonical-font-preload diff --git a/proofs/arch/updates/desktop-upgrade-gate.md b/proofs/arch/updates/desktop-upgrade-gate.md index 6f7bc06c..27325c35 100644 --- a/proofs/arch/updates/desktop-upgrade-gate.md +++ b/proofs/arch/updates/desktop-upgrade-gate.md @@ -10,6 +10,8 @@ Contract tests cover version comparison, source selection, development exemption A requirement is the release its pull request ships in, and the desktop release built from that release meets it, as does any later desktop release ([bumping the requirement](../../../specs/arch/updates/desktop-upgrade-gate.md#^ops-bump)). The decision contract's case for a shell above the requirement proves that a later release passes. The rest of the operations order no test ([spec purposes](../../../specs/arch/testing-policy.md#^spec-purposes)): maintainers keep the [release order](../../../specs/arch/updates/desktop-upgrade-gate.md#^ops-release-order), pausing npm publishing until the desktop release is out, and nothing checks that order. The publish workflow behavior it relies on, the pause file and publishing the version the pull request set, is covered by the [publish-workflow contract](./index.md#^updates-t-publish-order). What the order means for running apps composes from proven parts. The product's gate walks, which run in Electron on Linux, show that a shell below the requirement stays gated across reloads and reconnects. A gated app leaves the gate through its own update, whose gated part [the product's self-update acceptance](../../product/update-notifications.md#^ac-desktop-self-update) composes. The update runtime starts in the main process before any page loads ([its start contract](../desktop/updates.md#^desktop-updates-t-start)), and the gate is web content, so a gated app keeps checking for updates. [Gated downloaded-update acceptance](../../product/update-notifications.md#^ac-gate-downloaded-update) shows a gated real Electron app whose runtime reports a download changing to the downloaded-update message and sending its restart. The Electron gate journeys show that a relaunch reporting a version that meets the requirement passes the gate. A run recorded by the pull request that moved the desktop app to ToDesktop observed both from a gated app. +The native menu remains usable while the served gate halts the app. [Desktop product acceptance](../../product/desktop-app.md#^desktop-ac-gate-disconnect) proves [Disconnect from Server on the gate](../../../specs/arch/updates/desktop-upgrade-gate.md#^gate-screen); connection architecture owns its deletion and cancellation breadth. The [app proof](../../ui/app/index.md#^ap-ac-gate-uncovered) proves that a later auth rejection cannot replace a displayed gate. These compose with the existing boot-barrier and gate-lifetime evidence rather than repeating version permutations. + ## Test hooks @@ -24,7 +26,7 @@ Per [the domain's test-hook posture](./index.md#^updates-test-hooks). The gate a - **Contract** (server producer side of the `/desktop/connect-check` HTTP seam; a real HTTP client talks to normally composed authless and token-requiring servers; authored query values cover present, absent, and malformed informational versions; no mocks or test hooks): authless and valid-bearer requests receive `200` with a JSON object containing `product: "television"`, missing/invalid credentials receive `401`, and query content never changes the result; additional response members are permitted — *covered by both cases under `packages/server/test/desktop-connect-check.test.ts` “GET /desktop/connect-check”*. ^updates-t-preflight-server-contract - **Contract** (desktop reader side of the same HTTP seam; a recording HTTP peer returns authored statuses and bodies — a declared mock forfeiting real server authentication and emission to the sibling producer contract): the shell sends exactly one `GET /desktop/connect-check` with its informational version and optional bearer token, accepts `200` with the product marker alone or with unknown members, rejects non-`200`, non-JSON, non-object bodies including `null` and arrays, and missing/wrong-marker responses, and never requests `/display` — *covered by all cases under `packages/desktop/test/connect-preflight.test.ts` “preflightConnection desktop connect check”*. ^updates-t-connect-check-reader-contract -- **Contract** (main-process connect IPC; `fetch` and Electron's `BrowserWindow` are recording peers — declared mocks forfeiting real HTTP to the producer contract above and real Electron navigation to the product gate acceptance path): on marker success, the shell saves the normalized connection and loads the server-origin root with Electron mode, the token when present, and the same resolved `desktopAppVersion` it sent on the connect check; on connect-check failure it saves nothing and never navigates — *covered by `packages/desktop/test/main.test.ts` “connect IPC accepts additive connect-check JSON, saves normalized fields, and loads the versioned remote URL” and “connect IPC does not save submitted fields or loadURL when the connect check fails”*. ^updates-t-preflight-desktop-contract +- **Contract** (main-process connection boundary; `fetch`, Electron window and storage are recording peers, forfeiting real HTTP/IPC/disk/navigation to [desktop connection acceptance](../../product/desktop-app.md#^desktop-ac-connect-link)): under [the desktop connect check](../../../specs/arch/updates/desktop-upgrade-gate.md#^pre-gate-desktop), before success the shell neither saves the submitted link nor loads the remote page; marker success permits saving its normalized origin/token and, after the setup success presentation, loading the root with Electron mode, token when present, and the same resolved version used by the check. Failure saves nothing and makes no remote navigation. A server requiring a newer app still passes a successful identity check; its served page decides the gate. The local renderer's completion/cancellation ordering belongs to [product connection acceptance](../../product/desktop-app.md#^desktop-ac-connect-link) and [disconnect](../desktop/connect-flow.md#^desktop-t-disconnect). *(covered by `packages/desktop/test/connect-flow.test.ts` “starts without a record in setup and saves a checked link before the Connected handoff” and “keeps rejected setup links inline and a tokenless retry carries no previous token”; supplemented by inherited tests: `packages/desktop/test/main.test.ts` “connect IPC accepts additive connect-check JSON, saves normalized fields, and loads the versioned remote URL” and “connect IPC does not save submitted fields or loadURL when the connect check fails”)* ^updates-t-preflight-desktop-contract - **Contract** (legacy `/display` producer side; a real HTTP client with a valid bearer token talks to a normally composed server, then the verbatim predicate from published `0.1.206` revision `244b0b833` is applied as an authored fixture; no mocks or test hooks): focused and empty display states both satisfy the [bounded compatibility response](../../../specs/arch/updates/desktop-upgrade-gate.md#^pre-gate-legacy-display) — *covered by `packages/server/test/desktop-connect-check.test.ts` “published 0.1.206 /display identity predicate” → “accepts focused and empty display states”; retire this assertion under [the cutoff procedure](../../../specs/arch/updates/desktop-upgrade-gate.md#^pre-gate-legacy-cutoff)*. ^updates-t-legacy-connect-check-contract ### Test assertions diff --git a/proofs/index.md b/proofs/index.md index 8b5ecaa8..45e14616 100644 --- a/proofs/index.md +++ b/proofs/index.md @@ -8,8 +8,8 @@ Generated from the first-line description of every proof by `scripts/specs-index - `artifact-navigation.md` (34 lines) — *How the promises in Artifact navigation are proven.* - `artifacts.md` (46 lines) — *How the promises in Artifacts are proven.* - `channels.md` (29 lines) — *How the promises in Channels are proven.* - - `cli.md` (83 lines) — *How the promises in CLI are proven.* - - `desktop-app.md` (26 lines) — *How the downloaded desktop app's promises are proven: continuous real-Electron tests, and checks of an unreleased ToDesktop build, run by agents or by a person on a real Mac, before a change to the app's behavior reaches `main`.* + - `cli.md` (97 lines) — *How the promises in CLI are proven.* + - `desktop-app.md` (42 lines) — *How the downloaded desktop app's promises are proven: continuous real-Electron tests, and checks of an unreleased ToDesktop build, run by agents or by a person on a real Mac, before a change to the app's behavior reaches `main`.* - `keyboard-navigation.md` (32 lines) — *How the promises in Keyboard navigation are proven.* - `licensing.md` (30 lines) — *How Television's licensing promises are proven where users receive them: the license gate over the real tree, the packed CLI tarball, the desktop upload directory, the served browser notices, and Electron's license files in the installed desktop app.* - `tab-pages.md` (29 lines) — *How the promises in Tab pages are proven.* @@ -20,7 +20,7 @@ Generated from the first-line description of every proof by `scripts/specs-index - **onboarding/** - `onboarding-channels.md` (57 lines) — *How the promises in Onboarding channels are proven.* - **arch/** - - `canonical.md` (34 lines) — *How the promises in Canonical are proven.* + - `canonical.md` (36 lines) — *How the promises in Canonical are proven.* - `developer-skills.md` (21 lines) — *Developer skill installation proven through real temporary homes and installer processes.* - `licensing.md` (25 lines) — *How the licensing machinery is proven: generator, gate and loader contracts over authored inputs, and seams through every real shipping build, the suite's inventory handoff and the committed asset manifest.* - `making-skills.md` (18 lines) — *How the promises in Making skills are proven.* @@ -33,14 +33,14 @@ Generated from the first-line description of every proof by `scripts/specs-index - `proxy-caching.md` (20 lines) — *How the promises in Artifact proxy caching are proven.* - `reload-navigation.md` (97 lines) — *How the promises in Reload and navigation are proven.* - **channel-state/** - - `index.md` (51 lines) — *How the promises in Channel and client state (architecture) are proven.* + - `index.md` (53 lines) — *How the promises in Channel and client state (architecture) are proven.* - **cli/** - `admin-guide.md` (9 lines) — *Review and publication checks for the standalone administrator guide.* - - `index.md` (248 lines) — *How the promises in CLI architecture are proven.* + - `index.md` (253 lines) — *How the promises in CLI architecture are proven.* - `startup-bind-failure.md` (39 lines) — *How the promises in Startup bind failure are proven.* - **desktop/** - - `appearance.md` (21 lines) — *How the promises in Desktop appearance are proven across renderer composition, Electron IPC, and the real desktop application.* - - `connect-flow.md` (21 lines) — *How the promises in Desktop connection flow are proven.* + - `appearance.md` (29 lines) — *How the promises in Desktop appearance are proven across renderer composition, Electron IPC, and the real desktop application.* + - `connect-flow.md` (32 lines) — *Desktop connection coverage across link parsing, saved state, the packaged local page, native IPC, and server navigation.* - `distribution.md` (34 lines) — *How the ToDesktop build's promises are proven: repository checks of the desktop workspace, its ToDesktop configuration, a generated upload directory, the build script's exit status and the real ToDesktop CLI's dry run, and the product's real-host checks for everything ToDesktop does.* - `e2e-harness.md` (21 lines) — *How the promises in Electron e2e harness are proven.* - `index.md` (28 lines) — *How the desktop architecture root's promises are proven: main-process identity contracts and a real-Electron data-location seam, composed with the module proofs and the product's real-host checks.* @@ -92,7 +92,7 @@ Generated from the first-line description of every proof by `scripts/specs-index - `overflow-fade.md` (26 lines) — *How the overflow-fade helper contract is proven.* - **updates/** - `desktop-self-update-notice.md` (26 lines) — *How the desktop self-update notice is proven: contracts for the page's desktop update state and the notice controller with a stand-in bridge, the body constant checked against its authored copy, a real-browser rendering seam, and the product's real-Electron acceptance.* - - `desktop-upgrade-gate.md` (54 lines) — *How the desktop upgrade gate is proven: connect-check contracts over real HTTP, decision and selection contracts with the production thresholds, gate-screen contracts with a stand-in for the update operations, a real-browser boot barrier, and the product's real-Electron gate walks, with maintainer release order left untested.* + - `desktop-upgrade-gate.md` (56 lines) — *How the desktop upgrade gate is proven: connect-check contracts over real HTTP, decision and selection contracts with the production thresholds, gate-screen contracts with a stand-in for the update operations, a real-browser boot barrier, and the product's real-Electron gate walks, with maintainer release order left untested.* - `desktop-upgrade-recommendation.md` (27 lines) — *How the desktop upgrade recommendation is proven: the production thresholds read directly, decision and presentation contracts, a real-browser rendering seam, and the product's real-Electron journeys for the apps installed from npm that it reaches.* - `index.md` (39 lines) — *How the update domain's shared rules are proven: version validation and comparison contracts, repository checks of workspace versions and the publish workflow, including eligibility, queueing and its pause file, and the release order left to maintainers.* - `update-channel.md` (50 lines) — *How the promises in Update channel are proven.* @@ -100,15 +100,15 @@ Generated from the first-line description of every proof by `scripts/specs-index - **ui/** - `index.md` (13 lines) — *How the promises in UI policy are proven.* - **app/** - - `index.md` (62 lines) — *How the promises in App (UI) are proven.* + - `index.md` (67 lines) — *How the promises in App (UI) are proven.* - **artifact-frame/** - - `index.md` (30 lines) — *How the promises in Artifact frame (UI) are proven.* + - `index.md` (32 lines) — *How the promises in Artifact frame (UI) are proven.* - **copy-button/** - `index.md` (32 lines) — *How the promises in Copy button (UI) are proven.* - **desktop-upgrade-gate/** - `index.md` (22 lines) — *How the promises in Desktop upgrade gate (UI) are proven.* - **dialog/** - - `index.md` (23 lines) — *How the promises in Dialog (UI) are proven.* + - `index.md` (24 lines) — *How the promises in Dialog (UI) are proven.* - **settings/** - `index.md` (27 lines) — *How the promises in Settings (UI) are proven.* - **sidebar/** @@ -118,7 +118,7 @@ Generated from the first-line description of every proof by `scripts/specs-index - **stage/** - `index.md` (39 lines) — *How the promises in Stage (UI) are proven.* - **system-modal/** - - `index.md` (23 lines) — *How the promises in System modal (UI) are proven.* + - `index.md` (27 lines) — *Coverage of the dialog shown while Television cannot use its server, with real input for blocking behavior and native Electron dragging.* - **tab-strip/** - `index.md` (37 lines) — *How the promises in Tab strip (UI) are proven.* - **top-bar/** @@ -126,7 +126,7 @@ Generated from the first-line description of every proof by `scripts/specs-index - **update-notification/** - `index.md` (27 lines) — *How the promises in Update notification (UI) are proven.* - **foundation/** - - `index.md` (34 lines) — *How the promises in Foundation (UI) are proven.* + - `index.md` (36 lines) — *How the promises in Foundation (UI) are proven.* - **button/** - `index.md` (15 lines) — *How the promises in Button (UI) are proven.* - **checkbox-list/** @@ -145,6 +145,8 @@ Generated from the first-line description of every proof by `scripts/specs-index - `index.md` (19 lines) — *How the Markdown editor’s authoritative color sheet reaches production, and where its exact appearance is judged.* - **onboarding-artifacts/** - `index.md` (99 lines) — *How onboarding design sources are proven through the bake and the browser components they use.* + - **setup/** + - `index.md` (25 lines) — *Setup coverage at the local renderer boundary, with real Electron evidence for connection, confirmation before navigation, and window dragging.* - **skillbench/** - `index.md` (13 lines) — *How the promises in Skillbench (UI) are proven.* - **skills/** diff --git a/proofs/product/cli.md b/proofs/product/cli.md index dc004fdd..20a22919 100644 --- a/proofs/product/cli.md +++ b/proofs/product/cli.md @@ -25,12 +25,18 @@ The [transitional service path](../../specs/product/cli.md#^cli-retired-service- The breadth of home resolution, the config reader and writer, `tv config` parsing, the client-port rule, retired options, and ignored legacy variables is proven by contract tests in [the CLI architecture proof](../arch/cli/index.md), which crosses the config-port and home handoffs once more at real seams. -Two promises have no test: Television never writes `~/.tv-home`, and a running server does not adopt config edits made after it starts ([config snapshot](../../specs/product/cli.md#^cli-config-snapshot)). Neither has a code path that could break it. Only the home resolver touches `.tv-home`, and it only reads it. The server receives its settings as options that `tv serve` reads from the config file once, at startup, and it never reads the file itself. The [invalid-config acceptance](#^cli-ac-invalid-config) does check that `tv serve` leaves the file it read unchanged. +Television never writes `~/.tv-home`; that promise orders no separate test because it has no write path. Only the home resolver touches `.tv-home`, and it only reads it. The server receives settings read at startup; [links acceptance](#^cli-ac-links) observes link output after config edits to catch accidentally using the file instead of those running settings. The [invalid-config acceptance](#^cli-ac-invalid-config) does check that `tv serve` leaves the file it read unchanged. The CLI's selected-theme application reference composes with [theme delivery](../arch/themes/delivery.md#^theme-delivery-t-visible-app) for visible browser selection, clearing, and live edits; CLI [command contracts](../arch/cli/index.md#^2b573706) own argument mapping and [real CLI/server coverage](../arch/cli/index.md#^bd409c7e) owns the handoff. This reference correction adds no duplicate browser or CLI journey. The [theme-registry scanner contract](../arch/themes/index.md#^themes-t-scan) proves that dot-prefixed folders never enter a completed snapshot. The CLI command contract reports only an exact matching registry error and the real CLI/server seam covers the ordinary missing-theme response. Those clean handoffs compose to the product distinction between an invalid discovered folder and no discovered match; no dot-folder-specific spawned CLI journey is required. +Connect-link acceptance proves live-server token decisions and the command's success, refusal and unreachable exits through built processes. [CLI architecture](../arch/cli/index.md#^cli-links-contract) carries address, option and token-file breadth. The [shared-formatter contract](../arch/cli/index.md#^cli-osc8-visible) covers links and both startup callers, including terminal inputs; process acceptance crosses real piped stdout. The existing [persisted-install walk](#^cli-ac-persist-home-install) crosses plain piped output in its designated-host suite; no additional daemon installation journey is needed. + +## Test hooks + +The connect-link walks add no production hooks: they use the built executable, real process streams, isolated homes and ordinary config/token files. + ## Assertions ### Acceptance criteria: CLI spine @@ -55,7 +61,7 @@ The single dispatch seam is **argument tokens → command dispatch → command a - **Acceptance.** Healthy and unhealthy status are both successful process outcomes: a spawned built CLI reports `healthy: true` for a running foreground server and `healthy: false` after that server stops, with exit code `0` in both cases — *(policy-grade test: `test/node/cli-acceptance.test.ts` "reports healthy and unhealthy status as successful process outcomes")*. ^cli-ac-status - **Acceptance.** [Status](../../specs/product/cli.md#Server lifecycle commands) reports `home` as the absolute path of the temporary home the command was given, in both the healthy and the unhealthy output, while the server's `/health` response names no home or other filesystem path — *(policy-grade test: `test/node/cli-acceptance.test.ts` "reports healthy and unhealthy status as successful process outcomes")*. ^cli-ac-status-home - **Acceptance.** Against the running server produced by the same build, `tv status` prints a `version` equal to the exact release version in the actual built CLI manifest, the version returned by the real HTTP health request, and the release-version portion of a separately observed `tv --version` output. The status value contains no developer commit annotation. The built CLI and server processes, real HTTP health request, and process output are unmocked; *covered by `test/node/cli-acceptance.test.ts` “reports healthy and unhealthy status as successful process outcomes”*. ^cli-ac-status-version -- **Acceptance.** Foreground serve startup crosses the source executable, real server, and signal boundaries with the explicit tokenless opt-out: over a temporary home whose config file sets port `0` and `"auth": false`, `tv --home serve` binds an operating-system-chosen port, prints its terminal-hyperlinked URL and the tokenless warning, serves health, and records lifecycle shutdown after `SIGTERM`. The temporary home and its config file are the only fixtures and no mechanism is replaced. The exact warning texts are contracts in [the CLI architecture proof](../arch/cli/index.md#^cli-tokenless-warnings) — *(covered by inherited test: `test/node/cli-integration.test.ts` "tv serve records lifecycle start and stop when terminated")*. ^c7f9f202 +- **Acceptance.** Foreground serve startup crosses the source executable, real server, and signal boundaries with the explicit tokenless opt-out: over a temporary home whose config file sets port `0` and `"auth": false`, `tv --home serve` binds an operating-system-chosen port, prints its plain URL to piped stdout and the tokenless warning, serves health, and records lifecycle shutdown after `SIGTERM`. The temporary home and its config file are the only fixtures and no mechanism is replaced. The exact warning texts are contracts in [the CLI architecture proof](../arch/cli/index.md#^cli-tokenless-warnings) — *(covered by inherited test: `test/node/cli-integration.test.ts` “tv serve records lifecycle start and stop when terminated”, minimally extended for plain piped output)*. ^c7f9f202 - **Acceptance.** Multi-listener foreground startup crosses the built executable, config-file, and real server boundaries, with `tv config set` writing the settings that the next `tv serve` reads. The test discovers the host's assigned non-loopback IPv4 through `os.networkInterfaces()`. The built `tv --home config set port 0 auth false listen `, run against a new temporary home, exits `0`, creates the home, and prints the [confirmation](../../specs/product/cli.md#^cli-config-set) naming `/config.json` and the keys `port`, `auth`, and `listen`. The built `tv --home serve` then prints the tokenless warning and the non-loopback warning naming that address, prints startup URLs for `127.0.0.1` and that address on one operating-system-chosen port, and answers `/health` on both listeners with both addresses reported as bound; loopback is bound although `listen` names only the non-loopback address. The temporary home is the only fixture; no mechanism is replaced. The test lives in ordinary `e2e:node`, rather than the designated-host daemon suite, because both developer and Blaxel workers expose a bindable non-loopback IPv4 — *(policy-grade test: `test/node/cli-integration.test.ts` "tv serve binds loopback and the host non-loopback IPv4 that tv config set wrote to listen")*. ^cli-ac-multi-listener-success - **Acceptance.** The listener-bind failure exit crosses the built executable, config-file, real network-bind, and log-file boundaries: `tv --home serve`, whose home's config file sets port `0` and a `listen` address that cannot bind, exits with status `69`, leaves no listener running, and records the fatal diagnosis in `/logs/tv.log`. The test uses real arguments and no mocks. The detailed bind-failure contract and its breadth are owned by [the startup bind-failure spec](../arch/cli/startup-bind-failure.md#^t-spine-fatal-exit) — *(policy-grade test: `test/node/cli-integration.test.ts` "tv serve exits with status 69 and closes all listeners after a bind failure")*. ^cli-ac-bind-failure - **Acceptance.** [Default-home selection](../../specs/product/cli.md#^cli-home-selection) crosses the built executable, the process environment, and the real filesystem with no `--home`. Each command runs with `HOME` set to a temporary directory that stands in for the operating-system home directory, which production `os.homedir()` reads from `HOME` on Linux and macOS. With no `.tv-home` in that directory, `tv config show` exits `0` and prints the [documented output](../../specs/product/cli.md#^cli-config-show) with `home` equal to `/.television`, `configFileExists: false`, and the default settings: port `32848`, an empty `listen`, `auth: true`, and `installedByAgent: null`. `tv themes-path` prints `{"themesPath":"/.television/themes"}`. After the test writes `~/tv-elsewhere/` and a newline into `/.tv-home`, both commands report `/tv-elsewhere` as the home; given `--home ` as well, both report that other home. The temporary operating-system home, its `.tv-home`, and the other home are fixtures; no mechanism or test hook is replaced — *(policy-grade test: `test/node/cli-acceptance.test.ts` "selects the default home from the operating-system home and ~/.tv-home")*. ^cli-ac-default-home @@ -64,7 +70,7 @@ The single dispatch seam is **argument tokens → command dispatch → command a - **Acceptance.** Direct skills installation crosses the built executable and real filesystem boundaries: the packaged `dist/skills` collection is copied into a temporary destination, the command exits `0`, the destination's complete top-level directory set matches the skills build manifest read at test time, and its complete recursive tree and every file's bytes match the packaged collection. The installed `television` guidance carries its canonical HTML entry points and advisory authored-app-version query, current tab-page focus and deletion language, visible command families including `update-channel` among channel commands, and sibling `theming.md`; installed specialist guidance uses current artifact-frame terminology. These positive facts make the byte comparison evidence for the intended content rather than an opaque identical tree. The command receives a temporary home with `--home`, so the telemetry identity it resolves stays out of the operator's default home, and telemetry is suppressed through `DO_NOT_TRACK`; no mechanism is replaced — *(policy-grade test: `test/node/cli-acceptance.test.ts` "copies the packaged bundled skills through the built CLI process boundary")*. ^cli-ac-skills-copy - **Acceptance.** Direct skills installation through the built CLI begins with a stale `tv-theme` directory in the real temporary destination, removes it, and installs the production packaged collection in which `television/SKILL.md` references `television/theming.md`. The complete resulting destination tree remains byte-identical to the packaged collection. The command receives a temporary home with `--home`, and telemetry is suppressed through `DO_NOT_TRACK`; no mechanism is replaced — *(policy-grade test: `test/node/cli-acceptance.test.ts` "removes a stale tv-theme directory through the built CLI process boundary")*. ^cli-ac-stale-theme-skill-cleanup - **Acceptance.** Persisted installation crosses the packed-package, installed-binary, process, production daemon-construction, host service-manager, and server boundaries. The test globally installs a freshly packed `@telepath-computer/television` package into the active npm prefix and, through its real `tv` symlink, runs `tv --home serve --persist` over a new temporary home whose config file holds a stable nonzero port, using production `createDaemon` unchanged. It asserts exit `0`, the literal `com.television.server` identity, the production description, installed-and-running status, and a successful `/health` response from the daemon booted by the service manager. The temporary home and a `DO_NOT_TRACK` environment value are declared fixtures; no mechanism is replaced — *(policy-grade test: the initial-install phase of `test/node/daemon-acceptance.test.ts` "installs, refreshes, boots, stops, and persist-uninstalls the production service")*. ^cli-ac-persist-install -- **Acceptance.** The same initial install shows the home's config file carrying the service's settings. The test writes the stable port with the installed `tv --home config set port `, and the config file has no `auth` key. Installation prints exact connect output whose startup URL carries `?token=` with the token in `/state/token`, which the install created. The service definition carries [home-only serve arguments](../../specs/product/cli.md#^cli-persist-home-service): the Node executable, the installed CLI entry, `--home`, the absolute home, and `serve`, with no port, listener, authentication, installed-by, or storage setting. Installed `tv --home status`, given no `--port`, reports `healthy: true` with that home and port, which shows a client finding the service through its home's config port — *(policy-grade test: the initial-install phase of `test/node/daemon-acceptance.test.ts` "installs, refreshes, boots, stops, and persist-uninstalls the production service")*. ^cli-ac-persist-home-install +- **Acceptance.** The same initial install shows the home's config file carrying the service's settings. The test writes the stable port with the installed `tv --home config set port `, and the config file has no `auth` key. Under [link output](../../specs/product/cli.md#^cli-link-output), installation prints its startup headings and a plain URL on piped stdout, without OSC-8 escapes. The URL carries `?token=` with the token in `/state/token`, which the install created. The service definition carries [home-only serve arguments](../../specs/product/cli.md#^cli-persist-home-service): the Node executable, the installed CLI entry, `--home`, the absolute home, and `serve`, with no port, listener, authentication, installed-by, or storage setting. Installed `tv --home status`, given no `--port`, reports `healthy: true` with that home and port, which shows a client finding the service through its home's config port — *(policy-grade test: the initial-install phase of `test/node/daemon-acceptance.test.ts` "installs, refreshes, boots, stops, and persist-uninstalls the production service")*. ^cli-ac-persist-home-install - **Acceptance.** Persisted reinstall begins with that production-identity service installed, running, and answering health. Without a bulldozer between commands, the installed `tv --home serve --persist` runs again from a second temporary home whose config file sets a different stable nonzero port. The test observes exactly one `com.television.server` definition and registration, verifies that the definition carries the second installation's arguments and references neither the first home nor the first port, receives `/health` on the second port, observes the first endpoint close, and then continues through the real stop path — *(policy-grade test: the refresh phase of `test/node/daemon-acceptance.test.ts` "installs, refreshes, boots, stops, and persist-uninstalls the production service")*. ^cli-ac-persist-reinstall - **Acceptance.** Persisted stop continues from the refreshed production-identity installation and invokes the installed `tv stop` process with real arguments. It exits `0`, prints exactly `{ "status": "stopped" }`, and leaves the production daemon absent and inactive according to both `Daemon.status()` and the host service manager — *(policy-grade test: the stop phase of `test/node/daemon-acceptance.test.ts` "installs, refreshes, boots, stops, and persist-uninstalls the production service")*. ^cli-ac-persist-stop - **Acceptance.** The distinct persisted-uninstall spelling continues from a second real production-identity installation and invokes installed `tv --home serve --persist-uninstall`. It exits `0`, prints exactly `{ "status": "stopped" }`, and leaves the service absent and inactive — *(policy-grade test: the persist-uninstall phase of `test/node/daemon-acceptance.test.ts` "installs, refreshes, boots, stops, and persist-uninstalls the production service")*. ^cli-ac-persist-uninstall-alias @@ -81,3 +87,11 @@ These five persisted-service assertions live in the manually selected `daemon-ac Installer configuration retains the raw local value under the existing config assertions. Telemetry normalization is proven by [the persisted config-to-event acceptance](./telemetry.md#^tel-ac-persist-config-home) and [the harness-name normalizer](../arch/telemetry/derivation.md#^t-installed-by-agent). + +### Connect-link acceptance + +The spawned-process boundary and home isolation declared above apply. Use a real server and real stdout pipes, with no client, stream, network or filesystem mocks. + +- **Acceptance — links from the running server.** Under [connect links](../../specs/product/cli.md#^cli-links), spawn built `tv links` for a running token-requiring server. It exits `0`, leaves stderr empty and prints exactly one plain connect link per health-reported listener on the bound port, each with the home's token and no heading or escape sequence. Edit config auth to false without restarting: links remain token-bearing and usable for an authenticated read. Restart authless with a leftover token file, edit config auth back to true, and observe plain origins. The files are fixtures and the settings change uses `tv config set`; authentication and health remain real. [Architecture contracts](../arch/cli/index.md#^cli-links-contract) carry arbitrary listener permutations. *(policy-grade test: `test/node/cli-acceptance.test.ts` “links follows running authentication across config edits and an authless restart”)* ^cli-ac-links +- **Acceptance — rejected home token.** Under [the links refusal](../../specs/product/cli.md#^cli-links), invoke built `tv links` against the real token-requiring server with a second temporary home containing a wrong token. It exits `1`, prints no links, and writes the specified unauthorized message naming that home's token file to stderr. *(policy-grade test: `test/node/cli-acceptance.test.ts` “links rejects a wrong home token without printing links”)* ^cli-ac-links-unauthorized +- **Acceptance — unreachable server.** Under [the links failure](../../specs/product/cli.md#^cli-links), invoke built `tv links` for the isolated test server after stopping it. A TCP forwarder keeps the client endpoint reserved throughout: it relays a real health request while the server is live and closes the connection when the stopped server cannot be reached, without supplying an HTTP response or replacing a Television mechanism. The command exits `1`, writes no stdout, and prints the specified localhost reachability message to stderr. *(policy-grade test: `test/node/cli-acceptance.test.ts` “links reports an unreachable server without printing links”)* ^cli-ac-links-unreachable diff --git a/proofs/product/desktop-app.md b/proofs/product/desktop-app.md index 9c4a8ec3..24c22dc6 100644 --- a/proofs/product/desktop-app.md +++ b/proofs/product/desktop-app.md @@ -18,6 +18,22 @@ The implementation pull request also records a run of `npm run verify -- local` Some promises order no test ([spec purposes](../../specs/arch/testing-policy.md#^spec-purposes)). That no build exists for any computer but an Apple Silicon Mac, and that the download link keeps its address across releases, follow from the build target chosen in the ToDesktop dashboard and from ToDesktop's hosting, which no Television test observes. That wording about the desktop app names the Mac where it names a platform ([^desktop-mac-wording](../../specs/product/desktop-app.md#^desktop-mac-wording)) is copy, which review checks. That the administrator guide gives the user the link and the installation steps is guide content, which [the guide's spec](../../specs/arch/cli/admin-guide.md#Testing) leaves to review. That the checks run before a change reaches `main` is a procedure that review keeps, and which checks a change needs is the judgment of its author and reviewers; no test decides either. The npm package's deprecation message and its installable published versions are npm registry state, which no repository test observes. The private desktop workspace, proven by the [ToDesktop build proof](../arch/desktop/distribution.md#^desktop-dist-t-workspace), is what keeps npm from publishing a new version of it. +The connection outcomes below run in the real Electron app against a running Television server. They enter through the packaged local page and use isolated server homes and Electron user-data directories. Saved records and test artifacts are authored data; setup success itself is observed through the real save path. These walks observe local connection IPC and its absence from served pages. Contract breadth, navigation-failure delivery, and packaged assets belong to [connection architecture](../arch/desktop/connect-flow.md). The [setup](../ui/setup/index.md) and [system-modal](../ui/app/system-modal/index.md) proofs own component interaction. These walks do not replace the signed-Mac checks above. + +## Test hooks + +The connection walks use the [desktop user-data and version hooks](../arch/desktop/connect-flow.md#test-hooks). A compatible shell-version override forfeits version provenance to the [unhooked seam](../arch/updates/desktop-upgrade-gate.md#^t-shell-version-param). The gated walk additionally uses the [server-version](../arch/updates/version-advertisement.md#^hook-server-version) and [required-version](../arch/updates/desktop-upgrade-gate.md#^hook-required-version) hooks. These overrides substitute advertised versions, not the gate decision, IPC or server mechanisms. It points `TV_UPDATE_CHANNEL_URL` at a deliberately unreachable address, forfeiting public-channel delivery while exercising the gate without channel instructions or a server-update notice. No direct-remote-page shortcut is used. + +## Connection assertions + +The boundary is the real Electron app, driven through its local field, buttons, and installed native menu. The fixtures are temporary homes, saved records where named, and a running Television server on dynamically acquired ports. The success, saved-outage and saved-auth walks use a continuously bound forwarding proxy. A request barrier holds the first saved-startup request until the test has observed Connecting; it substitutes transport timing, not the server response or client timers. The outage proxy returns 502 while its real backend is stopped, then forwards to the restarted backend. Otherwise there are no mechanism mocks beyond the version overrides above and the standing CSS-motion override, except that the success walk explicitly keeps CSS motion real. Menu activation may invoke the real installed Electron menu item's callback; this proves its application wiring. Native Mac menu and keyboard activation are not observed by these assertions; the bridge check does not claim them. + +- **Acceptance — connect, remember and disconnect.** Under [Connecting to a server](../../specs/product/desktop-app.md#connecting-to-a-server), an empty user-data directory opens setup with Disconnect from Server disabled. Under [setup interaction](../../specs/ui/setup/index.md#interaction), press the prompt once and observe Chromium selecting its whole text without changing the ready state. Submit one token-bearing link to a running token-requiring server without using Copy first. Observe Connecting, then the rendered Connected presentation before the served root loads. The saved record contains the normalized origin and token; under [server page entry](../../specs/arch/desktop/connect-flow.md#^shell-page-entry), navigation carries Electron mode and the shell version, consumes the token into the live session and removes it from the visible URL. The packaged local page's connect operation works through real preload and IPC; the served page receives neither connect nor disconnect under [the bridge boundary](../../specs/arch/desktop/connect-flow.md#^desktop-connect-local-bridge). Restart with that record: Connecting leads to the served interface without setup appearing. Invoke Disconnect from Server through the installed menu: the record is deleted, setup returns without confirmation, the menu item becomes disabled, and another launch stays on setup. For [setup's Connected state](../../specs/ui/setup/index.md#states), keep CSS motion real and record render/animation and navigation events from before submission. One normal-motion walk covers this handoff; observe completion without imposing an extra dwell or choosing the implementation's navigation mechanism. *(covered by `packages/desktop/test/e2e/connect-screen.01.test.ts` “a link connects, renders Connected, survives restart and is forgotten by the menu”)* ^desktop-ac-connect-link +- **Acceptance — setup refusal and correction.** Under [Connecting to a server](../../specs/product/desktop-app.md#connecting-to-a-server) and [failed local entry](../../specs/arch/desktop/connect-flow.md#^desktop-connect-entry), submit a malformed link, then a link with a rejected token to a real token-requiring server. Each stays in setup with its entered link retained, editable and invalid, and its failure message inline rather than a system modal. The token failure explains obtaining the current link rather than asking for a separate token. No record is saved. A corrected current link submitted through the other native form activation (Connect versus Return) succeeds. These are the validation and authenticated-refusal exits; the architecture's contracts carry the remaining failure classifications. *(covered by `packages/desktop/test/e2e/connect-screen.01.test.ts` “setup keeps rejected links editable and accepts the corrected current link”)* ^desktop-ac-setup-errors +- **Acceptance — saved server unavailable, then recovered.** Under [automatic reconnection](../../specs/product/desktop-app.md#connecting-to-a-server) and [saved startup](../../specs/arch/desktop/connect-flow.md#^desktop-connect-entry), launch with a valid saved record for a stopped test server. Observe Connecting then Can't connect with server, its server address, a progressing retry countdown and in-flight reconnect wording, and no setup. Restart the real server behind the continuously bound front proxy; an automatic retry reaches the served page without input. Observe actual attempt and connection signals rather than replacing timers. *(covered by `packages/desktop/test/e2e/connect-screen.02.test.ts` “saved startup retries an unavailable server and recovers without setup”)* ^desktop-ac-saved-recovery +- **Acceptance — saved token refused.** Launch with a saved wrong-token record for a running token-requiring server. Under [saved startup](../../specs/arch/desktop/connect-flow.md#^desktop-connect-entry), Access token required replaces Connecting, no setup appears and attempts stop. Observe request traffic through a would-be retry interval; the architecture contract proves the scheduling boundary without a long wait matrix. The local Disconnect from Server button deletes the record and reveals editable setup. The error dialog uses the same local action; the [state/context contract](../ui/app/system-modal/index.md#^sm-ac-markup-smoke) covers its button without a second Electron recovery walk. This supplies the local recovery promised by [Connecting to a server](../../specs/product/desktop-app.md#connecting-to-a-server). *(covered by `packages/desktop/test/e2e/connect-screen.02.test.ts` “a rejected saved token stops checks and the local Disconnect button forgets it”)* ^desktop-ac-saved-auth +- **Acceptance — leave a gated server.** With a valid saved record and the version fixtures above, the real server's connect check passes and its served interface shows the upgrade gate. Under [Connecting to a server](../../specs/product/desktop-app.md#connecting-to-a-server), the installed Disconnect from Server menu item remains enabled; activating it deletes the record and returns to setup. No connection-control bridge is granted to the served gate. Existing [gate acceptance](./update-notifications.md#^ac-gate-blocks) owns the rest of the gate outcome. *(covered by `packages/desktop/test/e2e/connect-screen.02.test.ts` “Disconnect from Server leaves the served upgrade gate”)* ^desktop-ac-gate-disconnect + ## Assertions - **Acceptance — Mac install** (boundary: a signed, unreleased build of the candidate commit, a [candidate build](../../specs/arch/desktop/distribution.md#^desktop-dist-candidate-builds) or a signed [test build](../../specs/arch/desktop/distribution.md#^desktop-dist-test-builds), its DMG downloaded from its ToDesktop build page, installed on a [supported real Mac](../../specs/product/desktop-app.md#^desktop-product-support) where an earlier desktop app has saved a connection, and opened from Applications): macOS opens the app without a warning, so it accepts the signature and notarization; the Dock and the application menu show Television; the About panel shows the exact release version with no separate build number; and the app opens with the earlier app's saved connection. The operator also reads the installed bundle by hand and records the output: `spctl --assess --type execute` accepts it with the source `Notarized Developer ID`, its `CFBundleIdentifier` is `computer.telepath.television`, and its `CFBundleShortVersionString` and `CFBundleVersion` are both the release version, which is what operating-system metadata inspectors show. This proves [installation from the download link](../../specs/product/desktop-app.md#^desktop-install-download), [Television identity](../../specs/product/desktop-app.md#^desktop-product-identity), [saved data](../../specs/product/desktop-app.md#^desktop-saved-data) and the [About version](../../specs/product/desktop-app.md#^desktop-about-version). — *(manual; run before a change that needs it reaches `main`; recorded by the implementation pull request)*. ^desktop-ac-mac-install diff --git a/proofs/product/licensing.md b/proofs/product/licensing.md index de5262e3..4f491c05 100644 --- a/proofs/product/licensing.md +++ b/proofs/product/licensing.md @@ -21,10 +21,10 @@ Test shapes follow the [testing-policy.md](../../specs/arch/testing-policy.md) d - **Acceptance — gate passes on the real tree.** Boundary: the license gate run inside the standard suite, over the surface inventories produced by the suite's real pre-build of both products, the CLI and the desktop app (the enforcement path itself — wiring owned by [arch/licensing.md](../../specs/arch/licensing.md)). Asserts the gate passes on the tree as it stands, enumerating every checked surface — proving the shipped inventory, config, and committed source-surface notices are actually in a passing state, not just that the machinery can fail. No mocks. ^licensing-ac-gate-passes - **Acceptance — gate failure exit.** Boundary: the gate as a spawned process. Against a fixture dependency tree containing a shipped dependency with a disallowed license, asserts the gate exits nonzero and names the offending package. Fixture: an authored minimal package tree; declared here because the real tree (rightly) contains no violation to observe. ^licensing-ac-gate-fails - **Acceptance — published CLI package.** Boundary: the real package artifact — build the CLI workspace and pack it with `npm pack`, then inspect the tarball. Asserts the tarball contains a `LICENSE` byte-identical to the repository root `LICENSE`, and a third-party notices file carrying at least one known entry from **every input class**: the CLI bundle (`express`), the web bundle (`dompurify`, shown under its elected Apache-2.0), a bundled view (a CodeMirror package), a skill bundle (`@rupertsworld/event-target`), and the vendored assets (Hind, Phosphor, the Nord color palette) — plus the **complete notice text** of at least one package whose notice is config-resolved rather than read from a license file (`cookie-signature`, whose MIT grant lives only in its README). Spot entries per class rather than "every package," because each class entry proves its whole collection seam is wired; per-package completeness is the generator contract's job in [arch/licensing.md](../../specs/arch/licensing.md). No mocks; the built tree is the fixture. ^licensing-ac-cli-tarball -- **Acceptance — desktop upload directory.** Boundary: an [upload directory](../../specs/arch/desktop/distribution.md#^desktop-dist-upload) generated by the real desktop build script after a real desktop build, stopped before the ToDesktop build by [the build script's test hook](../arch/desktop/distribution.md#test-hooks). Asserts the directory contains a `LICENSE` byte-identical to the repository root `LICENSE`, and contains `dist/THIRD-PARTY-NOTICES.txt` exactly when the desktop build produced entries, which today it does ([a file where there is something to declare](../../specs/product/licensing.md#^licensing-notices-when)), with the bytes that build wrote. This is the desktop's [upload-directory check](../../specs/arch/licensing.md#^licensing-upload-assertions). No mocks; the built tree is the fixture — *(covered by test: `test/node/licensing.test.ts` “desktop upload directory carries the project license, and notices exactly when entries exist”)*. ^licensing-ac-desktop-upload +- **Acceptance — desktop upload directory.** Boundary: an [upload directory](../../specs/arch/desktop/distribution.md#^desktop-dist-upload) generated by the real desktop build script after a real desktop build, stopped before the ToDesktop build by [the build script's test hook](../arch/desktop/distribution.md#test-hooks). Asserts the directory contains a `LICENSE` byte-identical to the repository root `LICENSE`, and contains `dist/THIRD-PARTY-NOTICES.txt` exactly when the desktop build produced entries, which today it does ([a file where there is something to declare](../../specs/product/licensing.md#^licensing-notices-when)), with the bytes that build wrote. It contains the local renderer’s bundled packages and the Hind, Phosphor, Tailwind and both Clouds photo attributions, establishing the [desktop theme-material promise](../../specs/product/licensing.md#^licensing-theme-material) on the delivered directory. This is the desktop's [upload-directory check](../../specs/arch/licensing.md#^licensing-upload-assertions). No mocks; the built tree is the fixture — *(covered by test: `test/node/licensing.test.ts` “desktop upload directory carries the project license, and notices exactly when entries exist”)*. ^licensing-ac-desktop-upload - **Acceptance — Electron's license files in the installed app.** The [aggregate condition](../../specs/product/licensing.md#^licensing-electron-aggregate) is accepted in the built Mac app by the desktop product's license-file check, which an agent runs on the build's Mac zip and which finds Electron's `LICENSE.electron.txt` and `LICENSES.chromium.html` in its `Contents/Resources` ([Electron's license files](./desktop-app.md#^desktop-ac-electron-license-files)). ^licensing-ac-desktop-install - **Acceptance — browser surface.** Boundary: a really-running Television server and a real HTTP client. Fetches the notices at their stable URL next to the client bundle and asserts real entries: a bundled package under its elected license (`dompurify` as Apache-2.0) and the vendored Hind and Phosphor records — proving the served file is the generator's merged output, not a raw bundler inventory that cannot know about assets or elections. No mocks. ^licensing-ac-web-notices -- **Acceptance — every shipped notices file reads for its audience.** Boundary: every notices file Television generates, collected from the real artifacts — the committed repository-root file, the packed CLI tarball, the desktop upload directory, each built view and skill directory that has one, each bundled theme folder that owes one, and the file the running server serves next to the browser bundle. The theme folders are selected from the asset manifest's records, never by looking for the file, so a folder missing the file it owes fails. The set is also asserted to be the right one: a notices file exists at each of those locations whose surface has entries, and at none of the others ([the existence rule](../../specs/product/licensing.md#^licensing-notices-when)). For each one, asserts that it names no ignored package in any form (no entry, no heading, no status line) and that it follows [the reader rule](../../specs/product/licensing.md#^licensing-notices-audience) throughout: the header names the product and what the file is, and nowhere in the file — header or entry — does an internal identifier, a configuration key, or a term of this project's own machinery appear. Composing both properties over the real files is what makes them true of what users receive, rather than only of a generator fixture — *(covered by test: `test/node/licensing.test.ts` “every real artifact has notices exactly where entries exist, and every file is written for readers”)*. ^licensing-ac-ignored-silent +- **Acceptance — every shipped notices file reads for its audience.** Boundary: every notices file Television generates, collected from the real artifacts — the committed repository-root file, the packed CLI tarball, the desktop upload directory and its Clouds folder, each built view and skill directory that has one, each bundled theme folder that owes one, and the file the running server serves next to the browser bundle. The theme folders are selected from the asset manifest's records, never by looking for the file, so a folder missing the file it owes fails. The set is also asserted to be the right one: a notices file exists at each of those locations whose surface has entries, and at none of the others ([the existence rule](../../specs/product/licensing.md#^licensing-notices-when)). For each one, asserts that it names no ignored package in any form (no entry, no heading, no status line) and that it follows [the reader rule](../../specs/product/licensing.md#^licensing-notices-audience) throughout: the header names the product and what the file is, and nowhere in the file — header or entry — does an internal identifier, a configuration key, or a term of this project's own machinery appear. Composing both properties over the real files is what makes them true of what users receive, rather than only of a generator fixture — *(covered by test: `test/node/licensing.test.ts` “every real artifact has notices exactly where entries exist, and every file is written for readers”)*. ^licensing-ac-ignored-silent - **Acceptance — a bundled theme's attribution ships with it.** Boundary: the real packed CLI tarball, the same artifact as the published-CLI acceptance. For Nord, the theme whose asset record exists today, asserts [the theme rule](../../specs/product/licensing.md#^licensing-theme-material) on what a user receives: `dist/themes/nord/theme.css` opens with a comment carrying Nord's address, its copyright line, its license name, and the notices filename; `dist/themes/nord/THIRD-PARTY-NOTICES.txt` carries the complete Nord license text; and the tarball's umbrella notices list the Nord color palette. The copy from that packaged folder into a user's data directory is forfeited here and carried by [the bundled-theme package-copy and lifecycle assertions](../arch/themes/bundled-installation.md#^bundled-t-package-copy), which prove the complete folder is copied byte-for-byte. The README's fixed-revision link is authored prose judged in review and orders no test. No mocks; the built tree is the fixture — *(covered by `test/node/licensing.test.ts` “CLI tarball ships the Nord theme with its attribution”)*. ^licensing-ac-theme-attribution - **Acceptance — manifest coverage.** Boundary: the manifest check run as a spawned process. Asserts it exits nonzero when any workspace manifest omits `"license": "MIT"`, and zero on the real tree. ^licensing-ac-manifests diff --git a/proofs/ui/app/artifact-frame/index.md b/proofs/ui/app/artifact-frame/index.md index 9f90d563..f8223b83 100644 --- a/proofs/ui/app/artifact-frame/index.md +++ b/proofs/ui/app/artifact-frame/index.md @@ -8,6 +8,8 @@ Proves [specs/ui/app/artifact-frame/index.md](../../../../specs/ui/app/artifact- View contracts use authored artifact/navigation state and recording application-service mocks to prove names, directional controls, the browser demo mode return button and which views show it, menu actions, and deletion confirmation. These contracts exercise `tv-menu` composition and retain action-routing evidence. Independently, a real Frameset/Vite rendering contract proves the authored delete-confirmation frame required by the extended [Testing](../../../../specs/ui/app/artifact-frame/index.md#Testing) guidance, including default and supplied title handling. Missing and unsupported document contracts retain their inherited metadata coverage. Foundation menu and dialog proofs own generic interaction, while product and bridge proofs retain browser, server, navigation, and document-delivery crossings. Menu placement follows the shared popover proof, with no artifact-specific direction assertion. +The [non-artifact frame composition](../../../../specs/ui/app/artifact-frame/index.md#artifact-frame-ui) is exercised by [setup's state contract](../../setup/index.md#^setup-t-states): held content replaces the document and the menu is absent, without artifact machinery. Ordinary artifact-frame assertions remain unchanged. The external-page placeholder's Mac-only explanation is authored [content](../../../../specs/ui/app/artifact-frame/error-page/content.yml), checked in review alongside the product's [Mac wording](../../../product/desktop-app.md#coverage-model); it orders no exact-copy test. Existing URL-target and delivery assertions continue to cover the placeholder's behavior. + ## Window edge regression The raster fixtures supply the client-owned sidebar width and integral trigger widths explicitly, so native font metrics do not move the sampled fractional edge. Classic-scrollbar coverage authors a scrollbar width in the fixture; it does not depend on the host's overlay-scrollbar preference. diff --git a/proofs/ui/app/copy-button/index.md b/proofs/ui/app/copy-button/index.md index 758520e8..43d24625 100644 --- a/proofs/ui/app/copy-button/index.md +++ b/proofs/ui/app/copy-button/index.md @@ -6,7 +6,7 @@ Proves [specs/ui/app/copy-button/index.md](../../../../specs/ui/app/copy-button/ ## Coverage model -The suite retains its existing clipboard, activation, timer, and announcement coverage. The markup contract covers caller-supplied `intent` alongside the copy lifecycle. +The suite retains its existing clipboard, activation, timer, and announcement coverage. The markup contract covers caller-supplied `intent` and size alongside the copy lifecycle. Setup supplies the standard size through [its state contract](../../setup/index.md#^setup-t-states); clipboard and confirmation remain proven once here for all callers. ## Test hooks @@ -19,7 +19,7 @@ The suite retains its existing clipboard, activation, timer, and announcement co The suite proves the button's authored states, clipboard crossing, confirmation lifecycle, and exact dwell. It does not measure the no-resize swap or container reflow, which are styling conformance judged in staging and review ([arch/testing-policy.md#^ui-styling-out](../../../../specs/arch/testing-policy.md#^ui-styling-out)). -- **Contract** (the production copy-button template helper as the consumer of caller label, prompt, intent, and copied state, exercised in a mocked DOM; those values are fixtures, direct copied-state posing is a mock that forfeits the transition to [#^cb-ac-confirm](#^cb-ac-confirm), and the mocked DOM forfeits clipboard behavior to [#^cb-ac-clipboard](#^cb-ac-clipboard); no styling or motion claim): one markup smoke table covers idle plain and copied primary-intent rows. Each renders one native small button with the prompt and optional intent reflected exactly, copy/check icons, the caller's idle label, and the fixed Copied confirmation; only the copied row carries the copied mark — *(covered by `packages/web/test/copy-button.test.ts`; “copyButtonTemplate (^cb-ac-markup-smoke)” covers idle plain and copied primary-intent rows)*. ^cb-ac-markup-smoke +- **Contract** (production Copy helper as consumer of caller label, prompt, intent, size and copied state, in a mocked DOM; direct state posing forfeits real transitions to [confirmation coverage](#^cb-ac-confirm), and the DOM mock forfeits clipboard to [clipboard coverage](#^cb-ac-clipboard)): under [the authored frame](../../../../specs/ui/app/copy-button/copy-button.frame), omitted size and `sm` render `size="sm"`, `default` omits the attribute, and `lg` renders `size="lg"`. Combine these with the idle/copied and plain/primary rows: each has one native button, exact prompt/intent, copy/check content, the caller's accessible name and adjacent confirmation region; only the copied row carries the copied mark. This asserts attribute composition, not measured size. *(covered by `packages/web/test/copy-button.test.ts` “copyButtonTemplate (^cb-ac-markup-smoke)”)* ^cb-ac-markup-smoke - **Seam** (copy-button activation → system clipboard, crossed in a real browser with the production clipboard handler and system clipboard; the primary row wraps `navigator.clipboard.writeText` only to record that the real method ran, without replacing its result; the fallback row removes `navigator.clipboard` — a declared mock of browser capability detection that forfeits only detection, while the real synchronous `execCommand("copy")` path and clipboard readback remain in the test; the standing motion override may run and forfeits only CSS motion, which this assertion does not claim): activating the button places its prompt on the system clipboard through the modern path when available and the fallback path when it is absent — *(covered by inherited tests: `packages/web/test/e2e/copy-button.test.ts` “^cb-copy: clicking the inner button copies the prompt to the system clipboard” and “fallback path (?no-clipboard): execCommand still lands the prompt on the system clipboard”, minimally modified to mount the target production surface and address its native-button root)*. ^cb-ac-clipboard - **Contract** (the production copy-button activation side of the clipboard seam in a mocked DOM; `navigator.clipboard.writeText` is replaced by a rejecting recorder and `document.execCommand` by a call recorder — both greenlit mocks that forfeit real clipboard behavior to [#^cb-ac-clipboard](#^cb-ac-clipboard)): when the available modern writer rejects, activation calls it exactly once with the prompt, does not invoke the legacy fallback after the gesture, exposes no rejection, and still enters the optimistic copied confirmation — *(covered by `packages/web/test/copy-button.test.ts` “absorbs one rejected modern write without invoking the legacy fallback”)*. ^cb-ac-clipboard-rejection - **Contract** (production copy-button activation → caller callback in a mocked DOM with an injected clipboard recorder and scheduler, which forfeit clipboard and timer behavior to [#^cb-ac-clipboard](#^cb-ac-clipboard) and [#^cb-ac-confirm](#^cb-ac-confirm)): each button activation invokes the optional `onActivate` callback exactly once after attempting the clipboard write, including repeated activation — *(covered by `packages/web/test/copy-button.test.ts` “invokes the caller activation callback once per copy attempt”)*. ^cb-ac-activate-callback diff --git a/proofs/ui/app/desktop-upgrade-gate/index.md b/proofs/ui/app/desktop-upgrade-gate/index.md index 5ae42651..2550c1da 100644 --- a/proofs/ui/app/desktop-upgrade-gate/index.md +++ b/proofs/ui/app/desktop-upgrade-gate/index.md @@ -6,7 +6,7 @@ Proves [specs/ui/app/desktop-upgrade-gate/index.md](../../../../specs/ui/app/des ## Coverage model -Coverage declarations are carried inside the migrated assertion blocks below. +Contracts cover gate content and native dialog mechanics, with state and update-bridge substitutes that forfeit real startup and IPC to the named architecture/product walks. The gate can show while connected and authenticated: it means Television cannot use the server until the desktop requirement is met. Under [Decisions](../../../../specs/ui/app/desktop-upgrade-gate/index.md#decisions), its bare-ground composition is covered by [the app proof](../index.md#^ap-ac-gate-uncovered), and its desktop drag strip by [the system-modal Electron seam](../system-modal/index.md#^sm-ac-drag-strip). No additional gate-version or drag matrix is ordered here. ## Assertions diff --git a/proofs/ui/app/dialog/index.md b/proofs/ui/app/dialog/index.md index 1bb75484..7f5c9ce6 100644 --- a/proofs/ui/app/dialog/index.md +++ b/proofs/ui/app/dialog/index.md @@ -6,10 +6,12 @@ Proves [specs/ui/app/dialog/index.md](../../../../specs/ui/app/dialog/index.md). ## Coverage model -The markup contract covers the composed overlay and native dialog with caller-supplied contents. A real-browser contract covers native modal input/focus containment, immediate presentation, ordinary dismissal, and closure of production floating controls. The browser fixture exercises the production floating elements. Surface proofs retain entry conditions, contents, and the upgrade-gate dismissal exception; this proof adds no new dialog presentation mode. +The markup contract covers the composed overlay and native dialog with caller-supplied contents. A real-browser contract covers native modal input/focus containment, immediate presentation, ordinary dismissal, and closure of production floating controls. The browser fixture exercises the production floating elements. Surface proofs retain entry conditions, contents, and the system-modal and upgrade-gate dismissal exceptions; this proof adds no new dialog presentation mode. Canonical dialog edge-paint consumption is composed from [foundation panel coverage](../../foundation/index.md#^foundation-ac-panel-edges). Native modal behavior remains with this proof. +The optional top-layer content keeps window controls inside the native modal boundary, outside the scrolling panel. The [system-modal drag seam](../system-modal/index.md#^sm-ac-drag-strip) proves that handoff through both the shared dialog and the gate composition; this proof's ordinary-dialog cases retain the empty-slot behavior. + ## Assertions ### Test assertions @@ -19,5 +21,4 @@ The suite samples the panel/overlay markup and proves the blocking behavior shar - **Contract** (the production dialog rendering as the consumer side of the caller-content/dialog-markup handoff, exercised in a mocked DOM; plain and alert-shaped supplied contents are authored fixtures; the mocked DOM forfeits browser focus, input blocking, and popover interaction to the functional contract below; no styling or motion claim): one table-driven markup smoke check covers plain and alert-shaped contents. Each rendering contains one overlay holding one open native dialog and the supplied content exactly once, without rewriting it — *(covered by `packages/web/test/dialog.test.ts`; “dialogTemplate (^dg-ac-markup-smoke)” covers plain and alert contents within the required overlay)*. ^dg-ac-markup-smoke - **Contract** (the production dialog behavior mounted in a real browser with authored background controls, already-open production floating controls, with fixture cases for non-manual and manual `tv-popover`, `tv-menu`, and `tv-select`, and alert contents carrying a non-closing inside control; an implementation-mount hook presents and withdraws the dialog without replacing its focus, input-blocking, dismissal, or popover-closing mechanisms; direct presentation state is a mock that forfeits environment-driven entry and exit to the composing surface rows; animation and transition overrides are absent; transition, animation, Web Animations API, and presentation-state recorders start before entry and each withdrawal and remain through two animation frames; entry also observes removal of the panel `open` state and the Cancel `focus` event; blocked-focus/input and inside-control non-dismissal use focus, click, and cancel recorders): presenting the alert closes the open floating controls, focuses Cancel, and reaches its presented state immediately with no presentation motion; Tab, programmatic focus, and real pointer input cannot reach the screen behind it; pressing the non-closing control inside the panel records no dismissal and leaves the dialog presented; pressing the backdrop or Escape each records one dismissal and leaves the dialog withdrawn immediately with no presentation motion — *(covered by `packages/web/test/e2e/dialog.test.ts`; “native dialog modal behavior and dismissal (^dg-ac-modal-behaviour)” and the parameterized “dialog entry closes” cases cover manual and non-manual popovers, menus, and selects)*. ^dg-ac-modal-behaviour -Coverage relationship: composing-surface markup smoke checks own each dialog's contents and semantic state, including the artifact and channel alerts' title, consequence, Cancel-first order, and destructive action. Their functional and product assertions own what Cancel, Connect, Delete, and environment changes do. This row owns only the common panel/overlay rendering, focus containment, background inertness, backdrop/Escape dismissal, inside-panel hit discrimination, and open-popover closure. The desktop upgrade gate is the explicit blocking exception: its product and UI owners require that surface to ignore ordinary dialog dismissal and remain presented ([product/update-notifications.md#^gate-behavior](../../../../specs/product/update-notifications.md#^gate-behavior), [ui/app/desktop-upgrade-gate/index.md](../../../../specs/ui/app/desktop-upgrade-gate/index.md)). [arch/ui/foundation.md#^ui-t-foundation-copy](../../../arch/ui/foundation.md#^ui-t-foundation-copy) owns delivery of the authoritative dialog stylesheet. - +Coverage relationship: composing-surface markup smoke checks own each dialog's contents and semantic state, including the artifact and channel alerts' title, consequence, Cancel-first order, and destructive action. Their functional and product assertions own what Cancel, Connect, Delete, and environment changes do. This row owns only the common panel/overlay rendering, focus containment, background inertness, backdrop/Escape dismissal, inside-panel hit discrimination, and open-popover closure. System modals and the desktop upgrade gate are explicit blocking exceptions: [system-modal blocking coverage](../system-modal/index.md#^sm-ac-blocking) and the gate owners require them to ignore ordinary dialog dismissal and remain presented ([product/update-notifications.md#^gate-behavior](../../../../specs/product/update-notifications.md#^gate-behavior), [ui/app/desktop-upgrade-gate/index.md](../../../../specs/ui/app/desktop-upgrade-gate/index.md)). [arch/ui/foundation.md#^ui-t-foundation-copy](../../../arch/ui/foundation.md#^ui-t-foundation-copy) owns delivery of the authoritative dialog stylesheet. diff --git a/proofs/ui/app/index.md b/proofs/ui/app/index.md index 697f7cdd..f2509966 100644 --- a/proofs/ui/app/index.md +++ b/proofs/ui/app/index.md @@ -6,7 +6,7 @@ Proves [specs/ui/app/index.md](../../../specs/ui/app/index.md). ## Coverage model -Coverage declarations are carried inside the migrated assertion blocks below. +The connection states describe Television being unable to use its server, including a connected app halted at the gate. Their contracts cover selection and failed-attempt counting with substituted connection sources and clocks. They forfeit real transport, authentication and recovery to the browser acceptances below. Those use a running server, real token rejection at boot and after an established session, and an outage long enough to reach the third failed reconnect. They honor the spec's real-browser and retained-node directives. The first-connection error uses the same unreachable-result and retry handoff as outage recovery; the state and markup contracts cover its no-prior-session selection and bare background without another browser walk. The [system-modal proof](./system-modal/index.md) owns contents and non-dismissal; [desktop product coverage](../../product/desktop-app.md) owns the separate packaged local-page lifecycle. The shell markup contract samples every meaningful application state and requires the permanent `#foreground-overlay` in every row. A second contract composes the production shell view with the theme-resource owner under a mocked DOM to cover the optional frames' exact body order, IDs, sandbox, inertness, focus exclusion, and accessibility attributes across independently enabled and jointly enabled states. The theme registry and iframe documents are posed fixtures at that contract; this forfeits real registry lookup and iframe behavior to [theme delivery](../../arch/themes/delivery.md#^theme-delivery-t-frame-browser) and the [product browser spine](../../product/themes-and-appearance.md#^theme-ac-sandboxed-frames). @@ -30,6 +30,9 @@ The native macOS traffic-light request is configuration rather than stylesheet r `TelevisionAppOptions.onRenderComplete` is the optional production callback for observing when the connected app view has rendered a selected `ApplicationState.kind`. The production entry point supplies it to notify callbacks registered through the [`window.__telepath` test seam](../../arch/channel-state/index.md#Test hooks); when no test has registered a callback, the notification has no effect on application state or rendering. + +Connection assertions may observe the production owner and render-complete signal through [the existing `window.__telepath` seam](../../arch/channel-state/index.md#^cs-telepath-test-seam). Server lifecycle control and real event-socket termination create outages; tests do not set application state on acceptance paths. Stored token values are browser-storage fixtures. The gate uses the [version hooks](../../arch/updates/desktop-upgrade-gate.md#test-hooks); they replace advertised versions, not gate decisions. + The browser's native `Animation` named `sidebar-collapse-boundary` is observed and its timeline paused/advanced for motion conformance. Production normally advances that timeline itself. Tests keep the production pose calculation, animation-frame callbacks, DOM application, and browser layout intact; this introduces no production-only testing setter. ## Assertions @@ -40,16 +43,18 @@ The suite samples the shell's meaningful composition branches, proves its state Shared contract declaration for the mocked assertions below ([arch/testing-policy.md#^declaration-schema](../../../specs/arch/testing-policy.md#^declaration-schema)): they prove the production app-shell view side of the application-state/view handoff defined by [arch/ui/index.md](../../../specs/arch/ui/index.md). Channels, pages, notices, connection facts, and selected modal outcomes are fixtures. An implementation-mount hook supplies fake server and application-state sources and exposes the production view's render-complete signal; those sources and direct state posing are mocks, greenlit here. They forfeit real authentication, gate, disconnect, channel, tab-page, and update-state delivery to the real paths named below and to [channels.md](../../../specs/product/channels.md), [product/tab-pages.md](../../../specs/product/tab-pages.md), [arch/channel-state/index.md#^cs-connection-carve-out](../../../specs/arch/channel-state/index.md#^cs-connection-carve-out), and [arch/updates/desktop-upgrade-gate.md](../../../specs/arch/updates/desktop-upgrade-gate.md). Child surfaces retain their own markup and behavior assertions; this row checks only their shell-level composition. -- **Contract** (the production app-shell view exercised in a mocked DOM under the shared declaration; resolved modal state and authored child inputs are posed directly, which forfeits production state selection to [#^ap-ac-one-state](#^ap-ac-one-state); the mocked DOM forfeits browser layout and scrolling to [#^ap-ac-root-contained](#^ap-ac-root-contained); no styling or motion claim): one table-driven markup smoke check for this view covers a connected populated channel, no channels, an empty focused channel, connecting, disconnected, unauthorized, error, and needs-upgrade. Every row renders exactly one permanent inert and accessibility-hidden `#foreground-overlay`. Connected rows render one `#app[tabindex="-1"]`, one channel-sidebar region, and one main region, with one navbar before one stage and no system modal. The no-channel and empty-focused-channel rows render each child's corresponding posed state in that same channel-sidebar/top-bar/stage arrangement; their internal state contents remain with the child rows. Disconnected renders the same shell nodes plus one disconnected modal. Connecting, unauthorized, error, and needs-upgrade each render exactly one corresponding modal and no shell — *(covered by `packages/web/test/app-root.test.ts` “commits each shell state before reporting render complete”)*. ^ap-ac-markup-smoke +- **Contract** (the production app-shell view exercised in a mocked DOM under the shared declaration; resolved modal state and authored child inputs are posed directly, which forfeits production state selection to [#^ap-ac-one-state](#^ap-ac-one-state); the mocked DOM forfeits browser layout and scrolling to [#^ap-ac-root-contained](#^ap-ac-root-contained); no styling or motion claim): one table-driven markup smoke check for this view covers a connected populated channel, no channels, an empty focused channel, connecting, disconnected, unauthorized, error, and needs-upgrade. Every row renders exactly one permanent inert and accessibility-hidden `#foreground-overlay`. Connected rows render one `#app[tabindex="-1"]`, one channel-sidebar region, and one main region, with one navbar before one stage and no system modal. The no-channel and empty-focused-channel rows render each child's corresponding posed state in that same channel-sidebar/top-bar/stage arrangement; their internal state contents remain with the child rows. Under [connection states](../../../specs/ui/app/index.md#^ap-connection-states), Disconnected and error after a prior session retain the shell with one corresponding modal. Error before a session, Connecting, unauthorized and needs-upgrade each render one corresponding modal and no shell. Desktop rows with no shell request the [system-modal drag strip](../../../specs/ui/app/system-modal/index.md#interaction). Rows with a retained shell, including a collapsed sidebar, and browser rows request no strip. The unauthorized desktop-served row supplies the modal's `desktop` context, selecting its menu-based recovery guidance rather than browser address-bar guidance — *(covered by `packages/web/test/app-root.test.ts` “commits each shell state before reporting render complete” and “requests a desktop drag strip only over a bare background”)*. ^ap-ac-markup-smoke - **Contract** (the production app-shell view exercised in a mocked DOM under the shared declaration; authored open and collapsed states with and without a focused channel are fixtures; direct state posing and the mocked DOM forfeit real persistence, layout, and motion to [#^ap-ac-sidebar-collapse](#^ap-ac-sidebar-collapse); no styling claim): under [the collapsed-shell composition promise](../../../specs/ui/app/index.md#^ap-sidebar-collapse), open rows compose one channel-sidebar region beside the main region, while collapsed rows omit only the channel-sidebar region and retain one main region containing the navbar before the stage. The app shell passes the collapsed and focused-channel facts to the navbar; [navbar markup coverage](./top-bar/index.md#^top-ac-lead-markup) owns the resulting lead contents — *(covered by test: `packages/web/test/app-root.test.ts` “collapsed shell composition (^ap-ac-collapse-composition)” / “omits only the sidebar and passes collapsed focus facts to the top bar”)*. ^ap-ac-collapse-composition - **Acceptance** (production app in a real browser against a running server, with real CSS motion and live artifact content; the only substituted input is the native animation clock, forfeiting real-time scheduling and perceived smoothness): under [the animation conformance directive](../../../specs/ui/app/index.md#testing), measure resting geometry, then compare actual boundary, toggle position and clipping, titlebar and switcher opacity, and lead reservation with the reference at expanded, collapsed, docking, mid-wipe, and one earlier-slide pose. Sample collapse and expand at a few known elapsed times through the reference timing function, plus an interrupted reversal; the existing reduced-motion row establishes immediate settlement. Observe actual layout and computed styles, not merely production helper return values or a test-written pose. The selected page remains centred and follows its sizing rule; the tab strip follows the navbar layout while its lead reservation changes. A long channel name and the minimum sidebar width exercise a collapsed lead wider than the sidebar: while the switcher is visible, actual control edges remain at least 8px clear to the left of the tab strip and 48px to its right in both motion directions, including during its reveal. Retain the existing resized-width and state-continuity walk, asserting iframe and document identity as well as mutated document content and selection. The comparison uses the stated pixel/opacity tolerances and browser frame signals, never wall-clock sleeps — *(covered by `packages/web/test/e2e/sidebar-collapse.test.ts`, “matches the executable sidebar motion reference (^ap-ac-sidebar-motion-reference)” and “keeps clear drag bands while a wide lead appears beside a narrow sidebar”)*. ^ap-ac-sidebar-motion-reference - **Contract** (the desktop main-process side of the Television-to-Electron window-constructor seam; the production main-process module runs against the suite's recording replacement of the Electron module — `app`, `BrowserWindow`, `Menu`, `ipcMain`, `nativeTheme`, and `shell` — greenlit here; the replacement forfeits native presentation to [real-Electron smoke](../../arch/desktop/e2e-harness.md#^desktop-t-e2e-smoke) and exact visual placement to staging and review, while the menu, IPC, native-theme, and shell mechanisms it also replaces are outside this assertion's claim and keep their own assertions; no test hooks): application startup asks Electron for `titleBarStyle: "hidden"` and `trafficLightPosition: { x: 15, y: 15 }`, exactly matching the authored [horizontal](../../../specs/ui/app/measures.yml#traffic_lights.x_px) and [vertical](../../../specs/ui/app/measures.yml#traffic_lights.y_px) traffic-light measures — *(covered by test: `packages/desktop/test/main.test.ts` “Electron main process” / “creates the hidden titlebar at the authored traffic-light position”)*. ^ap-t-traffic-light-position - **Contract** (production app-shell view plus its single theme-resource owner in a mocked DOM; independently enabled and jointly enabled registered frame states and architecture-owned `srcdoc` values are fixtures, while the registry boundary is mocked and forfeits real lookup and iframe execution to [theme delivery](../../arch/themes/delivery.md#^theme-delivery-t-frame-browser)): when present, `#theme-iframe-background` precedes `#app`, permanent `#foreground-overlay` follows `#app`, and `#theme-iframe-overlay` follows the foreground div. Each frame has exactly `sandbox="allow-scripts"`, `inert`, `tabindex="-1"`, `aria-hidden="true"`, and the supplied `srcdoc`; the foreground div has `inert` and `aria-hidden="true"`. Omitted declarations omit only their matching frame without changing the order or attributes of the remaining layers — *(covered by `packages/web/test/app-root.test.ts` “composes enabled frames around the production app root and permanent foreground” and `packages/web/test/theme.test.ts` “creates independent and joint frames with exact documents, attributes, and body order”)*. ^ap-ac-theme-layers -- **Contract** (production application-state selection and app-shell view on the consumer side of the server-state/view handoff; fake sources supply simultaneous production-shaped auth-required, gate-halted, connection, prior-session, and first-connect-error facts, while the production selector and view remain real; the mocks forfeit real delivery to [#^ap-ac-signin-first](#^ap-ac-signin-first), [#^ap-ac-gate-uncovered](#^ap-ac-gate-uncovered), and [#^ap-ac-drop-holds-shell](#^ap-ac-drop-holds-shell); synchronization waits for the source notification and the production view's render-complete signal, never an immediate DOM snapshot): a table of overlapping conditions always resolves to one state — authorization first, then a gate without a connection interstitial, then disconnected only when a session has existed, otherwise connecting or first-connect error — and never renders two system-modal states together — *(covered by `packages/web/test/app-root.test.ts` “application-state selection (^ap-ac-one-state) > resolves every shell input and overlapping connection fact to one presentation”; each posed source notification is followed through the production render-complete event before inspection)*. ^ap-ac-one-state -- **Seam** (authentication rejection → unauthorized shell state, crossed through the production app in a real browser against a really-running token-requiring server; the mid-session HTTP-rejection row uses the standing request-header interception as a declared mock of the token source and forfeits only that source, while the server rejection, application state, and view path remain real; the boot-time websocket-rejection row uses an authored wrong-token local-storage fixture and no mock; the standing motion override may run because no motion is claimed; a `MutationObserver` starts before rejection and records the shell commit through the unauthorized view's readiness signal, then through any Web Animations API completion or two animation frames with no running animation, so a competing modal or shell that appears during the handoff cannot pass): both a mid-session HTTP rejection and a boot-time websocket rejection clear the stored token and settle on sign-in with no shell or other system-modal state — *(covered by `packages/web/test/e2e/auth.01.test.ts` “stored-token HTTP 401 clears localStorage and returns to the auth modal” and “stored-token WebSocket 4401 clears localStorage and returns to the auth modal”)*. ^ap-ac-signin-first -- **Seam** (`server-status` gate decision → halted shell presentation, crossed over a real `/events` websocket in a real browser loaded with the Electron-mode inputs; the required version and old shell version are authored fixtures under [arch/updates/desktop-upgrade-gate.md](../../../specs/arch/updates/desktop-upgrade-gate.md)'s declared hooks; the standing motion override may run because no motion is claimed; request instrumentation and a `MutationObserver` begin before the first status and remain active until retraction, while synchronization uses the real status, socket reconnect, and page-reload signals rather than a wall-clock observation): the gate surface appears with no shell and no connecting interstitial at any point; retracting the requirement reloads into the normal shell — *(covered by `packages/web/test/e2e/gate-boot-barrier.spec.ts` “a first message requiring a newer desktop halts boot at the gate; retraction reloads into a normal boot”)*. ^ap-ac-gate-uncovered -- **Acceptance** (outer boundary: a real browser against a really-running server; a live authored HTML artifact with mutable document state is the fixture; the server-side event-socket termination hook severs the real connection without replacing reconnect behavior; the standing motion override is greenlit and forfeits CSS motion, which this assertion does not claim; event-socket connected/gone signals and modal insertion/removal are the settle signals, and a `MutationObserver` records any shell, top-bar, channel-sidebar, stage, frame, or frame-document removal from before termination until reconnect): after a session drops, exactly the disconnected modal appears over the mounted shell; the shell and every captured child and frame node remain the same nodes with the artifact's document state intact throughout; reconnect removes the modal and resumes that same shell — *(covered by `packages/web/test/e2e/reload-after-reconnect.02.test.ts` “a disconnected modal preserves the live shell and artifact document through automatic reconnect”)*. ^ap-ac-drop-holds-shell +- **Contract — state priority.** Exercise production state selection and shell composition with authored simultaneous auth, retained-gate, connection, prior-session and unreachable-failure facts. Fake sources and a mocked DOM forfeit live delivery to the acceptance and gate seam below. Under [connection states](../../../specs/ui/app/index.md#^ap-connection-states), a displayed gate stays the only presentation until reload, even after token rejection. Otherwise a definite auth or gate answer takes effect immediately, including from Disconnected or error; before a session, pending and unreachable results select Connecting and error; after a session, outage selects Disconnected then error at the threshold. No pair of system modals renders together. Wait for production render completion after each source change. *(covered by `packages/web/test/app-root.test.ts` “resolves every shell input and overlapping connection fact to one presentation” and `packages/web/test/application-service.test.ts` “projects connection facts without placing them in display or channel state”)* ^ap-ac-one-state +- **Acceptance — rejected token and current-link recovery.** Boundary: the production app in a real browser against a running token-requiring Television server. A wrong stored token is the boot fixture; for the established-session row, connect with a valid link, then restart the real server with a new token in its isolated home's token file. The token file is input data, not a mocked authorization response. No mechanism mocks beyond the standing unrelated CSS-motion override. Under [connection states](../../../specs/ui/app/index.md#^ap-connection-states), rejection at boot and after a session clears stored credentials, stops attempts and shows Access token required without a shell or competing modal. Opening a current connect link recovers the live shell. Observe real rejection, request traffic and committed presentations; the contract below proves the retry-stop scheduling boundary. *(covered by `packages/web/test/e2e/auth.01.test.ts` “rejected tokens at boot and after a session clear credentials and recover with a current link”)* ^ap-ac-signin-first +- **Seam — retained gate presentation.** Cross real server-status delivery into the production view in a browser loaded with Electron-mode/version inputs, against a running Television server. Use the version hooks above; no connection or state mocks. A continuously bound stable front proxy keeps the browser origin while each real backend binds port 0. It forwards real HTTP and WebSocket traffic and closes WebSocket peers on backend failure, without substituting server-status messages. Begin with an established session, then restart the server with a higher required desktop version. Under [connection states](../../../specs/ui/app/index.md#^ap-connection-states), its reconnect status replaces the outage presentation with the lone gate and no shell. Record presentation changes from before the reconnect status through render completion, so a transient covering modal cannot pass. The [boot-barrier seam](../../arch/updates/desktop-upgrade-gate.md#^t-gate-boot-barrier) supplies first-connect and retraction/reload coverage. [State priority](#^ap-ac-one-state) proves a later auth result cannot replace the gate; [auth acceptance](#^ap-ac-signin-first) crosses real rejection. *(covered by `packages/web/test/e2e/gate-boot-barrier.spec.ts` “a reconnect requirement replaces the outage with the lone retained gate”)* ^ap-ac-gate-uncovered +- **Acceptance — outage escalation and recovery.** Boundary: a real browser against a running server, with an API-created live HTML artifact whose document has mutable input and scroll state. Stop the real server long enough for three production reconnect attempts to fail, then restart behind the same continuously bound development proxy, with each backend binding port 0; the proxy forwards real WebSockets and closes their peers on backend failure, without a substitute WebSocket result; no transport/state/timer mocks, and only the standing unrelated CSS-motion override. Under [reconnect escalation](../../../specs/ui/app/index.md#^ap-reconnect-escalation) and [shell continuity](../../../specs/ui/app/index.md#^ap-connection-states), the drop and first two failed reconnects show Disconnected, the third shows Can't connect with server, and successful reconnect removes it. Countdown/in-flight wording continues in both retry states. Throughout, the shell, sidebar, top bar, stage, artifact hosts, frames and artifact document remain the same nodes and retain document state. Observe attempt completion and production render signals, plus removal/document-lifecycle recorders installed before the outage. *(covered by `packages/web/test/e2e/reload-after-reconnect.02.test.ts` “outage escalation preserves the live shell and artifact document through automatic reconnect”)* ^ap-ac-drop-holds-shell +- **Contract — completed reconnect attempts.** Exercise the production connection owner on the consumer side of socket/client outcomes with recording peers and a controlled scheduler; these mocks forfeit real transport and timing to [outage acceptance](#^ap-ac-drop-holds-shell) and [auth acceptance](#^ap-ac-signin-first). Under [escalation](../../../specs/ui/app/index.md#^ap-reconnect-escalation), the initial drop is not a failed reconnect; one failed attempt contributes one count even if its peer emits both error and close; the first two completed unreachable attempts retain Disconnected and the third selects error. Success resets the count for a later outage. A definite auth/gate result takes its own immediate path and is not an unreachable failure; auth rejection leaves no scheduled retry. *(covered by `packages/web/test/server-connection.test.ts` “counts completed unreachable reconnects once and resets on success” and the [state selection contract](#^ap-ac-one-state))* ^ap-ac-retry-count + - **Contract** (a real browser mounts the production composed shell with an overflowing tab strip and stage from fixtures supplied through the shared service mock; each viewport and content state is fixed before mount; the standing motion override may run and no motion is claimed; readiness waits for the view's render-complete signal, a `ResizeObserver` delivery, and two consecutive animation-frame samples with stable document and region boxes; a root `scroll` listener is installed before programmatic horizontal input and retained through the post-input animation-frame pair as an absence instrument): at desktop window widths the document's horizontal scroll range is zero even while child bands overflow, and an attempted document-level horizontal scroll leaves `scrollX` at zero and emits no root scroll — *(covered by `packages/web/test/e2e/app-shell.test.ts` “application root containment (^ap-ac-root-contained) > contains overflowing child bands at desktop window widths”)*. ^ap-ac-root-contained - **Acceptance** (outer boundary: a real browser against a really-running server; a live authored HTML artifact created through the production API is the stage fixture, and authored absent, committed, and out-of-bounds `tv-channel-sidebar-width` records are browser-storage fixtures that a browser init script writes before the applicable boot without replacing any production mechanism; real pointer input starts and moves every drag, releases the commit and bounds rows, and supplies the double-click, while real keyboard input supplies Escape; the pointer-cancellation row invokes the production listener with a synthetic `pointercancel` and therefore forfeits only native browser generation of that cancellation, and the capture-loss row releases real programmatic pointer capture; the standing CSS-motion override is the second mock, greenlit by [the motion rule](../../../specs/arch/testing-policy.md#^motion-rule), and forfeits CSS motion (transitions and animations), which this assertion does not claim; no other mocks and no production test hooks): under [the sidebar resize, bounds, and client-property contract](../../../specs/ui/app/index.md#^ap-sidebar-resize) and [its commit and cancellation contract](../../../specs/ui/app/index.md#^ap-resize-commit), an absent record boots at the default with `--sidebar-width` on the application root, the sidebar box, and the remaining main-region box in agreement. A held drag moves those three observations one-for-one with pointer travel from the grab point until a bound, without changing storage; it continues while the pointer crosses the live artifact document, and release stores the committed whole-pixel width. Reload restores that width. Real pointer travel beyond each bound clamps the live and committed width to that bound. Escape, pointer cancellation, and unexpected capture loss each restore the width at gesture start and leave storage unchanged, while capture loss after release leaves the committed width intact. Double-clicking the band clears the committed record, restores the default, and survives reload. On separate boots, stored integers below and above the bounds apply at the corresponding bound — *(covered by `packages/web/test/e2e/sidebar-resize.test.ts` “drags, clamps, restores, persists, and clears the production sidebar width (^ap-ac-sidebar-resize)”)*. ^ap-ac-sidebar-resize - **Acceptance** (outer boundary: a real browser against a really-running server; a focused channel with a live authored HTML artifact is created through the production API, and an authored committed sidebar-width record is a browser-storage fixture consumed by production; real pointer input activates the moving control; all CSS transitions and animations on the path run without the shared override; normal-motion synchronization uses transition and animation lifecycle events, Web Animations API timing, `ResizeObserver`, and animation frames, never wall-clock sleeps; reduced-motion coverage uses the real `prefers-reduced-motion: reduce` browser preference rather than a motion-disabling stylesheet; no mocks or production test hooks): under [the collapsed-shell promise](../../../specs/ui/app/index.md#^ap-sidebar-collapse), uninterrupted collapse removes the channel-sidebar region, lets the main region span the application, leaves the navbar with the same control node now acting as Expand and with the channel switcher, and preserves the committed width. Expanding in that document removes the remembered collapsed state and restores the channel-sidebar region at the committed width, with that control node back in the titlebar and the main region taking the remainder. Collapsing once more and reloading lands directly in the collapsed composition; expanding in the reloaded document clears the remembered state and restores the width, and another reload lands open at that width. Under [the boundary-driven transition promise](../../../specs/ui/app/index.md#^ap-collapse-function), table-driven normal-motion rows for collapse interrupted by expand and expand interrupted by collapse start the first reversed frame at the current boundary without a jump, proceed monotonically toward the new target, and report a remaining duration scaled from the authored duration by the remaining boundary distance rather than a full restart; each row settles in the matching state above. The unchanged control node also covers the single-control mechanic from [the choreography promise](../../../specs/ui/app/index.md#^ap-collapse-choreography). Under reduced motion, collapse and expand reach the same settlements on the state-changing render without a running shell animation — *(covered by `packages/web/test/e2e/sidebar-collapse.test.ts` “collapses, expands, persists, interrupts, and honors reduced motion (^ap-ac-sidebar-collapse)”)*. ^ap-ac-sidebar-collapse @@ -57,6 +62,6 @@ Shared contract declaration for the mocked assertions below ([arch/testing-polic - **Contract** (the production app stylesheet side of the app-style/document handoff, isolated by loading the built production app stylesheet in a fixed document fixture in a real browser; an `#app` root containing ordinary interface text and a separate readable region whose local authored CSS sets `user-select: text` are fixtures; application views and services are absent because selection is the stylesheet boundary's subject; the standing CSS-transition override is the only mock and forfeits CSS transition motion, which this assertion does not claim; there are no test hooks; both regions fit fully within the viewport and each real pointer path stays inside its region, so none can scroll and no animated aftermath or settle signal is involved): dragging or double-clicking ordinary interface text inside `#app` creates no selection, while dragging across the readable region selects its expected text — *(covered by `packages/web/test/e2e/app-shell.test.ts` “application selection boundary (^ap-ac-selection) > ordinary interface text is inert while a readable region remains selectable”)*. ^ap-ac-selection - **Seam** (the authoritative complete foundation → its app-delivered production crossing, crossed through the real repository filesystem; no mocks or test hooks): `specs/ui/foundation/index.css` imports the authoritative foundation color sheet, `packages/web/src/foundation/index.css` imports its production copy, neither foundation entry declares `color-scheme`, and among the sheets each entry imports only its color sheet declares Television's native-scheme defaults — *(covered by test: `packages/web/test/foundation-copy.test.ts` “foundation entries leave color-scheme defaults to their color sheets”)*. ^ap-ac-foundation-color-scheme -Coverage relationship: [ui/app/sidebar/index.md](../../../specs/ui/app/sidebar/index.md), [ui/app/top-bar/index.md](../../../specs/ui/app/top-bar/index.md), and [ui/app/stage/index.md](../../../specs/ui/app/stage/index.md) own their child surfaces; [#^ap-ac-markup-smoke](#^ap-ac-markup-smoke) proves only the presence and arrangement of their connected, no-channel, empty-channel, and interrupted states. [ui/app/system-modal/index.md](../../../specs/ui/app/system-modal/index.md) owns each modal's contents and token-form behavior. The standing real browser token flows keep the authentication seam honest; [arch/updates/desktop-upgrade-gate.md#^t-gate-boot-barrier](../../arch/updates/desktop-upgrade-gate.md#^t-gate-boot-barrier) owns the boot barrier's no-functional-session proof; and [arch/channel-state/index.md#^cs-ac-reconnect](../../arch/channel-state/index.md#^cs-ac-reconnect) owns convergence on current server state. This row adds only the shell outcomes those paths do not currently prove. [#^ap-ac-selection](#^ap-ac-selection) owns the functional result of the app's selection stylesheet; the foundation reset is deliberately selection-neutral ([ui/foundation/index.md](../../../specs/ui/foundation/index.md)) and orders no duplicate selection assertion. [#^ap-ac-foundation-color-scheme](#^ap-ac-foundation-color-scheme) owns the app-delivered foundation's sole-source convention; [arch/ui/foundation.md#^ui-t-foundation-copy](../../arch/ui/foundation.md#^ui-t-foundation-copy) owns the imported sheets' production crossing, and [arch/themes/delivery.md#^theme-delivery-t-theme-color-scheme](../../arch/themes/delivery.md#^theme-delivery-t-theme-color-scheme) owns the resulting browser cascade. [arch/cli/index.md#^cli-build-web-copy](../../arch/cli/index.md#^cli-build-web-copy) carries the completed production app build into the packaged CLI. +Coverage relationship: [ui/app/sidebar/index.md](../../../specs/ui/app/sidebar/index.md), [ui/app/top-bar/index.md](../../../specs/ui/app/top-bar/index.md), and [ui/app/stage/index.md](../../../specs/ui/app/stage/index.md) own their child surfaces; [#^ap-ac-markup-smoke](#^ap-ac-markup-smoke) proves only the presence and arrangement of their connected, no-channel, empty-channel, and interrupted states. [ui/app/system-modal/index.md](../../../specs/ui/app/system-modal/index.md) owns each modal's contents, non-dismissal and local disconnect action. The rejected-token acceptance above crosses the real authentication path; [arch/updates/desktop-upgrade-gate.md#^t-gate-boot-barrier](../../arch/updates/desktop-upgrade-gate.md#^t-gate-boot-barrier) owns the boot barrier's no-functional-session proof; and [arch/channel-state/index.md#^cs-ac-reconnect](../../arch/channel-state/index.md#^cs-ac-reconnect) owns convergence on current server state. This row adds only the shell outcomes those paths do not currently prove. [#^ap-ac-selection](#^ap-ac-selection) owns the functional result of the app's selection stylesheet; the foundation reset is deliberately selection-neutral ([ui/foundation/index.md](../../../specs/ui/foundation/index.md)) and orders no duplicate selection assertion. [#^ap-ac-foundation-color-scheme](#^ap-ac-foundation-color-scheme) owns the app-delivered foundation's sole-source convention; [arch/ui/foundation.md#^ui-t-foundation-copy](../../arch/ui/foundation.md#^ui-t-foundation-copy) owns the imported sheets' production crossing, and [arch/themes/delivery.md#^theme-delivery-t-theme-color-scheme](../../arch/themes/delivery.md#^theme-delivery-t-theme-color-scheme) owns the resulting browser cascade. [arch/cli/index.md#^cli-build-web-copy](../../arch/cli/index.md#^cli-build-web-copy) carries the completed production app build into the packaged CLI. The travelling sidebar-toggle paints use the same explicit `sm` icon size as the resting controls. Their visual size is checked in the animation frame; existing pose acceptance continues to cover the control trajectory. diff --git a/proofs/ui/app/sidebar/index.md b/proofs/ui/app/sidebar/index.md index bf0c12d2..802e36f2 100644 --- a/proofs/ui/app/sidebar/index.md +++ b/proofs/ui/app/sidebar/index.md @@ -20,7 +20,7 @@ Shared contract declaration for view behavior ([arch/testing-policy.md#^declarat - **Contract** (the production channel-sidebar view, as the consumer side of the handoff from application and transient view state to rendered markup, exercised in a mocked DOM; authored channel lists, names, pins, and selection are fixtures; the fake application service, mocked layout and pointer-capture methods, and synthetic pointer input are mocks, greenlit here, and forfeit real service/transport and browser interaction to the product and real-browser assertions below; no styling measurement and no motion claim): one grouped markup smoke check for this view covers its conditional branches — no channels, each group alone, and both groups; only nonempty groups render, the titlebar carries the `electron-draggable` marker, the create affordance remains, every channel renders once, a long name remains complete in the row text, and, when channels exist, one `role="option"` channel carries `aria-selected="true"` across either group inside the Channels listbox. Every row has a separately paired `tv-menu` with the appropriate pinned or unpinned actions; rename state substitutes the labeled field and commit action; placeholders occupy authored slots at the beginning, middle, and end; and a carried row bound for unpinning carries the Unpin action — `packages/web/test/channel-sidebar.test.ts` (“channel sidebar base markup (^sb-ac-markup-smoke)”). ^sb-ac-markup-smoke - **Contract** (the production channel-sidebar view in the same mocked-DOM fixture and service-mock boundary as [#^sb-ac-markup-smoke](#^sb-ac-markup-smoke); no styling, window movement, or motion claim): under [the titlebar-controls promise](../../../../specs/ui/app/sidebar/index.md#^sb-titlebar-controls), the titlebar contains, in order, a native ghost icon button labelled and titled “New channel”, a vertical separator, and a native ghost icon button labelled and titled “Collapse sidebar” carrying the `sidebar` icon. This row proves only that markup. Native drag exclusion is composed from the `electron-draggable` marker the titlebar carries ([#^sb-ac-markup-smoke](#^sb-ac-markup-smoke)), [the foundation selector contract](../../../arch/ui/foundation.md#^ui-t-electron-drag-region), and [the titlebar Electron seam](#^sb-ac-titlebar-drag), which together carry the drag boundary required by [the titlebar promise](../../../../specs/ui/app/sidebar/index.md#^sb-titlebar) — *(covered by test: `packages/web/test/channel-sidebar.test.ts` “channel sidebar titlebar controls (^sb-ac-titlebar-controls)” / “renders create, separator, and collapse controls in order”)*. ^sb-ac-titlebar-controls -- **Seam** (channel-sidebar titlebar hit region → native Electron `BrowserWindow` movement, crossed in the real Electron app against a really-running server; a focused channel and enough path artifacts to overflow the neighbouring tab strip are fixtures created through production APIs; the real strip is scrolled rightward and its positive scroll offset and arrival at the right scroll limit are observed before the drag; `launchDesktop({ connectTo })` seeds an isolated saved-connection record, a setup mock that forfeits manual connection entry and persistence to [desktop connection flow's real page-entry seam](../../../arch/desktop/connect-flow.md#^desktop-t-page-entry), while desktop startup and server-page loading remain real; this case runs only on Linux/X11 and supplies no macOS native-input or window-manager evidence; `x11-window-drag-driver.py` stands in for the window manager absent under Xvfb: it accepts Electron's native `_NET_WM_MOVERESIZE` request, grabs the X pointer, and moves only the requesting top-level window as XTest supplies native pointer motion, replacing the host window manager's response and leaving platform-window-manager behavior outside this assertion while preserving the production Electron request and real `BrowserWindow` movement under claim; main-process observation reads window bounds without replacing movement; synchronization uses the native `BrowserWindow` move event and pointer release before reading the changed bounds; the standing CSS-motion override runs and forfeits only CSS motion, while native window movement remains real): after the neighbouring tab strip overflows and is scrolled rightward, dragging the empty titlebar moves the window, satisfying [the mandatory regression condition](../../../../specs/ui/app/sidebar/index.md#^sb-titlebar-native-testing) — *(covered by test: `packages/desktop/test/e2e/window-drag-regions.test.ts` “dragging the empty sidebar titlebar and top-bar ground moves the native window”)*. ^sb-ac-titlebar-drag +- **Seam** (channel-sidebar titlebar hit region → native Electron `BrowserWindow` movement, crossed in the real Electron app against a really-running server; a focused channel and enough path artifacts to overflow the neighbouring tab strip are fixtures created through production APIs; the real strip is scrolled rightward and its positive scroll offset and arrival at the right scroll limit are observed before the drag; `launchDesktop({ connectTo })` seeds an isolated saved-connection record, a setup mock that forfeits manual connection entry and persistence to [desktop product connection acceptance](../../../product/desktop-app.md#^desktop-ac-connect-link), while desktop startup and server-page loading remain real; this case runs only on Linux/X11 and supplies no macOS native-input or window-manager evidence; `x11-window-drag-driver.py` stands in for the window manager absent under Xvfb: it accepts Electron's native `_NET_WM_MOVERESIZE` request, grabs the X pointer, and moves only the requesting top-level window as XTest supplies native pointer motion, replacing the host window manager's response and leaving platform-window-manager behavior outside this assertion while preserving the production Electron request and real `BrowserWindow` movement under claim; main-process observation reads window bounds without replacing movement; synchronization uses the native `BrowserWindow` move event and pointer release before reading the changed bounds; the standing CSS-motion override runs and forfeits only CSS motion, while native window movement remains real): after the neighbouring tab strip overflows and is scrolled rightward, dragging the empty titlebar moves the window, satisfying [the mandatory regression condition](../../../../specs/ui/app/sidebar/index.md#^sb-titlebar-native-testing) — *(covered by test: `packages/desktop/test/e2e/window-drag-regions.test.ts` “dragging the empty sidebar titlebar and top-bar ground moves the native window”)*. ^sb-ac-titlebar-drag - **Contract** (the shared real-browser view boundary, fixtures, service mock, and implementation-mount hook above; the default motion override is the only additional mock; synchronization uses scroll events and observed positions, never transition waits or wall-clock sleeps): with enough pinned and unpinned channels to overflow, scrolling either group advances their shared list, and moving away from and back to the top sets and clears the `continues-start` scroll-state mark — *(covered by test: `packages/web/test/e2e/channel-sidebar.test.ts` “both groups move in one list and repaints preserve its continuation state”)*. ^sb-ac-scroll - **Contract** (the same boundary and setup, with the list already scrolled; the default motion override is the only additional mock): Rename from a row menu puts a focused field in that row with its current name selected whole; Enter, the check, and leaving a nonempty field commit, while Escape or leaving it empty preserves the old name; every exit restores the row without changing the list's scroll position — *(covered by test: `packages/web/test/e2e/channel-sidebar.test.ts` “focuses and selects the field and implements every commit and restore exit without moving the list”)*. ^sb-ac-rename - **Contract** (real-motion declaration for the create/delete walk: all animation and transition overrides are absent, and synchronization uses Web Animations API, focus, native dialog `cancel`, and presentation-state signals, never wall-clock sleeps; the companion definitive-rejection test uses the standing CSS-motion override and forfeits CSS motion, which its no-motion rejection outcome does not claim; both tests use the same real-browser view boundary and service mock; the fixture service acknowledges successful create and delete requests with production-shaped state): one New-channel press issues one create callback and grows the inserted row over `drag.yml`'s displacement duration, and only when growth completes does the rename field take its seat focused with its text selected; Delete presents the named alert with Cancel focused; Cancel, backdrop dismissal, and Escape each leave the row in place without a delete callback; confirmation shrinks the acknowledged-deleted row over that duration while its neighbours close the gap; and definitive create or delete rejection leaves the server-authored rows intact — *(covered by the create/delete walk and definitive-rejection test in `packages/web/test/e2e/channel-sidebar.test.ts` under “channel sidebar row motion (^sb-ac-row-motion)”)*. ^sb-ac-row-motion diff --git a/proofs/ui/app/system-modal/index.md b/proofs/ui/app/system-modal/index.md index 970ae4e7..5ae1191b 100644 --- a/proofs/ui/app/system-modal/index.md +++ b/proofs/ui/app/system-modal/index.md @@ -1,23 +1,27 @@ -*How the promises in System modal (UI) are proven.* +*Coverage of the dialog shown while Television cannot use its server, with real input for blocking behavior and native Electron dragging.* -# System modal (UI) — proof +# System modal — proof Proves [specs/ui/app/system-modal/index.md](../../../../specs/ui/app/system-modal/index.md). ## Coverage model -Coverage declarations are carried inside the migrated assertion blocks below. +The gate may be connected and authenticated while blocking use of the server. A small state/context table proves the dialog's salient contents and local-only action. It poses state and records the disconnect callback, so it forfeits state selection, live retry timing and connection effects. The [app proof](../index.md#^ap-ac-one-state) owns served-state priority and real-browser recovery; [desktop connection coverage](../../../arch/desktop/connect-flow.md) owns saved startup and the [product walk](../../../product/desktop-app.md#^desktop-ac-saved-auth) crosses the local button into real deletion and setup. -## Assertions +A real-browser contract proves that Escape and backdrop input leave the production connection dialog open. It shares native dialog mechanics with the [dialog proof](../dialog/index.md), which still owns ordinary dismissal, focus containment and background inertness. The [gate proof](../desktop-upgrade-gate/index.md) owns gate contents, restart and its dismissal exception. The app owns routing into the gate; its [real reconnect walk](../index.md#^ap-ac-gate-uncovered) proves that the gate replaces the connection dialog without stacking. + +The drag seam uses real Electron hit testing and movement while a modal occupies the top layer, on both the local page and the served gate. The X11 driver substitutes the host window manager, as declared below; no automated Mac movement claim is made. The 36px measure, typography, colours and panel geometry remain styling authority under conformance and review. The state table requests authored icons and their spinning state; the [icon proof](../../foundation/icons/index.md) owns glyphs and spinner motion. -### Test assertions +## Test hooks -The suite samples the authored modal states and proves the access-token form's own behavior ([arch/testing-policy.md#^ui-suite-scope](../../../../specs/arch/testing-policy.md#^ui-suite-scope)). Styling stays outside the suite: no assertion measures the panel, centering, responsive width, type, tint, or wash ([arch/testing-policy.md#^ui-styling-out](../../../../specs/arch/testing-policy.md#^ui-styling-out)). The DOM is only the instrument used to observe the state and interaction claims below ([arch/testing-policy.md#^ui-dom-instrument](../../../../specs/arch/testing-policy.md#^ui-dom-instrument)). +View contracts may supply state and a recording disconnect callback at the production view boundary; production obtains them from the served application or local connection owner. These are mocks of those owners, not alternate production states. -Shared contract declaration for the assertions below ([arch/testing-policy.md#^declaration-schema](../../../../specs/arch/testing-policy.md#^declaration-schema)): they prove the system-modal view side of the application-service/view handoff defined by [arch/ui/index.md](../../../../specs/arch/ui/index.md). Server addresses, failure messages, selected upgrade instructions, and each authored modal state are fixtures. An implementation-mount hook supplies a fake application service and directly poses state; both are mocks, greenlit here, and forfeit real state selection, precedence, retry, authentication storage, and server behavior to [ui/app/index.md](../../../../specs/ui/app/index.md) and [arch/channel-state/index.md#^cs-connection-carve-out](../../../../specs/arch/channel-state/index.md#^cs-connection-carve-out). The mocked DOM substitutes for native focus, form submission and constraint validation, layout, scrolling, and animation; the assertions therefore claim only rendered markup and view logic after the submit handler receives a value, while real form and server wiring remain with [ui/app/index.md#^ap-ac-signin-first](../index.md#^ap-ac-signin-first) and [arch/channel-state/index.md#^cs-connection-carve-out](../../../../specs/arch/channel-state/index.md#^cs-connection-carve-out). Neither assertion claims styling or motion; the spinner's exact glyph belongs to [ui/foundation/icons/index.md#^ic-ac-markup-smoke](../../foundation/icons/index.md#^ic-ac-markup-smoke), and its turning behavior belongs to [ui/foundation/icons/index.md#^ic-ac-spinning](../../foundation/icons/index.md#^ic-ac-spinning). +The served drag route uses the update runtime’s simulation and test-mode restart recording, as declared by [desktop update coverage](../../../arch/desktop/updates.md#test-hooks), to provide an actionable dialog button. Native pointer hit testing and window movement remain real; an actual downloaded update and runtime restart are outside this drag claim. -- **Contract** (the production system-modal view mounted in the mocked DOM under the shared declaration): one table-driven markup smoke check covers connecting; disconnected with a numbered reattempt and with an attempt in flight; unauthorized before submission and after rejection; error; and needs-upgrade. Each row renders only its selected interrupting surface. Connecting asks for the spinning spinner and shows its title with no supporting line. Both disconnected rows ask for that spinner and show the title with the corresponding reattempt line. Unauthorized shows the locked icon, copy, labeled required password field, and Connect button; its rejected state leaves the field empty, sets aria-invalid="true", links aria-describedby to the tv-error rejection alert, and removes that state and link when a fresh unauthorized state is presented. Error shows the authored title, server address, and supplied failure message. Needs-upgrade routes the supplied instructions into the desktop-upgrade-gate surface rather than wrapping them in the standard interior, and with a desktop update state whose stand-in bridge has reported a version it routes the restart state too, so the gate shows the downloaded-update message and its restart button — *(covered by `packages/web/test/app-root.test.ts` “system modal (^sm-ac-markup-smoke) > renders each selected interrupting surface and no other”)*. ^sm-ac-markup-smoke -- **Contract** (the same production view, mocked DOM, implementation-mount hook, and fake application service; a production-shaped rejection update is injected through that service and forfeits the real authentication round trip as declared above): native prevention of an empty submission before the handler runs is carried as markup by the required field in [#^sm-ac-markup-smoke](#^sm-ac-markup-smoke); when the view's submit handler receives an empty or whitespace-only token it invokes nothing; an entered token invokes the service once; and when the service reports that token rejected, the input clears, the alert appears, and a replacement token can be submitted — *(covered by `packages/web/test/app-root.test.ts` “system-modal token submission (^sm-ac-auth-submit) > refuses blank values, authenticates entered tokens, and clears a rejected token”)*. ^sm-ac-auth-submit +The Electron seam uses [desktop harness hooks](../../../arch/desktop/connect-flow.md#test-hooks) and the [required-version hook](../../../arch/updates/desktop-upgrade-gate.md#^hook-required-version) to reach the served gate. -Coverage relationship: [ui/app/index.md](../../../../specs/ui/app/index.md) owns choosing one modal state, sign-in and gate precedence, and whether the shell stays mounted behind the wash. The connection lifecycle retained under [arch/channel-state/index.md#^cs-connection-carve-out](../../../../specs/arch/channel-state/index.md#^cs-connection-carve-out) owns retry timing, authentication against a real server, and leaving error or unauthorized after a successful connection; this surface consumes those outcomes without repeating their full browser/server paths. [ui/app/desktop-upgrade-gate/index.md](../../../../specs/ui/app/desktop-upgrade-gate/index.md) owns the gate's interior, markdown, controls, and scrolling; this row proves only that the needs-upgrade state routes to it. The dialog element row owns generic background inertness and floating-surface dismissal, and the icon element row owns spinner motion. +## Assertions +- **Contract — contents by state and place.** Mount the production presentation in a mocked DOM with authored state, context, server address, retry deadline/in-flight fixtures. State posing and a controlled clock for the displayed countdown forfeit live state/timing to the [app](../index.md#^ap-ac-drop-holds-shell) and [desktop](../../../product/desktop-app.md#^desktop-ac-saved-recovery) walks; the DOM mock forfeits native input to the next assertion. Under [States](../../../../specs/ui/app/system-modal/index.md#states) and the [frame/content](../../../../specs/ui/app/system-modal/system-modal.frame), Connecting requests a spinning icon and title only; Disconnected requests that icon plus countdown or Reconnecting now; error shows its title, address, guidance and the same reconnect line, without an icon. Access token required shows the lock and context-specific link/disconnect guidance, without a token form. Only local unauthorized/error rows offer the destructive Disconnect from Server button, which invokes its supplied callback once. Sample countdown progression once, sharing the formatting between disconnected and error. Every row composes only its selected surface. *(covered by `packages/web/test/app-root.test.ts` “renders connection contents by state and place, with only local disconnect actions”)* ^sm-ac-markup-smoke +- **Contract — non-dismissal.** Mount the production connection-dialog composition in a real browser with posed connecting, disconnected, unauthorized and error states and recording owner callbacks. Posing state forfeits live entry/exit to the app and desktop walks above; native dialog cancellation and pointer hit testing remain real. Under [Interaction](../../../../specs/ui/app/system-modal/index.md#interaction), real Escape and backdrop presses leave each dialog presented and issue no dismissal or disconnect. Withdraw it through the owner to prove state-driven removal still works. Gate non-dismissal is already [covered separately](../desktop-upgrade-gate/index.md#^dug-ac-blocking-interaction). *(covered by `packages/web/test/e2e/system-modal.test.ts` “connection dialogs resist Escape and backdrop input until the owner withdraws them (^sm-ac-blocking)”)* ^sm-ac-blocking +- **Seam — dialog drag strip.** Cross native pointer input over the production strip into Electron's move request and real `BrowserWindow` bounds. Reach a local saved-connection error using a stored-record fixture, and a served gate using a real server and the version hooks above. Use the Linux/X11 driver declared by [sidebar dragging](../sidebar/index.md#^sb-ac-titlebar-drag), which replaces the window manager's response to `_NET_WM_MOVERESIZE`; host-window-manager behavior and macOS input remain outside this assertion. Under [Interaction](../../../../specs/ui/app/system-modal/index.md#interaction), the window moves from the top strip while the dialog stays open, including when native dialog top-layer hit testing is active. Dialog buttons remain usable without moving the window. The two routes are distinct packaged/served style and composition handoffs; no state-by-state native drag matrix is ordered. *(covered by `packages/desktop/test/e2e/window-drag-regions.test.ts` “packaged setup and saved-error dialogs move the window while controls stay usable” and “the served gate keeps a native drag strip and usable dialog controls”)* ^sm-ac-drag-strip diff --git a/proofs/ui/app/top-bar/index.md b/proofs/ui/app/top-bar/index.md index d2431723..dbe3ca5e 100644 --- a/proofs/ui/app/top-bar/index.md +++ b/proofs/ui/app/top-bar/index.md @@ -37,7 +37,7 @@ Shared contract declaration for the markup, browser-layout, and controls-continu - **Acceptance** (outer boundary: a real browser against a really-running server; channels and live artifact fixtures are created through production APIs, collapsed state is an authored browser-storage fixture, and real pointer/keyboard input drives existing channel commands; CSS motion remains real because moving or removing rows can affect focus; no production hooks or service mocks): under [the context-menu promise](../../../../specs/ui/app/top-bar/index.md#^tb-switcher-context-menu), opening a row menu and using Rename or Pin/Unpin leaves the focused channel and its live artifact frame unchanged. Rename updates the selected channel label; Escape abandons an edit without closing the switcher. Pin and Unpin move the row to the right group while keeping it visible and retaining focus that was within the row. Opening a sibling menu preserves the switcher; Escape closes the menu alone, and a subsequent row selection or create press still performs its ordinary action. Covered by `packages/web/test/e2e/channel-switcher-context-menu.test.ts`. ^top-ac-switcher-context-menu - **Acceptance** (a real browser against a really-running server; pinned and unpinned channels plus enough rows to overflow the switcher are fixtures created through production APIs; collapsed state is authored browser-storage input; real pointer input and Escape drive the gesture; all motion remains real and synchronization uses pointer capture, scroll events, settled channel state, and animation lifecycle signals): under [the switcher drag promise](../../../../specs/ui/app/top-bar/index.md#^tb-switcher-drag), drag an unpinned row into a pinned slot, reorder it, then release it outside the panel to unpin. Each release commits once, preserves the selected channel and live artifact frame, leaves the sidebar collapsed, and keeps the switcher open. In a scrolled list, edge scrolling exposes a further target slot. Visibility of the carried row beyond the list and panel bounds is checked in the production browser inspection below. Escape during a subsequent drag restores the arrangement without a mutation or dismissal; a later Escape dismisses normally. This covers the popover boundary and dismissal composition; the shared group-state and interruption matrix remains with [sidebar drag coverage](../sidebar/index.md#^sb-ac-drag) and [edge-scroll coverage](../sidebar/index.md#^sb-ac-drag-scroll) — *(covered by `packages/web/test/e2e/channel-switcher-context-menu.test.ts` “switcher drag reorders, pins, unpins outside, and cancels without selecting or dismissing”; exactly-once writes remain covered by the shared captured-release matrix)*. ^top-ac-switcher-drag - **Acceptance** (same real-browser/server boundary and real-motion fixtures as [#^top-ac-switcher-context-menu](#^top-ac-switcher-context-menu); real shared modal presentation and dismissal): opening Delete confirmation closes the menu and switcher. Cancel, Escape, and backdrop dismissal restore the switcher at its saved scroll position and focus the originating row. Confirmed deletion reopens the switcher when channels remain, reflects the resulting selection, and focuses that selected row; deleting the final channel keeps it closed, removes its trigger, and focuses the expand control. Covered by `packages/web/test/e2e/channel-switcher-context-menu.test.ts`. ^top-ac-switcher-delete -- **Seam** (top-bar ground → native Electron `BrowserWindow` movement, crossed in the real Electron app against a really-running server; a focused channel and overflowing path artifacts are fixtures created through production APIs; `launchDesktop({ connectTo })` seeds an isolated saved-connection record, a setup mock that forfeits manual connection entry and persistence to [desktop connection flow's real page-entry seam](../../../arch/desktop/connect-flow.md#^desktop-t-page-entry), while desktop startup and server-page loading remain real; this case runs only on Linux/X11 and supplies no macOS native-input or window-manager evidence; `x11-window-drag-driver.py` stands in for the window manager absent under Xvfb: it accepts Electron's native `_NET_WM_MOVERESIZE` request, grabs the X pointer, and moves only the requesting top-level window as XTest supplies native pointer motion, replacing the host window manager's response and leaving platform-window-manager behavior outside this assertion while preserving the production Electron request and real `BrowserWindow` movement under claim; main-process observation reads window bounds without replacing movement; real native pointer input begins on empty bar ground; the standing CSS-motion override forfeits only CSS motion, while native movement remains real; synchronization uses the native `BrowserWindow` move event and pointer release before reading the changed bounds): dragging the empty top-bar ground moves the window — *(covered by test: `packages/desktop/test/e2e/window-drag-regions.test.ts` “dragging the empty sidebar titlebar and top-bar ground moves the native window”)*. ^top-ac-window-drag +- **Seam** (top-bar ground → native Electron `BrowserWindow` movement, crossed in the real Electron app against a really-running server; a focused channel and overflowing path artifacts are fixtures created through production APIs; `launchDesktop({ connectTo })` seeds an isolated saved-connection record, a setup mock that forfeits manual connection entry and persistence to [desktop product connection acceptance](../../../product/desktop-app.md#^desktop-ac-connect-link), while desktop startup and server-page loading remain real; this case runs only on Linux/X11 and supplies no macOS native-input or window-manager evidence; `x11-window-drag-driver.py` stands in for the window manager absent under Xvfb: it accepts Electron's native `_NET_WM_MOVERESIZE` request, grabs the X pointer, and moves only the requesting top-level window as XTest supplies native pointer motion, replacing the host window manager's response and leaving platform-window-manager behavior outside this assertion while preserving the production Electron request and real `BrowserWindow` movement under claim; main-process observation reads window bounds without replacing movement; real native pointer input begins on empty bar ground; the standing CSS-motion override forfeits only CSS motion, while native movement remains real; synchronization uses the native `BrowserWindow` move event and pointer release before reading the changed bounds): dragging the empty top-bar ground moves the window — *(covered by test: `packages/desktop/test/e2e/window-drag-regions.test.ts` “dragging the empty sidebar titlebar and top-bar ground moves the native window”)*. ^top-ac-window-drag Coverage relationship: [ui/app/tab-strip/index.md](../../../../specs/ui/app/tab-strip/index.md) owns the strip markup and in-band scrolling; [ui/app/stage/index.md](../../../../specs/ui/app/stage/index.md) owns tab selection and reorder; the skill-selector, settings, and update-notification rows own their controls. [Channel-sidebar markup](../sidebar/index.md#^sb-ac-markup-smoke) owns the channel-list groups, options, and rename row once; [#^top-ac-lead-markup](#^top-ac-lead-markup) proves only the switcher host composition and shared row-menu use. [Popover lifecycle coverage](../../foundation/popover/index.md#^po-ac-lifecycle) owns Escape and light dismissal through the non-manual production element, while [popover placement coverage](../../foundation/popover/index.md#^po-ac-placement) owns outer panel sizing and placement. [#^top-ac-switcher-overflow](#^top-ac-switcher-overflow) owns scrolling and footer behavior inside that panel. [Channel product acceptance](../../../product/channels.md#^ch-ac-switcher-focus-broadcast) owns switcher focus reaching another client. The navbar drag and no-drag declarations are surface-local: [#^top-ac-window-drag](#^top-ac-window-drag) proves its empty ground reaches native movement, while the child-surface proofs above own interaction inside its no-drag bands. [Foundation](../../../arch/ui/foundation.md#^ui-t-electron-drag-region) separately proves the shared `[electron-draggable]` selector convention used by other surfaces. The app-shell row owns top-bar/stage composition, collapse motion, and the integrated empty states. diff --git a/proofs/ui/foundation/button/index.md b/proofs/ui/foundation/button/index.md index 34e6bbd6..3c5c6230 100644 --- a/proofs/ui/foundation/button/index.md +++ b/proofs/ui/foundation/button/index.md @@ -8,7 +8,7 @@ Proves [specs/ui/foundation/button/index.md](../../../../specs/ui/foundation/but ### Test coverage -A button has no standalone view or custom behavior: its markup is the native element a composing surface writes, and its variation is authored styling. Markup smoke for each surface proves that the surface emits a native button with the attributes and accessible label it needs. [Navbar lead markup](../../app/top-bar/index.md#^top-ac-lead-markup) therefore owns proof that the channel switcher requests `target`, while [navbar pressure coverage](../../app/top-bar/index.md#^top-ac-lead-floor) proves that the resulting lead remains usable under pressure. Neither assertion measures the exact `target` padding; that geometry remains authored styling, including the rule that `target` adds nothing to an icon button. Native focus, keyboard activation, form participation, and semantics come from the browser once the markup exists. The production-copy seam at [arch/ui/foundation.md#^ui-t-foundation-copy](../../../arch/ui/foundation.md#^ui-t-foundation-copy) proves that the authoritative stylesheet for this element reaches the built app. No direct button assertion is ordered: measuring intent colours, geometry, hover, active, focus, disabled, or specificity outcomes would test styling, which stays in staging and review ([arch/testing-policy.md#^ui-styling-out](../../../../specs/arch/testing-policy.md#^ui-styling-out)). +A button has no standalone view or custom behavior: its markup is the native element a composing surface writes, and its variation is authored styling. Markup smoke for each surface proves that the surface emits a native button with the attributes and accessible label it needs. [Navbar lead markup](../../app/top-bar/index.md#^top-ac-lead-markup) therefore owns proof that the channel switcher requests `target`, while [navbar pressure coverage](../../app/top-bar/index.md#^top-ac-lead-floor) proves that the resulting lead remains usable under pressure. Neither assertion measures the exact `target` padding; that geometry remains authored styling, including the rule that `target` adds nothing to an icon button. Native focus, keyboard activation, form participation, and semantics come from the browser once the markup exists. The production-copy seam at [arch/ui/foundation.md#^ui-t-foundation-copy](../../../arch/ui/foundation.md#^ui-t-foundation-copy) proves that the authoritative stylesheet for this element reaches the built app. [Setup coverage](../../setup/index.md#^setup-t-states) proves the consumer requests `size="lg"`; [Copy coverage](../../app/copy-button/index.md#^cb-ac-markup-smoke) covers the reusable control's size input. The foundation and [live canonical](../../../arch/canonical.md#^cn-t-authored-build) crossings include the large-size CSS. No direct button assertion is ordered: measuring intent colours, geometry, hover, active, focus, disabled, or specificity outcomes would test styling, which stays in staging and review ([arch/testing-policy.md#^ui-styling-out](../../../../specs/arch/testing-policy.md#^ui-styling-out)). ## Assertions diff --git a/proofs/ui/foundation/index.md b/proofs/ui/foundation/index.md index 0329f9e9..7d8c3cbf 100644 --- a/proofs/ui/foundation/index.md +++ b/proofs/ui/foundation/index.md @@ -16,6 +16,8 @@ The manifest-driven root marker and the foundation-owned `color-scheme` are comp The shared app-bar height and traffic-light reserve are application token styling, so their exact values and computed padding receive no direct foundation assertion. [UI architecture](../../arch/ui/index.md#^ui-t-platform-marker) proves that the application root marks Electron context exactly. [Channel-sidebar titlebar coverage](../app/sidebar/index.md#^sb-ac-titlebar-controls), [navbar lead coverage](../app/top-bar/index.md#^top-ac-lead-markup), and [collapse acceptance](../app/index.md#^ap-ac-sidebar-collapse) prove that the consuming surfaces render and remain usable; conformance and staging retain exact alignment and spacing. +The [text-token material](../../../specs/ui/foundation/tokens/text.css), including `--line-control-lg`, crosses into the web and live canonical builds through [foundation distribution](../../arch/ui/foundation.md#^ui-t-foundation-copy) and [canonical distribution](../../arch/canonical.md#^cn-t-authored-build). The large button/input geometry remains styling authority and adds no token-value test. [Setup](../setup/index.md#^setup-t-states) proves the consumer attributes. + ## Assertions ### Test assertions diff --git a/proofs/ui/foundation/input/index.md b/proofs/ui/foundation/input/index.md index 6c5a596b..60460283 100644 --- a/proofs/ui/foundation/input/index.md +++ b/proofs/ui/foundation/input/index.md @@ -4,4 +4,4 @@ Proves [input UI](../../../../specs/ui/foundation/input/index.md). -The input is native markup with ambient styling, so it orders no standalone behavior or styling tests. The [foundation distribution seam](../../../arch/ui/foundation.md#^ui-t-foundation-copy) includes its stylesheet in the real web copy and fresh production build; [canonical distribution](../../../arch/canonical.md#^cn-t-authored-build) includes it in live canonical while preserving frozen v1. The [system-modal markup and submission assertions](../../app/system-modal/index.md#^sm-ac-markup-smoke) cover the real consumer’s reported and cleared validation state, associated error paragraph, and authentication retry. Native editing behavior is browser-owned. Styling is governed by conformance and review, not permanent color or geometry assertions. +The input is native markup with ambient styling, so it orders no standalone behavior or styling tests. The [foundation distribution seam](../../../arch/ui/foundation.md#^ui-t-foundation-copy) includes its stylesheet in real production copies and a fresh build; [canonical distribution](../../../arch/canonical.md#^cn-t-authored-build) includes it in live canonical while preserving frozen v1. [Setup's state contract](../../setup/index.md#^setup-t-states) proves the consumer requests `data-size="lg"`, reports and clears validation state, and associates the supplied error with the field. Native editing is browser-owned; exact control height and styling remain conformance and review concerns. diff --git a/proofs/ui/setup/index.md b/proofs/ui/setup/index.md new file mode 100644 index 00000000..cfa2e1ce --- /dev/null +++ b/proofs/ui/setup/index.md @@ -0,0 +1,25 @@ +*Setup coverage at the local renderer boundary, with real Electron evidence for connection, confirmation before navigation, and window dragging.* + +# Setup screen — proof + +Proves [specs/ui/setup/index.md](../../../specs/ui/setup/index.md). + +## Coverage model + +A renderer contract covers the four authored states and the local connection handoff. It replaces the connection bridge and clipboard sink, proving what setup sends and renders, not IPC, authentication or the system clipboard. [Desktop product acceptance](../../product/desktop-app.md#^desktop-ac-connect-link) crosses the packaged page, preload, main process, server and served interface; [setup failure acceptance](../../product/desktop-app.md#^desktop-ac-setup-errors) proves inline recovery. The product success walk uses real CSS motion to observe Connected before navigation, without introducing an additional dwell requirement. The renderer contract controls animation frames and returns no active animations, covering the reduced-motion completion path without transition events; it forfeits real painting to that product walk. + +The shared [Copy proof](../app/copy-button/index.md) owns clipboard success, fallback, confirmation and its timer for all callers. Setup proves the prompt supplied to that control and that copying does not advance or disable either step. The product connection walk includes native prompt selection before submission. Drag coverage uses the existing X11 harness and explicitly forfeits the host window manager; it does not treat a computed CSS property as movement evidence. + +The card is static content using the frame's look. Its contract checks that it creates no artifact document or controls; setup requires no artifact runtime. Geometry, colours, Clouds styling and the reduced-motion CSS treatment stay with stylesheet delivery, UI conformance and review. The connection walk observes the normal-motion handoff; this proof orders no second Electron connection solely for the CSS preference. [Desktop asset delivery](../../arch/desktop/connect-flow.md#^desktop-t-local-assets) and [appearance](../../arch/desktop/appearance.md#^desktop-appearance-t-local) prove delivery and mode selection. General markup-comparison automation remains deferred under [TV-649](../../../specs/arch/ui/conformance.md#release-exception-for-tv-649); active stylesheet and functional evidence still applies. + +## Test hooks + +Electron assertions use the [user-data and version hooks](../../arch/desktop/connect-flow.md#test-hooks); the renderer contract supplies a recording local bridge at its dependency boundary, where production receives the packaged preload bridge. + +The Copy dependency seam is declared by [the Copy proof](../app/copy-button/index.md#test-hooks); setup contracts may use its recording clipboard sink, forfeiting the clipboard to that proof. + +## Assertions + +- **Contract — states and submission.** Mount the production setup renderer in a mocked DOM with link/error fixtures and a recording bridge whose promises the test resolves or rejects. These mocks forfeit native submission and the real lifecycle to the product walks named above. Under [States](../../../specs/ui/setup/index.md#states) and [the frame](../../../specs/ui/setup/setup.frame), ready has the prompt and an empty, labeled, required link field; Copy requests standard size, the field `data-size="lg"`, and Connect `size="lg"`. Connecting disables the field and Connect and requests the spinner. Connected presents the check and Connected content with the steps inert. Error keeps the full link editable and connects its invalid marker to the supplied error paragraph in place of the hint. The card has its title but no artifact document, menu or navigation controls. At the [submission boundary](../../../specs/ui/setup/index.md#interaction), empty input submits nothing, a nonempty link can be submitted without copying first, and a failed attempt can be corrected and resubmitted; retry leaves the error treatment for the connecting state. With no active animations, the Connected handoff still completes after its animation frames without a transition event. *(covered by `packages/desktop/test/connect-page.test.ts` “renders ready, connecting, inline error and Connected through a single-link submission” and “completes the Connected handoff without transition events under reduced motion”)* ^setup-t-states +- **Contract — Copy composition.** Use the production renderer with the same mocked DOM/bridge and the Copy recording sink. Under [Interaction](../../../specs/ui/setup/index.md#interaction), activating Copy supplies exactly the frame's prompt, including its guide URL, while keeping the entered link, connection state, and both steps' availability unchanged. Clipboard and confirmation mechanics remain with [Copy coverage](../app/copy-button/index.md#^cb-ac-clipboard). *(covered by `packages/desktop/test/connect-page.test.ts` “Copy supplies the frame prompt and leaves both steps and the entered link available”)* ^setup-t-copy +- **Seam — setup dragging.** Cross native pointer input on the packaged setup background into Electron's native move request and `BrowserWindow` movement. Use the Linux/X11 driver declared by [sidebar dragging](../app/sidebar/index.md#^sb-ac-titlebar-drag): it substitutes the window manager's response to `_NET_WM_MOVERESIZE`, leaving host-window-manager and macOS behavior outside this automated assertion; Electron hit testing and window bounds remain real. Under [Interaction](../../../specs/ui/setup/index.md#interaction), dragging the background moves the window, while input inside the card selects/edits or activates a control without moving it. Observe move events and bounds after native pointer release. *(covered by `packages/desktop/test/e2e/window-drag-regions.test.ts` “packaged setup and saved-error dialogs move the window while controls stay usable”)* ^setup-t-drag diff --git a/scripts/licenses/assets.json b/scripts/licenses/assets.json index da782820..b4553991 100644 --- a/scripts/licenses/assets.json +++ b/scripts/licenses/assets.json @@ -17,7 +17,8 @@ "surfaces": [ "web", "view:markdown", - "source" + "source", + "desktop" ] }, { @@ -39,7 +40,8 @@ "surfaces": [ "web", "view:markdown", - "source" + "source", + "desktop" ] }, { @@ -116,7 +118,8 @@ ], "surfaces": [ "cli", - "source" + "source", + "desktop" ], "noticesFolder": "packages/server/assets/themes/clouds" }, @@ -138,7 +141,8 @@ "surfaces": [ "web", "view:markdown", - "source" + "source", + "desktop" ] }, { diff --git a/specs/arch/channel-state/index.md b/specs/arch/channel-state/index.md index f1228d5d..97fe09cb 100644 --- a/specs/arch/channel-state/index.md +++ b/specs/arch/channel-state/index.md @@ -159,7 +159,7 @@ The pre-redesign client persisted a browser-local record holding a configured-se ## Code-governed carve-out -**The connection lifecycle is deliberately code-governed.** Socket management, auto-reconnect and backoff, visibility/network wake signals, auth-rejection routing to the token screen, and disposal are carried from the shipping implementation, whose suites hold them. Slices of the connection already under other specs' authority stay theirs: the `server-status` message and unknown-message routing ([version-advertisement.md#^events-version](../updates/version-advertisement.md#^events-version), [version-advertisement.md#^unknown-messages](../updates/version-advertisement.md#^unknown-messages)), the Electron boot barrier ([desktop-upgrade-gate.md#^boot-barrier](../updates/desktop-upgrade-gate.md#^boot-barrier)), and the telemetry signals riding the socket ([client-signals.md](../telemetry/client-signals.md)). The outcomes that must hold regardless of mechanism: losing the server is a shell-wide transition ([channels.md#^ch-disconnect](../../product/channels.md#^ch-disconnect)), and reconnection converges on server truth ([#^cs-converge](#^cs-converge)). ^cs-connection-carve-out +**The connection lifecycle is deliberately code-governed.** Socket management, auto-reconnect and backoff, visibility/network wake signals, auth-rejection handling, and disposal are carried from the shipping implementation, whose suites hold them; which connection state the person sees, including the escalation after repeated failed reconnects, is the [app shell](../../ui/app/index.md#^ap-connection-states)'s. Slices of the connection already under other specs' authority stay theirs: the `server-status` message and unknown-message routing ([version-advertisement.md#^events-version](../updates/version-advertisement.md#^events-version), [version-advertisement.md#^unknown-messages](../updates/version-advertisement.md#^unknown-messages)), the Electron boot barrier ([desktop-upgrade-gate.md#^boot-barrier](../updates/desktop-upgrade-gate.md#^boot-barrier)), and the telemetry signals riding the socket ([client-signals.md](../telemetry/client-signals.md)). The outcomes that must hold regardless of mechanism: losing the server is a shell-wide transition ([channels.md#^ch-disconnect](../../product/channels.md#^ch-disconnect)), and reconnection converges on server truth ([#^cs-converge](#^cs-converge)). ^cs-connection-carve-out **The transport vocabulary is deliberately code-governed — the model it carries is not.** Route names, event discriminants, and transport envelopes shared only by the server and its served web bundle are lockstep contracts, so they may change freely between releases and are not worth authority; they live in the server and shared-client code, described by the legacy network API documentation until that area migrates ([spec-migration.md](../../spec-migration.md)). Cross-release desktop identity transport, including its bounded output exception, is owned by [arch/updates/desktop-upgrade-gate.md#^pre-gate-handshake](../updates/desktop-upgrade-gate.md#^pre-gate-handshake), so no identity contract is defined by channel state. `ChannelStateFields`, `ChannelDisplayState`, and their semantics remain this spec's authority **wherever they are transported**: renaming any other route or event type is a code change, but changing these fields or their rules requires a spec change first. ^cs-wire-carve-out diff --git a/specs/arch/cli/index.md b/specs/arch/cli/index.md index cc80a121..1ed8184e 100644 --- a/specs/arch/cli/index.md +++ b/specs/arch/cli/index.md @@ -68,6 +68,8 @@ import type { TelevisionClient } from "@telepath-computer/television-shared"; export type Writable = { write(chunk: string | Uint8Array): unknown; + /** True when the stream is an interactive terminal; decides link formatting. */ + isTTY?: boolean; }; export type CLIServer = Pick< @@ -255,9 +257,12 @@ The CLI contract with `TelevisionClient` is the exact set of client calls each c | `set-theme` | First attempt `client.display.get()` to capture the selection in effect when the command began; a failure marks the previous selection unavailable and does not abort. For any case-insensitive spelling of `none`, call `client.display.patch({ activeThemeName: null })` without refreshing. Otherwise preserve the theme ID argument exactly, call `client.themes.refresh()`, report an error when the refreshed registry contains one for that exact theme ID, then call `client.display.patch({ activeThemeName: themeID })`. After a successful patch, print the applicable success form below. | | `focus-artifact` | `client.display.focus({ artifactID })`. | | `status` | `client.health()`, then `client.telemetry.status()`, inside a health check that catches all errors. Health fields are assigned before the telemetry call, so a telemetry failure preserves `healthy: true`, `version`, `bindAddresses`, and `port` while omitting `telemetry`. `home` comes from the invocation's resolved home, and the config read and client-port rule run before the health check, so their failures are command errors rather than `healthy: false`. | +| `links` | `client.health()`, then an authenticated request through the client to learn whether the running server accepts the home's token, and an unauthenticated one to learn whether it requires a token at all. Each address in the health response's `bindAddresses` with its `port` becomes `buildConnectURL(buildServerURL(address, port), token)`, where `token` is the `readAuthToken(home)` value when the server requires a token and `null` otherwise. A failed health request, or a `401` for the home's token, is a command error. The config file's `auth` setting does not decide token inclusion, because the server reads it only at startup. | | `telemetry enable` | `client.telemetry.enable()`. | | `telemetry disable` | `client.telemetry.disable()`. | +Every link the CLI prints — `tv serve` and `tv serve --persist` startup URLs and `tv links` output — passes through one formatter that wraps it as an OSC-8 hyperlink only when the output stream it writes to reports `isTTY` as `true`, and writes the plain URL otherwise ([product link output](../../product/cli.md#^cli-link-output)). + The health response may supply an exact release version. `tv status` copies that value unchanged, including the `0.0.0` development sentinel, and omits `version` when the health response does not supply the field. Other health fields pass through unchanged. Help and user-facing command documentation describe `none` as using no theme, without the internal term `null theme`. Successful `set-theme` output renders non-null selections as exact theme IDs and `null` as the product's user-facing `None` label. A known changed selection prints exactly `Active theme changed from '' to ''.`; a known unchanged selection prints exactly `Active theme unchanged: ''.`; and an unavailable previous selection prints only `Active theme: ''.`. The opening display read's failure is suppressed. A refresh, registry error, or display write failure produces no success output. diff --git a/specs/arch/desktop/appearance.md b/specs/arch/desktop/appearance.md index adf6a90b..950a6830 100644 --- a/specs/arch/desktop/appearance.md +++ b/specs/arch/desktop/appearance.md @@ -2,7 +2,7 @@ # Desktop appearance -Electron owns one application-wide appearance source. The *appearance input* is the `system`, `light`, or `dark` value that the [shell appearance rule](../themes/delivery.md#^theme-delivery-shell-appearance) produces from confirmed `activeThemeColorScheme` and `appearanceMode`. The source starts at `system`, remains unchanged while the shell shows its connect screen or is disconnected, and takes the next connected server's appearance input after the renderer receives its confirmed display state. The [appearance explainer](../explainer-appearance.md) places this native path alongside the browser and theme paths. +Electron owns one application-wide appearance source. The *appearance input* is the `system`, `light`, or `dark` value that the [shell appearance rule](../themes/delivery.md#^theme-delivery-shell-appearance) produces from confirmed `activeThemeColorScheme` and `appearanceMode`. The source starts at `system`, remains unchanged while the shell shows its [local page](./connect-flow.md#^desktop-local-page) or is disconnected, and takes the next connected server's appearance input after the renderer receives its confirmed display state. The [appearance explainer](../explainer-appearance.md) places this native path alongside the browser and theme paths. ## IPC path @@ -18,7 +18,7 @@ The preload validates before `ipcRenderer.send`. Main validates again in its `ip ## Lifecycle -Main's default is Electron's `system`. A disconnect or navigation to the connect screen does not reset the last appearance input. When another server's display state is ready, its appearance input replaces the retained value before connected content or its first artifact webview attaches. Existing webviews stay loaded when the appearance input changes. Changing the stored preference under a fixed theme leaves the appearance input and loaded presentation unchanged. ^desktop-appearance-lifecycle +Main's default is Electron's `system`. A disconnect or navigation to the local page does not reset the last appearance input. When another server's display state is ready, its appearance input replaces the retained value before connected content or its first artifact webview attaches. Existing webviews stay loaded when the appearance input changes. Changing the stored preference under a fixed theme leaves the appearance input and loaded presentation unchanged. ^desktop-appearance-lifecycle ## Testing diff --git a/specs/arch/desktop/connect-flow.md b/specs/arch/desktop/connect-flow.md index 2c14d43f..45ad6eb1 100644 --- a/specs/arch/desktop/connect-flow.md +++ b/specs/arch/desktop/connect-flow.md @@ -1,34 +1,50 @@ -*The desktop main-process connection flow: local connection entry, URL normalization, saved connections, server-page loading and recovery, and the local-only IPC bridge.* +*The desktop main-process connection flow: the local page, connection entry from a link, saved connections and their reconnection, Disconnect from Server, server-page loading and recovery, and the local-only IPC bridge.* -**Plain english:** this governs how the desktop app remembers a Television server, checks it before connecting, opens its interface, and returns to connection entry when that interface cannot load. +**Plain english:** this governs how the desktop app connects to a Television server from a link, remembers it, reconnects to it when the app starts, forgets it when the person disconnects, and opens the server's interface. # Desktop connection flow ## What this owns -This module owns the Electron main process's connection lifecycle around the server-served application: how users enter or reuse a server connection, how the shell normalizes and stores it, the page URL the shell loads, and which navigation failures return to the connect screen. The cross-release identity request that precedes page loading is owned by [the desktop upgrade architecture](../updates/desktop-upgrade-gate.md#^pre-gate-handshake); the served application's live HTTP and websocket session remains code-governed under [channel-state architecture](../channel-state/index.md#^cs-connection-carve-out). +This module owns the Electron main process's connection lifecycle around the server-served application: the local page, how a person enters a connection or the app reuses a saved one, how the shell reads and stores it, what the local page shows while it connects, Disconnect from Server, the page URL the shell loads, and which navigation failures return to the local page. The cross-release identity request that precedes page loading is owned by [the desktop upgrade architecture](../updates/desktop-upgrade-gate.md#^pre-gate-handshake); the served application's live HTTP and websocket session remains code-governed under [channel-state architecture](../channel-state/index.md#^cs-connection-carve-out). -Connection-screen copy, timing, and presentation remain code-governed. This spec owns the architectural boundaries that let the local screen hand off safely to server-served code. +The [setup screen](../../ui/setup/index.md) owns the connect screen's interaction, markup and styling, and the [system modal](../../ui/app/system-modal/index.md) the dialogs the local page shows. How a failed connect check is classified, and the wording of the message the setup screen shows for it, remain code-governed ([^pre-gate-handshake](../updates/desktop-upgrade-gate.md#^pre-gate-handshake)). The [connection states explainer](../explainer-connection-states.md) places this flow alongside the connection states in the server's interface. + +## The local page + +The *local page* is the package's `connect.html`, loaded from the application's own files over `file:`. It shows the setup screen, and the system modal's states while a saved connection is starting. It is an application document ([arch/themes/delivery.md](../themes/delivery.md#^theme-delivery-app-document)) that wears the [Clouds theme](../../ui/themes/clouds/index.md) whatever theme the server uses, light or dark as the window's appearance currently is ([appearance.md](./appearance.md#^desktop-appearance-lifecycle)). Its setup screen and dialogs match the web client's design for them. How the local page achieves that, whether by using the web client's stylesheets with its own markup, by importing the web client's code for those surfaces, or otherwise, is left to the implementation. ^desktop-local-page + +The connect preload exposes the connection IPC — connecting with a link, and disconnecting — only while the main frame is the local `file:` page. Server-served pages do not receive that bridge. This keeps link entry, and the token in it, in packaged code without granting the remote application access to main-process connection controls. ^desktop-connect-local-bridge ## Local connection entry -Production startup opens the package's local `connect.html` page in **bootstrap** mode. A saved connection pre-fills the form and is submitted automatically. The native “Connect to server…” menu opens the same page in **manual** mode, where saved values pre-fill the form without automatic submission. A failed attempt leaves the entered values available for correction. ^desktop-connect-entry +Production startup opens the local page. ^desktop-connect-entry + +- **No saved connection.** The local page shows the setup screen. A submitted link is read as below and checked with the [desktop connect check](../updates/desktop-upgrade-gate.md#^pre-gate-desktop). When the check passes, the shell saves the connection and loads the server's page. When it fails for any reason, including a missing or rejected token, the setup screen shows its error state with the failure's message; no system-modal state appears on the setup screen. When the server requires a token the link lacks, or rejects the link's token, the message explains the problem in terms of the link, for example that the person needs the current link from their agent; its exact wording is code-governed. +- **A saved connection.** The local page never shows the setup screen. It shows the system modal's Connecting state and runs the desktop connect check automatically. When the check passes, the shell loads the server's page. When the server answers with `401`, the local page shows Access token required and makes no further attempts. Any other failure shows Can't connect with server, and the local page keeps repeating the check with backoff until it passes or the server answers `401`. -The connect preload exposes the connection IPC only while the main frame is the local `file:` page. Server-served pages do not receive that bridge. This keeps URL and token entry in packaged code without granting the remote application access to main-process connection controls. ^desktop-connect-local-bridge +Neither path decides whether the desktop app must be upgraded: a server that requires a newer app is reached like any other, and the server's page then shows the upgrade gate ([^boot-barrier](../updates/desktop-upgrade-gate.md#^boot-barrier)). + +## Disconnect from Server + +*Disconnect from Server* forgets the saved connection: the shell stops any attempt in progress, deletes the saved record, and loads the local page, which shows the setup screen. It asks for no confirmation. ^desktop-disconnect-server + +- The Television application menu always lists **Disconnect from Server**, with the accelerator `CmdOrCtrl+,` (⌘, on a Mac). The item is enabled exactly while a connection is saved: from the moment a connection is first saved until it is deleted. +- The item works whatever the window shows: the local page, the server's page, or the server's page halted at the [upgrade gate](../updates/desktop-upgrade-gate.md#^gate-screen). +- On the local page, Access token required and Can't connect with server also offer it as a button ([ui/app/system-modal/index.md](../../ui/app/system-modal/index.md#Interaction)). The server's page has no way to disconnect. ## URL normalization and persistence -Connect input normalizes to an HTTP or HTTPS server origin. Explicit HTTP(S) URLs are accepted; a bare host or host-and-port receives the `http:` scheme; invalid input and other schemes are rejected. The normalized origin is the value used by the [desktop connect check](../updates/desktop-upgrade-gate.md#^pre-gate-desktop), persistence, and page navigation. It is never passed to an operating-system URL opener. ^desktop-connect-normalization +Connect input is one link. Its `token` query parameter, when present, is the connection's token; the rest of the link normalizes to an HTTP or HTTPS server origin, discarding any path and other query parameters. Explicit HTTP(S) URLs are accepted; a bare host or host-and-port receives the `http:` scheme; invalid input and other schemes are rejected as a failed attempt. A link with no token makes a connection with no token. The normalized origin is the value used by the desktop connect check, persistence, and page navigation. It is never passed to an operating-system URL opener. ^desktop-connect-normalization -The saved `connection.json` record lives under Electron's `userData` directory and contains the normalized `serverURL` and token. Writes use a temporary sibling followed by rename. A missing or malformed record is treated as no saved connection. The [desktop connect check](../updates/desktop-upgrade-gate.md#^pre-gate-desktop) owns when the record changes and navigation begins. ^desktop-connect-persistence +The saved `connection.json` record lives under Electron's `userData` directory and contains the normalized `serverURL` and token. Writes use a temporary sibling followed by rename. A missing or malformed record is treated as no saved connection. [Disconnect from Server](#^desktop-disconnect-server) deletes the record. The [desktop connect check](../updates/desktop-upgrade-gate.md#^pre-gate-desktop) owns when the record changes and navigation begins. ^desktop-connect-persistence ## Server page entry The shell loads the normalized server origin's root. The page URL carries `mode=electron`, `desktopAppVersion`, and, for an authenticated connection, the token under the frozen `token` key. The renderer consumes the token for the live server connection and immediately removes it from the visible URL. Electron context is owned by [the upgrade gate](../updates/desktop-upgrade-gate.md#^electron-context), and the version handoff by [telemetry client architecture](../telemetry/client.md#^desktop-version-param). ^shell-page-entry -A genuine top-level remote load failure returns the window to the local connect screen in manual mode. Aborted navigations, subframe failures, and failures while the local page is loading do not trigger that recovery. ^desktop-connect-load-recovery +A genuine top-level remote load failure returns the window to the local page, which connects with the saved connection exactly as at startup. Aborted navigations, subframe failures, and failures while the local page is loading do not trigger that recovery. ^desktop-connect-load-recovery ## Testing -The path from the local connect screen to the served page is tested once from end to end in the real Electron app against a running Television server that requires a token. The [desktop connect check](../updates/desktop-upgrade-gate.md#Desktop connect check) owns tests of which identity responses pass. It also owns proof that the app saves the connection and loads the page only after the check passes. This spec requires no additional tests for those cases. - +The path from the setup screen to the served page is tested once from end to end in the real Electron app against a running Television server that requires a token. The [desktop connect check](../updates/desktop-upgrade-gate.md#Desktop connect check) owns tests of which identity responses pass. It also owns proof that the app saves the connection and loads the page only after the check passes. This spec requires no additional tests for those cases. diff --git a/specs/arch/desktop/index.md b/specs/arch/desktop/index.md index 9544e190..36acd80f 100644 --- a/specs/arch/desktop/index.md +++ b/specs/arch/desktop/index.md @@ -12,7 +12,7 @@ This root owns the desktop domain's module map, terms, cross-module coverage mod | Module | Spec | Owns | | --- | --- | --- | -| Desktop connection flow | [connect-flow.md](./connect-flow.md) | local connection entry, URL normalization, saved connections, server-page loading and recovery, and the local-only IPC bridge | +| Desktop connection flow | [connect-flow.md](./connect-flow.md) | the local page, connection entry from a link, saved connections and their reconnection, Disconnect from Server, server-page loading and recovery, and the local-only IPC bridge | | Desktop appearance | [appearance.md](./appearance.md) | confirmed appearance state across renderer, preload, main, app-wide native theme, disconnect, and reconnect | | ToDesktop build | [distribution.md](./distribution.md) | the private desktop workspace, the ToDesktop configuration and build target, the upload directory, the build script, candidate and test builds, desktop releases, and the download link | | Desktop updates | [updates.md](./updates.md) | the update runtime's start and options, the record of a downloaded update, the update operations on the native preload bridge, and the restart that installs an update | diff --git a/specs/arch/explainer-connection-states.md b/specs/arch/explainer-connection-states.md new file mode 100644 index 00000000..3ac87e20 --- /dev/null +++ b/specs/arch/explainer-connection-states.md @@ -0,0 +1,60 @@ +*Explainer: what a person sees while Television can't use its server — the desktop setup screen, the connection dialogs in the browser and the desktop app, and the rules that choose between them.* + +# Connecting and the connection dialogs + +People reach Television with a connect link, in a browser or in the Mac desktop app. While Television can't use its server — before it connects, after the connection drops, when the server can't be reached, when it wants an access token, or when it requires a newer desktop app — the person sees one of a small set of screens. This account follows those screens from first launch to recovery in one place. + +## How to read this + +This is a derived *explainer* under [spec policy](../spec-policy.md#^explainer-type). It brings together rules owned by the linked specs; those specs remain authoritative and win if this account conflicts with them. + +## Where the screens appear + +The screens appear in three places: + +- **The server's interface in a browser.** The web client the server delivers. +- **The server's interface in the desktop app.** The same web client, loaded into the desktop window. It has no address bar and no way to disconnect the app; the Television menu does that. +- **The desktop app's local page.** A page packaged in the app, which shows the [setup screen](../ui/setup/index.md) and the dialogs for starting a saved connection. It always wears the Clouds theme ([connection flow](./desktop/connect-flow.md#^desktop-local-page)). + +## The screens + +- **Setup screen** — the desktop app's connect screen when no connection is saved: a prompt for the person's agent and a field for the [connect link](../product/cli.md#^cli-connect-link) it returns ([setup screen](../ui/setup/index.md)). +- **Connecting** — the app has not connected yet. +- **Disconnected** — a working connection dropped and the app is reconnecting. +- **Can't connect with server** — the server can't be reached; the app keeps trying. +- **Access token required** — the server wants a token or rejected the one it was given; the app waits for the person. +- **Upgrade gate** — the server requires a newer desktop app ([gate](../ui/app/desktop-upgrade-gate/index.md)). + +The last five are states of the [system modal](../ui/app/system-modal/index.md), which owns what each shows in each place. + +## First launch of the desktop app + +With no saved connection, the local page shows the setup screen. The person pastes the link their agent gave them and presses Connect. If the app's connect check passes, it saves the connection and loads the server's interface. If the check fails, for any reason, the setup screen shows the failure under the field and no dialog appears; a token problem is explained in terms of the link ([connection flow](./desktop/connect-flow.md#^desktop-connect-entry)). A server that needs a newer desktop app is not a failure here: the connection is saved, the interface loads, and it shows the upgrade gate. + +## Starting the desktop app with a saved connection + +The local page never shows the setup screen while a connection is saved. It shows Connecting and checks the connection. If the server answers that the token is wrong, it shows Access token required and stops trying. If the server can't be reached, it shows Can't connect with server and keeps trying until it can. On the local page both dialogs carry a Disconnect from Server button ([connection flow](./desktop/connect-flow.md#^desktop-connect-entry)). + +## Inside the server's interface + +In a browser, and in the desktop app once the interface has loaded, the [app shell's rules](../ui/app/index.md#^ap-connection-states) choose the state: + +1. A definite answer from the server shows its own state at once: Access token required, or the upgrade gate. Disconnected and Can't connect with server never stand in for one. Once the gate shows, it stays until the page reloads. +2. A first connection that can't reach the server shows Can't connect with server. +3. A dropped connection shows Disconnected, and after three attempts in a row fail to reach the server, Can't connect with server ([escalation](../ui/app/index.md#^ap-reconnect-escalation)). +4. Disconnected and Can't connect with server keep trying and close by themselves on success. Access token required waits: in a browser the person opens a current connect link, and in the desktop app they use Disconnect from Server. +5. After a drop, the interface and its open artifacts stay rendered behind the dialog and resume without reloading. Before the first connection, and behind Access token required and the gate, nothing is rendered. + +If the server's interface fails to load in the desktop app, the window returns to the local page, which starts the saved connection again ([load recovery](./desktop/connect-flow.md#^desktop-connect-load-recovery)). + +## Leaving a server + +Disconnect from Server forgets the saved connection and returns to the setup screen. The Television menu always lists it, enabled while a connection is saved, and it works in every state, including the upgrade gate ([Disconnect from Server](./desktop/connect-flow.md#^desktop-disconnect-server)). On the local page, Access token required and Can't connect with server also offer it as a button; the server's interface can't disconnect the app, so its wording points to the menu. + +## Moving the window + +The desktop window has no title bar, so the rendered shell's own areas, such as the channel sidebar's titlebar and the navbar, are its drag handle. Where a dialog shows with no shell behind it, on the local page or in the server's interface, a 36px strip across the top of the window moves the window instead; the setup screen drags by its background ([system modal](../ui/app/system-modal/index.md#Interaction), [setup screen](../ui/setup/index.md#Interaction)). + +## No dismissal + +None of these dialogs closes on Escape or a backdrop press. Each changes or leaves only when the connection state changes ([system modal](../ui/app/system-modal/index.md#Interaction)). diff --git a/specs/arch/explainer-desktop-app.md b/specs/arch/explainer-desktop-app.md index cc2cc875..9dc4e79a 100644 --- a/specs/arch/explainer-desktop-app.md +++ b/specs/arch/explainer-desktop-app.md @@ -29,7 +29,7 @@ The two parts can come from different Television releases. The served interface ## Connecting to a server -The app starts on a local connect page packaged with it. [Desktop connection flow](./desktop/connect-flow.md) owns that page, the saved connection in the app's data directory, how an entered address is normalized, the page URL the app loads, and the return to the connect page when the server's page fails to load. +The app starts on a local page packaged with it. With no saved connection, that page shows the [setup screen](../ui/setup/index.md), where the person gives their agent a prompt and pastes back the [connect link](../product/cli.md#^cli-connect-link) it returns. With a saved connection, the page reconnects by itself, showing the [system modal](../ui/app/system-modal/index.md)'s connection states, and Television › Disconnect from Server returns it to the setup screen. [Desktop connection flow](./desktop/connect-flow.md) owns that page, the saved connection in the app's data directory, how a pasted link is read, Disconnect from Server, the page URL the app loads, and the return to the local page when the server's page fails to load. Which connection state the server's interface shows is the [app shell](../ui/app/index.md#^ap-connection-states)'s. The [connection states explainer](./explainer-connection-states.md) follows those screens from first launch to recovery. Before loading the server's page, the app checks that the address belongs to a reachable Television server that accepts its token. That *desktop connect check* is owned by the [upgrade gate spec](./updates/desktop-upgrade-gate.md#^pre-gate-handshake), because shells from older releases must still get through it to reach the gate. @@ -74,4 +74,4 @@ The gate and the recommendation compare the shell's version with thresholds set ## What stays with code -Some desktop behavior is deliberately left to code: the connect page's copy, timing and presentation ([connection flow](./desktop/connect-flow.md#what-this-owns)), how an artifact is routed to an iframe or a webview ([frame core](./artifact-frame/index.md#^frame-core-carve-out)), and the served interface's connection lifecycle ([channel state](./channel-state/index.md#^cs-connection-carve-out)). The [migration map](../spec-migration.md) records these boundaries. +Some desktop behavior is deliberately left to code: how a failed connect check is classified and the message the setup screen shows for it ([connection flow](./desktop/connect-flow.md#what-this-owns)), how the local page reproduces the web client's design ([^desktop-local-page](./desktop/connect-flow.md#^desktop-local-page)), how an artifact is routed to an iframe or a webview ([frame core](./artifact-frame/index.md#^frame-core-carve-out)), and the served interface's connection lifecycle ([channel state](./channel-state/index.md#^cs-connection-carve-out)). The [migration map](../spec-migration.md) records these boundaries. diff --git a/specs/arch/licensing.md b/specs/arch/licensing.md index 0f591899..dd5dca11 100644 --- a/specs/arch/licensing.md +++ b/specs/arch/licensing.md @@ -233,7 +233,7 @@ Some tracked folders leave the package as a unit: [bundled-theme installation](. The [product rule](../product/licensing.md#^licensing-theme-material) for a bundled theme that includes externally licensed material is met by one asset record per theme and, for material held in a text file, an attribution comment: ^licensing-theme-records -- the record's paths are every tracked file that contains the material, in the theme's UI spec and in its production folder; its components name each upstream work, with the license text copied verbatim from the upstream license file at a fixed revision whose terms are the ones declared, or from dated retained website terms for the [Unsplash material](../product/licensing.md#^licensing-unsplash); its surfaces are `cli` and `source`; its `noticesFolder` is the theme's folder under `packages/server/assets/themes/`; +- the record's paths are every tracked file that contains the material, in the theme's UI spec and in its production folder; its components name each upstream work, with the license text copied verbatim from the upstream license file at a fixed revision whose terms are the ones declared, or from dated retained website terms for the [Unsplash material](../product/licensing.md#^licensing-unsplash); its surfaces are `cli` and `source`, plus `desktop` when the desktop app ships the theme; its `noticesFolder` is the theme's folder under `packages/server/assets/themes/`; - a text file that contains the material opens with a comment that names the upstream project and its address, reproduces its copyright line when supplied by upstream, names its license, and points to `THIRD-PARTY-NOTICES.txt`. For a color scheme that file is the stylesheet: the comment is authored in the UI spec's `styles.css` and reaches `theme.css` through the byte-identical copy. A binary file such as an image carries no comment; the folder notices attribute it. A release tag or registry package is a valid source only when its own license file carries the declared terms. Whether a theme includes externally licensed material is decided in review, like every other vendored-asset question. diff --git a/specs/arch/updates/desktop-upgrade-gate.md b/specs/arch/updates/desktop-upgrade-gate.md index 46cddae5..ab2bde83 100644 --- a/specs/arch/updates/desktop-upgrade-gate.md +++ b/specs/arch/updates/desktop-upgrade-gate.md @@ -88,7 +88,7 @@ While halted, the client renders the gate screen as the entire interface. It del The authoritative built-in texts are the gate surface's content — [ui/app/desktop-upgrade-gate/index.md](../../ui/app/desktop-upgrade-gate/index.md) owns the words (`content.yml`: `downloaded_update_instructions` and `fallback_instructions`) and the restart button's labels. ^gate-fallback-content - **The restart action.** With the downloaded-update message, the screen shows the restart action, which works as the [desktop self-update notice's](./desktop-self-update-notice.md#^desktop-self-update-notice-restart) does. It is the screen's only control. ^gate-restart -- It has no dismiss affordance and nothing interactive exists behind it — the normal interface was never started. The shell's native menu remains available (it belongs to the shell, not the page), so "Connect to server…" still works — a gated user can still switch servers. +- It has no dismiss affordance and nothing interactive exists behind it — the normal interface was never started. The shell's native menu remains available (it belongs to the shell, not the page), so [Disconnect from Server](../desktop/connect-flow.md#^desktop-disconnect-server) still works — a gated user can always leave the server. - A halted client shows no update notice or bell, since nothing beyond the gate screen boots ([product/update-notifications.md#^gate-supersedes-toast](../../product/update-notifications.md#^gate-supersedes-toast)). The suppression is a first-class contract, not a reach into notice internals: the shared surface subscribes to presentation state whose suppression switch is the explicitly owned interface, and the gate's only interaction is flipping that switch. The switch is one-way for a page lifetime — every gate exit is a reload ([#^gate-reevaluation](#^gate-reevaluation)), so nothing ever un-suppresses in place. ^gate-precedence ## Telemetry: *desktop upgrade gate shown* diff --git a/specs/arch/updates/runbook-ux-staging.md b/specs/arch/updates/runbook-ux-staging.md index 73f794ac..e3333506 100644 --- a/specs/arch/updates/runbook-ux-staging.md +++ b/specs/arch/updates/runbook-ux-staging.md @@ -108,7 +108,7 @@ npx tsx tv-gate-server.local.mts TV_TEST_MODE=true TV_TEST_DESKTOP_APP_VERSION=1.0.0 npm run start:electron ``` -In the connect window, enter `http://127.0.0.1:4402` (no token — the demo server runs authless) and hit Connect: the window loads the interface from the server and halts at the gate screen with the channel's desktop instructions; no toast, no bell ([desktop-upgrade-gate.md#^gate-precedence](./desktop-upgrade-gate.md#^gate-precedence)). If the app auto-connects to a previously saved server, use the app menu's "Connect to server…". +If a connection is already saved, choose **Disconnect from Server** in the Television application menu to forget it and return to [setup](../desktop/connect-flow.md#^desktop-disconnect-server). In setup's link field, paste `http://127.0.0.1:4402` (the demo server needs no token) and press **Connect**. Setup shows Connected, then the window loads the server's interface and halts at the gate with the channel's desktop instructions; no toast, no bell ([setup interaction](../../ui/setup/index.md#interaction), [gate precedence](./desktop-upgrade-gate.md#^gate-precedence)). For the upgrade-and-relaunch exit, quit and relaunch claiming a current shell — normal boot, no gate, and the previously superseded toast presents: diff --git a/specs/index.md b/specs/index.md index 93952707..a480aa01 100644 --- a/specs/index.md +++ b/specs/index.md @@ -12,13 +12,13 @@ Generated from the first-line description of every spec by `scripts/specs-index. - `spec-ui.md` (82 lines) — *How visual design is specified and held authoritative: a spec per surface whose reference frames are rendered truth, staged for judgment in the workshop ([staging.md](staging.md)), and enforced by automated conformance.* - `spec-workflow.md` (248 lines) — *The authority chain and the spec-first workflow that keeps specs the source of truth.* - `staging.md` (46 lines) — *The staging workshop: where spec renders are staged and judged — the frameset viewer, the frames tree, and its conventions.* -- `terms.md` (172 lines) — *Glossary of Television spec terms; each term links to the spec that owns it. Maintained by review, not generated.* +- `terms.md` (176 lines) — *Glossary of Television spec terms; each term links to the spec that owns it. Maintained by review, not generated.* - **product/** - `artifact-navigation.md` (54 lines) — *The user's mental model for navigating inside an artifact that has links or multiple pages: browser-like back/forward history, discarding the forward trail on a new move, and remembering where the user was.* - `artifacts.md` (109 lines) — *What the user can do with and rely on from an artifact: its document's independence and interactivity, its name, deleting it, a shared one outliving its producer, and what survives moving around the app.* - `channels.md` (65 lines) — *What a channel is to a user: identity, creation, renaming, deletion, pinning, ordering, and how one channel is the focused one everywhere.* - - `cli.md` (561 lines) — *The user-facing `tv` command surface: help, the installation home and its config file, connection, server lifecycle, channels, artifacts, display focus, themes, bundled skills, and output.* - - `desktop-app.md` (54 lines) — *The desktop application users download: its Mac installer and download link, the platforms it supports, Television identity and saved data, and the npm package that distributed releases through 1.3.x.* + - `cli.md` (574 lines) — *The user-facing `tv` command surface: help, the installation home and its config file, connection, server lifecycle, channels, artifacts, display focus, themes, bundled skills, and output.* + - `desktop-app.md` (60 lines) — *The desktop application users download: its Mac installer and download link, the platforms it supports, Television identity and saved data, and the npm package that distributed releases through 1.3.x.* - `keyboard-navigation.md` (51 lines) — *How the keyboard moves through the app: the navigation chord that steps between tab pages and between channels, where it always works, and the one place it can't.* - `licensing.md` (109 lines) — *Television's licensing promises: the project is MIT and every published package and the desktop application say so, every shipped artifact carries the licenses and attributions of the third-party code and assets it redistributes, and the standard test suites block unacceptably-licensed dependencies from shipping.* - `tab-pages.md` (60 lines) — *What tab pages promise the user: one tab per page of the focused channel, labeled by its artifact, stepped through by tab or keyboard, with selection private to each browser.* @@ -32,6 +32,7 @@ Generated from the first-line description of every spec by `scripts/specs-index. - `canonical.md` (73 lines) — *The canonical bundle: the stylesheet and components script Television serves an artifact document at `/canonical/v/*`, the live and frozen inputs that produce it, and the compatibility contract over each version.* - `developer-skills.md` (23 lines) — *Developer skill distribution: the editable source collection and its installation into agent skill directories under the developer’s home.* - `explainer-appearance.md` (97 lines) — *Explainer: how Television combines a theme with the server-wide appearance preference across the application, artifacts, theme script frames, and Electron-native surfaces.* + - `explainer-connection-states.md` (60 lines) — *Explainer: what a person sees while Television can't use its server — the desktop setup screen, the connection dialogs in the browser and the desktop app, and the rules that choose between them.* - `explainer-desktop-app.md` (77 lines) — *Explainer: the desktop app across the spec tree — what users install, how it connects to a server and shows the served interface, how it stays current, how it is built, released and tested, and which specs own each part.* - `licensing.md` (280 lines) — *The licensing implementation: bundle-derived third-party notices generation for the esbuild, Vite, and skill outputs, the config file for elections, notice texts, and ignored packages, the vendored-asset provenance manifest, the committed source-surface notices, the suite-run allowlist gate, and the assertions on the CLI tarball and the desktop upload directory — wired into the existing build, verify, and publish pipeline.* - `making-skills.md` (35 lines) — *Arch spec: how a bundled skill gets made — authority, bundle derivation, optional UI-spec staging, and final consumer delivery.* @@ -47,11 +48,11 @@ Generated from the first-line description of every spec by `scripts/specs-index. - `index.md` (167 lines) — *Where channel and workspace state lives and how it stays consistent: the server-shared model — the channel record, pinning and pin order, the focused channel — what each browser keeps for itself, and the sync and convergence rules the client's state layer must satisfy.* - **cli/** - `admin-guide.md` (17 lines) — *The standalone administrator guide: procedural authority, human review, source and publication.* - - `index.md` (460 lines) — *The `packages/cli` module and packaged `tv` binary: source-module contracts, home and config resolution, command runtime boundaries, build assets, and service integration.* + - `index.md` (465 lines) — *The `packages/cli` module and packaged `tv` binary: source-module contracts, home and config resolution, command runtime boundaries, build assets, and service integration.* - `startup-bind-failure.md` (90 lines) — *All-or-nothing listener startup: any requested address that cannot bind is fatal — one structured log record, sockets closed, exit 69 — with the service manager's restart cadence as the sole retry loop.* - **desktop/** - `appearance.md` (26 lines) — *Desktop appearance: how confirmed display state controls Electron renderers, webviews, menus, and dialogs.* - - `connect-flow.md` (34 lines) — *The desktop main-process connection flow: local connection entry, URL normalization, saved connections, server-page loading and recovery, and the local-only IPC bridge.* + - `connect-flow.md` (50 lines) — *The desktop main-process connection flow: the local page, connection entry from a link, saved connections and their reconnection, Disconnect from Server, server-page loading and recovery, and the local-only IPC bridge.* - `distribution.md` (100 lines) — *The ToDesktop build of the desktop app: the private workspace, the ToDesktop configuration and build target, the upload directory, the build script, candidate and test builds, desktop releases, and the download link.* - `e2e-harness.md` (101 lines) — *The Electron end-to-end harness: preparing the exact runtime, planning its Linux environment, handing a validated executable to Playwright, launching the package, and proving real-Electron seams.* - `index.md` (59 lines) — *The desktop architecture root: server connection, appearance, the ToDesktop build, the app's updates, the Electron runtime for development and tests, the test harness, and main-process identity.* @@ -113,9 +114,9 @@ Generated from the first-line description of every spec by `scripts/specs-index. - **ui/** - `index.md` (7 lines) — *UI policy authority: cross-cutting requirements shared by every Television surface and browser-facing behavior.* - **app/** - - `index.md` (115 lines) — *UI spec: the app shell — its two interface regions and the noninteractive visual layers that installed themes can place around them.* + - `index.md` (117 lines) — *UI spec: the app shell — its two interface regions and the noninteractive visual layers that installed themes can place around them.* - **artifact-frame/** - - `index.md` (64 lines) — *UI spec: the artifact frame — the surface that holds one artifact's document.* + - `index.md` (66 lines) — *UI spec: the artifact frame — the surface that holds one artifact's document.* - **copy-button/** - `index.md` (45 lines) — *UI spec: the copy button — a control that copies a value and briefly confirms, without resizing.* - **desktop-upgrade-gate/** @@ -131,7 +132,7 @@ Generated from the first-line description of every spec by `scripts/specs-index. - **stage/** - `index.md` (135 lines) — *UI spec: the stage — the region artifacts are shown in, and the filmstrip of pages it holds.* - **system-modal/** - - `index.md` (41 lines) — *UI spec: the system modal — the app's interrupting surface: one dialog, its contents by state.* + - `index.md` (30 lines) — *UI spec: the system modal — the dialog that interrupts Television while it cannot use a server, its contents by state.* - **tab-strip/** - `index.md` (60 lines) — *UI spec: the tab strip — the row of open artifacts, and the tab that stands for each.* - **top-bar/** @@ -141,13 +142,13 @@ Generated from the first-line description of every spec by `scripts/specs-index. - **foundation/** - `index.md` (127 lines) — *UI spec: the foundation — the design tokens, base element styling, and materials every surface builds on.* - **button/** - - `index.md` (56 lines) — *UI spec: the button — what a button looks like across its states, and the attributes that vary it.* + - `index.md` (57 lines) — *UI spec: the button — what a button looks like across its states, and the attributes that vary it.* - **checkbox-list/** - `index.md` (18 lines) — *UI spec: the checklist — the static done/not-done idiom an authored artifact writes as markup.* - **icons/** - `index.md` (61 lines) — *UI spec: the icon — what one looks like, the set of them, and how one is sized.* - **input/** - - `index.md` (39 lines) — *UI spec: text inputs and textareas — shared text-entry styling in the application and artifacts.* + - `index.md` (43 lines) — *UI spec: text inputs and textareas — shared text-entry styling in the application and artifacts.* - **menu/** - `index.md` (25 lines) — *UI spec: the menu — a popover whose contents are actions, and the vocabulary its interior is built from.* - **popover/** @@ -158,6 +159,8 @@ Generated from the first-line description of every spec by `scripts/specs-index. - `index.md` (29 lines) — *UI spec: the Markdown editor’s color treatment across rendered Markdown, source-reveal states, editing affordances, and interactive tables.* - **onboarding-artifacts/** - `index.md` (227 lines) — *UI spec: four designed onboarding channels and their artifacts — the design-source markup, styling, content, and shared conventions.* + - **setup/** + - `index.md` (25 lines) — *UI spec: the setup screen — the desktop app's connect screen when it has no saved server connection, where a person pastes the link their agent gives them.* - **skillbench/** - `index.md` (26 lines) — *UI spec: skillbench — the eval review page's interaction, markup, and styling.* - **skills/** diff --git a/specs/product/cli.md b/specs/product/cli.md index 13c3e789..4369b5fc 100644 --- a/specs/product/cli.md +++ b/specs/product/cli.md @@ -51,6 +51,7 @@ The visible command surface is: | `tv skills` | Manage the bundled skill collection. | | `tv stop` | Uninstall/stop the persisted Television service. | | `tv status` | Print the selected home, server health, version, telemetry state, and service status as JSON. | +| `tv links` | Print the running server's connect links, one per line. | | `tv telemetry` | Enable or disable telemetry for the running server. | The canonical help flag is `--help`, with `-h` as its alias. The canonical version flag is `--version`, with `-V` and `-v` as its aliases. @@ -110,9 +111,9 @@ Output formatting is currently inconsistent and depends on the command. Successf | `tv telemetry enable` and `tv telemetry disable` | `{ "state": "active" \| "opted-out" \| "suppressed" \| "unavailable", "reason": "do-not-track" \| "ci" \| "development" \| "developer-host" \| null, "guidPresent": boolean, "region": "us" }` | | `tv stop` and `tv serve --persist-uninstall` | `{ "status": "stopped" }` | -Successful commands that change state or run against the server usually print human-readable text. `tv set-theme` reports its successful selection transition as described under [Display focus and themes](#display-focus-and-themes). `tv config set` confirms the file it wrote. `tv serve` and `tv serve --persist` print human-readable startup text. Errors print to stderr and return `1`; there is no separate usage-error exit code in `tv`. The one distinguishable nonzero status is listener-bind failure: `tv serve` exits with status `69` when a required listener cannot bind, as owned by [arch/cli/startup-bind-failure.md](../arch/cli/startup-bind-failure.md). +Successful commands that change state or run against the server usually print human-readable text. `tv set-theme` reports its successful selection transition as described under [Display focus and themes](#display-focus-and-themes). `tv config set` confirms the file it wrote. `tv serve` and `tv serve --persist` print human-readable startup text, and `tv links` prints one connect link per line. Errors print to stderr and return `1`; there is no separate usage-error exit code in `tv`. The one distinguishable nonzero status is listener-bind failure: `tv serve` exits with status `69` when a required listener cannot bind, as owned by [arch/cli/startup-bind-failure.md](../arch/cli/startup-bind-failure.md). -Startup URLs are printed as terminal hyperlinks using OSC-8 escape sequences, with the visible URL as the link text. A terminal that does not render OSC-8 hyperlinks still shows the URL text. When auth is required, the terminal-hyperlinked URL includes `?token=`. +When a command's stdout is an interactive terminal, each link it prints is a terminal hyperlink using OSC-8 escape sequences, with the visible URL as the link text; a terminal that does not render OSC-8 hyperlinks still shows the URL text. When stdout is anything else, such as a pipe, a file, or an agent's shell tool, each link is the plain URL with no escape sequences, because some tools that capture output strip escape sequences in a way that removes the URL with them. This applies to the startup URLs of `tv serve` and `tv serve --persist` and to `tv links`. ^cli-link-output ## Television home and configuration @@ -243,7 +244,7 @@ Neither command contacts a server. ### Connecting to the server -Commands that contact a server — the artifact, channel, focus, and theme-selection commands, `tv telemetry enable` and `tv telemetry disable`, and `tv status` — connect only to `http://localhost:`, where `` is the config file's effective `port`. There is no `--server ` override. Passing `--server` to a command that contacts the server is an unsupported-option error. +Commands that contact a server — the artifact, channel, focus, and theme-selection commands, `tv telemetry enable` and `tv telemetry disable`, `tv status`, and `tv links` — connect only to `http://localhost:`, where `` is the config file's effective `port`. There is no `--server ` override. Passing `--server` to a command that contacts the server is an unsupported-option error. These commands read the bearer token from `/state/token`, trimming surrounding whitespace. A missing or empty token file means the command sends no bearer token. If a token-protected server returns `401`, the CLI prints: @@ -260,6 +261,18 @@ tv list-channels requires --port because sets port 0. Pass the por tv list-channels --port 43123 does not accept --port because sets port 32848. Omit --port to use the configured port. ``` +### Connect links + +A *connect link* is the URL a person opens to use Television, in a browser or in the desktop app. It is the server's origin for one listening address, followed by `/?token=` when the server requires the bearer token; a tokenless server's connect link is the plain origin. A server listening on several addresses has one connect link per address. The startup URLs that `tv serve` and `tv serve --persist` print are connect links. ^cli-connect-link + +`tv links` prints the running server's connect links, one per line, with no other output: + +```bash +tv [--home ] links [--port ] +``` + +The links cover every address the server reports it is listening on, with the port it bound. They follow the running server, not the config file, which the server reads only when it starts: when the server requires the bearer token they carry the token from `/state/token`, and when it does not they are plain origins. If the server requires a token and rejects that one, the command prints the unauthorized message under [Connecting to the server](#Connecting to the server) to stderr, prints no links, and exits `1`. When the server cannot be reached, the command prints `Could not reach Television server at http://localhost:: ` to stderr, prints no links, and exits `1`. Links follow the [link output rule](#^cli-link-output). ^cli-links + ## Server lifecycle commands The foreground form of `tv serve` starts the server in the current process. This synopsis is complete for foreground operation; the persisted-service forms have separate synopses below. diff --git a/specs/product/desktop-app.md b/specs/product/desktop-app.md index c342a75f..1c1d14ab 100644 --- a/specs/product/desktop-app.md +++ b/specs/product/desktop-app.md @@ -6,7 +6,7 @@ Television's desktop application opens the Television interface in a native wind ## What this owns -This spec owns the user-visible installation, platform support, identity and saved data of the desktop application, and the status of the npm package that distributed desktop releases through 1.3.x. How the app is built, signed and hosted, the Electron runtime used for development and tests, and the main-process identity rules are architecture under [arch/desktop/index.md](../arch/desktop/index.md). How users learn about and receive updates, including the screen that can require a newer desktop release, is owned by [update-notifications.md](./update-notifications.md). The [desktop app explainer](../arch/explainer-desktop-app.md) walks through the whole app and links to each owning spec. +This spec owns the user-visible installation, platform support, connection to a server, identity and saved data of the desktop application, and the status of the npm package that distributed desktop releases through 1.3.x. How the app is built, signed and hosted, the Electron runtime used for development and tests, and the main-process identity rules are architecture under [arch/desktop/index.md](../arch/desktop/index.md). How users learn about and receive updates, including the screen that can require a newer desktop release, is owned by [update-notifications.md](./update-notifications.md). The [desktop app explainer](../arch/explainer-desktop-app.md) walks through the whole app and links to each owning spec. The *desktop application* is the Television app that ToDesktop builds and that users install from Television's download link. @@ -20,6 +20,12 @@ The desktop application supports Apple Silicon Macs running macOS 12 or later. O The desktop app is a Mac app, so wording about the desktop app or updating it names the Mac where it names a platform. ^desktop-mac-wording +## Connecting to a server + +The desktop application connects to one Television server with a [connect link](./cli.md#^cli-connect-link). At first launch, and whenever no connection is saved, it shows the [setup screen](../ui/setup/index.md), which offers a prompt the person gives their agent and a field for the link the agent returns. Once connected, it remembers the connection and reconnects by itself each time it opens. + +The Television menu's **Disconnect from Server** forgets the saved connection and returns to the setup screen. It is available whenever a connection is saved, including while the app shows that it must be upgraded, so a person can always leave a server. What the app shows while it cannot use its server is owned by the [app shell's connection states](../ui/app/index.md#^ap-connection-states) and the [desktop connection flow](../arch/desktop/connect-flow.md#^desktop-connect-entry). + ## The npm package Desktop releases through 1.3.x were published to npm as `@telepath-computer/television-desktop` and started with the `tv-desktop` command. The package receives no release after 1.3.x. Its npm page carries a deprecation message, and its published versions stay installable. Every npm desktop release is 1.3.x or earlier, and every downloaded desktop release is 1.4.0 or later. Update notices shown to npm-installed apps are owned by [update-notifications.md](./update-notifications.md). ^desktop-npm-package diff --git a/specs/product/licensing.md b/specs/product/licensing.md index 930ad1f9..e23eda56 100644 --- a/specs/product/licensing.md +++ b/specs/product/licensing.md @@ -41,7 +41,7 @@ The promise: **every shipped surface that redistributes third-party material car - a text file in the theme that contains the material, such as the stylesheet holding a color scheme, opens with a comment naming the upstream project and its address, its copyright line when upstream supplies one, and its license, because a theme's files are copied and served on their own; - the theme's folder carries a notices file with the complete license terms of every such material in it, because Television copies that folder into the user's data directory, where the user owns it; -- Television's notices for the public repository and the published CLI package list the upstream project with everything else they declare. +- Television's notices for the public repository, the published CLI package, and the desktop app when it ships the theme list the upstream project with everything else they declare. The theme's README links each upstream license at a fixed revision whose terms are the ones declared. For a photo licensed through a website without versioned terms, it records the photo URL, photographer, license URL, and date the retained terms were retrieved. Attribution never invents an upstream copyright holder or year. diff --git a/specs/spec-migration.md b/specs/spec-migration.md index 5fe76222..f0f012fe 100644 --- a/specs/spec-migration.md +++ b/specs/spec-migration.md @@ -12,7 +12,7 @@ Specs govern the requirements they state. Code governs behavior outside those re ## The redesign boundary -The redesigned application is spec-governed only at the product, UI, and architecture contracts listed below. Code beneath those contracts remains code-authoritative when no owning spec has migrated it; the listed frame-core, connection-lifecycle, navigation-recording, and ordinary desktop-connect presentation carve-outs are deliberate examples, not missing placeholder specs. This keeps the redesign from claiming untouched server, shared-client, transport, rendering, and service internals merely because a new contract reaches them. +The redesigned application is spec-governed only at the product, UI, and architecture contracts listed below. Code beneath those contracts remains code-authoritative when no owning spec has migrated it; the listed frame-core, connection-lifecycle, navigation-recording, and desktop connect-check failure-message carve-outs are deliberate examples, not missing placeholder specs. This keeps the redesign from claiming untouched server, shared-client, transport, rendering, and service internals merely because a new contract reaches them. A redesign contract may impose one requirement on an otherwise unmigrated module. That requirement lives in a clearly marked **buffer** in the nearest owning spec and is authoritative there; surrounding module behavior does not migrate with it. Layout migration, channel-state display migration, onboarding server/store integration, telemetry server integration, startup bind failure, and the ACP agent home context use this pattern. A later architecture spec absorbs the buffer and its assertion relationships without changing the requirement merely to relocate it. @@ -25,7 +25,7 @@ The migrated areas, authoritative here: - **Testing** → the cross-cutting [testing-policy.md](arch/testing-policy.md) (how tests are written and judged), and the test-runner subsystem under `specs/arch/test-runner/`: [test-runner.md](arch/test-runner/test-runner.md) and its companions — the test registry, preflight, reporting, sharded execution, the Blaxel provider, GitHub CI, attestation, and flaky tests. - **Node and npm versions** → [arch/node-versions.md](arch/node-versions.md) for the development/build toolchain, the CI/publish toolchain, and the published consumer floor. - **Product versioning** → [product/versioning.md](product/versioning.md) for Television release naming, exact Semantic Versions, the version each desktop release carries, and the Television 1.3 theme-release baseline; [arch/updates/index.md](arch/updates/index.md) for version validation, comparison rules, workspace synchronization, npm publishing eligibility, serialization and action pinning, and publication order. -- **Desktop application and connection flow** → [product/desktop-app.md](product/desktop-app.md) for installation, platform support, identity, saved data, and the npm package's status; [arch/desktop/connect-flow.md](arch/desktop/connect-flow.md) for local connection entry, URL normalization, saved connections, server-page loading and recovery, and the local-only IPC bridge; [arch/desktop/appearance.md](arch/desktop/appearance.md) for confirmed appearance state across the renderer, preload, main, and Electron native theme; the remaining architecture modules under `specs/arch/desktop/` own the ToDesktop build, the app's updates, the Electron runtime for development and tests, and the Electron e2e harness. Connection-screen copy, timing, and presentation remain code-authoritative under the connection spec's stated boundary. +- **Desktop application and connection flow** → [product/desktop-app.md](product/desktop-app.md) for installation, platform support, connection to a server, identity, saved data, and the npm package's status; [arch/desktop/connect-flow.md](arch/desktop/connect-flow.md) for the local page, connection entry from a link, saved connections and their reconnection, Disconnect from Server, server-page loading and recovery, and the local-only IPC bridge; [arch/desktop/appearance.md](arch/desktop/appearance.md) for confirmed appearance state across the renderer, preload, main, and Electron native theme; the remaining architecture modules under `specs/arch/desktop/` own the ToDesktop build, the app's updates, the Electron runtime for development and tests, and the Electron e2e harness. The connect screen's interaction, markup, and styling are owned by [ui/setup/index.md](ui/setup/index.md); how a failed desktop connect check is classified, and the wording of its message, remain code-authoritative under the connection spec's stated boundary. - **Artifacts, their frame, bridge, and navigation** → [product/artifacts.md](product/artifacts.md) and [product/artifact-navigation.md](product/artifact-navigation.md) for user-facing behavior; the modules under [arch/artifact-frame/index.md](arch/artifact-frame/index.md) for the iframe/webview bridge, readiness, local path-content updates, reload, and navigation contracts; [arch/artifact-frame/proxy-caching.md](arch/artifact-frame/proxy-caching.md) for the artifact proxy's complete cache contract; [ui/app/artifact-frame/index.md](ui/app/artifact-frame/index.md) exclusively owns the frame's interaction, markup, and styling. [Reload and navigation](arch/artifact-frame/reload-navigation.md#^rn-local-content-change) carries a narrow integration buffer for the shared file/folder content watcher and the path-artifact owner that translates its handoffs into the artifact event. [Theme architecture](arch/themes/index.md#^themes-active-package-watch) owns the active-package owner and its separate theme event while reusing those buffered watcher mechanics; surrounding server and store behavior remains code-authoritative. The local folder watcher and the remaining artifact-frame implementation conform to these contracts. The frame's rendering core (renderer routing, markdown host, navigation internals) deliberately remains code-authoritative except for the Markdown editor color treatment listed with the UI specs below; the boundary is stated in [the frame-core carve-out](arch/artifact-frame/index.md#^frame-core-carve-out). The presentation of the frame's loading and unreachable states is likewise code-authoritative ([product/artifacts.md#^af-states-carve-out](product/artifacts.md#^af-states-carve-out)); the behavioral promises around unreachable producers remain the product spec's. - **Administrator guide** → [arch/cli/admin-guide.md](arch/cli/admin-guide.md) for the standalone guide’s procedural authority, human review, and publication. - **CLI** → [product/cli.md](product/cli.md) for user-facing `tv` behavior, including the Television home and its config file; [arch/cli/index.md](arch/cli/index.md) for `packages/cli`, the home and config resolver, reader, and writer that the CLI imports from the server package, the packaged binary, build assets, runtime boundaries, and service integration, plus a buffer for the home context that the ACP bridge and web chat give launched agents. @@ -40,7 +40,7 @@ The migrated areas, authoritative here: - **Keyboard navigation** → [product/keyboard-navigation.md](product/keyboard-navigation.md) for the adopted navigation-chord behavior, and [arch/ui/keyboard-navigation.md](arch/ui/keyboard-navigation.md) for the shell handler and delegation map across bridge, state, and UI owners. The implementation conforms. - **Layout model** → [arch/layout/index.md](arch/layout/index.md) for the adopted channel layout data model (the ordered tab-page list), stored-format versioning, and layout-update semantics, with [arch/layout/migration.md](arch/layout/migration.md) for the one server migration — the boot-time pass carrying a data directory across everything the redesign changes on disk, from the shipped name rename through the layout and display-record conversion. Pages carry a size beside their geometry ([arch/layout/index.md#^ly-page-size](arch/layout/index.md#^ly-page-size)). - **Channel and client state** → [arch/channel-state/index.md](arch/channel-state/index.md) for the adopted state inventory (server-shared vs. browser-persisted vs. in-memory), shared-state contracts (channel wire shape, pinned list, focused channel), sync and convergence rules, and migration of pre-redesign browser-persisted state. The implementation conforms. The connection lifecycle, the wire vocabulary, and client service internals remain code-authoritative; the boundary is stated in the spec's carve-out section. -- **UI specs** → the system in [spec-ui.md](spec-ui.md) (templates, styles, and content as rendered truth, staged in the workshop, [staging.md](staging.md)), and the surfaces under `specs/ui/`: the app shell, channel sidebar, navbar, tab strip, stage and its navigation, system modal, skill selector, copy button, desktop upgrade gate, update notification, Markdown editor color treatment, skillbench, the sidebar-view skill surface, and the onboarding artifact designs — plus the foundation (tokens, reset, element styling, controls, panels, fonts) and the elements (button, popover, menu, icons, dialog). The implementation conforms to these UI contracts. The artifact frame's UI surface is listed with its domain above; the Markdown editor's remaining rendering core stays within that domain's code-authoritative carve-out. +- **UI specs** → the system in [spec-ui.md](spec-ui.md) (templates, styles, and content as rendered truth, staged in the workshop, [staging.md](staging.md)), and the surfaces under `specs/ui/`: the app shell, channel sidebar, navbar, tab strip, stage and its navigation, system modal, skill selector, copy button, desktop upgrade gate, update notification, Markdown editor color treatment, skillbench, the sidebar-view skill surface, the onboarding artifact designs, and the desktop setup screen — plus the foundation (tokens, reset, element styling, controls, panels, fonts) and the elements (button, popover, menu, icons, dialog). The implementation conforms to these UI contracts. The artifact frame's UI surface is listed with its domain above; the Markdown editor's remaining rendering core stays within that domain's code-authoritative carve-out. Areas are added to this list as they migrate. diff --git a/specs/terms.md b/specs/terms.md index 381a8cd9..8fe3dccd 100644 --- a/specs/terms.md +++ b/specs/terms.md @@ -85,6 +85,7 @@ These are the terms used to talk about the spec system itself. Domain terms are - *Television home* (also *home*) — the directory holding one Television installation: its optional config file and all the state and content the server keeps. Selected by `--home`, otherwise the default home; distinct from the operating-system home directory. Owned by [product/cli.md](product/cli.md#^cli-home). - *default home* — the Television home a command uses when it is given no `--home`: the directory named in `~/.tv-home` when that file exists, otherwise `~/.television`. Owned by [product/cli.md](product/cli.md#^cli-home-selection); `~/.tv-home` by [product/cli.md](product/cli.md#^cli-home-pointer). - *config file* — a home's optional `config.json`, holding the settings the server and local commands share: port, additional listeners, authentication, and the installed-by agent. Owned by [product/cli.md](product/cli.md#^cli-config-file); its reader and writer by [arch/cli/index.md](arch/cli/index.md). +- *connect link* — the URL a person opens to use Television, in a browser or the desktop app: the server's origin for one listening address, with `/?token=` when the server requires the bearer token. Owned by [product/cli.md](product/cli.md#^cli-connect-link). - *directive error* — a usage error the CLI itself detects while parsing arguments or validating whether a command and its options can be dispatched. Owned by `product/cli.md`. - *focus directive* — a creation-command flag that asks for the new channel or artifact to be shown immediately. CLI spelling and signal emission are owned by [product/cli.md](product/cli.md); the resulting focus behavior by the product owner below. - *channel focus* — the persisted active channel. Owned by [product/channels.md](product/channels.md) and its shared-state contracts. @@ -95,6 +96,9 @@ These are the terms used to talk about the spec system itself. Domain terms are ## Desktop - *desktop application* — defined and owned by [product/desktop-app.md](product/desktop-app.md). +- *setup screen* — the desktop app's connect screen when it has no saved server connection. Owned by [ui/setup/index.md](ui/setup/index.md). +- *local page* — the desktop app's packaged `connect.html`, which shows the setup screen and the connection dialogs while a saved connection is starting. Owned by [arch/desktop/connect-flow.md](arch/desktop/connect-flow.md#^desktop-local-page). +- *Disconnect from Server* — the desktop app's command that forgets the saved connection and returns to the setup screen. Owned by [arch/desktop/connect-flow.md](arch/desktop/connect-flow.md#^desktop-disconnect-server). - *Electron runtime* — defined and owned by [arch/desktop/runtime.md](arch/desktop/runtime.md). - *valid Electron runtime* — defined and owned by [arch/desktop/runtime.md](arch/desktop/runtime.md). - *upload directory* — defined and owned by [arch/desktop/distribution.md](arch/desktop/distribution.md). diff --git a/specs/ui/app/artifact-frame/artifact-frame.frame b/specs/ui/app/artifact-frame/artifact-frame.frame index 02f59f7e..cba9af3d 100644 --- a/specs/ui/app/artifact-frame/artifact-frame.frame +++ b/specs/ui/app/artifact-frame/artifact-frame.frame @@ -4,6 +4,8 @@ params: src: type: string description: the artifact document this frame shows + body: + description: "markup the frame holds in place of a document, for a surface that is not an artifact; absent, the frame holds the src document" title: type: string default: "Roadmap" @@ -24,6 +26,10 @@ params: type: boolean default: false description: "an external page artifact in a browser under browser demo mode: the artifact icon is a button that returns the frame to the artifact's own URL, and Back and Forward never render" + menu: + type: boolean + default: true + description: "the title bar offers the frame's menu; off for a frame with no artifact to act on" imports: - content: ./content.yml style: | @@ -99,6 +105,14 @@ style: | border: 0; } + /* Held content in place of a document takes the same room, and draws on + the frame's own ground. */ + .artifact-frame-clip > .artifact-frame-content { + flex: 1; + min-width: 0; + min-height: 0; + } + /* The title bar: a quiet band below the document, naming the artifact; its contents grow as they are designed. The band is a step off the frame's surface rather than the surface itself — the faintest step there is, enough that the @@ -148,7 +162,7 @@ style: | ---
- + {% if body %}
{{ body }}
{% else %}{% endif %}
{% if can_return %} @@ -161,8 +175,10 @@ style: | {% endif %} + {% if menu %} {% render './artifact-menu', full_screen: full_screen %} + {% endif %}
diff --git a/specs/ui/app/artifact-frame/error-page/content.yml b/specs/ui/app/artifact-frame/error-page/content.yml index 31320919..84e4fdb9 100644 --- a/specs/ui/app/artifact-frame/error-page/content.yml +++ b/specs/ui/app/artifact-frame/error-page/content.yml @@ -6,6 +6,6 @@ missing_path_label: "Registered path:" missing_recovery: Recreate the file at this path, update the artifact path, or remove the artifact. url_title: This is an external web page url_address_label: "This is the URL you're trying to visit:" -url_explanation: Television can't display external web pages directly inside a web browser. You can view external web pages right inside Television with the desktop app. -url_install_intro: "To install the Television desktop app, ask your agent to follow the Television admin guide:" +url_explanation: Television can't display external web pages directly inside a web browser. The Television desktop app can show them right inside Television; it is currently available only for Macs. +url_install_intro: "To install the desktop app on a Mac, ask your agent to follow the Television admin guide:" url_install_guide: https://television.run/install.md diff --git a/specs/ui/app/artifact-frame/index.md b/specs/ui/app/artifact-frame/index.md index dc20d39d..001e5ad2 100644 --- a/specs/ui/app/artifact-frame/index.md +++ b/specs/ui/app/artifact-frame/index.md @@ -4,6 +4,8 @@ The surface that holds a single artifact's document, and the affordances to interact with that artifact and see information about it, such as its title. Every artifact Television shows is shown in an artifact frame. +Another surface may wear the frame's look around content of its own, as the [setup screen](../../setup/index.md) does: the frame's `body` parameter holds that content in place of a document, and `menu` turns the title bar's menu off. Such a surface is not an artifact frame. It has no document, no artifact, and none of the frame's behavior or machinery. + **Status:** implemented stage-one frame UI authority. ## Markup and styling diff --git a/specs/ui/app/copy-button/copy-button.frame b/specs/ui/app/copy-button/copy-button.frame index abfe6fb6..12275907 100644 --- a/specs/ui/app/copy-button/copy-button.frame +++ b/specs/ui/app/copy-button/copy-button.frame @@ -6,6 +6,7 @@ params: prompt: { type: string, default: "Make me a table", description: the value copied to the clipboard } intent: { type: string, description: 'the button intent (for example, "primary" or "danger")' } copied: { type: boolean, default: false, description: whether the confirmed state is shown } + size: { enum: [sm, default, lg], default: sm, description: "the button's size; default renders no size attribute" } imports: - content: ./content.yml style: | @@ -55,7 +56,7 @@ style: | white-space: nowrap; } --- - diff --git a/specs/ui/app/desktop-upgrade-gate/desktop-upgrade-gate.frame b/specs/ui/app/desktop-upgrade-gate/desktop-upgrade-gate.frame index 195e1f40..1e718944 100644 --- a/specs/ui/app/desktop-upgrade-gate/desktop-upgrade-gate.frame +++ b/specs/ui/app/desktop-upgrade-gate/desktop-upgrade-gate.frame @@ -8,6 +8,8 @@ params: enum: [absent, ready, restarting] default: absent description: the restart button shown with the downloaded-update message; absent, none renders + top_layer_content: + description: optional window controls passed to the dialog outside its scrolling panel imports: - content: ./content.yml style: | @@ -63,4 +65,4 @@ style: | } --- {% capture instructions %}
{{ body }}
{% if restart == 'ready' %}
{% elsif restart == 'restarting' %}
{% endif %}{% endcapture %} -
{% render '../dialog/dialog', content: instructions %}
+
{% render '../dialog/dialog', content: instructions, top_layer_content: top_layer_content %}
diff --git a/specs/ui/app/desktop-upgrade-gate/index.md b/specs/ui/app/desktop-upgrade-gate/index.md index 25321c3c..5a5e58c0 100644 --- a/specs/ui/app/desktop-upgrade-gate/index.md +++ b/specs/ui/app/desktop-upgrade-gate/index.md @@ -8,7 +8,7 @@ The production implementation is `packages/web/src/views/desktop-upgrade-gate.ts The supporting artifacts, each authoritative: -- [ui/app/desktop-upgrade-gate/desktop-upgrade-gate.frame](./desktop-upgrade-gate.frame) — the surface's rendered markup, whole; its parameters are `body`, the selected message's markdown output, and `restart`, the restart button's state. +- [ui/app/desktop-upgrade-gate/desktop-upgrade-gate.frame](./desktop-upgrade-gate.frame) — the surface's rendered markup, whole; its parameters are `body`, the selected message's markdown output; `restart`, the restart button's state; and `top_layer_content`, the system modal's optional window drag strip, passed through to the shared dialog. - [ui/app/desktop-upgrade-gate/content.yml](./content.yml) — the surface's copy: the downloaded-update message and the built-in fallback instructions (markdown), and the restart button's labels ([arch/updates/desktop-upgrade-gate.md#^gate-instructions](../../../arch/updates/desktop-upgrade-gate.md#^gate-instructions) owns the selection, and [the product](../../../product/update-notifications.md#^gate-instructions-fallback) what each message tells the user). The template imports only the button labels — the selected message arrives as the `body` argument; a stated exception to [spec-ui.md](../../../spec-ui.md)'s imported-content shape. ## Interaction @@ -31,4 +31,4 @@ Under [Tests are the validation mechanism](../../../arch/testing-policy.md#Tests ## Decisions - No chrome: no fixed heading or fixed text beyond the restart button's labels — whoever authors the message authors the rest of the screen. -- The gate stands over no chrome, on the app's bare ground under the dialog's backdrop — the same look as every hidden state ([ui/app/system-modal/index.md](../system-modal/index.md), Appearance). Nothing renders behind a halted app. +- The gate stands over no chrome, on the app's bare ground under the dialog's backdrop ([ui/app/index.md](../index.md#^ap-connection-states)). Nothing renders behind a halted app. In the desktop app, the [system modal](../system-modal/index.md#Interaction)'s drag strip lets the window move. diff --git a/specs/ui/app/dialog/dialog.frame b/specs/ui/app/dialog/dialog.frame index 7e588249..e2f931b4 100644 --- a/specs/ui/app/dialog/dialog.frame +++ b/specs/ui/app/dialog/dialog.frame @@ -3,6 +3,8 @@ title: Dialog params: content: description: "the panel's contents: the composing surface's markup" + top_layer_content: + description: "optional window controls inside the modal boundary, outside the scrolling panel" style: | /* Every rule is wrapped in `:where()` — ambient styling is the weakest thing in the cascade ([[ui/foundation/button/index.md#^bt-ambient-is-weakest]]), @@ -57,6 +59,7 @@ style: | ---
+ {{ top_layer_content }}
{{ content }}
diff --git a/specs/ui/app/dialog/index.md b/specs/ui/app/dialog/index.md index 6ebc4271..83763a33 100644 --- a/specs/ui/app/dialog/index.md +++ b/specs/ui/app/dialog/index.md @@ -2,7 +2,7 @@ # Dialog (UI) -A panel that obstructs the current screen: a backdrop over everything, with a centred dialog on it. A backdrop press or Escape dismisses the dialog. Pressing inside the panel does not dismiss it unless the activated control closes it; the app's environment may also withdraw it. A dialog is used to interrupt the user for an important decision or issue. Its styling is part of Television's base element vocabulary ([ui/foundation/index.md](../../foundation/index.md), Elements). +A panel that obstructs the current screen: a backdrop over everything, with a centred dialog on it. A backdrop press or Escape dismisses the dialog, unless the composing surface holds it in place, as the [system modal](../system-modal/index.md#Interaction) and the [desktop upgrade gate](../desktop-upgrade-gate/index.md#Interaction) do. Pressing inside the panel does not dismiss it unless the activated control closes it; the app's environment may also withdraw it. A dialog is used to interrupt the user for an important decision or issue. Its styling is part of Television's base element vocabulary ([ui/foundation/index.md](../../foundation/index.md), Elements). **Status:** current stage-one dialog authority. The panel and backdrop are deliberately minimal; styling grows only when a composing surface needs it. @@ -38,9 +38,9 @@ Presenting and withdrawing the dialog must happen immediately, with no CSS trans Under [The suite does not assert styling adherence](../../../arch/testing-policy.md#^ui-styling-out), this surface's suite does not cover the backdrop, centring, dimensions, padding, panel chrome, or destructive colour. Those remain staging and review concerns. -Under [Tests are the validation mechanism](../../../arch/testing-policy.md#Tests are the validation mechanism), [artifact-frame testing](../../app/artifact-frame/index.md#Testing), [channel deletion](../../app/sidebar/index.md#Deleting), and [system-modal testing](../../app/system-modal/index.md#Testing) own the contents, semantics, controls, and surface-specific actions of their dialog compositions. Those specs also own when their dialogs are presented or withdrawn. This dialog suite owns only rendering the shared panel and overlay, keeping focus and input from reaching controls behind the dialog, dismissing an ordinary dialog after a backdrop press or Escape, distinguishing a press inside the panel from a backdrop press, and closing an open popover when the dialog appears. +Under [Tests are the validation mechanism](../../../arch/testing-policy.md#Tests are the validation mechanism), [artifact-frame testing](../../app/artifact-frame/index.md#Testing), [channel deletion](../../app/sidebar/index.md#Deleting), and the [system modal](../../app/system-modal/index.md) own the contents, semantics, controls, and surface-specific actions of their dialog compositions. Those specs also own when their dialogs are presented or withdrawn. This dialog suite owns only rendering the shared panel and overlay, keeping focus and input from reaching controls behind the dialog, dismissing an ordinary dialog after a backdrop press or Escape, distinguishing a press inside the panel from a backdrop press, and closing an open popover when the dialog appears. -Under the same rule, [desktop-upgrade-gate testing](../../app/desktop-upgrade-gate/index.md#Testing) owns the exception in which a backdrop press and Escape leave the gate presented. [Product update acceptance](../../../product/update-notifications.md#Testing) owns the gate's blocking behavior in the real Electron app. This dialog suite does not apply its ordinary dismissal requirement to that gate. It does not repeat the gate's proof. +Under the same rule, [desktop-upgrade-gate testing](../../app/desktop-upgrade-gate/index.md#Testing) and the [system modal](../../app/system-modal/index.md#Interaction) own the exception in which a backdrop press and Escape leave their dialog presented. [Product update acceptance](../../../product/update-notifications.md#Testing) owns the gate's blocking behavior in the real Electron app. This dialog suite does not apply its ordinary dismissal requirement to those dialogs. It does not repeat the gate's proof. Under the same rule, [foundation architecture](../../../arch/ui/foundation.md#Testing) owns proof that the authoritative dialog stylesheet reaches a fresh production web build. The dialog suite requires no duplicate test of that delivery. diff --git a/specs/ui/app/index.md b/specs/ui/app/index.md index fddbfc6e..7b8e301a 100644 --- a/specs/ui/app/index.md +++ b/specs/ui/app/index.md @@ -72,19 +72,21 @@ Collapse and expansion preserve channel selection, tab order, page selection, an ## Connection states -The shell shows at most one system-modal state at a time, chosen by this order: - -- **Sign-in outranks everything.** While the server requires a token, the sign-in state is the whole presentation: no interstitial, no gate, no shell. -- **The gate is never covered.** A client halted at the desktop upgrade gate is not "connecting" — the connection interstitial never stacks over the gate. (What halts a client is [arch/updates/desktop-upgrade-gate.md](../../arch/updates/desktop-upgrade-gate.md)'s.) -- **A lost session dims; a missing session hides.** After a session has existed, the disconnected state holds the shell — and its live frames — mounted behind the backdrop; before any session, the connecting and error states stand on bare ground with no shell rendered ([ui/app/system-modal/index.md](./system-modal/index.md), Appearance). - +While the server's interface can't use its server, the [system modal](./system-modal/index.md) shows one state at a time, chosen by these rules. The [connection states explainer](../../arch/explainer-connection-states.md) follows these rules alongside the desktop app's own screens. ^ap-connection-states + +- **A definite answer from the server shows its own state at once.** When the server asks for an access token or rejects the one it was given, the state is Access token required. When the desktop app is gated ([arch/updates/desktop-upgrade-gate.md](../../arch/updates/desktop-upgrade-gate.md#^boot-barrier)), the state is the upgrade gate. This holds on the first connection and on every reconnect, including while Disconnected or Can't connect with server is showing; neither of those ever stands in for a definite answer. Once the upgrade gate shows, it stays until the page reloads: nothing replaces or covers it, including a later rejected token ([^gate-screen](../../arch/updates/desktop-upgrade-gate.md#^gate-screen)). +- **Access token required waits.** A rejected token does not fix itself, so the app makes no further attempts, and a browser forgets the token it had stored. In a browser, the person recovers by opening a current connect link; in the desktop app, by Disconnect from Server. +- **A first connection that cannot reach the server** shows Can't connect with server. Before that the state is Connecting. +- **A dropped connection** shows Disconnected. After three reconnect attempts in a row fail to reach the server, it shows Can't connect with server. Only attempts that cannot reach the server count toward the three, and a successful connection starts the count again. ^ap-reconnect-escalation +- **Disconnected and Can't connect with server keep trying,** and leave by themselves when an attempt succeeds. +- **What is behind the dialog.** After a session has existed, the shell, with its live artifact frames, stays mounted behind Disconnected and Can't connect with server, and resumes without reloading when the connection returns. Before any session, and for Access token required and the upgrade gate, no shell is rendered; the dialog stands on the app's bare ground. ## Testing Under [What a UI surface's suite is responsible for](../../arch/testing-policy.md#What a UI surface's suite is responsible for), this surface's suite must prove the shell composition for the `connected`, `no-channel`, `empty-channel`, `connecting`, `disconnected`, `unauthorized`, `error`, and `needs-upgrade` application states. The permanent foreground div is sampled in every state in that matrix. When the facts for more than one connection state are true, the suite must prove that the [connection-state rules](#connection-states) select one presentation. It must also prove that two system-modal states never appear together. -Real-browser acceptance of sign-in precedence must use a running server that requires a token. It must cover rejection of a stored token during boot. It must also cover rejection of a stored token after a session has been established. In both cases, the stored token must be cleared. Sign-in must be the only presentation, with no shell or other system-modal state. +Acceptance of the connection states runs in a real browser against a running server, one that requires a token where a token is involved. A rejected stored token is covered both during boot and after a session has been established. -Disconnection acceptance must run in a real browser against a running server. When an established session drops, the disconnected modal must appear over the mounted shell. No other system-modal state may appear. The shell and its live artifact frames must remain the same nodes, and artifact document state must remain intact. Reconnecting must remove the modal and resume that shell without replacing those nodes. +Acceptance of a dropped connection holds the shell's identity: the shell and its live artifact frames remain the same nodes, and artifact document state stays intact, through Disconnected, through [the escalation](#^ap-reconnect-escalation) to Can't connect with server, and through reconnecting. At desktop window widths, real-browser acceptance must show that an overflowing tab strip or stage remains contained inside the shell. The document must have no horizontal scroll range. An attempt to scroll the document horizontally must leave the window's horizontal scroll position unchanged. diff --git a/specs/ui/app/system-modal/content.yml b/specs/ui/app/system-modal/content.yml index b32c01e2..b8fefaaa 100644 --- a/specs/ui/app/system-modal/content.yml +++ b/specs/ui/app/system-modal/content.yml @@ -1,13 +1,13 @@ # The system modal's copy ([[ui/app/system-modal/index.md]], States). The -# reattempt line interpolates the template's `seconds`. +# reconnect line interpolates the template's `seconds`. connecting_title: Connecting disconnected_title: Disconnected -reattempting: "Reattempting in {{ seconds }}s…" -reattempting_now: Reattempting now… -unauthorized_title: Enter access token -unauthorized_copy: This server requires an access token to connect. -unauthorized_placeholder: paste token here -unauthorized_field_label: Access token -unauthorized_invalid: The previous token was rejected. Try again. -unauthorized_submit: Connect +reconnecting: "Reconnecting in {{ seconds }}s…" +reconnecting_now: Reconnecting now… +unauthorized_title: Access token required +unauthorized_copy_browser: This server requires a valid access token to connect. Ask your agent for the current link, and paste the whole link into the address bar. +unauthorized_copy_desktop: This server requires a valid access token to connect. Choose Television › Disconnect from Server, then try again. +unauthorized_copy_local: This server requires a valid access token to connect. Disconnect from Server, then paste the current link from your agent. error_title: Can’t connect with server +error_copy: Check your internet connection and that the server is running. +disconnect: Disconnect from Server diff --git a/specs/ui/app/system-modal/index.md b/specs/ui/app/system-modal/index.md index d4d1a1fd..c19a9ae9 100644 --- a/specs/ui/app/system-modal/index.md +++ b/specs/ui/app/system-modal/index.md @@ -1,41 +1,30 @@ -*UI spec: the system modal — the app's interrupting surface: one dialog, its contents by state.* +*UI spec: the system modal — the dialog that interrupts Television while it cannot use a server, its contents by state.* # System modal (UI) -The app's interrupting surface, shown as a dialog ([ui/app/dialog/index.md](../dialog/index.md)): one surface, displaying different contents by state. Its states so far: the connection interstitial — never yet connected, and connection lost — authorization, the failed first connect, and the desktop upgrade gate. The app composes it and passes the state ([ui/app/index.md](../index.md)). - -**Status:** implemented stage-one authority for the connection, authorization, error, and upgrade states. - -The native text field uses [foundation input styling](../../foundation/input/index.md); this surface owns its placement and interaction. +The system modal is the dialog a person sees while Television can't use its server: while it connects, after the connection drops, when the server can't be reached, when it wants an access token, and when it requires a newer desktop app. It is one surface showing different contents by state, composed on the [dialog](../dialog/index.md). Which state shows, and when, is the [app shell](../index.md#Connection states)'s for the server's interface and the [desktop connection flow](../../../arch/desktop/connect-flow.md#^desktop-connect-entry)'s for the desktop app's local page. The [connection states explainer](../../../arch/explainer-connection-states.md) follows all of it from first launch to recovery. ## Markup and styling -- [ui/app/system-modal/system-modal.frame](./system-modal.frame) — the dialog with the state's contents: the connection states' spinner, title and reattempt line; the unauthorized form; the error panel; and the needs-upgrade routing to the gate's surface. +- [ui/app/system-modal/system-modal.frame](./system-modal.frame) — the dialog in each state and place, and the desktop window's drag strip. - [ui/app/system-modal/content.yml](./content.yml) — the copy. -## States - -- **Connecting** — never yet connected: the spinner and **Connecting**. There is no last state to show; the app renders no shell until first connect ([ui/app/index.md](../index.md)). -- **Disconnected** — the connection lost: the spinner, **Disconnected**, and the reattempt line. -- The reattempt line counts down the seconds to the next attempt — `seconds` renders one moment of it — and says **Reattempting now…** while an attempt is in flight. -- **Unauthorized** — the server requires an access token: the locked icon, **Enter access token**, a line of copy, the token field, and **Connect**. With `invalid` — a submitted token was rejected — the field carries the error tint and an alert line says so. Submitting the entered token is the surface's one interaction. An empty or whitespace-only entry is not submitted. A rejected token clears the field. -- **Error** — the first connect failed outright (a bad address, an unreachable server): **Can't connect with server**, the server's address, and the failure's own message. An app panel and nothing more, for now. The failure is not a stopping point: the connect attempts continue behind this state with their usual backoff, and a success leaves it for the app on its own. -- **Needs upgrade** — the desktop app is gated: the upgrade instructions and, when the app has downloaded its update, the restart button. The state routes to the gate's own surface ([ui/app/desktop-upgrade-gate/index.md](../desktop-upgrade-gate/index.md)), which brings its interior with it. - -## Appearance - -- The interior: the icon over the title where a state carries one, the supporting line beneath, all centred, at a fixed width that gives way only when the window is narrower ([ui/app/system-modal/system-modal.frame](./system-modal.frame) states the measure); height follows the contents. -- Whether the screen shows behind the modal is semantic. A state interrupting the current session — a dropped connection — **dims** it: the shell is still yours, held behind the backdrop. An app-level or before-load state **hides** it: the modal stands over no chrome, on the bare ground. Disconnected dims; connecting, unauthorized, error and needs-upgrade hide — there is no session yet. The backdrop itself is the dialog's ([ui/app/dialog/index.md](../dialog/index.md)). Hiding is compositional, not visual: a hiding state's shell is not rendered at all, so nothing exists behind the backdrop to leak through — which is why the upgrade gate needs no opaque floor of its own. That guarantee holds exactly as long as "hidden" means not mounted. - -## Testing +The dialog shows in three places, which the frame's `context` parameter names: the server's interface in a browser (`browser`), the server's interface in the desktop app (`desktop`), and the desktop app's local page (`local`, which always wears the Clouds theme, [arch/desktop/connect-flow.md](../../../arch/desktop/connect-flow.md#^desktop-local-page)). In the server's interface the dialog wears the server's theme. -Under [What a UI surface's suite is responsible for](../../../arch/testing-policy.md#What a UI surface's suite is responsible for), this surface's suite must cover `connecting`, `error`, and `needs-upgrade`. It must cover `disconnected` with a numbered reattempt and with an attempt in flight. It must cover `unauthorized` before submission and after rejection. Each case must render only its selected interrupting surface. The connection cases must show their spinner, title, and applicable reattempt line. The error case must show the server address and supplied failure message. The `needs-upgrade` case must pass the supplied upgrade instructions and restart state to the desktop-upgrade-gate surface. It must not wrap that surface in the standard system-modal interior. +## States -Coverage of `unauthorized` must show the locked icon, supporting copy, labeled required password field, and Connect button. Submitting an empty or whitespace-only entry must invoke no authentication. A nonblank token must have its surrounding whitespace removed and be submitted once. When the application reports that it rejected the token, the field must clear and the rejection alert must appear. A replacement token must remain submittable. +- **Connecting** — before the first connection has been made: a spinning icon and **Connecting**. +- **Disconnected** — a working connection has dropped: a spinning icon, **Disconnected**, and the reconnect line. The reconnect line counts down the seconds to the next attempt and, while an attempt is under way, says **Reconnecting now…**. +- **Access token required** — the server has asked for a token or rejected the one it was given: a lock icon, **Access token required**, and one line saying what to do, which differs by place. In a browser it says to paste the whole link from the agent into the address bar. In the server's interface in the desktop app it says to choose Television › Disconnect from Server. On the local page it says to disconnect and paste the current link, and the dialog carries a **Disconnect from Server** button. +- **Can't connect with server** — the server cannot be reached: **Can't connect with server**, the server's address, a line asking the person to check their internet connection and that the server is running, and the reconnect line. On the local page the dialog also carries a **Disconnect from Server** button. +- **Needs upgrade** — the server requires a newer desktop app: the [desktop upgrade gate](../desktop-upgrade-gate/index.md)'s surface, which brings its own interior. -Under [The suite does not assert styling adherence](../../../arch/testing-policy.md#^ui-styling-out), this surface's suite makes no claim about panel dimensions, centring, responsive width, typography, tint, or the appearance of the backdrop. +## Interaction -Under [Tests are the validation mechanism](../../../arch/testing-policy.md#Tests are the validation mechanism), [app-shell testing](../index.md#Testing) owns selection and precedence among interrupting states and whether the shell remains mounted behind the backdrop. The [channel-state connection lifecycle](../../../arch/channel-state/index.md#Code-governed carve-out) owns retry timing, authentication against a real server, and leaving the error or unauthorized state after a successful connection. This surface's suite does not repeat those application and server outcomes. +- No state is dismissed by Escape or a backdrop press. The dialog changes or leaves only when the connection state changes. +- **Disconnect from Server** is a destructive button. Pressing it hands the disconnect to the desktop app ([arch/desktop/connect-flow.md](../../../arch/desktop/connect-flow.md#^desktop-disconnect-server)). +- The desktop window has no title bar. In the desktop app, where the dialog shows with no shell behind it to drag the window by, a 36px strip across the top of the window moves the window: on the local page, and in the server's interface while no shell is rendered. Over a rendered shell, the shell's own drag areas remain the handle, as they are with the channel sidebar collapsed. -Under the same rule, the [desktop upgrade gate](../desktop-upgrade-gate/index.md#Testing) owns its interior, markdown, controls, non-dismissal rule, and scrolling. This surface owns only routing `needs-upgrade` to it. The [dialog](../dialog/index.md#Behaviour) owns generic modal presentation, focus containment, background inertness, and ordinary dismissal. The icon element owns the spinner's [glyph](../../foundation/icons/index.md#Markup) and [motion](../../foundation/icons/index.md#Spinning). This surface's suite must request the authored icons and the spinner's spinning state. It does not repeat the behaviors owned by the upgrade gate, dialog, or icon element. +## Appearance +The interior is centred: the icon over the title where the state has one, the supporting lines beneath, and the button, if any, last, at a fixed width that gives way only when the window is narrower ([system-modal.frame](./system-modal.frame) states the measure). Whether the interface shows dimmed behind the dialog is the [app shell](../index.md#Connection states)'s. diff --git a/specs/ui/app/system-modal/system-modal.frame b/specs/ui/app/system-modal/system-modal.frame index b218313b..b44abd5c 100644 --- a/specs/ui/app/system-modal/system-modal.frame +++ b/specs/ui/app/system-modal/system-modal.frame @@ -8,16 +8,17 @@ params: server_url: type: string description: the server the connect was to, shown while error - message: - type: string - description: "the failure's own message, shown while error" - seconds: - type: number - description: seconds until the next reconnection attempt; absent, an attempt is in flight - invalid: + context: + enum: [browser, desktop, local] + default: browser + description: "where the dialog shows: the server's interface in a browser; the server's interface in the desktop app; or the desktop app's local page, which alone offers Disconnect from Server as a button" + drag_strip: type: boolean default: false - description: the previous token was rejected, shown while unauthorized + description: "the desktop window's 36px drag strip across the top; the desktop app shows it where the interface's own drag handle cannot be reached" + seconds: + type: number + description: "seconds until the next reconnection attempt, shown while disconnected or error; absent, an attempt is in flight" upgrade_instructions: description: the selected upgrade instructions, passed to the gate while needs-upgrade; markdown pipeline output (sanitized), placed verbatim default: "

Desktop app update required

This version of the Television desktop app does not work with this server and needs to be updated. Download the latest version for Mac and install it.

" @@ -50,37 +51,43 @@ style: | color: var(--color-text-muted); } - /* Shared input styling owns the field; this surface stretches it. */ - .auth-form .auth-token { - align-self: stretch; - } - - .auth-form .auth-submit { - align-self: stretch; - } - /* The error state's server line: the address as an address. */ .system-modal .server-url { font-family: var(--font-mono); font-size: var(--text-sm); } + + .system-modal .system-modal-disconnect { + margin-top: var(--space-8); + } + + /* The desktop window has no title bar: where nothing behind the dialog + offers a drag handle, this strip across the top moves the window. */ + .window-drag-strip { + position: fixed; + inset: 0 0 auto 0; + height: 36px; + } --- +{% capture drag_region %}{% if drag_strip %}{% endif %}{% endcapture %} +{% capture disconnect_button %}{% endcapture %} +{% if seconds %}{% assign seconds_placeholder = "{{ seconds }}" %}{% assign reconnect_line = content.reconnecting | replace: seconds_placeholder, seconds %}{% else %}{% assign reconnect_line = content.reconnecting_now %}{% endif %} {% if state == "connecting" %} {% capture body %}
{% render '../../foundation/icons/icon', name: 'spinner', size: 'xl', spinning: true %}

{{ content.connecting_title }}

{% endcapture %} -{% render '../dialog/dialog', content: body %} +{% render '../dialog/dialog', content: body, top_layer_content: drag_region %} {% endif %} {% if state == "disconnected" %} -{% capture body %}
{% render '../../foundation/icons/icon', name: 'spinner', size: 'xl', spinning: true %}

{{ content.disconnected_title }}

{% if seconds %}{{ content.reattempting }}{% else %}{{ content.reattempting_now }}{% endif %}

{% endcapture %} -{% render '../dialog/dialog', content: body %} +{% capture body %}
{% render '../../foundation/icons/icon', name: 'spinner', size: 'xl', spinning: true %}

{{ content.disconnected_title }}

{{ reconnect_line }}

{% endcapture %} +{% render '../dialog/dialog', content: body, top_layer_content: drag_region %} {% endif %} {% if state == "unauthorized" %} -{% capture body %}
{% render '../../foundation/icons/icon', name: 'locked', size: 'xl' %}

{{ content.unauthorized_title }}

{{ content.unauthorized_copy }}

{% if invalid %}{% endif %}
{% endcapture %} -{% render '../dialog/dialog', content: body %} +{% capture body %}
{% render '../../foundation/icons/icon', name: 'locked', size: 'xl' %}

{{ content.unauthorized_title }}

{% if context == "local" %}{{ content.unauthorized_copy_local }}{% elsif context == "desktop" %}{{ content.unauthorized_copy_desktop }}{% else %}{{ content.unauthorized_copy_browser }}{% endif %}

{% if context == "local" %}{{ disconnect_button }}{% endif %}
{% endcapture %} +{% render '../dialog/dialog', content: body, top_layer_content: drag_region %} {% endif %} {% if state == "error" %} -{% capture body %}

{{ content.error_title }}

{{ server_url }}

{{ message }}

{% endcapture %} -{% render '../dialog/dialog', content: body %} +{% capture body %}

{{ content.error_title }}

{{ server_url }}

{{ content.error_copy }} {{ reconnect_line }}

{% if context == "local" %}{{ disconnect_button }}{% endif %}
{% endcapture %} +{% render '../dialog/dialog', content: body, top_layer_content: drag_region %} {% endif %} {% if state == "needs-upgrade" %} -{% render '../desktop-upgrade-gate/desktop-upgrade-gate', body: upgrade_instructions, restart: upgrade_restart %} +{% render '../desktop-upgrade-gate/desktop-upgrade-gate', body: upgrade_instructions, restart: upgrade_restart, top_layer_content: drag_region %} {% endif %} diff --git a/specs/ui/foundation/button/button.css b/specs/ui/foundation/button/button.css index 8d868117..ce4db4cb 100644 --- a/specs/ui/foundation/button/button.css +++ b/specs/ui/foundation/button/button.css @@ -212,6 +212,16 @@ padding: var(--space-2); } +:where(button[size="lg"]) { + font-size: var(--text-lg); + line-height: var(--line-control-lg); + padding: var(--space-8) var(--space-20); +} + +:where(button[size="lg"][icon]) { + padding: var(--space-8); +} + /* A sized control sizes the icon it holds: whatever size the composer put on the icon, the control's own wins — `tv-icon[size]` names a fixed pixel size, so left alone the icon would ignore the control shrinking around it. diff --git a/specs/ui/foundation/button/index.md b/specs/ui/foundation/button/index.md index 563b05f4..9ca79d52 100644 --- a/specs/ui/foundation/button/index.md +++ b/specs/ui/foundation/button/index.md @@ -26,6 +26,7 @@ Each is added when a surface needs one, and states what it is *for* rather than - `icon` — the button's whole content is an icon, and the button stands as if it held a line of text: its content is a line-sized cell, so it is exactly as tall as a labelled button, and square. The glyph sits a step below the line — the icon scale's step under it, `sm` at the default size — because filling the line outsizes the letters beside it and matching them leaves the glyph adrift in the cell. The button's line governs both cell and step; a control that wants a compact button inside it tightens the line rather than shrinking the icon. The glyph must be an element child (``, a ``); an anonymous text node cannot be placed into the cell. - `target` — a snug press-target around inline content, for a button living dense in chrome — a bar, a row, a line — rather than standing alone: the shared block padding step with a tightened inline step, at the default text size. It composes with `variant` and `intent` like any geometry; an `icon` button is already snug, so `target` adds nothing there. If a third fit ever joins `target` and `icon`, the pair promotes to an enum the way the bare `ghost` attribute became `variant="ghost"`. - `size="sm"` — the step-down size, for a button sitting inside another control or a dense row, pairing a text step with a padding step and sizing the icon the button holds. +- `size="lg"` — the step-up size, for a screen built around one or two prominent controls, such as the [setup screen](../../setup/index.md)'s Connect button. A [text field](../input/index.md#Size) beside it takes the same size, so the two are the same height. ## Pointer and expanded states diff --git a/specs/ui/foundation/input/index.md b/specs/ui/foundation/input/index.md index e8e9cdb8..b13462ff 100644 --- a/specs/ui/foundation/input/index.md +++ b/specs/ui/foundation/input/index.md @@ -12,6 +12,10 @@ Use a native input or textarea with an accessible label. Native editing, selecti Use `disabled` to disable a control and `readonly` to retain selectable, readable contents without editing. Set `aria-invalid="true"` when the application presents a validation error, and associate its error message with `aria-describedby`. Do not mark untouched required fields invalid merely because they are empty. +## Size + +A text field takes `data-size="lg"` to match a large button beside it ([ui/foundation/button/index.md](../button/index.md)). The attribute is `data-size` because `size` is already a native input attribute, the field's width in characters. + ## Error messages Use a native paragraph with `class="tv-error"` for an error message. This class is an explicit exception to attribute-based styling hooks. It is shared message styling and may be used outside inputs; it adds no validation, visibility, focus or announcement behavior. diff --git a/specs/ui/foundation/input/styles.css b/specs/ui/foundation/input/styles.css index 398d6625..c3f7efca 100644 --- a/specs/ui/foundation/input/styles.css +++ b/specs/ui/foundation/input/styles.css @@ -34,6 +34,15 @@ resize: vertical; } +/* The large size matches a large button beside the field + ([[ui/foundation/button/button.css]]): the same text step, line and + vertical padding step, so the two are the same height. */ +:where(:is(input:not([type]), input[type=""], input[type="text"], input[type="email"], input[type="url"], input[type="tel"], input[type="password"], input[type="number"], textarea)[data-size="lg"]) { + font-size: var(--text-lg); + line-height: var(--line-control-lg); + padding: var(--space-8) var(--space-12); +} + /* Shared error-message styling; validation and announcement belong to the surface. */ :where(.tv-error) { margin: 0; diff --git a/specs/ui/foundation/tokens/text.css b/specs/ui/foundation/tokens/text.css index 2ba1fc67..a10e0e69 100644 --- a/specs/ui/foundation/tokens/text.css +++ b/specs/ui/foundation/tokens/text.css @@ -16,11 +16,14 @@ --text-4xl: round(calc(var(--text-base) * 30 / 14), 1px); /* The control lines: the line-height a control's label uses, and the one - input its height derives from — the line, plus the space-2 padding step + input its height derives from — the line, plus the size's padding step top and bottom, plus a 1px border each side, gives every control the - same height: 18 + 4 + 2 = 24px (14 + 4 + 2 = 20px at the small step). + same height at each size: 18 + 4 + 2 = 24px at the default size (the + space-2 step), 14 + 4 + 2 = 20px at the small step (space-2), and + 22 + 16 + 2 = 40px at the large step (space-8). Stated in pixels, not a ratio: a control's cell is tighter than prose, immune to the leading around it, and lands on even pixels in any font. */ --line-control: 18px; --line-control-sm: 14px; + --line-control-lg: 22px; } diff --git a/specs/ui/setup/index.md b/specs/ui/setup/index.md new file mode 100644 index 00000000..1d81003c --- /dev/null +++ b/specs/ui/setup/index.md @@ -0,0 +1,25 @@ +*UI spec: the setup screen — the desktop app's connect screen when it has no saved server connection, where a person pastes the link their agent gives them.* + +# Setup screen (UI) + +The setup screen is what the desktop app shows when it has no saved Television server: at first launch, and after Disconnect from Server. It gives the person a prompt for their agent and a field for the link the agent returns. When and why the app shows it, and what a submitted link does, are the [desktop connection flow](../../arch/desktop/connect-flow.md#^desktop-connect-entry)'s; the [connection states explainer](../../arch/explainer-connection-states.md) follows the whole path. + +## Markup and styling + +- [setup.frame](./setup.frame) — the screen in each of its states, with its copy and the prompt. + +The card wears the [artifact frame](../app/artifact-frame/index.md)'s look, holding the screen's content in place of a document; it is not an artifact frame. The screen wears the [Clouds theme](../themes/clouds/index.md) whatever theme the server uses, light or dark as the desktop window currently is ([arch/desktop/connect-flow.md](../../arch/desktop/connect-flow.md#^desktop-local-page)). + +## States + +- **ready** — the starting state: the prompt and an empty link field. +- **connecting** — the person has submitted a link and the app is waiting for the server. The field and Connect are disabled, and Connect shows that it is connecting. +- **connected** — the connection succeeded. The steps give way to a check and **Connected** in the same card, before the app loads the server's interface. +- **error** — the attempt failed. The field keeps the link, stays editable, and is marked invalid, with the failure's message under it in place of the hint. The message is the one the connection flow supplies. + +## Interaction + +- The two numbered steps show the usual order: give an agent the prompt, then paste the link it returns. Both are always shown at full strength and both are always usable. Nothing tracks or enforces the order, so a person who already has a link pastes it straight away. +- Copy copies the prompt and confirms as the [copy button](../app/copy-button/index.md#Interaction) does. It changes nothing else on the screen. One press on the prompt text selects all of it. +- Connect, or Return in the field, submits the field when it is not empty. +- Dragging the background around the card moves the window. The card's contents stay interactive and do not move the window. diff --git a/specs/ui/setup/setup.frame b/specs/ui/setup/setup.frame new file mode 100644 index 00000000..3998cdae --- /dev/null +++ b/specs/ui/setup/setup.frame @@ -0,0 +1,297 @@ +--- +title: Setup screen +params: + state: { enum: [ready, connecting, connected, error], default: ready, description: "ready: waiting for a link; connecting: Connect was pressed and the app is waiting for the server; connected: the card shows a check and Connected; error: the connection failed" } + link: { type: string, default: "http://100.101.102.103:32848/?token=3f9c2a7e", description: "the link in the step 2 field while connecting, connected, and after an error" } + error: { type: string, default: "Couldn't reach http://100.101.102.103:32848 — is the server running?", description: "the failure's message, shown in the error state" } +data: + prompt: "Read the Television admin guide at https://television.run/install.md and help me get Television installed. I'm on the desktop app connect screen." +style: | + /* The screen: the wallpaper, blurred, filling the window, with the frame + centred on it. */ + .setup-screen { + position: relative; + isolation: isolate; + min-height: 100vh; + box-sizing: border-box; + display: grid; + place-items: center; + padding: var(--space-32); + overflow: hidden; + /* The desktop app's window has no title bar, so the window is dragged + by this background; the card itself is not a drag area. */ + -webkit-app-region: drag; + } + + /* Oversized past the window so the blur doesn't fade at the edges. */ + .setup-wallpaper { + position: absolute; + inset: calc(-1 * var(--space-48)); + z-index: -1; + background: var(--app-wallpaper); + filter: blur(24px); + } + + .setup-window { + width: min(640px, 100%); + -webkit-app-region: no-drag; + } + + /* The steps and the Connected confirmation share one cell, so the frame + keeps its size when the steps fade out. */ + .setup-body { + display: grid; + } + + .setup-body > * { + grid-area: 1 / 1; + transition: opacity 250ms ease; + } + + .setup-done { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: var(--space-16); + opacity: 0; + pointer-events: none; + } + + .setup-screen[data-state="connected"] .setup-document { + opacity: 0; + pointer-events: none; + } + + .setup-screen[data-state="connected"] .setup-done { + opacity: 1; + } + + .setup-done-mark { + display: grid; + place-items: center; + width: 56px; + height: 56px; + border-radius: 50%; + box-sizing: border-box; + border: 2px solid var(--color-success); + color: var(--color-success); + } + + .setup-done p { + margin: 0; + font-size: var(--text-xl); + font-weight: var(--font-weight-semibold); + } + + /* The document: reading text one step above the prose size. */ + .setup-document { + display: flex; + flex-direction: column; + gap: var(--space-32); + padding: var(--space-48); + font-size: var(--text-lg); + } + + .setup-document h1, + .setup-document h2, + .setup-document p, + .setup-steps { + margin: 0; + } + + .setup-header { + display: flex; + flex-direction: column; + gap: var(--space-10); + } + + .setup-document h1 { + font-size: var(--text-3xl); + font-weight: var(--font-weight-semibold); + line-height: 1.2; + } + + /* The Television logo, sized to the heading it sits in. */ + .setup-logo { + width: 1em; + height: 1em; + margin: 0 0.05em; + vertical-align: -0.14em; + } + + /* The steps: numbered markers joined by a line down the left. */ + .setup-steps { + display: flex; + flex-direction: column; + gap: var(--space-32); + padding: 0; + list-style: none; + } + + .setup-step { + position: relative; + display: grid; + grid-template-columns: 28px minmax(0, 1fr); + column-gap: var(--space-16); + } + + .setup-step:not(:last-child)::before { + content: ""; + position: absolute; + left: 13px; + top: 36px; + bottom: calc(-1 * var(--space-32) + var(--space-8)); + width: 2px; + border-radius: 1px; + background: var(--color-border); + } + + /* Both markers are always at full strength: the numbers show the usual + order, and nothing tracks a current step. */ + .setup-marker { + display: grid; + place-items: center; + width: 28px; + height: 28px; + border-radius: 50%; + box-sizing: border-box; + border: 1.5px solid var(--color-primary); + background: var(--color-primary); + color: var(--color-primary-text); + font-size: var(--text-sm); + font-weight: var(--font-weight-semibold); + } + + .setup-step-body { + display: flex; + flex-direction: column; + gap: var(--space-12); + padding-top: 3px; + } + + .setup-document h2 { + font-size: var(--text-lg); + font-weight: var(--font-weight-semibold); + } + + /* The prompt card, tilted slightly; one press selects the whole prompt. */ + .setup-prompt { + display: flex; + align-items: center; + gap: var(--space-16); + padding: var(--space-16); + border: 1px solid var(--color-border); + border-radius: 14px; + background: var(--color-surface-muted); + box-shadow: var(--shadow-md); + transform: rotate(-1deg); + } + + .setup-prompt p { + flex: 1; + min-width: 0; + font-family: var(--font-mono); + font-size: var(--text-md); + line-height: 1.5; + overflow-wrap: break-word; + user-select: all; + } + + .setup-prompt-actions { + flex: none; + } + + .setup-link { + display: flex; + gap: var(--space-8); + } + + .setup-link input { + flex: 1; + min-width: 0; + } + + /* Connect's idle and connecting faces share one grid cell, so the button + keeps the wider face's width. */ + .setup-submit { + display: inline-grid; + } + + .setup-submit > span { + grid-area: 1 / 1; + display: inline-flex; + align-items: center; + justify-content: center; + gap: var(--space-6); + visibility: hidden; + } + + .setup-screen:is([data-state="ready"], [data-state="error"]) .setup-submit-idle, + .setup-screen:is([data-state="connecting"], [data-state="connected"]) .setup-submit-busy { + visibility: visible; + } + + .setup-hint code { + font-family: var(--font-mono); + font-size: var(--text-md); + } + + @media (prefers-reduced-motion: reduce) { + .setup-body > * { + transition: none; + } + } +--- +{% capture setup %} +
+
+
+

Let’s connect your Television

+

Television runs alongside your AI agent. Ask your agent to help you get connected, then paste the link it gives you.

+
+
    +
  1. + 1 +
    +

    Give your agent this prompt

    +
    +

    {{ prompt }}

    +
    + {% render '../app/copy-button/copy-button', label: 'Copy', prompt: prompt, size: 'default' %} +
    +
    +
    +
  2. +
  3. + 2 +
    +

    Paste the link from your agent

    + + {% if state == 'error' %} + + {% else %} +

    The link looks like http://…:32848/?token=…

    + {% endif %} +
    +
  4. +
+
+ +
+{% endcapture %} +
+ +
+ {% render '../app/artifact-frame/artifact-frame', title: 'Connect to Television', body: setup, menu: false %} +
+
diff --git a/test/helpers/stable-front-proxy.ts b/test/helpers/stable-front-proxy.ts index 55efc82f..a3cfdbb1 100644 --- a/test/helpers/stable-front-proxy.ts +++ b/test/helpers/stable-front-proxy.ts @@ -10,7 +10,10 @@ export interface StableFrontProxy { } /** Keep one kernel-selected browser origin while independently bound backends restart. */ -export async function startStableFrontProxy(initialTargetURL: string): Promise { +export async function startStableFrontProxy( + initialTargetURL: string, + beforeForward?: (request: IncomingMessage) => Promise, +): Promise { let target = parseTarget(initialTargetURL); const sockets = new Set(); const peers = new Map(); @@ -34,7 +37,10 @@ export async function startStableFrontProxy(initialTargetURL: string): Promise proxyRequest(req, res, target)); + const server = createServer((req, res) => { + if (beforeForward) void beforeForward(req).then(() => proxyRequest(req, res, target)); + else proxyRequest(req, res, target); + }); server.on("connection", (socket) => { sockets.add(socket); socket.on("error", () => closeLinked(socket)); diff --git a/test/node/cli-acceptance.test.ts b/test/node/cli-acceptance.test.ts index 466df478..9577c30b 100644 --- a/test/node/cli-acceptance.test.ts +++ b/test/node/cli-acceptance.test.ts @@ -1,6 +1,6 @@ import { afterEach, describe, expect, it, vi } from "vitest"; import { execFileSync } from "node:child_process"; -import { createServer as createNetServer } from "node:net"; +import { connect, createServer as createNetServer, type Socket } from "node:net"; import { existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, rmSync, writeFileSync } from "node:fs"; import os from "node:os"; import path from "node:path"; @@ -211,6 +211,93 @@ describe("CLI product spine acceptance", () => { for (const dir of dirs.splice(0).reverse()) rmSync(dir, { recursive: true, force: true }); }); + // [[product/cli.md#^cli-ac-links]]: built processes, real config/token files + // and HTTP. The config changes while the running server keeps its settings. + it("links follows running authentication across config edits and an authless restart", async () => { + const home = makeTempDir("television-cli-links-", dirs); + const running = await startBuiltCLI(home); + const token = readFileSync(path.join(home, "state", "token"), "utf8").trim(); + const health = await (await fetch(new URL("/health", running.startupURL))).json() as { bindAddresses: string[]; port: number }; + const expected = health.bindAddresses.map((address) => `http://${address}:${health.port}/?token=${token}\n`).join(""); + const links = () => runBuiltCLI(["--home", home, "links", "--port", String(running.port)]); + expect(await links()).toEqual({ exitCode: 0, signal: null, stdout: expected, stderr: "" }); + + const config = await runBuiltCLI(["--home", home, "config", "set", "auth", "false"]); + expect(config.exitCode, config.stderr).toBe(0); + const afterEdit = await links(); + expect(afterEdit).toEqual({ exitCode: 0, signal: null, stdout: expected, stderr: "" }); + for (const link of afterEdit.stdout.trim().split("\n")) { + const url = new URL(link); + const response = await fetch(new URL("/display", url), { headers: { authorization: `Bearer ${url.searchParams.get("token")}` } }); + expect(response.status).toBe(200); + } + await running.process.dispose(); + + const authless = await startBuiltServer(["--home", home, "serve"], cliEnvironment()); + expect(readFileSync(path.join(home, "state", "token"), "utf8").trim()).toBe(token); + const restored = await runBuiltCLI(["--home", home, "config", "set", "auth", "true"]); + expect(restored.exitCode, restored.stderr).toBe(0); + const authlessHealth = await (await fetch(new URL("/health", authless.startupURL))).json() as { bindAddresses: string[]; port: number }; + expect(await runBuiltCLI(["--home", home, "links", "--port", String(authless.port)])).toEqual({ + exitCode: 0, signal: null, stderr: "", + stdout: authlessHealth.bindAddresses.map((address) => `http://${address}:${authlessHealth.port}\n`).join(""), + }); + }); + + // [[product/cli.md#^cli-ac-links-unauthorized]] + it("links rejects a wrong home token without printing links", async () => { + const home = makeTempDir("television-cli-links-server-", dirs); + const running = await startBuiltCLI(home); + const clientHome = makeTempDir("television-cli-links-client-", dirs); + writeHomeConfig(clientHome, { port: 0 }); + mkdirSync(path.join(clientHome, "state")); + writeFileSync(path.join(clientHome, "state", "token"), "wrong-token\n"); + const result = await runBuiltCLI(["--home", clientHome, "links", "--port", String(running.port)]); + expect(result.exitCode).toBe(1); + expect(result.signal).toBeNull(); + expect(result.stdout).toBe(""); + expect(result.stderr).toContain(`Television server at http://localhost:${running.port} rejected the request as unauthorized. Check the token in ${path.join(clientHome, "state", "token")}.`); + expect(result.stderr).toContain("Television ships bundled skills."); + }); + + // [[product/cli.md#^cli-ac-links-unreachable]]: a TCP forwarder keeps the + // client endpoint reserved. It relays real traffic and closes on connection + // failure; it supplies no HTTP response or substitute Television behavior. + it("links reports an unreachable server without printing links", async () => { + const home = makeTempDir("television-cli-links-unreachable-", dirs); + const running = await startBuiltCLI(home); + const sockets = new Set(); + const front = createNetServer((socket) => { + const upstream = connect(running.port, "127.0.0.1"); + const close = () => { socket.destroy(); upstream.destroy(); }; + for (const peer of [socket, upstream]) { + sockets.add(peer); + peer.on("error", close); + peer.on("close", () => { sockets.delete(peer); close(); }); + } + socket.pipe(upstream).pipe(socket); + }); + try { + await new Promise((resolve, reject) => { + front.once("error", reject); + front.listen(0, "127.0.0.1", resolve); + }); + const address = front.address(); + if (!address || typeof address === "string") throw new Error("TCP forwarder did not bind"); + const serverURL = `http://localhost:${address.port}`; + expect((await fetch(`${serverURL}/health`)).ok).toBe(true); + await running.process.dispose(); + const result = await runBuiltCLI(["--home", home, "links", "--port", String(address.port)]); + expect(result.exitCode).toBe(1); + expect(result.signal).toBeNull(); + expect(result.stdout).toBe(""); + expect(result.stderr).toContain(`Could not reach Television server at ${serverURL}:`); + } finally { + for (const socket of sockets) socket.destroy(); + await new Promise((resolve, reject) => front.close((error) => error ? reject(error) : resolve())); + } + }); + // Spec: [[product/cli.md#^cli-ac-help-version|help and version acceptance]]. it("prints help and version through the built CLI process boundary", async () => { const help = await runBuiltCLI(["--help"]); diff --git a/test/node/cli-integration.test.ts b/test/node/cli-integration.test.ts index 6e76f1c8..404d27bb 100644 --- a/test/node/cli-integration.test.ts +++ b/test/node/cli-integration.test.ts @@ -576,7 +576,8 @@ describe("CLI node integration artifact workflow", () => { processes.push(owned); const child = owned.child as ChildProcessByStdio; let output = ""; - child.stdout?.on("data", (chunk: Buffer | string) => { output += chunk.toString(); }); + let stdout = ""; + child.stdout?.on("data", (chunk: Buffer | string) => { stdout += chunk.toString(); output += chunk.toString(); }); child.stderr?.on("data", (chunk: Buffer | string) => { output += chunk.toString(); }); const serverURL = await Promise.race([ @@ -602,7 +603,8 @@ describe("CLI node integration artifact workflow", () => { await waitForOutput(() => output, "Television server running."); expect(output).toContain("Television server running."); expect(output).toContain(`http://127.0.0.1:${port}`); - expect(output).toContain(`\u001B]8;;http://127.0.0.1:${port}\u001B\\`); + expect(stdout).toContain(`Open Television:\n http://127.0.0.1:${port}\n`); + expect(stdout).not.toContain("\u001B"); expect(() => JSON.parse(output)).toThrow(); expect(output).toContain("WARNING: running without an auth token"); expect(output).toContain("Tokenless mode is insecure for typical setups"); @@ -641,7 +643,8 @@ describe("CLI node integration artifact workflow", () => { processes.push(owned); const child = owned.child as ChildProcessByStdio; let output = ""; - child.stdout?.on("data", (chunk: Buffer | string) => { output += chunk.toString(); }); + let stdout = ""; + child.stdout?.on("data", (chunk: Buffer | string) => { stdout += chunk.toString(); output += chunk.toString(); }); child.stderr?.on("data", (chunk: Buffer | string) => { output += chunk.toString(); }); const serverURL = await Promise.race([ @@ -667,7 +670,9 @@ describe("CLI node integration artifact workflow", () => { await waitForOutput(() => output, "Television server running."); expect(output).toContain("Television server running."); expect(output).toContain(`http://127.0.0.1:${port}/?token=`); - expect(output).toContain(`\u001B]8;;http://127.0.0.1:${port}/?token=`); + const token = readFileSync(path.join(storagePath, "state", "token"), "utf8").trim(); + expect(stdout).toContain(`Open Television:\n http://127.0.0.1:${port}/?token=${token}\n`); + expect(stdout).not.toContain("\u001B"); expect(() => JSON.parse(output)).toThrow(); expect(output).not.toContain("WARNING: running without an auth token"); diff --git a/test/node/daemon-acceptance.test.ts b/test/node/daemon-acceptance.test.ts index be9a8a15..53cdd034 100644 --- a/test/node/daemon-acceptance.test.ts +++ b/test/node/daemon-acceptance.test.ts @@ -238,11 +238,10 @@ async function installAndProveBoot( const token = readFileSync(path.join(home, "state", "token"), "utf8").trim(); expect(token).not.toBe(""); const connectURL = `${serverURL}/?token=${token}`; - const terminalURL = `\u001B]8;;${connectURL}\u001B\\${connectURL}\u001B]8;;\u001B\\`; expect(install).toEqual({ exitCode: 0, signal: null, - stdout: `Television service installed.\nOpen Television:\n ${terminalURL}\n`, + stdout: `Television service installed.\nOpen Television:\n ${connectURL}\n`, stderr: "", }); diff --git a/test/node/licensing.test.ts b/test/node/licensing.test.ts index 96ef771e..7f228ee6 100644 --- a/test/node/licensing.test.ts +++ b/test/node/licensing.test.ts @@ -72,10 +72,13 @@ describe("product licensing outputs", () => { test("esbuild desktop persists its bundled inventory and writes the matching notices file", () => { const inventory = readSurfaceInventory(inventoryRoot, "desktop"); - expect(packageNames(inventory)).toEqual(["@rupertsworld/event-target"]); + expect(packageNames(inventory)).toEqual(["@phosphor-icons/core", "@rupertsworld/event-target", "lit-html"]); expect(existsSync(path.join(desktopDist, noticeName))).toBe(true); const notice = readFileSync(path.join(desktopDist, noticeName), "utf8"); - expect(packageNamesFromNotice(notice)).toEqual(["@rupertsworld/event-target"]); + expect(packageNamesFromNotice(notice)).toEqual(expect.arrayContaining(packageNames(inventory))); + expect(notice).toContain("Hind variable font"); + expect(notice).toContain("@phosphor-icons/core"); + expect(notice).toContain("Tailwind CSS color palette"); expectNoIgnoredPackageNames(notice); }); @@ -219,6 +222,13 @@ describe("shipped package licensing", () => { if (expectedNotices !== null) { expect(readFileSync(uploadedNotices, "utf8")).toBe(expectedNotices); expect(readFileSync(uploadedNotices)).toEqual(readFileSync(path.join(desktopDist, noticeName))); + for (const asset of loadAssetManifest().filter(asset => ["hind-font", "phosphor-icons", "tailwind-foundation", "clouds-theme"].includes(asset.id))) { + expect(asset.surfaces).toContain("desktop"); + for (const component of asset.components) { + expect(expectedNotices).toContain(component.name ?? component.package!); + expect(expectedNotices).toContain(component.noticeText.trim()); + } + } } }); @@ -244,6 +254,10 @@ describe("shipped package licensing", () => { [path.join(repoRoot, noticeName), sourceExpected], [path.join(packedCLI, "dist", noticeName), cliExpected], [path.join(desktopUpload, "dist", noticeName), desktopExpected], + [ + path.join(desktopUpload, "dist/clouds", noticeName), + readFileSync(path.join(repoRoot, bundledThemesSource, "clouds", noticeName), "utf8"), + ], [sourceNotices.web, webExpected], [sourceNotices.markdown, markdownExpected], [sourceNotices.calendar, calendarExpected], diff --git a/test/repo/lib/ui-style-crossings.ts b/test/repo/lib/ui-style-crossings.ts index f6828ee6..562a06ec 100644 --- a/test/repo/lib/ui-style-crossings.ts +++ b/test/repo/lib/ui-style-crossings.ts @@ -58,6 +58,20 @@ export type UIStyleCrossing = | ImplementationOwnedStyleCrossing; export const UI_STYLE_CROSSINGS: readonly UIStyleCrossing[] = [ + { + kind: "sibling", + specSources: ["specs/ui/setup/setup.frame"], + productionModule: "packages/desktop/src/setup.ts", + stylesheet: "packages/desktop/src/setup.css", + documentDelivery: true, + }, + { kind: "implementation-owned", productionModule: "packages/desktop/src/connect-page.ts", stylesheet: "packages/web/src/foundation/index.css" }, + { kind: "implementation-owned", productionModule: "packages/desktop/src/connect-page.ts", stylesheet: "packages/web/src/foundation/app.css" }, + { kind: "implementation-owned", productionModule: "packages/desktop/src/connect-page.ts", stylesheet: "packages/web/src/global.css" }, + { kind: "implementation-owned", productionModule: "packages/desktop/src/connect-page.ts", stylesheet: "packages/desktop/src/connect-page.css" }, + { kind: "implementation-owned", productionModule: "packages/desktop/src/setup.ts", stylesheet: "packages/web/src/views/artifact-view.css" }, + { kind: "linked-document", document: "packages/desktop/src/connect.html", href: "connect-page.css" }, + { kind: "linked-document", document: "packages/desktop/src/connect.html", href: "clouds/theme.css" }, { kind: "aggregate", specSource: "specs/ui/foundation/index.css", diff --git a/test/repo/licensing-config.test.ts b/test/repo/licensing-config.test.ts index 9575c0ec..43e434d7 100644 --- a/test/repo/licensing-config.test.ts +++ b/test/repo/licensing-config.test.ts @@ -98,7 +98,7 @@ describe("licensing configuration", () => { "packages/web/src/foundation/fonts/Hind-Variable.woff2", "specs/ui/foundation/fonts/Hind-Variable.woff2", ]); - expect(hind.surfaces).toEqual(["web", "view:markdown", "source"]); + expect(hind.surfaces).toEqual(["web", "view:markdown", "source", "desktop"]); expect(hind.components).toHaveLength(1); expect(hind.components[0]).toMatchObject({ license: "OFL-1.1" }); expect(hind.components[0].noticeText).toContain("Copyright (c) 2014, Indian Type Foundry"); @@ -113,7 +113,7 @@ describe("licensing configuration", () => { "packages/web/src/elements/select.svg", "specs/ui/foundation/icons/select.svg", ]); - expect(phosphor.surfaces).toEqual(["web", "view:markdown", "source"]); + expect(phosphor.surfaces).toEqual(["web", "view:markdown", "source", "desktop"]); expect(phosphor.components[0]).toMatchObject({ package: "@phosphor-icons/core", version: "2.1.1", @@ -190,7 +190,7 @@ describe("licensing configuration", () => { ]); for (const id of ["tokyo-night-theme", "clouds-theme"]) { const asset = assets.find((entry) => entry.id === id)!; - expect(asset.surfaces).toEqual(["cli", "source"]); + expect(asset.surfaces).toEqual(id === "clouds-theme" ? ["cli", "source", "desktop"] : ["cli", "source"]); expect(asset.noticesFolder).toBe(`packages/server/assets/themes/${id.replace("-theme", "")}`); for (const component of asset.components.filter((entry) => entry.license === "LicenseRef-Unsplash")) { expect(component.noticeText).toContain("Images cannot be sold without significant modification."); @@ -234,7 +234,7 @@ describe("licensing configuration", () => { "packages/canonical/styles/canonical/v2/foundation/colors.css", "packages/canonical/frozen/v1/styles.css", ]); - expect(foundation.surfaces).toEqual(["web", "view:markdown", "source"]); + expect(foundation.surfaces).toEqual(["web", "view:markdown", "source", "desktop"]); expect(foundation.components).toEqual([tailwind]); const demo = assets.find((asset) => asset.id === "tailwind-token-demo")!; expect(demo.paths).toEqual(["staging/frames/token-system/styles.css"]); diff --git a/test/repo/skills-build.test.ts b/test/repo/skills-build.test.ts index 79fd05d8..161c1f6b 100644 --- a/test/repo/skills-build.test.ts +++ b/test/repo/skills-build.test.ts @@ -31,6 +31,7 @@ const CANONICAL_V2_SPEC_ROOT = path.join( interface CompatibilityFixture { resources: Record; tokens: Record; + nativeControls: Record>>; elements: Record< string, Record< @@ -276,6 +277,15 @@ describe("skills build", () => { expect(htmlGuidance, token).toContain(`\`${token}\``); expect(televisionSkill, token).toContain(`\`${token}\``); } + for (const [tag, attributes] of Object.entries(compatibility.nativeControls["canonical-v2"])) { + for (const [attribute, values] of Object.entries(attributes)) { + for (const value of values) { + for (const guidance of [htmlGuidance, televisionSkill]) { + expect(guidance).toContain(`<${tag} ${attribute}="${value}"`); + } + } + } + } const retiredTokens = (compatibility.tokens["canonical-v1"] ?? []) .filter((token) => !publicTokens.includes(token)); expect(retiredTokens.length).toBeGreaterThan(0); diff --git a/test/repo/test-infrastructure-address-allowlist.ts b/test/repo/test-infrastructure-address-allowlist.ts index 60eef827..8b5020e2 100644 --- a/test/repo/test-infrastructure-address-allowlist.ts +++ b/test/repo/test-infrastructure-address-allowlist.ts @@ -5,6 +5,21 @@ export interface InertNumericLoopbackURL { } export const INERT_NUMERIC_LOOPBACK_URLS: readonly InertNumericLoopbackURL[] = [ + { + file: "packages/desktop/test/e2e/connect-screen.02.test.ts", + url: "http://127.0.0.1:9", + reason: "Deliberately unreachable update-channel input; no test listener binds this port.", + }, + { + file: "packages/desktop/test/e2e/local-page.test.ts", + url: "http://127.0.0.1:9", + reason: "Saved unreachable-server fixture for packaged asset loading; no test listener binds this port.", + }, + { + file: "packages/desktop/test/e2e/window-drag-regions.test.ts", + url: "http://127.0.0.1:9", + reason: "Saved unreachable-server fixture for native dialog dragging; no test listener binds this port.", + }, { file: "packages/desktop/test/e2e/upgrade-gate.spec.ts", url: "http://127.0.0.1:9", diff --git a/test/repo/ui-style-delivery.test.ts b/test/repo/ui-style-delivery.test.ts index f339ed6b..452c774c 100644 --- a/test/repo/ui-style-delivery.test.ts +++ b/test/repo/ui-style-delivery.test.ts @@ -6,6 +6,8 @@ import { expectedDocumentStyleRoutes } from "./lib/ui-style-crossings.ts"; const REPO_ROOT = path.resolve(import.meta.dirname, "../.."); const WEB_ENTRY = path.join(REPO_ROOT, "packages/web/src/main.ts"); const WEB_VITE_CONFIG = path.join(REPO_ROOT, "packages/web/vite.config.ts"); +const DESKTOP_ENTRY = path.join(REPO_ROOT, "packages/desktop/src/connect-page.ts"); +const DESKTOP_DOCUMENT = path.join(REPO_ROOT, "packages/desktop/src/connect.html"); const MARKDOWN_ENTRY = path.join(REPO_ROOT, "packages/view-markdown/src/main.ts"); const MARKDOWN_DOCUMENT = path.join(REPO_ROOT, "packages/view-markdown/src/index.html"); @@ -297,6 +299,7 @@ function deliveryRoutes(graph: ImportGraph): string[] { const routes = new Set(htmlDeliveryRoutes([ ...productionHtmlEntries(), MARKDOWN_DOCUMENT, + DESKTOP_DOCUMENT, ])); for (const module of graph.modules.values()) { @@ -411,7 +414,7 @@ describe("production document style delivery (^ui-t-style-delivery)", () => { const appEntry = readFileSync(path.join(REPO_ROOT, "packages/web/src/index.html"), "utf8"); expect(appEntry).toContain(''); - const graph = importGraph([WEB_ENTRY, MARKDOWN_ENTRY]); + const graph = importGraph([WEB_ENTRY, MARKDOWN_ENTRY, DESKTOP_ENTRY]); expect.soft( graph.missingEntries, `missing production style-delivery graph entries:\n${graph.missingEntries.map((entry) => `- ${entry}`).join("\n")}`,