clone

a deno framework for local-first web apps that open instantly, work offline and sync when users choose

Merge pull request #82 from FelineStateMachine/feat/window-controls-evaluation

FelineStateMachine committed c87f579
feat(pwa): add window controls overlay recipe
commit
c87f5795138352d4e9de95e57fbae0018da1b027
parent
089be65520c423588355d8b6534472829ee95ec4
parent
7107277bd644af074ede3e0fc2248fcfd995f45a
author
FelineStateMachine <dami.etoile@gmail.com>
17 files changed, +471 -22
MREADME.md4+-
Mapps/reference/deno.json1+
Mdeno.json1+
Mdocs/getting-started.md29++++----
Mdocs/recipes/README.md2+
Adocs/recipes/window-controls-overlay.md117++++++++++++++++++++++++++++++
Mpackage/astro/mod.ts14++++
Mpackage/astro/mod_test.ts2+
Mpackage/create_core.ts7++-
Mpackage/create_core_test.ts9++-
Apackage/recipes/window-controls-overlay.ts97+++++++++++++++++++++++++
Apackage/recipes/window-controls-overlay_test.ts70++++++++++++++++++
Mpackage/testdata/starter.snapshot.json4+-
Mpackage/tooling/pwa-validation.ts30++++++++
Mpackage/tooling/pwa-validation_test.ts23++++++
Mtools/doc_lint.ts1+
Mtools/golden.ts82+++++++++++++++++++++-
M README.md +2 -2
M apps/reference/deno.json +1 -0
M deno.json +1 -0
M docs/getting-started.md +15 -14
@@ -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.
M docs/recipes/README.md +2 -0
A docs/recipes/window-controls-overlay.md +117 -0
@@ -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).
M package/astro/mod.ts +14 -0
@@ -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)} },
M package/astro/mod_test.ts +2 -0
M package/create_core.ts +6 -1
M package/create_core_test.ts +7 -2
A package/recipes/window-controls-overlay.ts +97 -0
@@ -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+}
A package/recipes/window-controls-overlay_test.ts +70 -0
@@ -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+});
M package/testdata/starter.snapshot.json +2 -2
M package/tooling/pwa-validation.ts +30 -0
M package/tooling/pwa-validation_test.ts +23 -0
M tools/doc_lint.ts +1 -0
M tools/golden.ts +81 -1
@@ -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);