From a8743806746eaa36e744ccbcc4b8428bb709071f Mon Sep 17 00:00:00 2001 From: Philip Trauner Date: Thu, 22 Oct 2026 11:27:15 +0200 Subject: [PATCH 1/9] feat: debug menu --- src/islands/client.ts | 1 + src/islands/debug-menu.ts | 301 ++++++++++++++++++++++++++++ src/islands/debug-trigger.ts | 98 +++++++++ src/layouts/Layout.astro | 5 +- src/middleware.ts | 15 +- src/pages/debug/foo.astro | 70 +++++++ src/styles/debug-menu.css | 197 ++++++++++++++++++ src/styles/global.css | 1 + src/types/feature.ts | 19 ++ src/utilities/feature.ts | 285 ++++++++++++++++++++++++++ src/utilities/scope.ts | 8 + test/feature.test.ts | 379 +++++++++++++++++++++++++++++++++++ 12 files changed, 1377 insertions(+), 2 deletions(-) create mode 100644 src/islands/debug-menu.ts create mode 100644 src/islands/debug-trigger.ts create mode 100644 src/pages/debug/foo.astro create mode 100644 src/styles/debug-menu.css create mode 100644 src/types/feature.ts create mode 100644 src/utilities/feature.ts create mode 100644 src/utilities/scope.ts create mode 100644 test/feature.test.ts diff --git a/src/islands/client.ts b/src/islands/client.ts index 3562925..71149cd 100644 --- a/src/islands/client.ts +++ b/src/islands/client.ts @@ -3,3 +3,4 @@ import "./filter-sheet.js"; import "./version-history.js"; import "./price-watch-links.js"; import "./device-search.js"; +import "./debug-trigger.js"; diff --git a/src/islands/debug-menu.ts b/src/islands/debug-menu.ts new file mode 100644 index 0000000..a1c7c22 --- /dev/null +++ b/src/islands/debug-menu.ts @@ -0,0 +1,301 @@ +import { html, LitElement, nothing } from "lit"; +import { state } from "lit/decorators.js"; +import { createRef, ref } from "lit/directives/ref.js"; +import { unsafeHTML } from "lit/directives/unsafe-html.js"; +import type { Ref } from "lit/directives/ref.js"; + +import { FEATURE_NAMES, FEATURES } from "../types/feature.js"; +import { defineElementOnce } from "../utilities/define-element.js"; +import { + FEATURE_EVENT, + featureEnable, + featureParamUrl, + features, + featuresReset, +} from "../utilities/feature.js"; +import { isAbortError } from "../utilities/is-abort-error.js"; +import { generic, render } from "../utilities/presentation/index.js"; +import { PresentationRenderPresetRoleIcon } from "../utilities/presentation/preset.js"; +import type { FeatureName, FeatureState } from "../types/feature.js"; + +const closeIcon = render( + generic("x"), + PresentationRenderPresetRoleIcon.withSize(18), +); + +/** how long the share popover stays up */ +const SHARE_FEEDBACK_MS = 2000; + +type ShareResult = "copied" | "failed"; + +const SHARE_MESSAGE: Record = { + copied: "Link copied", + failed: "Could not copy", +}; + +export class DebugMenu extends LitElement { + @state() private _open = false; + @state() private _features: FeatureState = features(); + /** a flag changed since this document was rendered*/ + @state() private _dirty = false; + /** outcome of the last share, said by the popover */ + @state() private _shareResult: ShareResult | undefined; + + private _shareTimer: number | undefined; + private _shareButton: Ref = createRef(); + private _feedback: Ref = createRef(); + + private _onKey = (e: KeyboardEvent): void => { + if (e.key === "Escape") { + this.close(); + } + }; + + private _onFeatures = (): void => { + this._features = features(); + }; + + // opt-out of shadow dom to be able to reference global css classes + protected override createRenderRoot(): HTMLElement { + return this; + } + + override connectedCallback(): void { + super.connectedCallback(); + window.addEventListener(FEATURE_EVENT, this._onFeatures); + } + + override disconnectedCallback(): void { + super.disconnectedCallback(); + window.removeEventListener(FEATURE_EVENT, this._onFeatures); + this._release(); + } + + open(): void { + if (this._open) { + return; + } + + this._open = true; + this._onFeatures(); + document.body.classList.add("modal-open"); + document.addEventListener("keydown", this._onKey); + } + + close = (): void => { + if (!this._open) { + return; + } + + this._open = false; + this._release(); + }; + + private _release(): void { + document.body.classList.remove("modal-open"); + document.removeEventListener("keydown", this._onKey); + window.clearTimeout(this._shareTimer); + this._shareTimer = undefined; + this._shareResult = undefined; + } + + /** the names the cookie carries */ + private get _enabled(): FeatureName[] { + return FEATURE_NAMES.filter((name) => this._features[name]); + } + + private _toggle(name: FeatureName): void { + this._features = featureEnable(name, !this._features[name]); + this._dirty = true; + } + + private _reset = (): void => { + this._features = featuresReset(); + this._dirty = true; + }; + + private _reload = (): void => { + window.location.reload(); + }; + + private _share = (): void => { + void this._shareLink(featureParamUrl(window.location.href, this._enabled)); + }; + + private async _shareLink(url: string): Promise { + if (typeof navigator.share === "function") { + try { + await navigator.share({ url }); + return; + } catch (error) { + if (isAbortError(error)) { + return; + } + } + } + + try { + await navigator.clipboard.writeText(url); + await this._report("copied"); + } catch { + await this._report("failed"); + } + } + + private async _report(result: ShareResult): Promise { + this._shareResult = result; + await this.updateComplete; + + const popover = this._feedback.value; + if (typeof popover === "undefined") { + return; + } + + window.clearTimeout(this._shareTimer); + if (!popover.matches(":popover-open")) { + const source = this._shareButton.value; + popover.showPopover( + typeof source !== "undefined" ? { source } : undefined, + ); + } + + this._shareTimer = window.setTimeout(() => { + this._shareTimer = undefined; + + const showing = this._feedback.value; + if (showing?.matches(":popover-open") === true) { + showing.hidePopover(); + } + }, SHARE_FEEDBACK_MS); + } + + private _renderRow(name: FeatureName) { + const { label, description } = FEATURES[name]; + + return html` + + `; + } + + override render() { + if (!this._open) { + return nothing; + } + + const enabled = this._enabled; + // link that carries enablement state for sharing + const link = featureParamUrl(window.location.href, enabled); + + return html` + + `; + } +} + +defineElementOnce("debug-menu", DebugMenu); + +/** mounts the menu on first use, so pages carry no markup for it */ +export const openDebugMenu = (): void => { + const existing = document.querySelector("debug-menu"); + if (existing !== null) { + existing.open(); + return; + } + + const menu = document.createElement("debug-menu"); + document.body.append(menu); + menu.open(); +}; + +declare global { + interface HTMLElementTagNameMap { + "debug-menu": DebugMenu; + } +} diff --git a/src/islands/debug-trigger.ts b/src/islands/debug-trigger.ts new file mode 100644 index 0000000..3887cc6 --- /dev/null +++ b/src/islands/debug-trigger.ts @@ -0,0 +1,98 @@ +import { + FEATURE_PARAM, + featureDatasetApply, + featureParamEnabled, + featuresSet, +} from "../utilities/feature.js"; + +/** taps needed to open the debug menu */ +export const TAP_GESTURE_COUNT = 10; + +/** a longer gap than this starts the count over, so only a deliberate burst counts */ +export const TAP_GESTURE_RESET_MS = 400; + +/** counts fast consecutive taps */ +const tapGesture = ( + count: number = TAP_GESTURE_COUNT, + resetMs: number = TAP_GESTURE_RESET_MS, +) => { + let taps = 0; + let previous: number | undefined; + + const reset = (): void => { + taps = 0; + previous = undefined; + }; + + return { + tap: (at: number) => { + const consecutive = + typeof previous !== "undefined" && at - previous <= resetMs; + taps = consecutive ? taps + 1 : 1; + previous = at; + + if (taps < count) { + return false; + } + + reset(); + return true; + }, + reset, + }; +}; + +const gesture = tapGesture(); +let opening = false; + +const open = async (): Promise => { + if (opening) { + return; + } + + opening = true; + try { + const { openDebugMenu } = await import("./debug-menu.js"); + openDebugMenu(); + } finally { + opening = false; + } +}; + +const onPointerDown = (event: PointerEvent): void => { + if ( + event.target instanceof Element && + event.target.closest("debug-menu") !== null + ) { + gesture.reset(); + return; + } + + if (gesture.tap(event.timeStamp)) { + void open(); + } +}; + +/** sources and removes enablement overrides from url and adds `` for prerendered pages */ +const apply = (): void => { + const url = new URL(window.location.href); + const override = featureParamEnabled(url.searchParams); + if (typeof override !== "undefined") { + featuresSet(override); + + // removes enablement overrides + url.searchParams.delete(FEATURE_PARAM); + window.history.replaceState(window.history.state, "", url); + return; + } + + featureDatasetApply(); +}; + +apply(); + +document.addEventListener("astro:after-swap", apply); +document.addEventListener("pointerdown", onPointerDown, { + capture: true, + passive: true, +}); diff --git a/src/layouts/Layout.astro b/src/layouts/Layout.astro index fabfca3..00a1354 100644 --- a/src/layouts/Layout.astro +++ b/src/layouts/Layout.astro @@ -9,6 +9,7 @@ import PlausibleAnalytics from "../components/PlausibleAnalytics.astro"; import { m } from "../paraglide/messages.js"; import { getLocale } from "../paraglide/runtime.js"; import globalCss from "../styles/global.css?url"; +import { featureDataset, features } from "../utilities/feature"; interface Props { title?: string; @@ -17,9 +18,11 @@ interface Props { } const { title, page = "page", back } = Astro.props; + +const featureAttributes = featureDataset(features()); --- - + diff --git a/src/middleware.ts b/src/middleware.ts index c17b13c..473fd9e 100644 --- a/src/middleware.ts +++ b/src/middleware.ts @@ -5,7 +5,9 @@ import { defineMiddleware, sequence } from "astro:middleware"; import "./io/http-cache.ts"; import { paraglideMiddleware } from "./paraglide/server.js"; +import { featureParamApply } from "./utilities/feature"; import { localeFromParam } from "./utilities/locale"; +import { scopeFeature } from "./utilities/scope.ts"; // Astro routes `src/pages/[...locale]/`, Paraglide resolves the locale for `getLocale()` const paraglide = defineMiddleware((context, next) => { @@ -26,6 +28,17 @@ const paraglide = defineMiddleware((context, next) => { return paraglideMiddleware(request, () => next()); }); +// applies enablement from `?feature=` query parameters and provides render context +const feature = defineMiddleware((context, next) => { + if (context.isPrerendered) { + return next(); + } + + featureParamApply(context); + + return scopeFeature.run(context.cookies, next); +}); + const noindex = defineMiddleware(async (_, next) => { const response = await next(); @@ -36,4 +49,4 @@ const noindex = defineMiddleware(async (_, next) => { return response; }); -export const onRequest = sequence(paraglide, noindex); +export const onRequest = sequence(feature, paraglide, noindex); diff --git a/src/pages/debug/foo.astro b/src/pages/debug/foo.astro new file mode 100644 index 0000000..4626004 --- /dev/null +++ b/src/pages/debug/foo.astro @@ -0,0 +1,70 @@ +--- +import Layout from "../../layouts/Layout.astro"; + +import { feature } from "../../utilities/feature"; + +export const prerender = false; + +const enabled = feature("foo"); +--- + + + + +
+
+

ssr

+ {enabled ? "enabled" : "disabled"} +
+ +
+

css query

+ enableddisabled +
+ +
+

csr

+ +
+
+
+ + + + diff --git a/src/styles/debug-menu.css b/src/styles/debug-menu.css new file mode 100644 index 0000000..64a0b49 --- /dev/null +++ b/src/styles/debug-menu.css @@ -0,0 +1,197 @@ +.debug-backdrop { + position: fixed; + inset: 0; + z-index: 200; + background: var(--scrim); + display: flex; + align-items: flex-end; + justify-content: center; + animation: modal-fade-in 160ms ease-out; +} +.debug-panel { + background: var(--surface); + color: var(--fg); + width: min(520px, 100%); + max-height: min(680px, 88vh); + display: flex; + flex-direction: column; + overflow: hidden; + border-radius: var(--radius-4) var(--radius-4) 0 0; + box-shadow: var(--shadow-4); + animation: sheet-slide-in 220ms cubic-bezier(0.2, 0, 0.2, 1); +} +@media (min-width: 768px) { + .debug-backdrop { + align-items: center; + padding: var(--space-6); + } + .debug-panel { + border-radius: var(--radius-4); + animation: modal-pop-in 180ms cubic-bezier(0.2, 0, 0.2, 1); + } +} + +.debug-head { + display: flex; + align-items: flex-start; + gap: var(--space-4); + padding: var(--space-5) var(--space-5) var(--space-4); + border-bottom: 1px solid var(--border); +} +.debug-head-main { + flex: 1; + min-width: 0; +} +.debug-title { + font-family: var(--font-sans); + font-size: var(--fs-16); + font-weight: var(--fw-semibold); + margin: 0; +} +.debug-close { + background: none; + border: 0; + padding: 4px; + display: inline-flex; + align-items: center; + justify-content: center; + color: var(--fg); + cursor: pointer; + border-radius: var(--radius-2); + flex-shrink: 0; +} + +.debug-body { + flex: 1; + overflow-y: auto; + padding: var(--space-2) var(--space-5) var(--space-4); +} + +.debug-row { + display: flex; + align-items: center; + gap: var(--space-4); + padding: var(--space-3) 0; + border-top: 1px solid var(--border-subtle); + cursor: pointer; +} +.debug-row:first-child { + border-top: 0; +} +.debug-row-main { + flex: 1; + min-width: 0; + display: flex; + flex-direction: column; + gap: 2px; +} +.debug-row-label { + font-size: var(--fs-14); + font-weight: var(--fw-medium); +} +.debug-empty { + margin: var(--space-4) 0; + font-size: var(--fs-13); + color: var(--fg-subtle); +} +.debug-row-description { + font-size: var(--fs-13); + line-height: var(--lh-snug); + color: var(--fg-muted); +} +.debug-row-name { + font-family: var(--font-mono); + font-size: var(--fs-12); + color: var(--fg-subtle); +} + +.debug-switch { + flex-shrink: 0; + appearance: none; + -webkit-appearance: none; + margin: 0; + width: 40px; + height: 24px; + border-radius: var(--radius-pill); + border: 1px solid var(--border-strong); + background: var(--surface-tint-mid); + cursor: pointer; + position: relative; + transition: background var(--transition-fast); +} +.debug-switch::after { + content: ""; + position: absolute; + top: 2px; + left: 2px; + width: 18px; + height: 18px; + border-radius: 50%; + background: var(--surface); + box-shadow: var(--shadow-1); + transition: transform var(--transition-fast); +} +.debug-switch:checked { + background: var(--primary); + border-color: var(--primary); +} +.debug-switch:checked::after { + transform: translateX(16px); + background: var(--fg-on-primary); +} +.debug-switch:focus-visible { + outline: 0; + box-shadow: var(--ring-focus); +} + +.debug-foot { + display: flex; + align-items: center; + justify-content: flex-end; + gap: var(--space-2); + padding: var(--space-3) var(--space-5); + border-top: 1px solid var(--border); + background: var(--surface-sunken); +} +/* keeps the reload/reset pair hard right whether or not share is showing */ +.debug-share { + margin-right: auto; +} +.debug-feedback { + position-area: top; + margin-bottom: var(--space-2); + padding: var(--space-2) var(--space-3); + background: var(--surface); + color: var(--fg); + border: 1px solid var(--border-strong); + border-radius: var(--radius-3); + box-shadow: var(--shadow-3); + font-size: var(--fs-13); + font-weight: var(--fw-medium); +} +.debug-actions { + display: flex; + align-items: center; + gap: var(--space-2); + flex-shrink: 0; +} +.debug-action { + font: inherit; + font-size: var(--fs-13); + font-weight: var(--fw-medium); + padding: 6px var(--space-3); + border: 1px solid var(--border-strong); + border-radius: var(--radius-3); + background: var(--surface); + color: var(--fg); + cursor: pointer; +} +.debug-action:disabled { + color: var(--fg-disabled); + cursor: default; +} +.debug-action.is-primary { + background: var(--primary); + border-color: var(--primary); + color: var(--fg-on-primary); +} diff --git a/src/styles/global.css b/src/styles/global.css index b70420c..bf11a0b 100644 --- a/src/styles/global.css +++ b/src/styles/global.css @@ -18,3 +18,4 @@ @import "./sheet.css"; @import "./loading.css"; @import "./redaction.css"; +@import "./debug-menu.css"; diff --git a/src/types/feature.ts b/src/types/feature.ts new file mode 100644 index 0000000..58c3927 --- /dev/null +++ b/src/types/feature.ts @@ -0,0 +1,19 @@ +export type FeatureDefinition = { + label: string; + description: string; +}; + +export const FEATURES = { + foo: { + label: "foo feature", + description: "enables foo", + }, +} as const satisfies Record; + +export type FeatureName = keyof typeof FEATURES; +export type FeatureState = Record; + +export const FEATURE_NAMES = Object.keys(FEATURES) as readonly FeatureName[]; + +export const isFeatureName = (value: unknown): value is FeatureName => + (FEATURE_NAMES as readonly unknown[]).includes(value); diff --git a/src/utilities/feature.ts b/src/utilities/feature.ts new file mode 100644 index 0000000..ae9a164 --- /dev/null +++ b/src/utilities/feature.ts @@ -0,0 +1,285 @@ +// eslint-disable-next-line @typescript-eslint/no-unused-vars -- false positive, referenced in comment +import type { AsyncLocalStorage } from "node:async_hooks"; + +import { FEATURE_NAMES, isFeatureName } from "../types/feature"; +import type { FeatureName, FeatureState } from "../types/feature"; + +export const FEATURE_COOKIE = "device-database.features"; + +/** spells the enabled set out in a url, so a state can be linked to */ +export const FEATURE_PARAM = "feature"; + +const COOKIE_MAX_AGE_SECONDS = 60 * 60 * 24 * 30; +const COOKIE_PATH = "/"; + +/** fired on `window` after a flag changed, so client features can re-render */ +export const FEATURE_EVENT = "device-database:features"; + +const SEPARATOR = "|"; + +export type FeatureCookieJar = { + get: (key: string) => { value: string } | undefined; +}; + +/** attributes of the cookie */ +type FeatureCookieOptions = { + path: string; + maxAge: number; + sameSite: "lax"; + secure: boolean; +}; + +export type FeatureCookieJarWritable = FeatureCookieJar & { + set: (key: string, value: string, options: FeatureCookieOptions) => void; + delete: (key: string, options: { path: string }) => void; +}; + +/** also needs the url because `secure` has to follow the scheme */ +export type FeatureTarget = { + cookies: FeatureCookieJarWritable; + url: { protocol: string }; +}; + +export type FeatureRequest = { + cookies: FeatureCookieJarWritable; + url: URL; +}; + +/** + * astro global is only available in `.astro` files and middlewares. + * page rendering happens in middleware callback chain + * → introduce global thunk that is set with {@link featureRenderInstall} in ssr context + * + * thunk is eventually populated with help of {@link AsyncLocalStorage} + */ +let renderer: (() => FeatureCookieJar | undefined) | undefined; +/** allows setting {@link renderer} */ +export const featureRenderInstall = ( + resolve: () => FeatureCookieJar | undefined, +) => { + renderer = resolve; +}; + +const fromDocument = (): string | undefined => { + if (typeof document === "undefined") { + return undefined; + } + + for (const pair of document.cookie.split(";")) { + const separator = pair.indexOf("="); + if ( + separator === -1 || + pair.slice(0, separator).trim() !== FEATURE_COOKIE + ) { + continue; + } + + const raw = pair.slice(separator + 1).trim(); + try { + return decodeURIComponent(raw); + } catch { + // a hand-edited cookie can be invalid percent-encoding + return raw; + } + } + + return undefined; +}; + +const cookieValue = (jar?: FeatureCookieJar): string | undefined => + typeof jar === "undefined" ? fromDocument() : jar.get(FEATURE_COOKIE)?.value; + +/** unknown names are dropped, so a cookie outliving a removed flag is harmless */ +export const featureParseEnabled = (value: string): Set => { + const enabled: Set = new Set(); + for (const name of value.split(SEPARATOR)) { + if (!isFeatureName(name)) { + continue; + } + + enabled.add(name); + } + + return enabled; +}; + +export const featureSerializeEnabled = (enabled: Set) => + [...enabled].join(SEPARATOR); + +const featureStateOf = (enabled: Set): FeatureState => + Object.fromEntries( + FEATURE_NAMES.map((name) => [name, enabled.has(name)] as const), + ) as FeatureState; + +/** what the cookie carries, empty when it is absent or unreadable */ +const enabledOf = (jar?: FeatureCookieJar): Set => { + const value = cookieValue(jar); + + return typeof value !== "undefined" ? featureParseEnabled(value) : new Set(); +}; + +/** `data-feature-*` attributes for css queries */ +export const featureDataset = (state: FeatureState): Record => + Object.fromEntries( + FEATURE_NAMES.map( + (name) => [`data-feature-${name}`, state[name] ? "on" : "off"] as const, + ), + ); + +/** applies data-feature-*` attributes for css queries in csr context */ +export const featureDatasetApply = (state: FeatureState = features()) => { + for (const [attribute, value] of Object.entries(featureDataset(state))) { + document.documentElement.setAttribute(attribute, value); + } +}; + +const cookieOptions = (url: { protocol: string }): FeatureCookieOptions => ({ + path: COOKIE_PATH, + maxAge: COOKIE_MAX_AGE_SECONDS, + sameSite: "lax", + secure: url.protocol === "https:", +}); + +const serializeCookie = ( + value: string, + options: FeatureCookieOptions, +): string => + [ + `${FEATURE_COOKIE}=${encodeURIComponent(value)}`, + `path=${options.path}`, + `Max-Age=${options.maxAge}`, + `SameSite=${options.sameSite}`, + ...(options.secure ? ["Secure"] : []), + ].join("; "); + +const writeCookie = (value: string, target?: FeatureTarget): void => { + if (typeof target !== "undefined") { + if (value.length === 0) { + target.cookies.delete(FEATURE_COOKIE, { path: COOKIE_PATH }); + } else { + target.cookies.set(FEATURE_COOKIE, value, cookieOptions(target.url)); + } + + return; + } + + if (typeof document === "undefined") { + throw new Error("cookie jar needed in ssr context"); + } + + const options = cookieOptions(window.location); + document.cookie = serializeCookie( + value, + value.length === 0 ? { ...options, maxAge: 0 } : options, + ); +}; + +const announce = (state: FeatureState): FeatureState => { + if (typeof document === "undefined") { + return state; + } + + featureDatasetApply(state); + + window.dispatchEvent( + new CustomEvent(FEATURE_EVENT, { detail: state }), + ); + + return state; +}; + +/** persists the whole set and announces it */ +export const featuresSet = ( + enabled: Set, + target?: FeatureTarget, +): FeatureState => { + writeCookie(featureSerializeEnabled(enabled), target); + + return announce(featureStateOf(enabled)); +}; + +/** persists enablement and announces it */ +export const featureEnable = ( + name: FeatureName, + enabled: boolean, + target?: FeatureTarget, +): FeatureState => { + const next = enabledOf(target?.cookies); + + if (enabled) { + next.add(name); + } else { + next.delete(name); + } + + return featuresSet(next, target); +}; + +/** drops the cookie, turning every feature off */ +export const featuresReset = (target?: FeatureTarget): FeatureState => + featuresSet(new Set(), target); + +/** what an enablement url asks for */ +export const featureParamEnabled = ( + params: URLSearchParams, +): Set | undefined => { + if (!params.has(FEATURE_PARAM)) { + return undefined; + } + + const enabled: Set = new Set(); + for (const name of params.getAll(FEATURE_PARAM)) { + if (!isFeatureName(name)) { + continue; + } + + enabled.add(name); + } + + return enabled; +}; + +/** all enabled features */ +export const features = (jar?: FeatureCookieJar): FeatureState => + featureStateOf(enabledOf(jar ?? renderer?.())); + +/** enablement state of single feature */ +export const feature = (name: FeatureName, jar?: FeatureCookieJar): boolean => + features(jar)[name]; + +/** builds enablement url */ +export const featureParamUrl = ( + url: URL | string, + enabled: Iterable, +): string => { + const next = new URL(url); + next.searchParams.delete(FEATURE_PARAM); + + const names = [...enabled]; + for (const name of names) { + next.searchParams.append(FEATURE_PARAM, name); + } + + if (names.length === 0) { + // empty feature parameter indicates that all features should be disabled + next.searchParams.append(FEATURE_PARAM, ""); + } + + return next.toString(); +}; + +/** applies feature override requested in `?feature=` enablement url */ +export const featureParamApply = (request: FeatureRequest): void => { + const asked = featureParamEnabled(request.url.searchParams); + if (typeof asked === "undefined") { + return; + } + + featuresSet(asked, request); +}; + +declare global { + interface WindowEventMap { + [FEATURE_EVENT]: CustomEvent; + } +} diff --git a/src/utilities/scope.ts b/src/utilities/scope.ts new file mode 100644 index 0000000..f87db58 --- /dev/null +++ b/src/utilities/scope.ts @@ -0,0 +1,8 @@ +import { AsyncLocalStorage } from "node:async_hooks"; + +import { featureRenderInstall } from "./feature"; +import type { FeatureCookieJar } from "./feature"; + +export const scopeFeature: AsyncLocalStorage = + new AsyncLocalStorage(); +featureRenderInstall(() => scopeFeature.getStore()); diff --git a/test/feature.test.ts b/test/feature.test.ts new file mode 100644 index 0000000..b7e89ca --- /dev/null +++ b/test/feature.test.ts @@ -0,0 +1,379 @@ +import { describe, test } from "vitest"; + +import { FEATURES } from "../src/types/feature"; +import { + feature, + FEATURE_COOKIE, + FEATURE_PARAM, + featureDataset, + featureEnable, + featureParamApply, + featureParamEnabled, + featureParamUrl, + featureParseEnabled, + features, + featureSerializeEnabled, + featuresReset, + featuresSet, +} from "../src/utilities/feature"; +import { scopeFeature } from "../src/utilities/scope"; +import type { FeatureName, FeatureState } from "../src/types/feature"; +import type { FeatureCookieJarWritable } from "../src/utilities/feature"; + +const names = Object.keys(FEATURES) as [FeatureName, ...FeatureName[]]; +const [name] = names; + +const stateWith = (enabled: Iterable): FeatureState => { + const on = new Set(enabled); + + return Object.fromEntries(names.map((n) => [n, on.has(n)])) as FeatureState; +}; + +const allOff = stateWith([]); + +/** read-only half, stand in for `Astro.cookies` */ +const jar = (value: string) => ({ + get: (key: string) => (key === FEATURE_COOKIE ? { value } : undefined), +}); + +type SetOptions = Parameters[2]; + +/** stand in for `Astro` */ +const target = (initial?: string, protocol = "https:") => { + let value = initial; + const writes: SetOptions[] = []; + let deletes = 0; + + return { + cookies: { + get: (key: string) => + key === FEATURE_COOKIE && typeof value !== "undefined" + ? { value } + : undefined, + set: (key: string, next: string, options: SetOptions) => { + if (key === FEATURE_COOKIE) { + value = next; + writes.push(options); + } + }, + delete: (key: string) => { + if (key === FEATURE_COOKIE) { + value = undefined; + deletes += 1; + } + }, + }, + url: { protocol }, + stored: () => value, + writes, + deletes: () => deletes, + }; +}; + +/** stand in for `Astro` on a server-rendered page, whose url can be questioned */ +const request = (href: string, initial?: string) => { + const url = new URL(href); + + return { ...target(initial, url.protocol), url }; +}; + +/** `?feature=a&feature=b` for the given names */ +const query = (...values: string[]): URLSearchParams => + new URLSearchParams(values.map((value) => [FEATURE_PARAM, value])); + +describe("featureParseEnabled", () => { + test("an empty cookie enables nothing", (t) => { + t.expect(featureParseEnabled("")).toEqual(new Set()); + }); + + test("names what the cookie lists", (t) => { + t.expect(featureParseEnabled(name)).toEqual(new Set([name])); + }); + + test("drops names that are not features", (t) => { + t.expect(featureParseEnabled("gone|also-gone")).toEqual(new Set()); + t.expect(featureParseEnabled(`gone|${name}`)).toEqual(new Set([name])); + }); +}); + +describe("featureSerializeEnabled", () => { + test("nothing enabled is an empty cookie", (t) => { + t.expect(featureSerializeEnabled(new Set())).toBe(""); + }); + + test("round-trips every feature", (t) => { + const all = new Set(names); + + t.expect(featureParseEnabled(featureSerializeEnabled(all))).toEqual(all); + }); +}); + +describe("features", () => { + test("answers for every feature in the registry, not just the listed ones", (t) => { + t.expect(Object.keys(features(jar(name))).toSorted()).toEqual( + [...names].toSorted(), + ); + }); + + test("sources from a cookie jar", (t) => { + t.expect(features(jar(name))).toEqual(stateWith([name])); + t.expect(feature(name, jar(name))).toBe(true); + }); + + test("without a jar and without a document everything is off", (t) => { + t.expect(features()).toEqual(allOff); + }); + + test("a jar without our cookie leaves everything off", (t) => { + t.expect(features({ get: () => undefined })).toEqual(allOff); + }); +}); + +describe("featureDataset", () => { + test("one data attribute per feature", (t) => { + const attributes = featureDataset(stateWith([name])); + + t.expect(Object.keys(attributes)).toHaveLength(names.length); + t.expect(attributes[`data-feature-${name}`]).toBe("on"); + }); + + test("an unlisted feature reads as off", (t) => { + t.expect(featureDataset(allOff)[`data-feature-${name}`]).toBe("off"); + }); +}); + +describe("featureEnable", () => { + test("writes through the jar and answers with the new state", (t) => { + const astro = target(); + + t.expect(featureEnable(name, true, astro)).toEqual(stateWith([name])); + t.expect(featureParseEnabled(astro.stored() ?? "")).toEqual( + new Set([name]), + ); + }); + + test("sources the current set from the same jar it writes to", (t) => { + const astro = target(featureSerializeEnabled(new Set(names))); + const remaining = new Set(names); + remaining.delete(name); + + featureEnable(name, false, astro); + + t.expect(featureParseEnabled(astro.stored() ?? "")).toEqual(remaining); + }); + + test("enabling twice is idempotent", (t) => { + const astro = target(); + featureEnable(name, true, astro); + const second = featureEnable(name, true, astro); + + t.expect(second).toEqual(stateWith([name])); + t.expect(featureParseEnabled(astro.stored() ?? "")).toEqual( + new Set([name]), + ); + }); + + test("turning the last feature off drops the cookie instead of blanking it", (t) => { + const astro = target(name); + + t.expect(featureEnable(name, false, astro)).toEqual(allOff); + t.expect(astro.stored()).toBeUndefined(); + t.expect(astro.deletes()).toBe(1); + }); + + test("the cookie outlives the request and stays on top-level navigations", (t) => { + const astro = target(); + featureEnable(name, true, astro); + + t.expect(astro.writes.at(0)).toMatchObject({ + path: "/", + sameSite: "lax", + }); + t.expect(astro.writes.at(0)?.maxAge).toBeGreaterThan(0); + }); + + test("secure follows the scheme, so local http dev still gets the cookie", (t) => { + const secure = target(undefined, "https:"); + const plain = target(undefined, "http:"); + + featureEnable(name, true, secure); + featureEnable(name, true, plain); + + t.expect(secure.writes.at(0)?.secure).toBe(true); + t.expect(plain.writes.at(0)?.secure).toBe(false); + }); + + test("refuses to guess a sink when there is no document and no jar", (t) => { + t.expect(() => featureEnable(name, true)).toThrowError( + "cookie jar needed in ssr context", + ); + }); +}); + +describe("featuresReset", () => { + test("deletes the cookie and turns everything off", (t) => { + const astro = target(featureSerializeEnabled(new Set(names))); + + t.expect(featuresReset(astro)).toEqual(allOff); + t.expect(astro.stored()).toBeUndefined(); + t.expect(astro.deletes()).toBe(1); + }); +}); + +describe("featuresSet", () => { + test("replaces the set rather than merging into it", (t) => { + const astro = target(featureSerializeEnabled(new Set(names))); + + t.expect(featuresSet(new Set(), astro)).toEqual(allOff); + t.expect(astro.stored()).toBeUndefined(); + }); +}); + +describe("featureParamEnabled", () => { + test("a url that does not ask leaves the cookie in charge", (t) => { + t.expect(featureParamEnabled(new URLSearchParams())).toBeUndefined(); + t.expect( + featureParamEnabled(new URLSearchParams("something=else")), + ).toBeUndefined(); + }); + + test("names what the query lists", (t) => { + t.expect(featureParamEnabled(query(name))).toEqual(new Set([name])); + }); + + test("asking for nothing is an ask, not a shrug", (t) => { + t.expect(featureParamEnabled(query(""))).toEqual(new Set()); + }); + + test("drops names that are not features", (t) => { + t.expect(featureParamEnabled(query("gone", name))).toEqual(new Set([name])); + }); +}); + +describe("featureParamUrl", () => { + test("spells out every enabled name", (t) => { + const url = new URL(featureParamUrl("https://example.com/a", names)); + + t.expect(url.searchParams.getAll(FEATURE_PARAM)).toEqual([...names]); + }); + + test("leaves the rest of the url alone", (t) => { + const url = new URL( + featureParamUrl("https://example.com/a?q=1#top", [name]), + ); + + t.expect(url.pathname).toBe("/a"); + t.expect(url.searchParams.get("q")).toBe("1"); + t.expect(url.hash).toBe("#top"); + }); + + test("replaces whatever the url already asked for", (t) => { + const before = featureParamUrl("https://example.com/", names); + const after = new URL(featureParamUrl(before, [])); + + t.expect(featureParamEnabled(after.searchParams)).toEqual(new Set()); + }); + + test("round-trips through featureParamEnabled", (t) => { + const url = new URL(featureParamUrl("https://example.com/", [name])); + + t.expect(featureParamEnabled(url.searchParams)).toEqual(new Set([name])); + }); +}); + +describe("featureParamApply", () => { + test("without a query the cookie decides, and nothing is written", (t) => { + const astro = request( + "https://example.com/", + featureSerializeEnabled(new Set([name])), + ); + featureParamApply(astro); + + t.expect(features(astro.cookies)).toEqual(stateWith([name])); + t.expect(astro.writes).toHaveLength(0); + t.expect(astro.deletes()).toBe(0); + }); + + test("a query outranks the cookie", (t) => { + const astro = request( + `https://example.com/?${FEATURE_PARAM}=`, + featureSerializeEnabled(new Set([name])), + ); + featureParamApply(astro); + + t.expect(features(astro.cookies)).toEqual(allOff); + }); + + test("writes back, so the state survives a navigation that drops the query", (t) => { + const astro = request(`https://example.com/?${FEATURE_PARAM}=${name}`); + featureParamApply(astro); + + t.expect(features(astro.cookies)).toEqual(stateWith([name])); + t.expect(featureParseEnabled(astro.stored() ?? "")).toEqual( + new Set([name]), + ); + }); + + test("an empty ask drops the cookie rather than blanking it", (t) => { + const astro = request( + `https://example.com/?${FEATURE_PARAM}=`, + featureSerializeEnabled(new Set([name])), + ); + featureParamApply(astro); + + t.expect(astro.stored()).toBeUndefined(); + t.expect(astro.deletes()).toBe(1); + }); + + test("a link built by the menu is understood by the server", (t) => { + const astro = request( + featureParamUrl("https://example.com/some/page", [name]), + ); + featureParamApply(astro); + + t.expect(features(astro.cookies)).toEqual(stateWith([name])); + t.expect(feature(name, astro.cookies)).toBe(true); + }); +}); + +describe("render context propagation", () => { + test("a bare read finds the request being rendered", async (t) => { + const astro = request( + "https://example.com/", + featureSerializeEnabled(new Set([name])), + ); + + // awaits inside, the way a render does, to show the scope outlives them + const state = await scopeFeature.run(astro.cookies, async () => { + await Promise.resolve(); + + return features(); + }); + + t.expect(state).toEqual(stateWith([name])); + t.expect(feature(name)).toBe(false); + }); + + test("concurrent renders do not see each other", async (t) => { + const one = request( + "https://example.com/", + featureSerializeEnabled(new Set()), + ); + const two = request( + "https://example.com/", + featureSerializeEnabled(new Set([name])), + ); + + const read = (astro: ReturnType) => + scopeFeature.run(astro.cookies, async () => { + await new Promise((resolve) => setTimeout(resolve, 1)); + + return features(); + }); + + t.expect(await Promise.all([read(one), read(two)])).toEqual([ + allOff, + stateWith([name]), + ]); + }); +}); From df7fe7c9c1b70f86f2fe2b0bd9addf1219d2e94b Mon Sep 17 00:00:00 2001 From: Piero <15440183+pierosavi@users.noreply.github.com> Date: Fri, 23 Oct 2026 14:17:08 +0200 Subject: [PATCH 2/9] Replace the tap gesture with a /debug page Ten quick taps triggered double-tap zoom on mobile and weren't usable with a keyboard or screen reader. The switches now live on /debug, which works with any input and can be linked to. --- src/islands/client.ts | 2 +- src/islands/debug-menu.ts | 174 ++++++++--------------------------- src/islands/debug-trigger.ts | 98 -------------------- src/islands/feature.ts | 26 ++++++ src/pages/debug/index.astro | 25 +++++ src/styles/debug-menu.css | 74 +-------------- 6 files changed, 92 insertions(+), 307 deletions(-) delete mode 100644 src/islands/debug-trigger.ts create mode 100644 src/islands/feature.ts create mode 100644 src/pages/debug/index.astro diff --git a/src/islands/client.ts b/src/islands/client.ts index 71149cd..7dd1687 100644 --- a/src/islands/client.ts +++ b/src/islands/client.ts @@ -3,4 +3,4 @@ import "./filter-sheet.js"; import "./version-history.js"; import "./price-watch-links.js"; import "./device-search.js"; -import "./debug-trigger.js"; +import "./feature.js"; diff --git a/src/islands/debug-menu.ts b/src/islands/debug-menu.ts index a1c7c22..f83fdce 100644 --- a/src/islands/debug-menu.ts +++ b/src/islands/debug-menu.ts @@ -1,7 +1,6 @@ import { html, LitElement, nothing } from "lit"; import { state } from "lit/decorators.js"; import { createRef, ref } from "lit/directives/ref.js"; -import { unsafeHTML } from "lit/directives/unsafe-html.js"; import type { Ref } from "lit/directives/ref.js"; import { FEATURE_NAMES, FEATURES } from "../types/feature.js"; @@ -14,15 +13,8 @@ import { featuresReset, } from "../utilities/feature.js"; import { isAbortError } from "../utilities/is-abort-error.js"; -import { generic, render } from "../utilities/presentation/index.js"; -import { PresentationRenderPresetRoleIcon } from "../utilities/presentation/preset.js"; import type { FeatureName, FeatureState } from "../types/feature.js"; -const closeIcon = render( - generic("x"), - PresentationRenderPresetRoleIcon.withSize(18), -); - /** how long the share popover stays up */ const SHARE_FEEDBACK_MS = 2000; @@ -34,10 +26,7 @@ const SHARE_MESSAGE: Record = { }; export class DebugMenu extends LitElement { - @state() private _open = false; @state() private _features: FeatureState = features(); - /** a flag changed since this document was rendered*/ - @state() private _dirty = false; /** outcome of the last share, said by the popover */ @state() private _shareResult: ShareResult | undefined; @@ -45,12 +34,6 @@ export class DebugMenu extends LitElement { private _shareButton: Ref = createRef(); private _feedback: Ref = createRef(); - private _onKey = (e: KeyboardEvent): void => { - if (e.key === "Escape") { - this.close(); - } - }; - private _onFeatures = (): void => { this._features = features(); }; @@ -63,40 +46,14 @@ export class DebugMenu extends LitElement { override connectedCallback(): void { super.connectedCallback(); window.addEventListener(FEATURE_EVENT, this._onFeatures); + this._onFeatures(); } override disconnectedCallback(): void { super.disconnectedCallback(); window.removeEventListener(FEATURE_EVENT, this._onFeatures); - this._release(); - } - - open(): void { - if (this._open) { - return; - } - - this._open = true; - this._onFeatures(); - document.body.classList.add("modal-open"); - document.addEventListener("keydown", this._onKey); - } - - close = (): void => { - if (!this._open) { - return; - } - - this._open = false; - this._release(); - }; - - private _release(): void { - document.body.classList.remove("modal-open"); - document.removeEventListener("keydown", this._onKey); window.clearTimeout(this._shareTimer); this._shareTimer = undefined; - this._shareResult = undefined; } /** the names the cookie carries */ @@ -106,16 +63,10 @@ export class DebugMenu extends LitElement { private _toggle(name: FeatureName): void { this._features = featureEnable(name, !this._features[name]); - this._dirty = true; } private _reset = (): void => { this._features = featuresReset(); - this._dirty = true; - }; - - private _reload = (): void => { - window.location.reload(); }; private _share = (): void => { @@ -191,89 +142,51 @@ export class DebugMenu extends LitElement { } override render() { - if (!this._open) { - return nothing; - } - const enabled = this._enabled; // link that carries enablement state for sharing const link = featureParamUrl(window.location.href, enabled); return html` -