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
3 changes: 2 additions & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ Each workspace has its own `CLAUDE.md` with its build, test and gotcha notes. Re
## Dependencies

- Pin every dependency to an exact version (`.npmrc` sets `save-exact=true`). The only ranges allowed are the adapters' `peerDependencies` (`react`, `vue`).
- Dependencies shared by more than one workspace live in the named `catalogs` of `pnpm-workspace.yaml` (`core`, `react`, `tailwind`, `tooling`) and are referenced as `catalog:<name>`. Bump them there, never in a `package.json`. A dependency used by a single workspace stays pinned in its own `package.json` until a second workspace needs it.
- pnpm 12 settings (`overrides`, `allowBuilds`) go in `pnpm-workspace.yaml`, never in `package.json`. Installs reject versions published less than 24h ago (`minimumReleaseAge`), so pick an older exact version instead of adding exclusions. New packages with install scripts must be added to `allowBuilds`.
- `packages/*` and `adapters/*` are published to npm as public packages and ship only `dist/` (`files`). `apps/*` and the root stay `private`. Workspace deps use `workspace:*`, which pnpm rewrites to exact versions on publish.
- Stay on TypeScript 6.x. TS 7.0 has no programmatic API, which breaks `dts-bundle-generator` (player-core) and `vue-tsc` (player-vue).
- TypeScript 7 (`catalog:core`) everywhere except `adapters/player-vue`, which stays on 6 (`catalog:vue-tooling`): TS 7 has no programmatic API, and `vue-tsc` and the Vue SFC loader need it. Tools that load TypeScript as a library don't work on 7, so check a new build/type tool against it before adding it.
10 changes: 5 additions & 5 deletions adapters/player-react/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -59,10 +59,10 @@
],
"devDependencies": {
"@rsbuild/plugin-react": "2.1.0",
"@rslib/core": "1.0.1",
"@size-limit/file": "14.0.0",
"@types/react": "19.3.0",
"size-limit": "14.0.0",
"typescript": "6.0.3"
"@rslib/core": "catalog:tooling",
"@size-limit/file": "catalog:tooling",
"@types/react": "catalog:react",
"size-limit": "catalog:tooling",
"typescript": "catalog:core"
}
}
1 change: 1 addition & 0 deletions adapters/player-vue/CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,3 +5,4 @@
- **The `vue-tsc` step in the `build` script is required.** Don't replace it with `dts: true` in `rslib.config.ts`: Rslib silently emits a generic `DefineComponent<Record<string, unknown>>` for `.vue` files. If the types look generic, inspect `dist/InteractiveDemo.vue.d.ts`, since a passing build proves nothing.
- `--noEmit false` in that step overrides `tsconfig.json`'s `noEmit: true` (which `check-types` needs).
- `vue` is a peer dependency (range), never a direct dependency.
- **Stays on TypeScript 6** (`catalog:vue-tooling`) while the rest of the repo is on 7: `vue-tsc` and the Vue loader (`@vue/compiler-sfc` resolving `defineProps` types) load TypeScript's JS API, which TS 7 removed. Move it to `catalog:core` once they support TS 7.
8 changes: 4 additions & 4 deletions adapters/player-vue/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -59,11 +59,11 @@
],
"devDependencies": {
"@rsbuild/plugin-vue": "2.0.1",
"@rslib/core": "1.0.1",
"@size-limit/file": "14.0.0",
"@rslib/core": "catalog:tooling",
"@size-limit/file": "catalog:tooling",
"@vue/language-core": "3.3.11",
"size-limit": "14.0.0",
"typescript": "6.0.3",
"size-limit": "catalog:tooling",
"typescript": "catalog:vue-tooling",
"vue": "3.5.43",
"vue-tsc": "3.3.11"
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -168,7 +168,7 @@ export function HotspotLayer({
} as React.CSSProperties
}
>
{/* Same anatomy as the player's hotspot (Hotspot.svelte) */}
{/* Same anatomy as the player's hotspot (player-core ui/Hotspot/Hotspot.ts) */}
<span className="wd-dot">
<span className="wd-halo" />
<span className="wd-ripple" />
Expand Down Expand Up @@ -221,7 +221,7 @@ function TooltipBubble({

const bg = hotspot.bgColor ?? defaultHotspotStyle.bgColor;
const placement = layout?.placement;
// Box + tail as one outline — identical to the player's Tooltip.svelte.
// Box + tail as one outline — identical to the player's tooltip (player-core ui/Tooltip/Tooltip.ts).
const d = layout
? bubblePath({
width: layout.width,
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
/*
* The player's hotspot + tooltip look, mirrored for the editor stage so what you edit is what
* ships. Source of truth: packages/player-core/src/components/{Hotspot,Tooltip,PhotoLayer}.svelte
* ships. Source of truth: packages/player-core/src/ui/{Hotspot,Tooltip,PhotoLayer}/*.styles.ts
* and packages/player-core/src/app.css (the --wd-* tokens). Keep the two in sync — these are
* demo visuals, not editor UI, which is why they use literal colors instead of theme tokens.
*/
Expand Down Expand Up @@ -44,7 +44,7 @@
.wd-hotspot:focus-visible .wd-dot {
scale: 1.15;
}
.wd-hotspot--dragging .wd-dot {

Check warning on line 47 in apps/extension/entrypoints/editor/components/stage/player-visuals.css

View workflow job for this annotation

GitHub Actions / ci

lint/style/noDescendingSpecificity

Descending specificity selector found. This selector specificity is (0, 2, 0)
scale: 1.1;
}
.wd-halo,
Expand Down Expand Up @@ -80,7 +80,7 @@
animation-delay: 0.9s;
}
.wd-hotspot:hover .wd-ripple,
.wd-hotspot--dragging .wd-ripple {

Check warning on line 83 in apps/extension/entrypoints/editor/components/stage/player-visuals.css

View workflow job for this annotation

GitHub Actions / ci

lint/style/noDescendingSpecificity

Descending specificity selector found. This selector specificity is (0, 2, 0)
animation-play-state: paused;
opacity: 0;
}
Expand All @@ -102,7 +102,7 @@
animation: wd-breathe 1.8s ease-in-out infinite;
}
.wd-hotspot:hover .wd-core,
.wd-hotspot--dragging .wd-core {

Check warning on line 105 in apps/extension/entrypoints/editor/components/stage/player-visuals.css

View workflow job for this annotation

GitHub Actions / ci

lint/style/noDescendingSpecificity

Descending specificity selector found. This selector specificity is (0, 2, 0)
animation-play-state: paused;
}
.wd-hotspot:focus-visible .wd-core {
Expand Down Expand Up @@ -186,7 +186,7 @@
}
}

/* the body: box + tail as one SVG outline (see HotspotLayer / player's Tooltip.svelte) */
/* the body: box + tail as one SVG outline (see HotspotLayer / player-core ui/Tooltip/Tooltip.ts) */
.wd-body {
position: absolute;
inset: 0 auto auto 0;
Expand Down
18 changes: 9 additions & 9 deletions apps/extension/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -33,22 +33,22 @@
"idb": "8.0.3",
"lucide-react": "1.47.0",
"mediabunny": "1.59.0",
"react": "19.3.0",
"react-dom": "19.3.0",
"react": "catalog:react",
"react-dom": "catalog:react",
"sonner": "2.0.8",
"tailwind-merge": "3.7.0"
},
"devDependencies": {
"@tailwindcss/vite": "4.3.3",
"@tailwindcss/vite": "catalog:tailwind",
"@types/chrome": "0.3.0",
"@types/react": "19.3.0",
"@types/react-dom": "19.3.0",
"@types/react": "catalog:react",
"@types/react-dom": "catalog:react",
"@wxt-dev/module-react": "1.2.2",
"tailwindcss": "4.3.3",
"tailwindcss": "catalog:tailwind",
"tw-animate-css": "1.4.0",
"typescript": "6.0.3",
"vite": "8.3.0",
"vitest": "5.0.1",
"typescript": "catalog:core",
"vite": "catalog:tooling",
"vitest": "catalog:tooling",
"wxt": "0.21.4"
}
}
29 changes: 28 additions & 1 deletion biome.json
Original file line number Diff line number Diff line change
Expand Up @@ -83,7 +83,34 @@
},
"overrides": [
{
"includes": ["**/*.svelte", "**/*.vue"],
"includes": ["packages/player-core/**"],
"linter": {
"rules": {
"style": {
"noRestrictedImports": {
"level": "error",
"options": {
"paths": {
"alien-signals": "Import signals from src/dom/signals.ts: it's the only module the build compiles alien-signals into dist/ through, so a direct import would ship as a dependency the package doesn't declare."
}
}
}
}
}
}
},
{
"includes": ["packages/player-core/src/dom/signals.ts"],
"linter": {
"rules": {
"style": {
"noRestrictedImports": "off"
}
}
}
},
{
"includes": ["**/*.vue"],
"linter": {
"rules": {
"style": {
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@
"@changesets/cli": "3.0.3",
"lefthook": "2.1.14",
"turbo": "2.11.3",
"typescript": "6.0.3"
"typescript": "catalog:core"
},
"packageManager": "pnpm@12.6.0",
"engines": {
Expand Down
16 changes: 8 additions & 8 deletions packages/exporter/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -57,13 +57,13 @@
}
],
"devDependencies": {
"@rslib/core": "1.0.1",
"@size-limit/file": "14.0.0",
"@vitest/browser": "5.0.1",
"@vitest/browser-playwright": "5.0.1",
"playwright": "1.63.0",
"size-limit": "14.0.0",
"typescript": "6.0.3",
"vitest": "5.0.1"
"@rslib/core": "catalog:tooling",
"@size-limit/file": "catalog:tooling",
"@vitest/browser": "catalog:tooling",
"@vitest/browser-playwright": "catalog:tooling",
"playwright": "catalog:tooling",
"size-limit": "catalog:tooling",
"typescript": "catalog:core",
"vitest": "catalog:tooling"
}
}
27 changes: 27 additions & 0 deletions packages/player-core/.size-limit.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
// What a host app actually ships from this package: bundled, minified and brotlied by
// esbuild, with the schema left out (the host already has it through the adapters).
const shared = {
path: "dist/index.js",
ignore: ["@rustrak/openshowcase-schema"],
};

export default [
{
...shared,
name: "Player (JS + CSS)",
import: "{ Player }",
limit: "35 KB",
},
{
...shared,
name: "Tooltip geometry only (the player must tree-shake away)",
import: "{ bubblePath, computeTooltipPlacement }",
limit: "2 KB",
// esbuild keeps the CSS of JS modules it tree-shook away (Rollup, webpack and Rspack
// drop it, per `sideEffects`), so only the JS is measured here.
modifyEsbuildConfig: (config) => ({
...config,
external: [...(config.external ?? []), "*.css"],
}),
},
];
24 changes: 15 additions & 9 deletions packages/player-core/CLAUDE.md
Original file line number Diff line number Diff line change
@@ -1,25 +1,31 @@
# @rustrak/openshowcase-player-core

Framework-agnostic playback engine used by both adapters and by the extension's editor preview. Written in Svelte 5 (runes) + Tailwind v4, compiled to plain JS. Consumers never see Svelte.
Framework-agnostic playback engine used by both adapters and by the extension's editor preview. Plain TypeScript + Tailwind v4, with no UI framework: components are functions built on a small in-house DOM layer (`src/dom/`) over `alien-signals`. The published package depends on nothing but the schema. Keep it that way: the player is embedded in other people's apps, so it must not ship a framework runtime or pull in dependencies.

## Build

- `pnpm build`: Vite (CSS inlined into JS, no `.css` output) + `dts-bundle-generator` for `dist/index.d.ts`.
- **Keep `exports["."].default` pointing at `./dist/index.js`** (only `types` points at `src`). Resolving raw source forces every consumer's bundler to compile `.svelte` files, and the extension's build breaks.
- `@rustrak/openshowcase-schema` stays `external` in `vite.config.ts`. Inlining it bundles a second copy of valibot.
- `pnpm size`: 35 KB brotli budget for `dist/index.js`.
- `pnpm test`: Vitest projects `unit` (Node, for `core/`) and `component` (covers `__test__/component` + `__test__/integration` in real Chromium via Playwright, not jsdom).
- `pnpm build`: Rslib in bundleless mode (`rslib.config.ts`): one readable, unminified ESM file per source module, so a host that imports only the tooltip geometry doesn't get the player. The host app's bundler minifies. Vite is only used by Vitest.
- **No side effects at import time.** `package.json` declares `"sideEffects": ["**/*.css"]`: `dist/app.css` (Tailwind) is the only side-effect import. Components inject their own CSS when they're created (`injectStyles`). Don't add module-level code that touches the DOM or globals.
- **alien-signals is compiled into `dist/`**, not a dependency: it's a `devDependency`, only `src/dom/signals.ts` imports it, and a second, bundled lib in `rslib.config.ts` builds that module (alien-signals included) into `dist/dom/signals.js`. Import signals from `dom/signals`, never from `alien-signals` directly (a Biome `noRestrictedImports` rule enforces it): a direct import would stay in `dist/` as an import of a package the published package doesn't declare.
- Declarations: `tsconfig.build.json` starts them at `src/index.ts`.
- **Keep `exports["."].default` pointing at `./dist/index.js`.**
- `pnpm size` (`.size-limit.mjs`): what a host ships, bundled and minified by esbuild. `Player` (JS + CSS) within 35 KB, and the tooltip geometry alone within 2 KB, which fails if the player stops tree-shaking away.
- `pnpm test`: Vitest projects `unit` (Node, for `core/`) and `component` (covers `__test__/component` + `__test__/integration` in real Chromium via Playwright, not jsdom). Run it with `--browser.headless`.

## Code structure

- `src/core/*.ts`: pure orchestration and timing logic (state machines, clip watching, overlay choreography), unit-tested with fake timers and no DOM.
- `src/components/*.svelte`: markup + wiring only. Once a component's script logic grows past ~100 lines, move the decision-making into `core/` with tests.
- `src/dom/`: the DOM layer. `h()`/`svg()` create elements; a function prop or child stays bound to the signals it reads (one effect per binding, nothing re-renders). `show()` is `{#if}` (with an optional exit animation), `each()` is a keyed list, `mount()` owns a scope whose dispose stops every binding. Views created by `show()`/`each()` live in scopes detached from the effect that creates them: alien-signals disposes an effect's child effects on every re-run.
- `src/ui/<Component>/`: one folder per component, `<Component>.ts` (the component) + `<Component>.styles.ts` (its CSS, when it has any), markup + wiring only. Props are `ViewProps<P>`: data as getters, `on*` callbacks as is. Once a component's logic grows past ~100 lines, move the decision-making into `core/` with tests.
- Signal writes apply at once. Group writes that must land together (a step render, the photo/video swap) in `batch()`.
- Tests: `renderView()` (`__test__/render-view.ts`) mounts a component with plain props, each one a signal that `rerender()` sets.

## Styling: the player lives in other people's pages

- `app.css` imports only Tailwind's theme + utilities, **never full `tailwindcss`** (its preflight resets the host page). Required resets are scoped under `.openshowcase-player`.
- Host CSS is unlayered and beats Tailwind's layered utilities, so visuals that must survive any site (hotspot, tooltip, navbar, spotlight) are styled in scoped `<style>` blocks.
- Host CSS is unlayered and beats Tailwind's layered utilities, so visuals that must survive any site (hotspot, tooltip, navbar, spotlight) are styled in each component's `*.styles.ts`, unlayered.
- Every selector in a `*.styles.ts` starts with `.openshowcase-player` (so it never leaks onto the host page) and every `@keyframes` name with `openshowcase-`. Class names are global inside the player: don't reuse one across components (`video.media`/`img.media` are told apart by tag).
- Overlay sizes use `--wd-u` (the root is an inline-size container), so they scale with the embed. Every animation has a `prefers-reduced-motion` branch.
- The tooltip box and its tail are **one SVG outline** (`core/bubble-path.ts`). Don't reintroduce a separate arrow element: it can't stay seamless with gradients, borders and shadows.
- Spotlight lights are a managed list with timed removal in `PhotoLayer.svelte`, not Svelte outro transitions (those leave elements behind when the step changes mid-transition).
- Spotlight lights are a managed list with timed removal in `ui/PhotoLayer/PhotoLayer.ts`, not exit animations (a light must leave in place even when the step changes mid-fade).
- The extension editor mirrors the hotspot/tooltip look (`apps/extension/entrypoints/editor/components/stage/player-visuals.css`). Update it when changing visuals here.
43 changes: 19 additions & 24 deletions packages/player-core/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -29,14 +29,17 @@
"default": "./dist/index.js"
}
},
"sideEffects": [
"**/*.css"
],
"files": [
"dist"
],
"publishConfig": {
"access": "public"
},
"scripts": {
"build": "vite build && dts-bundle-generator -o dist/index.d.ts --project tsconfig.json --disable-symlinks-following --external-imports @rustrak/openshowcase-schema -- src/index.ts",
"build": "rslib build",
"check-types": "tsc --noEmit",
"test": "vitest run",
"test:watch": "vitest",
Expand All @@ -45,29 +48,21 @@
"dependencies": {
"@rustrak/openshowcase-schema": "workspace:*"
},
"size-limit": [
{
"name": "dist/index.js (brotli)",
"path": "dist/index.js",
"limit": "35 KB"
}
],
"devDependencies": {
"@size-limit/file": "14.0.0",
"@sveltejs/vite-plugin-svelte": "7.3.1",
"@tailwindcss/vite": "4.3.3",
"@vitest/browser": "5.0.1",
"@vitest/browser-playwright": "5.0.1",
"@vitest/ui": "5.0.1",
"dts-bundle-generator": "9.5.1",
"playwright": "1.63.0",
"size-limit": "14.0.0",
"svelte": "5.57.1",
"tailwindcss": "4.3.3",
"typescript": "6.0.3",
"vite": "8.3.0",
"vite-plugin-css-injected-by-js": "5.0.2",
"vitest": "5.0.1",
"vitest-browser-svelte": "3.1.0"
"@rslib/core": "catalog:tooling",
"@size-limit/esbuild": "catalog:tooling",
"@size-limit/file": "catalog:tooling",
"@tailwindcss/postcss": "catalog:tailwind",
"@tailwindcss/vite": "catalog:tailwind",
"@vitest/browser": "catalog:tooling",
"@vitest/browser-playwright": "catalog:tooling",
"@vitest/ui": "catalog:tooling",
"alien-signals": "3.2.1",
"playwright": "catalog:tooling",
"size-limit": "catalog:tooling",
"tailwindcss": "catalog:tailwind",
"typescript": "catalog:core",
"vite": "catalog:tooling",
"vitest": "catalog:tooling"
}
}
38 changes: 38 additions & 0 deletions packages/player-core/rslib.config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import { defineConfig } from "@rslib/core";
import tailwindcss from "@tailwindcss/postcss";

export default defineConfig({
lib: [
// Bundleless: one readable ESM file per source module, not minified (the host app's
// bundler minifies), so a host that only imports the tooltip geometry gets just that.
{
format: "esm",
bundle: false,
source: {
entry: {
index: ["./src/**", "!./src/**/__test__/**", "!./src/dom/signals.ts"],
},
},
// tsconfig.build.json limits declarations to what `src/index.ts` reaches
dts: true,
},
// dom/signals with alien-signals compiled in (a devDependency, so it's bundled rather
// than left as an import): the published package has no runtime dependency on it.
{
format: "esm",
bundle: true,
source: { entry: { signals: "./src/dom/signals.ts" } },
output: { distPath: { root: "dist/dom" } },
dts: false,
},
],
source: {
tsconfigPath: "./tsconfig.build.json",
},
output: {
target: "web",
},
tools: {
postcss: (_, { addPlugins }) => addPlugins(tailwindcss()),
},
});
15 changes: 10 additions & 5 deletions packages/player-core/src/__test__/component/BrowserChrome.test.ts
Original file line number Diff line number Diff line change
@@ -1,23 +1,28 @@
import { describe, expect, it, vi } from "vitest";
import { render } from "vitest-browser-svelte";
import BrowserChrome from "../../components/BrowserChrome.svelte";
import {
BrowserChrome,
type BrowserChromeProps,
} from "../../ui/BrowserChrome/BrowserChrome";
import { renderView } from "../render-view";

const render = (props: BrowserChromeProps) => renderView(BrowserChrome, props);

describe("BrowserChrome", () => {
it("renders the demo title", async () => {
const screen = await render(BrowserChrome, { title: "My demo" });
const screen = await render({ title: "My demo" });
await expect.element(screen.getByText("My demo")).toBeInTheDocument();
});

it("renders three window dots", async () => {
const screen = await render(BrowserChrome, { title: "My demo" });
const screen = await render({ title: "My demo" });
expect(
screen.container.querySelectorAll("span.h-2\\.5.w-2\\.5"),
).toHaveLength(3);
});

it("calls onReload when the reload button is clicked", async () => {
const onReload = vi.fn();
const screen = await render(BrowserChrome, { title: "My demo", onReload });
const screen = await render({ title: "My demo", onReload });

await screen.getByTitle("Back to start").click();

Expand Down
Loading
Loading