diff --git a/.changeset/pip-placeholder-theme.md b/.changeset/pip-placeholder-theme.md new file mode 100644 index 0000000..753475e --- /dev/null +++ b/.changeset/pip-placeholder-theme.md @@ -0,0 +1,5 @@ +--- +"@karnstack/kino": patch +--- + +The picture-in-picture placeholder (the layer left inline while playback runs in the pip window) now follows `chromeTheme` instead of always painting a solid black void. Its colors move to `--kino-pip-fill`, `--kino-pip-card`, `--kino-pip-card-border`, `--kino-pip-text`, `--kino-pip-text-hover`, and `--kino-pip-sub`, which the `data-kino-theme="light"` block overrides, and the "playing in picture in picture" affordance sits in a small card so it reads on a light surface. Dark is unchanged; `--kino-pip-fill` must stay opaque because the master media keeps playing underneath it. diff --git a/src/scenes/pip-surfaces.test.ts b/src/scenes/pip-surfaces.test.ts index 0554f97..ac5946b 100644 --- a/src/scenes/pip-surfaces.test.ts +++ b/src/scenes/pip-surfaces.test.ts @@ -1,23 +1,66 @@ +import { readFileSync } from "node:fs" import { mountPipPlaceholder, mountPipOverlay } from "./pip-surfaces" afterEach(() => { vi.useRealTimers() }) +// vitest runs from the package root, so the stylesheet path is stable. +const css = readFileSync("src/styles/kino.css", "utf8") + +// Reads one flat rule body out of kino.css; the pip rules and both token +// blocks are flat, so a naive brace scan is enough. +function ruleBody(selector: string): string { + const start = css.indexOf(`${selector} {`) + expect(start, `missing rule: ${selector}`).toBeGreaterThan(-1) + const open = css.indexOf("{", start) + return css.slice(open + 1, css.indexOf("}", open)) +} + test("placeholder mounts icon and copy, forwards clicks, and cleans up", () => { const container = document.createElement("div") const onReturn = vi.fn() const cleanup = mountPipPlaceholder(container, onReturn) const el = container.querySelector(".kino-pip-placeholder") as HTMLElement - expect(el.querySelector("svg")).not.toBeNull() - expect(el.textContent).toContain("Playing in picture in picture") - expect(el.textContent).toContain("Click to return") + const card = el.querySelector(".kino-pip-placeholder-card") as HTMLElement + expect(card.querySelector("svg")).not.toBeNull() + expect(card.textContent).toContain("Playing in picture in picture") + expect(card.textContent).toContain("Click to return") el.click() expect(onReturn).toHaveBeenCalledOnce() cleanup() expect(container.querySelector(".kino-pip-placeholder")).toBeNull() }) +// Regression: the placeholder used to be a hardcoded black fill with light +// copy, so it stayed a black void on a light page. Every color it paints now +// comes from a token the light chrome block overrides. +test("placeholder colors are tokens the light chrome block overrides", () => { + const placeholder = ruleBody(".kino .kino-pip-placeholder") + expect(placeholder).toContain("background: var(--kino-pip-fill)") + expect(placeholder).toContain("color: var(--kino-pip-text)") + expect(placeholder).not.toMatch(/\b(black|white|#fff)\b/) + + const card = ruleBody(".kino .kino-pip-placeholder-card") + expect(card).toContain("var(--kino-pip-card)") + expect(card).toContain("var(--kino-pip-card-border)") + + const light = ruleBody('.kino[data-kino-theme="light"]') + for (const token of [ + "--kino-pip-fill", + "--kino-pip-card", + "--kino-pip-card-border", + "--kino-pip-text", + "--kino-pip-text-hover", + "--kino-pip-sub", + ]) { + expect(ruleBody(".kino"), `dark default: ${token}`).toContain(`${token}:`) + expect(light, `light override: ${token}`).toContain(`${token}:`) + } + // The whole point: the light fill is not another black void. + expect(light).not.toMatch(/--kino-pip-fill:\s*black/) +}) + type OverlayState = { paused: boolean activeCueText: string diff --git a/src/scenes/pip-surfaces.ts b/src/scenes/pip-surfaces.ts index accb1a7..630435e 100644 --- a/src/scenes/pip-surfaces.ts +++ b/src/scenes/pip-surfaces.ts @@ -42,13 +42,18 @@ export function mountPipPlaceholder( ): () => void { const el = document.createElement("div") el.className = "kino-pip-placeholder" - el.innerHTML = svg(PIP_PATH, 28) + // The fill covers the whole stage; the card carries the affordance so it + // reads as a panel in either chrome theme (see kino.css --kino-pip-*). + const card = document.createElement("div") + card.className = "kino-pip-placeholder-card" + card.innerHTML = svg(PIP_PATH, 28) const label = document.createElement("div") label.textContent = "Playing in picture in picture" const sub = document.createElement("div") sub.className = "kino-pip-placeholder-sub" sub.textContent = "Click to return" - el.append(label, sub) + card.append(label, sub) + el.appendChild(card) el.addEventListener("click", onReturn) container.appendChild(el) return () => el.remove() diff --git a/src/styles/kino.css b/src/styles/kino.css index 54911f1..ca70df6 100644 --- a/src/styles/kino.css +++ b/src/styles/kino.css @@ -40,6 +40,17 @@ --kino-sheet-grip: color-mix(in oklab, white 32%, transparent); --kino-sheet-chip: color-mix(in oklab, white 10%, transparent); + /* Picture-in-picture placeholder: the panel shown inline where the stage was + while playback runs in the pip window. --kino-pip-fill must stay opaque + (the master media keeps playing underneath it), so it is a literal color + rather than --kino-bg, which a host may set to transparent. */ + --kino-pip-fill: black; + --kino-pip-card: color-mix(in oklab, white 7%, transparent); + --kino-pip-card-border: color-mix(in oklab, white 12%, transparent); + --kino-pip-text: color-mix(in oklab, white 65%, transparent); + --kino-pip-text-hover: oklch(98% 0 0); + --kino-pip-sub: color-mix(in oklab, white 40%, transparent); + position: relative; width: 100%; height: 100%; @@ -81,6 +92,14 @@ --kino-menu-selected: color-mix(in oklab, black 12%, transparent); --kino-sheet-grip: color-mix(in oklab, black 24%, transparent); --kino-sheet-chip: color-mix(in oklab, black 8%, transparent); + /* Opaque like the dark fill, a shade under --kino-bg so the placeholder + still reads as a panel where the stage was. */ + --kino-pip-fill: oklch(95% 0 0); + --kino-pip-card: color-mix(in oklab, white 70%, transparent); + --kino-pip-card-border: color-mix(in oklab, black 10%, transparent); + --kino-pip-text: color-mix(in oklab, black 62%, transparent); + --kino-pip-text-hover: oklch(24% 0 0); + --kino-pip-sub: color-mix(in oklab, black 42%, transparent); } /* Keycaps are white-glass in dark; pointing them at --kino-surface would change the dark look, so the dark literals stay inline and only light flips them to @@ -134,14 +153,11 @@ position: absolute; inset: 0; display: flex; - flex-direction: column; align-items: center; justify-content: center; - gap: 10px; - /* the pip placeholder is always a black void where the video was, so its - text stays light in both themes (not the theme-flipping chrome tokens) */ - background: black; - color: color-mix(in oklab, white 65%, transparent); + /* Opaque fill: the master media plays on underneath this layer. */ + background: var(--kino-pip-fill); + color: var(--kino-pip-text); font-size: 14px; font-weight: 500; line-height: 1.4; @@ -149,6 +165,19 @@ user-select: none; transition: color 0.15s var(--kino-ease); } +/* The card keeps the "playing in pip" affordance readable on a light fill, + where a flat centered stack would just look like an empty frame. */ +.kino .kino-pip-placeholder-card { + display: flex; + flex-direction: column; + align-items: center; + gap: 8px; + padding: 18px 24px; + text-align: center; + background: var(--kino-pip-card); + border: 1px solid var(--kino-pip-card-border); + border-radius: var(--kino-radius); +} .kino .kino-pip-placeholder svg { width: 28px; height: 28px; @@ -156,7 +185,7 @@ transition: opacity 0.15s var(--kino-ease); } .kino .kino-pip-placeholder:hover { - color: oklch(98% 0 0); + color: var(--kino-pip-text-hover); } .kino .kino-pip-placeholder:hover svg { opacity: 0.8; @@ -164,7 +193,7 @@ .kino .kino-pip-placeholder-sub { font-size: 11px; font-weight: 400; - color: color-mix(in oklab, white 40%, transparent); + color: var(--kino-pip-sub); } .kino .kino-video-host { position: absolute;