diff --git a/src/grid/SpotlightLandscapeLayout.module.css b/src/grid/SpotlightLandscapeLayout.module.css index c2375242..63ac90d3 100644 --- a/src/grid/SpotlightLandscapeLayout.module.css +++ b/src/grid/SpotlightLandscapeLayout.module.css @@ -46,8 +46,12 @@ unconditionally select the container so we can use cq units */ /* On a landscape phone the fixed 180px filmstrip rail squeezes the spotlight to a sliver; shrink the rail (keeping the 4:3 tile ratio) to give the spotlight - room. Desktop landscape (height > 400px) is unaffected. */ -@media (max-height: 400px) { + room. Desktop landscape (height > 400px) is unaffected. + [lotus #32] `max-height: 400px` alone also matches a short-but-wide desktop + or PiP window that has nothing to do with a phone, so require a coarse + (touch) pointer too — a resized desktop/PiP window kept its mouse pointer, + so it won't match this and keeps the full-size rail. */ +@media (max-height: 400px) and (pointer: coarse) { .layer { --grid-slot-width: 132px; } diff --git a/src/index.css b/src/index.css index d1134513..cf3caea1 100644 --- a/src/index.css +++ b/src/index.css @@ -100,6 +100,26 @@ body.lotus-theme { --video-tile-background: var(--cpd-color-bg-subtle-secondary); } +/* [lotus #21] Subtle contrast guard for elements that sit directly on the +transparent canvas (no opaque tile background behind them) when +lotusTransparent is set: the host's real wallpaper is unknown to us, so a +light text-shadow/backdrop keeps name tags and header/footer controls legible +regardless of what's behind them. Kept behind `.lotus-transparent` so upstream +(non-Lotus) layouts are completely untouched. Hooked off stable data-testid/ +element selectors rather than CSS-module class names, since those are hashed +per-build and owned by their own component stylesheets. */ +body.lotus-transparent [data-testid="name_tag"], +body.lotus-transparent header, +body.lotus-transparent [data-testid="footer-container"] { + text-shadow: 0 1px 3px rgba(0, 0, 0, 0.6); +} + +body.lotus-transparent header, +body.lotus-transparent [data-testid="footer-container"] { + backdrop-filter: blur(8px); + -webkit-backdrop-filter: blur(8px); +} + @media (min-height: 330px) { body[data-background="gradient"]::before { content: ""; diff --git a/src/lotus/lotusWidget.test.ts b/src/lotus/lotusWidget.test.ts new file mode 100644 index 00000000..7b0a572d --- /dev/null +++ b/src/lotus/lotusWidget.test.ts @@ -0,0 +1,85 @@ +/* +Copyright 2026 Lotus Guild + +SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +Please see LICENSE in the repository root for full details. +*/ + +import { afterEach, describe, expect, test, vi } from "vitest"; + +vi.mock("../widget", () => ({ widget: null })); + +import { lotusFlag, lotusParam, lotusSendToHost } from "./lotusWidget"; +import { LotusWidgetActions } from "./lotusActions"; + +/** + * Point `window.location` at a URL for the duration of one assertion. Uses a + * path relative to the current origin — `pushState` throws a `SecurityError` + * on a cross-origin URL, and jsdom's default test origin doesn't match a + * hard-coded `http://localhost/`. + */ +function setLocation(path: string): void { + window.history.pushState({}, "", path); +} + +describe("lotusParam / lotusFlag", () => { + afterEach(() => { + setLocation("/"); + }); + + test("reads a param from the query string", () => { + setLocation("/?lotusTheme=1"); + expect(lotusParam("lotusTheme")).toBe("1"); + }); + + test("reads a param from the hash fragment's query portion", () => { + setLocation("/#/room?lotusTransparent=1"); + expect(lotusParam("lotusTransparent")).toBe("1"); + }); + + test("the hash fragment wins over the query string for the same key", () => { + setLocation("/?lotusTheme=fromQuery#/room?lotusTheme=fromHash"); + expect(lotusParam("lotusTheme")).toBe("fromHash"); + }); + + test("falls back to the query string for keys absent from the hash", () => { + setLocation("/?onlyInQuery=1#/room?lotusTheme=1"); + expect(lotusParam("onlyInQuery")).toBe("1"); + }); + + test("returns null when the param is present nowhere", () => { + setLocation("/#/room"); + expect(lotusParam("missing")).toBeNull(); + }); + + test.each([ + ["1", true], + ["true", true], + ["0", false], + ["false", false], + [null, false], + ])("lotusFlag treats %s as %s", (value, expected) => { + setLocation(value === null ? "/" : `/?f=${value}`); + expect(lotusFlag("f")).toBe(expected); + }); + + // [lotus #33] lotusParam must re-derive from window.location on every call + // rather than caching the first read, so a later navigation (e.g. lobby -> + // in-call) that rewrites the URL is picked up. + test("re-reads window.location on every call instead of caching the first result", () => { + setLocation("/?lotusTheme=1"); + expect(lotusParam("lotusTheme")).toBe("1"); + + setLocation("/?lotusTheme=0"); + expect(lotusParam("lotusTheme")).toBe("0"); + + setLocation("/"); + expect(lotusParam("lotusTheme")).toBeNull(); + }); +}); + +describe("lotusSendToHost", () => { + test("returns false when no widget transport is available", () => { + expect(lotusSendToHost(LotusWidgetActions.CallState, {})).toBe(false); + }); +}); diff --git a/src/lotus/lotusWidget.ts b/src/lotus/lotusWidget.ts index dab3775c..0386a722 100644 --- a/src/lotus/lotusWidget.ts +++ b/src/lotus/lotusWidget.ts @@ -21,27 +21,26 @@ import type { LotusWidgetActions } from "./lotusActions"; export { LotusWidgetActions } from "./lotusActions"; -let cachedParams: URLSearchParams | undefined; - /** * Read a URL param from either the query string or the hash fragment (Element * Call passes widget params via both depending on host), without depending on * EC's own `getUrlParams` parser (keeps the rebase surface small). + * + * [lotus #33] Re-parsed from `window.location` on every call instead of being + * cached at first use: parsing is cheap, and caching risked returning a + * stale value if `location.hash`/`search` are ever rewritten after first + * read (e.g. during in-iframe navigation). */ export function lotusParam(name: string): string | null { - if (!cachedParams) { - // Match EC's own ParamParser precedence: the hash fragment wins over the - // query string. So seed from the fragment first, then fill gaps from query. - const hash = window.location.hash.replace(/^#\/?/, ""); - const hashQuery = hash.includes("?") - ? hash.slice(hash.indexOf("?") + 1) - : ""; - cachedParams = new URLSearchParams(hashQuery); - for (const [k, v] of new URLSearchParams(window.location.search)) { - if (!cachedParams.has(k)) cachedParams.append(k, v); - } + // Match EC's own ParamParser precedence: the hash fragment wins over the + // query string. So seed from the fragment first, then fill gaps from query. + const hash = window.location.hash.replace(/^#\/?/, ""); + const hashQuery = hash.includes("?") ? hash.slice(hash.indexOf("?") + 1) : ""; + const params = new URLSearchParams(hashQuery); + for (const [k, v] of new URLSearchParams(window.location.search)) { + if (!params.has(k)) params.append(k, v); } - return cachedParams.get(name); + return params.get(name); } /** Whether a boolean-ish Lotus feature flag is enabled. */ diff --git a/src/useTheme.ts b/src/useTheme.ts index daf85016..1e3a2e16 100644 --- a/src/useTheme.ts +++ b/src/useTheme.ts @@ -8,6 +8,7 @@ Please see LICENSE in the repository root for full details. import { useEffect, useLayoutEffect, useRef, useState } from "react"; import { WidgetApiToWidgetAction } from "matrix-widget-api"; import { type IThemeChangeActionRequest } from "matrix-widget-api"; +import { logger } from "matrix-js-sdk/lib/logger"; import { getUrlParams } from "./UrlParams"; import { widget } from "./widget"; @@ -60,8 +61,22 @@ export const useTheme = (): void => { document.body.classList.remove("no-theme"); // [lotus #5] Native theming hooks, opted in by the host via URL flags, so // it no longer has to inject CSS into the iframe after load. - if (lotusFlag("lotusTransparent")) - document.body.classList.add("lotus-transparent"); - if (lotusFlag("lotusTheme")) document.body.classList.add("lotus-theme"); + const lotusTransparent = lotusFlag("lotusTransparent"); + const lotusTheme = lotusFlag("lotusTheme"); + if (lotusTransparent) document.body.classList.add("lotus-transparent"); + // [lotus #21] The two flags are only meaningful together: a transparent + // canvas with no matching theme leaves text/icon colours computed against + // the default dark canvas token while the real pixels behind them are + // whatever the host painted. If a host sets lotusTransparent without + // lotusTheme, warn and apply lotusTheme anyway rather than silently + // risking unreadable UI. + if (lotusTransparent && !lotusTheme) { + logger.warn( + "[lotus] lotusTransparent is set without lotusTheme; applying " + + "lotusTheme anyway since the two flags are only meaningful together.", + ); + } + if (lotusTheme || lotusTransparent) + document.body.classList.add("lotus-theme"); }, [previousTheme, requestedTheme]); };