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-window-chrome-theme.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@karnstack/kino": minor
---

`chromeTheme` now reaches the picture-in-picture window, not just the main tab. `createScenesProvider` takes a `chromeTheme` option and `ScenesProvider` gains `setChromeTheme`, which `ScenesPlayer` wires to its existing `chromeTheme` prop, so a theme flip mid-playback restyles an open pip window instead of leaving dark controls over a light stage. The overlay drawn on the pip window (which never loads `kino.css`) carries its own token sheet, dark by default with a light block keyed on `data-kino-theme`, and the pip window's backdrop follows `sceneTheme` rather than painting a hardcoded black behind a light stage. Omit both and nothing changes.
10 changes: 10 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -317,6 +317,16 @@ For deeper control, every visual is driven by CSS custom properties on the `.kin
}
```

### Light chrome

`chromeTheme` (`"light"` or `"dark"`, dark by default) flips the whole chrome to a light token set: control bar, captions, idle overlay, scrubber, menus, and the compact/touch UI. It is stamped as `data-kino-theme` on the `.kino` root, so your own overrides still win.

```tsx
<ScenesPlayer src="..." chromeTheme="light" sceneTheme="light" />
```

On `ScenesPlayer` it also reaches the picture-in-picture surfaces, which kino draws itself: the placeholder left inline where the stage was, and the controls over the pip window (that window never loads `kino.css`, so it carries its own copy of the tokens). Later values flip both live, so the player can follow your site's theme toggle mid-playback without closing pip. `chromeTheme` is distinct from `sceneTheme`, which themes the stage inside the host document.

## Keyboard shortcuts

The player is keyboard-first. Shortcuts are ignored while a text input, textarea, select, or contenteditable element is focused, and modifier-key combinations (Ctrl/Cmd/Alt) are passed through.
Expand Down
91 changes: 80 additions & 11 deletions src/scenes/pip-surfaces.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,9 @@
import { readFileSync } from "node:fs"
import { mountPipPlaceholder, mountPipOverlay } from "./pip-surfaces"
import {
mountPipPlaceholder,
mountPipOverlay,
pipStageBackdrop,
} from "./pip-surfaces"

afterEach(() => {
vi.useRealTimers()
Expand Down Expand Up @@ -85,7 +89,7 @@ function overlayHarness(state: OverlayState) {
test("overlay toggles play/pause off provider state and mirrors cue text", () => {
const state = { paused: true, activeCueText: "", currentTime: 0, duration: 0 }
const { deps, emit } = overlayHarness(state)
const cleanup = mountPipOverlay(window, deps)
const overlay = mountPipOverlay(window, deps)
const root = document.body.querySelector(
"[data-kino-pip-overlay]",
) as HTMLElement
Expand All @@ -103,7 +107,7 @@ test("overlay toggles play/pause off provider state and mirrors cue text", () =>
expect(root.textContent).toContain("hello from the sequence")
btn.click()
expect(deps.pause).toHaveBeenCalledOnce()
cleanup()
overlay.destroy()
expect(document.body.querySelector("[data-kino-pip-overlay]")).toBeNull()
})

Expand All @@ -115,20 +119,20 @@ test("time readout pads seconds and progress line tracks position", () => {
duration: 130,
}
const { deps } = overlayHarness(state)
const cleanup = mountPipOverlay(window, deps)
const overlay = mountPipOverlay(window, deps)
const root = document.body.querySelector(
"[data-kino-pip-overlay]",
) as HTMLElement
expect(root.textContent).toContain("1:05 / 2:10")
const progress = root.querySelector("[data-kino-pip-progress]") as HTMLElement
expect(progress.style.width).toBe("50%")
cleanup()
overlay.destroy()
})

test("cue pill hides entirely when empty and shows with text", () => {
const state = { paused: true, activeCueText: "", currentTime: 0, duration: 0 }
const { deps, emit } = overlayHarness(state)
const cleanup = mountPipOverlay(window, deps)
const overlay = mountPipOverlay(window, deps)
const cue = document.body.querySelector("[data-kino-pip-cue]") as HTMLElement
expect(cue.getAttribute("aria-live")).toBe("polite")
expect(cue.style.display).toBe("none")
Expand All @@ -139,7 +143,7 @@ test("cue pill hides entirely when empty and shows with text", () => {
state.activeCueText = ""
emit()
expect(cue.style.display).toBe("none")
cleanup()
overlay.destroy()
})

test("controls auto-hide while playing and reappear on pointer movement", () => {
Expand All @@ -151,7 +155,7 @@ test("controls auto-hide while playing and reappear on pointer movement", () =>
duration: 10,
}
const { deps, emit } = overlayHarness(state)
const cleanup = mountPipOverlay(window, deps)
const overlay = mountPipOverlay(window, deps)
const bar = document.body.querySelector("[data-kino-pip-bar]") as HTMLElement
const cue = document.body.querySelector("[data-kino-pip-cue]") as HTMLElement
expect(bar.style.opacity).not.toBe("0")
Expand All @@ -168,7 +172,72 @@ test("controls auto-hide while playing and reappear on pointer movement", () =>
expect(bar.style.opacity).toBe("1")
vi.advanceTimersByTime(5000)
expect(bar.style.opacity).toBe("1")
cleanup()
overlay.destroy()
})

// The pip document never loads kino.css, so the overlay ships its own token
// sheet: dark on the root, light overriding it, mirroring the chrome tokens.
test("overlay stamps the chrome theme and flips it live without a remount", () => {
const { deps } = overlayHarness({
paused: true,
activeCueText: "cue",
currentTime: 0,
duration: 10,
})
const overlay = mountPipOverlay(window, deps, "light")
const root = document.body.querySelector(
"[data-kino-pip-overlay]",
) as HTMLElement
expect(root.getAttribute("data-kino-theme")).toBe("light")
const sheet = document.head.querySelector(
"style[data-kino-pip-style]",
) as HTMLStyleElement
expect(sheet.textContent).toContain('[data-kino-theme="light"]')
// Every color the overlay paints comes from a token, so the light block can
// reach all of it.
for (const token of [
"--kino-pip-bar",
"--kino-pip-ctrl",
"--kino-pip-ctrl-dim",
"--kino-pip-ctrl-hover",
"--kino-pip-cue-fill",
"--kino-pip-cue-text",
"--kino-pip-progress",
]) {
expect(
sheet.textContent!.split(`${token}:`).length - 1,
`${token} needs a dark default and a light override`,
).toBe(2)
}
// Live flip: the pip window stays open, only the stamp changes.
overlay.setTheme("dark")
expect(root.getAttribute("data-kino-theme")).toBe("dark")
expect(document.body.querySelector("[data-kino-pip-overlay]")).toBe(root)
overlay.destroy()
expect(document.head.querySelector("style[data-kino-pip-style]")).toBeNull()
})

test("overlay defaults to dark chrome when no theme is passed", () => {
const { deps } = overlayHarness({
paused: true,
activeCueText: "",
currentTime: 0,
duration: 0,
})
const overlay = mountPipOverlay(window, deps)
const root = document.body.querySelector(
"[data-kino-pip-overlay]",
) as HTMLElement
expect(root.getAttribute("data-kino-theme")).toBe("dark")
overlay.destroy()
})

// The backdrop is what shows before the mirrored stage paints, so it follows
// the stage theme rather than the chrome theme.
test("stage backdrop follows the theme and is opaque in both", () => {
expect(pipStageBackdrop("dark")).toBe("#000")
expect(pipStageBackdrop("light")).not.toBe("#000")
expect(pipStageBackdrop("light")).not.toContain("transparent")
})

test("overlay cleanup unsubscribes and removes the pointermove listener", () => {
Expand All @@ -180,9 +249,9 @@ test("overlay cleanup unsubscribes and removes the pointermove listener", () =>
currentTime: 0,
duration: 0,
})
const cleanup = mountPipOverlay(window, deps)
const overlay = mountPipOverlay(window, deps)
expect(listeners.size).toBe(1)
cleanup()
overlay.destroy()
expect(listeners.size).toBe(0)
const added = addSpy.mock.calls.filter(([type]) => type === "pointermove")
const removed = removeSpy.mock.calls.filter(
Expand Down
106 changes: 85 additions & 21 deletions src/scenes/pip-surfaces.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,15 @@
// Parent-origin DOM for document picture-in-picture: the placeholder shown
// inline where the stage was, and the compact controls overlaid on the pip
// window (the main tab's chrome is not visible from there). The overlay uses
// inline styles because kino.css is not loaded in the pip window; where a
// value mirrors a kino design token it is hardcoded with a comment naming
// the token.
// window (the main tab's chrome is not visible from there). kino.css is not
// loaded in the pip window, so layout stays inline and every color comes from
// the small token sheet this module injects there; the sheet mirrors the
// chrome tokens in src/styles/kino.css, dark by default with a light block
// keyed on data-kino-theme, exactly like the in-page chrome.

import { formatTime } from "../util/format-time"

export type PipTheme = "light" | "dark"

export type PipOverlayDeps = {
play(): void
pause(): void
Expand All @@ -19,6 +22,59 @@ export type PipOverlayDeps = {
subscribe(listener: () => void): () => void
}

// Handle on a mounted overlay: the chrome theme can flip while the window
// stays open (the embedding site's theme toggle), so this outlives mount.
export type PipOverlay = {
setTheme(theme: PipTheme): void
destroy(): void
}

const theme = (t: PipTheme | undefined): PipTheme =>
t === "light" ? "light" : "dark"

/**
* Backdrop for the pip window body. It shows before the mirrored stage paints,
* so it follows the *stage* theme rather than the chrome theme, and it mirrors
* --kino-bg in src/styles/kino.css. Opaque in both themes: a transparent body
* would fall through to the browser's own window chrome.
*/
export function pipStageBackdrop(t: PipTheme): string {
return theme(t) === "light" ? "oklch(98% 0 0)" : "#000"
}

// Token sheet injected into the pip document. Values mirror the chrome tokens
// in src/styles/kino.css (--kino-control-*, --kino-text-dim, --kino-caption-*,
// --kino-hover, --kino-thumb) so the two chromes read as one player.
const OVERLAY_CSS = `
[data-kino-pip-overlay] {
--kino-pip-bar: linear-gradient(transparent, rgba(0, 0, 0, 0.85));
--kino-pip-ctrl: #fff;
--kino-pip-ctrl-dim: rgba(255, 255, 255, 0.65);
--kino-pip-ctrl-hover: rgba(255, 255, 255, 0.12);
--kino-pip-cue-fill: rgba(0, 0, 0, 0.6);
--kino-pip-cue-text: #fff;
--kino-pip-progress: rgba(255, 255, 255, 0.9);
}
[data-kino-pip-overlay][data-kino-theme="light"] {
--kino-pip-bar: linear-gradient(transparent, rgba(255, 255, 255, 0.88));
--kino-pip-ctrl: oklch(24% 0 0);
--kino-pip-ctrl-dim: rgba(0, 0, 0, 0.55);
--kino-pip-ctrl-hover: rgba(0, 0, 0, 0.08);
--kino-pip-cue-fill: rgba(255, 255, 255, 0.72);
--kino-pip-cue-text: oklch(28% 0 0);
--kino-pip-progress: rgba(0, 0, 0, 0.62);
}
[data-kino-pip-bar] { background: var(--kino-pip-bar); }
[data-kino-pip-overlay] button { color: var(--kino-pip-ctrl); }
[data-kino-pip-overlay] button:hover { background: var(--kino-pip-ctrl-hover); }
[data-kino-pip-time] { color: var(--kino-pip-ctrl-dim); }
[data-kino-pip-cue] {
color: var(--kino-pip-cue-text);
background: var(--kino-pip-cue-fill);
}
[data-kino-pip-progress] { background: var(--kino-pip-progress); }
`

// Exact path data from src/ui/icons.tsx (PlayIcon, PauseIcon, PipIcon) so the
// pip surfaces visually match kino's buttons.
const PLAY_PATH =
Expand Down Expand Up @@ -62,38 +118,41 @@ export function mountPipPlaceholder(
export function mountPipOverlay(
pipWindow: Window,
deps: PipOverlayDeps,
): () => void {
chromeTheme?: PipTheme,
): PipOverlay {
const doc = pipWindow.document

const root = doc.createElement("div")
root.setAttribute("data-kino-pip-overlay", "")
root.setAttribute("data-kino-theme", theme(chromeTheme))
// pointer-events none on the root; the control bar re-enables clicks.
root.style.cssText =
"position:absolute;inset:0;pointer-events:none;font-family:ui-sans-serif,system-ui,sans-serif;"

// :hover cannot be expressed inline; mirrors .kino-ctrl:hover
// (color-mix(in oklab, white 12%, transparent)).
// Colors and :hover live in the sheet: neither a custom property lookup nor
// a pseudo-class can be expressed inline, and keeping every color here is
// what lets a theme flip be one attribute change.
const style = doc.createElement("style")
style.textContent =
"[data-kino-pip-overlay] button:hover{background:rgba(255,255,255,0.12);}"
style.setAttribute("data-kino-pip-style", "")
style.textContent = OVERLAY_CSS
doc.head.appendChild(style)

const bar = doc.createElement("div")
bar.setAttribute("data-kino-pip-bar", "")
bar.style.cssText =
"position:absolute;left:0;right:0;bottom:0;display:flex;align-items:center;gap:10px;padding:10px 12px;pointer-events:auto;" +
`background:linear-gradient(transparent,rgba(0,0,0,0.85));transition:opacity 250ms ${EASE};`
`transition:opacity 250ms ${EASE};`

const btn = doc.createElement("button")
btn.type = "button"
btn.style.cssText =
"display:grid;place-items:center;width:34px;height:34px;padding:0;color:#fff;background:none;border:0;" +
"display:grid;place-items:center;width:34px;height:34px;padding:0;background:none;border:0;" +
`border-radius:8px;cursor:pointer;transition:background 150ms ${EASE};`

const time = doc.createElement("div")
// color mirrors --kino-text-dim (white at 65% alpha)
time.setAttribute("data-kino-pip-time", "")
time.style.cssText =
"font-size:12px;color:rgba(255,255,255,0.65);font-variant-numeric:tabular-nums;white-space:nowrap;"
"font-size:12px;font-variant-numeric:tabular-nums;white-space:nowrap;"

const cue = doc.createElement("div")
cue.setAttribute("data-kino-pip-cue", "")
Expand All @@ -102,15 +161,15 @@ export function mountPipOverlay(
// on each side) with a small gap.
cue.style.cssText =
"position:absolute;left:50%;bottom:58px;transform:translateX(-50%);max-width:85%;padding:4px 10px;" +
"font-size:13px;line-height:1.35;text-align:center;color:#fff;background:rgba(0,0,0,0.6);" +
"font-size:13px;line-height:1.35;text-align:center;" +
`border-radius:6px;transition:opacity 250ms ${EASE};`

// Always-visible playback position along the very bottom edge; updates at
// the ~10Hz state tick, which is smooth enough without a transition.
const progress = doc.createElement("div")
progress.setAttribute("data-kino-pip-progress", "")
progress.style.cssText =
"position:absolute;left:0;bottom:0;height:2px;width:0;background:rgba(255,255,255,0.9);"
"position:absolute;left:0;bottom:0;height:2px;width:0;"

// Auto-hide: bar + cue stay up while paused; while playing they fade after
// a stretch without pointer movement over the pip document.
Expand Down Expand Up @@ -177,11 +236,16 @@ export function mountPipOverlay(
bar.append(btn, time)
root.append(cue, bar, progress)
doc.body.appendChild(root)
return () => {
unsubscribe()
doc.removeEventListener("pointermove", onPointerMove)
clearHideTimer()
style.remove()
root.remove()
return {
setTheme(next) {
root.setAttribute("data-kino-theme", theme(next))
},
destroy() {
unsubscribe()
doc.removeEventListener("pointermove", onPointerMove)
clearHideTimer()
style.remove()
root.remove()
},
}
}
Loading
Loading