fix(lotus): transparent-theme contrast guard; phone-only rail shrink; live URL params

- lotusTransparent without lotusTheme warns and applies the theme anyway;
  name tags/header/footer get a subtle text-shadow + backdrop blur under
  body.lotus-transparent (#21).
- Landscape filmstrip shrink requires (pointer: coarse) so a short
  desktop/PiP window isn't reflowed as a phone (#32).
- lotusParam re-reads window.location on every call (#33).

Fixes #21
Fixes #32
Fixes #33

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
This commit is contained in:
Lotus CI
2026-09-13 01:22:20 -04:00
co-authored by Claude Opus 5
parent c7494e68ca
commit e9a59336c7
5 changed files with 142 additions and 19 deletions
+6 -2
View File
@@ -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;
}
+20
View File
@@ -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: "";
+85
View File
@@ -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);
});
});
+13 -14
View File
@@ -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. */
+18 -3
View File
@@ -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]);
};