Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/pip-placeholder-theme.md
Original file line number Diff line number Diff line change
@@ -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.
49 changes: 46 additions & 3 deletions src/scenes/pip-surfaces.test.ts
Original file line number Diff line number Diff line change
@@ -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
Expand Down
9 changes: 7 additions & 2 deletions src/scenes/pip-surfaces.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
45 changes: 37 additions & 8 deletions src/styles/kino.css
Original file line number Diff line number Diff line change
Expand Up @@ -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%;
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -134,37 +153,47 @@
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;
cursor: pointer;
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;
opacity: 0.5;
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;
}
.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;
Expand Down
Loading