- observe titlebar geometry behind an optional feature-detected API - preserve standalone, keyboard, high-contrast, RTL, narrow, and offline fallbacks - record tabbed display as a no-ship decision until it is portable and testable
- commit
- 7107277bd644af074ede3e0fc2248fcfd995f45a
- parent
- 089be65520c423588355d8b6534472829ee95ec4
- author
- Dami <dami.etoile@gmail.com>
| M | README.md | 4 | +- |
| M | apps/ | 1 | + |
| M | deno.json | 1 | + |
| M | docs/ | 29 | ++++---- |
| M | docs/ | 2 | + |
| A | docs/ | 117 | ++++++++++++++++++++++++++++++ |
| M | package/ | 14 | ++++ |
| M | package/ | 2 | + |
| M | package/ | 7 | ++- |
| M | package/ | 9 | ++- |
| A | package/ | 97 | +++++++++++++++++++++++++ |
| A | package/ | 70 | ++++++++++++++++++ |
| M | package/ | 4 | +- |
| M | package/ | 30 | ++++++++ |
| M | package/ | 23 | ++++++ |
| M | tools/ | 1 | + |
| M | tools/ | 82 | +++++++++++++++++++++- |
@@ -136,8 +136,8 @@ permission APIs.
136136 OS-facing capabilities stay out of the starter until the product opts in. The137137 [installed-app recipe catalog](docs/recipes/README.md) contains tested, isolated entrypoints for Web138138 Share, launch handling, file-import previews, custom-protocol links, presentation-only companion139-discovery, and reciprocal cross-origin app-window scope. Each keeps manifest changes opt-in and140-documents validation, behavior, and fallbacks.139+discovery, reciprocal cross-origin app-window scope, and desktop titlebar geometry. Each keeps140+manifest changes opt-in and documents validation, behavior, and fallbacks.141141 142142 ## Testing local-first behavior143143 @@ -18,6 +18,7 @@
1818 "@nzip/lofi/recipes/related-app-discovery": "../../package/recipes/related-app-discovery.ts",1919 "@nzip/lofi/recipes/scope-extension": "../../package/recipes/scope-extension.ts",2020 "@nzip/lofi/recipes/web-share": "../../package/recipes/web-share.ts",21+ "@nzip/lofi/recipes/window-controls-overlay": "../../package/recipes/window-controls-overlay.ts",2122 "@nzip/lofi/test": "../../package/commands/run_tests.ts",2223 "@nzip/lofi/testing": "../../package/testing/mod.ts",2324 "astro": "npm:astro@7.0.9",@@ -20,6 +20,7 @@
2020 "./recipes/related-app-discovery": "./package/recipes/related-app-discovery.ts",2121 "./recipes/scope-extension": "./package/recipes/scope-extension.ts",2222 "./recipes/web-share": "./package/recipes/web-share.ts",23+ "./recipes/window-controls-overlay": "./package/recipes/window-controls-overlay.ts",2324 "./testing": "./package/testing/mod.ts",2425 "./test": "./package/commands/run_tests.ts"2526 },@@ -62,20 +62,21 @@ allowing framework fixes to arrive through a package upgrade.
6262 6363 The final package map is intentionally small:6464 65-| Import | Supported use |66-| ------------------------------------------ | --------------------------------------------------- |67-| `@nzip/lofi` | App configuration, runtime, session, stores, PWA |68-| `@nzip/lofi/access` | Private, direct-share, and fixed-role group helpers |69-| `@nzip/lofi/preact` | Package-owned Preact hooks and diagnostic UI |70-| `@nzip/lofi/astro` | Package-owned Astro/Vite integration |71-| `@nzip/lofi/recipes/file-handler` | Optional validated installed-app file import drafts |72-| `@nzip/lofi/recipes/launch-handler` | Optional safe installed-window launch routing |73-| `@nzip/lofi/recipes/protocol-handler` | Optional allow-listed custom-protocol item links |74-| `@nzip/lofi/recipes/related-app-discovery` | Optional presentation-only companion discovery |75-| `@nzip/lofi/recipes/scope-extension` | Optional reciprocal cross-origin app-window scope |76-| `@nzip/lofi/recipes/web-share` | Optional inbound/outbound text and link sharing |77-| `@nzip/lofi/testing` | Local-first Playwright helpers |78-| `@nzip/lofi/{create,dev,...}` | Generator and generated-project command entrypoints |65+| Import | Supported use |66+| -------------------------------------------- | --------------------------------------------------- |67+| `@nzip/lofi` | App configuration, runtime, session, stores, PWA |68+| `@nzip/lofi/access` | Private, direct-share, and fixed-role group helpers |69+| `@nzip/lofi/preact` | Package-owned Preact hooks and diagnostic UI |70+| `@nzip/lofi/astro` | Package-owned Astro/Vite integration |71+| `@nzip/lofi/recipes/file-handler` | Optional validated installed-app file import drafts |72+| `@nzip/lofi/recipes/launch-handler` | Optional safe installed-window launch routing |73+| `@nzip/lofi/recipes/protocol-handler` | Optional allow-listed custom-protocol item links |74+| `@nzip/lofi/recipes/related-app-discovery` | Optional presentation-only companion discovery |75+| `@nzip/lofi/recipes/scope-extension` | Optional reciprocal cross-origin app-window scope |76+| `@nzip/lofi/recipes/web-share` | Optional inbound/outbound text and link sharing |77+| `@nzip/lofi/recipes/window-controls-overlay` | Optional desktop titlebar geometry observation |78+| `@nzip/lofi/testing` | Local-first Playwright helpers |79+| `@nzip/lofi/{create,dev,...}` | Generator and generated-project command entrypoints |7980 8081 All of these imports resolve through the single version pinned in `deno.json`. Upgrade that pin to8182 receive framework fixes; do not copy package files into `src/` or import unpublished internal paths.@@ -26,6 +26,8 @@ flowchart LR
2626 from exact verified-app matches without changing authentication or PWA installability.2727 - [Cross-origin app-window scope](scope-extension.md) — keep product-owned secondary-origin paths2828 inside an installed window after reciprocal deployment verification.29+- [Desktop window controls overlay](window-controls-overlay.md) — use noncritical titlebar space30+ without obscuring system controls, while preserving the standalone layout.2931 3032 ## Contract for every recipe3133 @@ -0,0 +1,117 @@
1+# Desktop window controls overlay2+3+Use this experimental recipe when a desktop productivity app has noncritical navigation that earns4+the titlebar space. Window Controls Overlay is not Baseline and is currently limited to some desktop5+browsers. The generated app remains fully usable in `standalone` mode without it.6+7+## Manifest opt-in and fallback8+9+Keep the portable fallback in `display` and put the experimental mode first in `display_override`:10+11+```json12+{13+ "display": "standalone",14+ "display_override": ["window-controls-overlay"]15+}16+```17+18+The override matters only for an installed desktop app in a supporting browser. A user can toggle19+the overlay, so feature presence never means it is visible. Existing installs may require a manifest20+refresh, browser restart, or reinstall before a changed display mode takes effect.21+22+## Collision-free layout23+24+Let the browser-provided physical geometry define the usable titlebar area. Keep the ordinary header25+as the default, then reposition only while the display mode is active:26+27+```css28+.app-titlebar {29+ min-block-size: 3rem;30+ display: flex;31+ align-items: center;32+ gap: 0.75rem;33+ border-block-end: 1px solid CanvasText;34+}35+36+@media (display-mode: window-controls-overlay) {37+ .app-titlebar {38+ position: fixed;39+ left: env(titlebar-area-x, 0px);40+ top: env(titlebar-area-y, 0px);41+ width: env(titlebar-area-width, 100%);42+ height: env(titlebar-area-height, 3rem);43+ min-block-size: 0;44+ app-region: drag;45+ }46+47+ .app-titlebar :is(a, button, input, select, textarea) {48+ app-region: no-drag;49+ }50+51+ main {52+ padding-block-start: env(titlebar-area-height, 3rem);53+ }54+}55+56+@media (display-mode: window-controls-overlay) and (max-width: 32rem) {57+ .app-titlebar .noncritical-action {58+ display: none;59+ }60+}61+62+@media (forced-colors: active) {63+ .app-titlebar {64+ border-color: CanvasText;65+ }66+}67+```68+69+Do not assume controls are on the right: macOS, Windows, browser UI, RTL, zoom, and resizing can all70+change the available physical rectangle. The `titlebar-area-*` variables already exclude system71+window controls. Never position content outside their rectangle or cover system-critical controls.72+73+Only noninteractive empty space should be draggable. Links, buttons, menus, inputs, and focus rings74+must remain keyboard reachable and `no-drag`. Preserve a visible title and border in high-contrast75+mode, do not communicate state by color alone, and remove noncritical actions before compressing76+interactive targets in a narrow window.77+78+## Runtime geometry79+80+CSS should own layout. Observe geometry only when application state genuinely needs it:81+82+```ts83+import { observeWindowControlsOverlay } from "@nzip/lofi/recipes/window-controls-overlay";84+85+const observer = observeWindowControlsOverlay({86+ onGeometry({ visible, titlebarArea }) {87+ document.documentElement.toggleAttribute("data-overlay-visible", visible);88+ console.debug("available titlebar width", titlebarArea.width);89+ },90+});91+92+// During component teardown:93+observer.dispose();94+```95+96+The observer feature-detects the API, reports initial and changed geometry, and stops cleanly. The97+standalone header remains the fallback when unsupported. Geometry events can fire rapidly while98+resizing, so avoid synchronous layout reads/writes in the callback and coalesce expensive work.99+100+Test installed and ordinary browser windows, overlay on/off, resize and zoom, controls on both101+sides, LTR and RTL, narrow width, keyboard-only use, drag/no-drag regions, high contrast, offline102+startup, manifest updates, and uninstall/reinstall.103+104+## Tabbed display: no ship105+106+Lofi does not expose `display_override: ["tabbed"]` or `tab_strip`. As of this evaluation, tabbed107+mode remains an incubation proposal without a credible cross-browser installed-app test matrix. Its108+home tab rules can also redirect ordinary same-document navigation into other application contexts,109+which needs product-specific lifecycle testing. The manifest validator reports it as lacking a110+tested lofi recipe. Continue using ordinary in-app document navigation, browser tabs, or separate111+standalone windows until implementations and automation support make the semantics portable and112+testable.113+114+References:115+[MDN Window Controls Overlay API](https://developer.mozilla.org/en-US/docs/Web/API/Window_Controls_Overlay_API),116+[WICG Window Controls Overlay draft](https://wicg.github.io/window-controls-overlay/), and the117+[WICG tabbed-mode explainer](https://wicg.github.io/manifest-incubations/tabbed-mode-explainer.html).@@ -68,6 +68,7 @@ const recipeFiles = [
6868 "related-app-discovery.ts",6969 "scope-extension.ts",7070 "web-share.ts",71+ "window-controls-overlay.ts",7172 ] as const;7273 7374 async function readPackageFile(path: string): Promise<string> {@@ -114,6 +115,13 @@ function renderConfig(projectRoot: string): string {
114115 "scope-extension.ts",115116 );116117 const webShareRecipe = join(projectRoot, ".lofi", "package", "recipes", "web-share.ts");118+ const windowControlsOverlayRecipe = join(119+ projectRoot,120+ ".lofi",121+ "package",122+ "recipes",123+ "window-controls-overlay.ts",124+ );117125 return `import preact from "@astrojs/preact";118126 import { defineConfig } from "astro/config";119127 import { jazzPlugin } from "jazz-tools/dev/vite";@@ -170,6 +178,9 @@ export default defineConfig({
170178 } },171179 { find: /^jsr:@nzip\\/lofi@[^/]+\\/recipes\\/web-share$/, replacement: ${172180 JSON.stringify(webShareRecipe)181+ } },182+ { find: /^jsr:@nzip\\/lofi@[^/]+\\/recipes\\/window-controls-overlay$/, replacement: ${183+ JSON.stringify(windowControlsOverlayRecipe)173184 } },174185 { find: /^jsr:@nzip\\/lofi@[^/]+\\/preact$/, replacement: ${JSON.stringify(preactEntry)} },175186 { find: /^jsr:@nzip\\/lofi@[^/]+\\/access$/, replacement: ${JSON.stringify(accessEntry)} },@@ -197,6 +208,9 @@ export default defineConfig({
197208 JSON.stringify(fileHandlerRecipe)198209 } },199210 { find: "@nzip/lofi/recipes/web-share", replacement: ${JSON.stringify(webShareRecipe)} },211+ { find: "@nzip/lofi/recipes/window-controls-overlay", replacement: ${212+ JSON.stringify(windowControlsOverlayRecipe)213+ } },200214 { find: "@nzip/lofi/preact", replacement: ${JSON.stringify(preactEntry)} },201215 { find: "@nzip/lofi/access", replacement: ${JSON.stringify(accessEntry)} },202216 { find: "@nzip/lofi", replacement: ${JSON.stringify(runtimeEntry)} },@@ -20,6 +20,7 @@ Deno.test("prepareLofiAstroConfig materializes package-owned vendor integration"
2020 ".lofi/package/recipes/related-app-discovery.ts",2121 ".lofi/package/recipes/scope-extension.ts",2222 ".lofi/package/recipes/web-share.ts",23+ ".lofi/package/recipes/window-controls-overlay.ts",2324 "^jsr:@nzip\\/lofi@[^/]+",2425 "^npm:preact@[^/]+",2526 "^npm:jazz-tools@[^/]+",@@ -43,6 +44,7 @@ Deno.test("prepareLofiAstroConfig materializes package-owned vendor integration"
4344 await Deno.stat(`${root}/.lofi/package/recipes/related-app-discovery.ts`);4445 await Deno.stat(`${root}/.lofi/package/recipes/scope-extension.ts`);4546 await Deno.stat(`${root}/.lofi/package/recipes/web-share.ts`);47+ await Deno.stat(`${root}/.lofi/package/recipes/window-controls-overlay.ts`);4648 } finally {4749 await Deno.remove(root, { recursive: true });4850 }@@ -85,7 +85,8 @@ OS-facing capabilities remain absent until the product opts in. Start with the
8585 for tested manifest patches, feature detection, input validation, offline behavior, and normal-web8686 fallbacks. Isolated entrypoints cover Web Share, installed-window launch handling, and validated8787 file-import previews, custom protocol links, and presentation-only companion discovery without adding88-any to the starter. Cross-origin app-window scope remains a reciprocal deployment opt-in.88+any to the starter. Cross-origin app-window scope and desktop titlebar geometry remain explicit89+experimental opt-ins.8990 9091 Public tasks: \`dev\`, \`doctor\`, \`test\`, \`build\`, and \`preview\`. Sync/backup and schema tasks:9192 \`jazz:provision\`, \`schema:validate\`, \`schema:deploy\`, \`migrations:create\`, and \`migrations:push\`.@@ -154,6 +155,10 @@ const lofiImportTargets: Record<string, { subpath: string; localPath: string }>
154155 subpath: "recipes/web-share",155156 localPath: "recipes/web-share.ts",156157 },158+ "@nzip/lofi/recipes/window-controls-overlay": {159+ subpath: "recipes/window-controls-overlay",160+ localPath: "recipes/window-controls-overlay.ts",161+ },157162 "@nzip/lofi/test": { subpath: "test", localPath: "commands/run_tests.ts" },158163 "@nzip/lofi/testing": { subpath: "testing", localPath: "testing/mod.ts" },159164 };@@ -106,8 +106,8 @@ Deno.test("createProject materializes the complete starter snapshot", async () =
106106 .filter(([name]) => name.startsWith("@nzip/lofi/"))107107 .map(([, specifier]) => String(specifier));108108 assert(109- lofiSpecifiers.length === 17,110- `expected one package prefix, access, two integrations, six commands, six recipes, and testing, received ${lofiSpecifiers.length}`,109+ lofiSpecifiers.length === 18,110+ `expected one package prefix, access, two integrations, six commands, seven recipes, and testing, received ${lofiSpecifiers.length}`,111111 );112112 assert(113113 lofiSpecifiers.every((specifier) => specifier.startsWith("jsr:@nzip/lofi@0.3.3/")),@@ -157,6 +157,7 @@ Deno.test("package manifest and generated version stay coupled", () => {
157157 "./recipes/related-app-discovery",158158 "./recipes/scope-extension",159159 "./recipes/web-share",160+ "./recipes/window-controls-overlay",160161 "./test",161162 "./testing",162163 ]);@@ -188,6 +189,10 @@ Deno.test("package manifest and generated version stay coupled", () => {
188189 packageManifest.exports["./recipes/web-share"],189190 "./package/recipes/web-share.ts",190191 );192+ assertEquals(193+ packageManifest.exports["./recipes/window-controls-overlay"],194+ "./package/recipes/window-controls-overlay.ts",195+ );191196 assertEquals(packageManifest.exports["./testing"], "./package/testing/mod.ts");192197 });193198 @@ -0,0 +1,97 @@
1+/**2+ * Opt-in geometry observation for desktop Window Controls Overlay layouts.3+ *4+ * @module5+ */6+7+/** A value-only copy of the titlebar area available to application content. */8+export type WindowControlsOverlayGeometry = {9+ readonly visible: boolean;10+ readonly titlebarArea: {11+ readonly x: number;12+ readonly y: number;13+ readonly width: number;14+ readonly height: number;15+ };16+};17+18+/** Minimal browser surface consumed by this recipe. */19+export type WindowControlsOverlayClient = {20+ readonly visible: boolean;21+ getTitlebarAreaRect(): Pick<DOMRectReadOnly, "x" | "y" | "width" | "height">;22+ addEventListener(type: "geometrychange", listener: EventListener): void;23+ removeEventListener(type: "geometrychange", listener: EventListener): void;24+};25+26+/** Disposable result from installing the geometry observer. */27+export type WindowControlsOverlayObserver = {28+ readonly supported: boolean;29+ dispose(): void;30+};31+32+/** Options for observing overlay visibility and geometry changes. */33+export type ObserveWindowControlsOverlayOptions = {34+ /** Called initially and whenever browser geometry changes. */35+ onGeometry(geometry: WindowControlsOverlayGeometry): void;36+ /** Override the browser API for tests. */37+ client?: WindowControlsOverlayClient;38+};39+40+function browserClient(): WindowControlsOverlayClient | undefined {41+ if (typeof navigator === "undefined") return undefined;42+ const candidate = navigator as Navigator & {43+ windowControlsOverlay?: WindowControlsOverlayClient;44+ };45+ const client = candidate.windowControlsOverlay;46+ return client && typeof client.getTitlebarAreaRect === "function" &&47+ typeof client.addEventListener === "function" &&48+ typeof client.removeEventListener === "function"49+ ? client50+ : undefined;51+}52+53+function finiteNonNegative(value: number): number {54+ return Number.isFinite(value) && value >= 0 ? value : 0;55+}56+57+function readGeometry(client: WindowControlsOverlayClient): WindowControlsOverlayGeometry {58+ const rect = client.getTitlebarAreaRect();59+ return {60+ visible: client.visible === true,61+ titlebarArea: {62+ x: finiteNonNegative(rect.x),63+ y: finiteNonNegative(rect.y),64+ width: finiteNonNegative(rect.width),65+ height: finiteNonNegative(rect.height),66+ },67+ };68+}69+70+/**71+ * Observe the experimental Window Controls Overlay geometry surface.72+ *73+ * CSS `titlebar-area-*` environment variables should own collision-free74+ * layout. This observer is for state that genuinely needs geometry changes.75+ * Unsupported browsers retain the ordinary standalone header and receive no76+ * callback.77+ */78+export function observeWindowControlsOverlay(79+ options: ObserveWindowControlsOverlayOptions,80+): WindowControlsOverlayObserver {81+ const client = options.client ?? browserClient();82+ if (!client) return { supported: false, dispose() {} };83+ let active = true;84+ const update = () => {85+ if (active) options.onGeometry(readGeometry(client));86+ };87+ client.addEventListener("geometrychange", update);88+ update();89+ return {90+ supported: true,91+ dispose() {92+ if (!active) return;93+ active = false;94+ client.removeEventListener("geometrychange", update);95+ },96+ };97+}@@ -0,0 +1,70 @@
1+import {2+ observeWindowControlsOverlay,3+ type WindowControlsOverlayClient,4+ type WindowControlsOverlayGeometry,5+} from "./window-controls-overlay.ts";6+7+function assert(condition: unknown, message: string): asserts condition {8+ if (!condition) throw new Error(message);9+}10+11+Deno.test("window controls observer reports initial and changed geometry", () => {12+ let listener: EventListener | undefined;13+ let visible = false;14+ let rect = { x: 0, y: 0, width: 640, height: 32 };15+ const client: WindowControlsOverlayClient = {16+ get visible() {17+ return visible;18+ },19+ getTitlebarAreaRect: () => rect,20+ addEventListener: (_type, next) => listener = next,21+ removeEventListener: (_type, next) => {22+ if (listener === next) listener = undefined;23+ },24+ };25+ const values: WindowControlsOverlayGeometry[] = [];26+ const observer = observeWindowControlsOverlay({27+ client,28+ onGeometry: (value) => values.push(value),29+ });30+ assert(observer.supported, "valid API was reported unsupported");31+ assert(values[0]?.titlebarArea.width === 640, "initial geometry was not delivered");32+ visible = true;33+ rect = { x: 72, y: 0, width: 480, height: 40 };34+ listener?.(new Event("geometrychange"));35+ assert(36+ values[1]?.visible && values[1].titlebarArea.x === 72,37+ "changed geometry was not delivered",38+ );39+ observer.dispose();40+ listener?.(new Event("geometrychange"));41+ assert(values.length === 2, "disposed observer received geometry");42+});43+44+Deno.test("window controls observer preserves unsupported standalone fallback", () => {45+ let called = false;46+ const observer = observeWindowControlsOverlay({ onGeometry: () => called = true });47+ assert(!observer.supported, "missing API was reported supported");48+ assert(!called, "unsupported API invented geometry");49+});50+51+Deno.test("window controls observer bounds malformed browser geometry", () => {52+ const values: WindowControlsOverlayGeometry[] = [];53+ const client: WindowControlsOverlayClient = {54+ visible: true,55+ getTitlebarAreaRect: () => ({56+ x: -1,57+ y: Number.NaN,58+ width: Number.POSITIVE_INFINITY,59+ height: 30,60+ }),61+ addEventListener() {},62+ removeEventListener() {},63+ };64+ observeWindowControlsOverlay({ client, onGeometry: (value) => values.push(value) });65+ assert(66+ JSON.stringify(values[0]?.titlebarArea) ===67+ JSON.stringify({ x: 0, y: 0, width: 0, height: 30 }),68+ "malformed geometry escaped",69+ );70+});@@ -1,7 +1,7 @@
11 {22 ".env.example": "0cfad81a3ba6442ac15d7cbe44a00374bf4dbf8af68ca412e54fb100241ddeab",33 ".gitignore": "9b9fa4b95f8f728ce80e93fc224d430328786b620096b80f067a3190c410a00e",4- "deno.json": "1fe481e9665f611accf1dc00a72eb826d01d31c238deb56d7b6570ad1b7f1c0d",4+ "deno.json": "baa094730400a53e4cf4a73f499cbd99a0753b68f04398c3c056e59e0c6ec550",55 "public/apple-touch-icon.png": "be2b77066d9681709210388413ddcd94318906799ddd6645934b789a04bd4a61",66 "public/favicon.svg": "35b92ad48231c470b348eaebe6d6fe14acf2ec4a86e6726a03ccb130328a0d85",77 "public/icon-192.png": "6ee711f9ec990ec55f789d7943813c5193973bf2ca9199078ee85d285317e07c",@@ -11,7 +11,7 @@
1111 "public/manifest.webmanifest": "c76d1d487e86fca4ce4f8c71a5c3b22a15a2d0e3370583ae0fb40aafd4513560",1212 "public/screenshot-narrow.png": "8d11e3779e2e7437ef9e44d07bd382d161570846663338b3ad3f1cdb065b018f",1313 "public/screenshot-wide.png": "812786db28f6803d12adc8bd3af5fffefa8e4e20851b61de8a256fcc538c114e",14- "README.md": "67a60351470cd1a61c2033581fb50f617a54ce139dbd499f9f131bb939aa9c27",14+ "README.md": "10d47c681ae44a3d70b790e3952e0ac361e0eeec4a433f0ff35cfe9db61a7778",1515 "src/app.ts": "4c4c6c3f6ad4123f6cffc7a480965c4ea38442676c2af662d1bbfe082d40194f",1616 "src/env.d.ts": "b44daed05ec5cdfacfd8d8acf7866974b5f6b8db923ab53bd244419093c719da",1717 "src/islands/AccountGate.tsx": "ef541886720e984f989c2054266be8d2272b6c43fea722bde8ce615352cc0210",@@ -17,6 +17,13 @@ type ImageResource = {
1717 const manifestFile = "manifest.webmanifest";1818 const syntheticOrigin = "https://lofi.invalid";1919 const displayModes = new Set(["browser", "fullscreen", "minimal-ui", "standalone"]);20+const displayOverrideModes = new Set([21+ "browser",22+ "fullscreen",23+ "minimal-ui",24+ "standalone",25+ "window-controls-overlay",26+]);2027 const launchClientModes = new Set([2128 "auto",2229 "focus-existing",@@ -301,6 +308,29 @@ async function parseAndValidateManifest(
301308 ),302309 );303310 }311+ if (manifest.display_override !== undefined) {312+ if (!Array.isArray(manifest.display_override) || manifest.display_override.length === 0) {313+ issues.push(314+ issue(`${manifestFile}: display_override must be a non-empty array`, remediation),315+ );316+ } else {317+ if (318+ !manifest.display_override.every((mode) =>319+ typeof mode === "string" && displayOverrideModes.has(mode)320+ )321+ ) {322+ issues.push(323+ issue(324+ `${manifestFile}: display_override contains a mode without a tested lofi recipe`,325+ remediation,326+ ),327+ );328+ }329+ if (new Set(manifest.display_override).size !== manifest.display_override.length) {330+ issues.push(issue(`${manifestFile}: display_override must not repeat modes`, remediation));331+ }332+ }333+ }304334 if (manifest.prefer_related_applications !== undefined) {305335 if (typeof manifest.prefer_related_applications !== "boolean") {306336 issues.push(@@ -197,6 +197,29 @@ Deno.test("source PWA validation checks an opt-in launch handler", async () => {
197197 }198198 });199199 200+Deno.test("source PWA validation checks window controls overlay with standalone fallback", async () => {201+ const { root, project } = await makeProject();202+ try {203+ const manifest = await readManifest(project);204+ manifest.display_override = ["tabbed", "window-controls-overlay", "window-controls-overlay"];205+ await writeManifest(project, manifest);206+ const issues = await sourcePwaIssues(project);207+ assert(208+ hasIssue(issues, "without a tested lofi recipe"),209+ "tabbed mode was presented as shipped",210+ );211+ assert(hasIssue(issues, "must not repeat modes"), "duplicate display override passed");212+213+ manifest.display_override = ["window-controls-overlay"];214+ await writeManifest(project, manifest);215+ const valid = await sourcePwaIssues(project);216+ assert(valid.length === 0, `valid window controls override failed: ${JSON.stringify(valid)}`);217+ assert(manifest.display === "standalone", "fixture lost its standalone fallback");218+ } finally {219+ await Deno.remove(root, { recursive: true });220+ }221+});222+200223 Deno.test("source PWA validation checks opt-in file handlers", async () => {201224 const { root, project } = await makeProject();202225 try {@@ -392,7 +392,7 @@ async function assertGeneratedBoundary(
392392 const lofiImports = Object.entries(config.imports).filter(([name]) =>393393 name === "@nzip/lofi" || name.startsWith("@nzip/lofi/")394394 );395- assert(lofiImports.length === 18, `expected 18 lofi imports, received ${lofiImports.length}`);395+ assert(lofiImports.length === 19, `expected 19 lofi imports, received ${lofiImports.length}`);396396 const expected = source === "registry"397397 ? `jsr:@nzip/lofi@${version}`398398 : pathToFileURL(join(repositoryRoot, "package")).href;@@ -981,6 +981,54 @@ const verified = verifyWebAppOriginAssociation(association, expected);
981981 );982982 }983983 984+async function installWindowControlsOverlayRecipe(projectRoot: string) {985+ const manifestPath = join(projectRoot, "public", "manifest.webmanifest");986+ const manifest = JSON.parse(await Deno.readTextFile(manifestPath));987+ manifest.display_override = ["window-controls-overlay"];988+ await Deno.writeTextFile(manifestPath, `${JSON.stringify(manifest, null, 2)}\n`);989+ await Deno.writeTextFile(990+ join(projectRoot, "src", "pages", "desktop-titlebar.astro"),991+ `---992+import DesktopTitlebar from "../islands/DesktopTitlebar.tsx";993+import Shell from "../layouts/Shell.astro";994+import "../styles/global.css";995+---996+<Shell title="Desktop workspace">997+ <DesktopTitlebar client:load />998+</Shell>999+<style is:global>1000+ .desktop-titlebar { min-block-size: 3rem; display: flex; align-items: center; gap: .75rem; border-block-end: 1px solid CanvasText; }1001+ @media (display-mode: window-controls-overlay) {1002+ .desktop-titlebar { position: fixed; left: env(titlebar-area-x, 0px); top: env(titlebar-area-y, 0px); width: env(titlebar-area-width, 100%); height: env(titlebar-area-height, 3rem); min-block-size: 0; app-region: drag; }1003+ .desktop-titlebar :is(button, output) { app-region: no-drag; }1004+ }1005+ @media (forced-colors: active) { .desktop-titlebar { border-color: CanvasText; } }1006+</style>1007+`,1008+ );1009+ await Deno.writeTextFile(1010+ join(projectRoot, "src", "islands", "DesktopTitlebar.tsx"),1011+ `import { useEffect, useState } from "preact/hooks";1012+import { observeWindowControlsOverlay, type WindowControlsOverlayGeometry } from "@nzip/lofi/recipes/window-controls-overlay";1013+export default function DesktopTitlebar() {1014+ const [supported, setSupported] = useState<boolean>();1015+ const [geometry, setGeometry] = useState<WindowControlsOverlayGeometry>();1016+ useEffect(() => {1017+ const observer = observeWindowControlsOverlay({ onGeometry: setGeometry });1018+ setSupported(observer.supported);1019+ return observer.dispose;1020+ }, []);1021+ return <header class="desktop-titlebar" data-desktop-titlebar>1022+ <strong>Field Notes</strong>1023+ <button type="button">Workspace menu</button>1024+ <output data-overlay-support>{supported === undefined ? "checking" : supported ? "supported" : "unsupported"}</output>1025+ {geometry && <output data-overlay-width>{geometry.titlebarArea.width}</output>}1026+ </header>;1027+}1028+`,1029+ );1030+}1031+9841032 async function main() {9851033 const { source, version } = parseArgs(Deno.args);9861034 await Deno.remove(artifactsRoot, { recursive: true }).catch((error) => {@@ -1239,6 +1287,10 @@ async function main() {
12391287 "install scope-extension recipe",12401288 () => installScopeExtensionRecipe(projectRoot!),12411289 );1290+ await stage(1291+ "install window-controls-overlay recipe",1292+ () => installWindowControlsOverlayRecipe(projectRoot!),1293+ );12421294 12431295 await stage(12441296 "build",@@ -1689,6 +1741,34 @@ async function main() {
16891741 description: "task app after scope-extension exercise",16901742 });16911743 });1744+ await stage("window-controls-overlay recipe browser", async () => {1745+ await page!.goto(`${origin}desktop-titlebar/`, { waitUntil: "domcontentloaded" });1746+ const titlebar = page!.locator("[data-desktop-titlebar]");1747+ await titlebar.waitFor({ state: "visible" });1748+ await page!.getByRole("button", { name: "Workspace menu" }).focus();1749+ assert(1750+ await page!.getByRole("button", { name: "Workspace menu" }).evaluate((element) =>1751+ element === document.activeElement1752+ ),1753+ "titlebar controls were not keyboard reachable",1754+ );1755+ const box = await titlebar.boundingBox();1756+ assert(1757+ Boolean(box && box.width > 0 && box.height > 0),1758+ "standalone titlebar fallback collapsed",1759+ );1760+ await context!.setOffline(true);1761+ try {1762+ await page!.goto(`${origin}desktop-titlebar/`, { waitUntil: "domcontentloaded" });1763+ await page!.locator("[data-desktop-titlebar]").waitFor({ state: "visible" });1764+ } finally {1765+ await context!.setOffline(false);1766+ }1767+ await page!.goto(origin, { waitUntil: "domcontentloaded" });1768+ await waitForReady(page!, taskAppReady, undefined, {1769+ description: "task app after window-controls-overlay exercise",1770+ });1771+ });16921772 await stage("waiting worker update browser", async () => {16931773 const workerPath = join(projectRoot!, "dist", "sw.js");16941774 const worker = await Deno.readTextFile(workerPath);