diff --git a/.agents/skills/create-vjsc-component/SKILL.md b/.agents/skills/create-vjsc-component/SKILL.md index 20387c1b9d..7efa72443f 100644 --- a/.agents/skills/create-vjsc-component/SKILL.md +++ b/.agents/skills/create-vjsc-component/SKILL.md @@ -53,6 +53,15 @@ Avoid structural selectors such as `:has()`, `has-*`, `group-has-*`, descendants - Organize style modules and output assets by role (`buttons`, `sliders`, `popups`, `feedback`, `layout`). Keep skin-only layout in the skin rather than a generic primitive module. - Put common utilities in `utilities` and selected skin or target differences in `variants` instead of duplicating a rule. +## Shared utilities and tokens + +- Put a recipe that several rules repeat into `packages/skins/src/styles/tailwind.css` as an `@utility`, and prefix it at the use site instead of building class strings dynamically. Name property families `-media-` (`mask-media-volume`, `clip-media-x-*`) and multi-property recipes `-media` (`surface-media`, `focus-ring-media`). +- Keep `@utility` bodies flat declaration lists. The Shadcn registry exporter cannot represent nested rules, so state, pseudo-element, and media handling belongs on the use site through variants, and preference modes belong in `themes/preferences.css` through tokens. +- Functional utilities read their argument with `--value([*])`; custom variants use the block form because the shorthand splits comma-separated media query lists. Name variants `media-` and use the layout variants `media-compact`, `media-wide`, `media-max-compact`, and `media-max-wide` instead of raw container prefixes. Reach for `media-transitioning` when starting and ending styles share a value, `media-highlighted` for hover, focus, expanded, and highlighted states, and `media-anchored` for anchor-positioning support checks. +- Prefer a `--media-*` token with an `@theme inline` alias over literal values, and use the named utility the alias produces, such as `duration-media-fast`, `scale-media-hidden`, or `shadow-media-thumb`. Reserve the `(--var)` shorthand for runtime values such as `--media-slider-pointer`. Use `rounded-media-pill` rather than `rounded-full`, whose `calc(infinity * 1px)` the style pipeline cannot serialize. +- The vjsc plugin writes a candidate manifest into the Vite cache directory and aliases it as `vjsc:candidates`; the dev Tailwind entry imports it so scanning sees the utilities the transform resolves. The plugin also re-includes the manifest in the Vite watcher, which skips the cache directory by default, so Tailwind recompiles as modules record. Raw style modules are not scanned. +- Composed rules override the rules they extend by order in CSS output, but Tailwind output has no runtime class merging, so a same-property override across composed rules still needs `!` unless Tailwind emits the shorthand first. + ## Example Input: “Add a tooltip to the volume-popover button.” diff --git a/apps/sandbox/app/styles.css b/apps/sandbox/app/styles.css index f28a0f470d..73afd01c7c 100644 --- a/apps/sandbox/app/styles.css +++ b/apps/sandbox/app/styles.css @@ -3,6 +3,7 @@ @source "../app"; @source "../templates"; +@source "./_generated/components"; @source "./_generated/html"; :root { diff --git a/packages/skins/README.md b/packages/skins/README.md index 2a061e9347..b19f5e2955 100644 --- a/packages/skins/README.md +++ b/packages/skins/README.md @@ -1,17 +1,73 @@ # @videojs/skins -> **Internal package — do not install directly.** +> **Internal package.** Private and unpublished. The framework packages and the Shadcn registry consume its output. -Canonical VJSC skin sources and the generators that deliver them through [`@videojs/html`](../html), [`@videojs/react`](../react), CDN templates, and the Shadcn registry. +Canonical VJSC skin sources and the generators that deliver them to [`@videojs/html`](../html), [`@videojs/react`](../react), and the Shadcn registry. Write a skin once here; the build lowers it to every framework and styling target. -The package is private (`"private": true` in `package.json`) and is not published to npm. +## How a skin comes together -## Structure +Follow one skin from source to output. -- `src/` — target-neutral skin components, styles, target transforms, and contract tests. -- `build/` — shared Skin and framework-package output helpers. -- `registry/` — Shadcn catalog items, build configuration, and focused policy validation. -- `dev/` — the VJSC React/HTML and CSS/Tailwind development matrix. +1. **A skin is a component tree.** [`src/skins/default-video/skin.tsx`](./src/skins/default-video/skin.tsx) composes preset parts from [`src/skins/video/`](./src/skins/video) with shared components such as `Container` and `Poster`. Each `-` folder owns only what differs for that skin. +2. **Components pair markup with styles.** [`src/components/`](./src/components) holds the target-neutral UI. Every `x.tsx` sits beside an `x.styles.ts` that lists Tailwind classes per rule, with `default` and `minimal` variants where the themes differ. Skin-only overrides live beside the skin, for example [`src/skins/default-video/controls.styles.ts`](./src/skins/default-video/controls.styles.ts). +3. **Classes resolve through tokens.** Style modules read `--media-*` tokens through Tailwind theme keys such as `duration-media-fast`, never literal values that vary per theme. Tokens are declared in [`src/styles/themes/`](./src/styles/themes) and classified in [`src/styles/vars.ts`](./src/styles/vars.ts). +4. **The build lowers everything per target.** The [vjsc](../vjsc) compiler, configured in [`build/`](./build), turns each module into React and HTML implementations, compiles class lists into scoped CSS for the CSS targets, and emits Shadcn registry items. +5. **The playground shows the result.** [`dev/`](./dev) renders every skin across framework, styling, width, and color scheme. Add `compare=styles` to the URL to see the CSS and Tailwind variants together, `dir=rtl` to flip the text direction, and use the copy button for a report with environment details. + +## Where things live + +| Path | Owns | +| ---------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------ | +| [`src/components/`](./src/components) | Shared UI grouped as buttons, controls, feedback, layout, menus, and sliders. | +| [`src/skins/`](./src/skins) | One folder per skin (`default-video`, `minimal-audio`, and so on), plus `video/`, `live-video/`, `audio/`, and `shared/` for reused parts. | +| [`src/styles/`](./src/styles) | Base resets, themes, tokens, the shared Tailwind source, and style modules grouped like the components. | +| [`src/presets/`](./src/presets) | The handwritten background preset, copied into both packages as is. | +| [`src/meta.ts`](./src/meta.ts), [`src/render.ts`](./src/render.ts) | Skin and component metadata for the registry, and render targets that pick an element per framework. | +| [`src/gaps.md`](./src/gaps.md) | Deferred parity gaps. Maintain it with the `maintain-vjsc-skin-gaps` skill. | +| [`src/tests/`](./src/tests) | Contract tests for tokens, the utility catalog, metadata, and poster behavior. | +| [`build/`](./build) | Pack config, transform resolvers, framework targets, package writers, and the Shadcn registry. | +| [`dev/`](./dev) | The preview matrix and its Vite config. | + +## Styles and tokens + +[`base.css`](./src/styles/base.css) fixes the cascade: `base.theme` holds tokens and `base.preferences` overrides them, so a reduced motion, reduced transparency, or forced colors preference wins regardless of selector specificity. + +- [`themes/theme.css`](./src/styles/themes/theme.css) declares every default token, grouped by colors, shadows, controls, motion, popups, sliders, and frame. +- [`themes/minimal.css`](./src/styles/themes/minimal.css), [`themes/video.css`](./src/styles/themes/video.css), and [`themes/audio.css`](./src/styles/themes/audio.css) override tokens per theme and preset. +- [`themes/preferences.css`](./src/styles/themes/preferences.css) collapses durations and neutralizes hidden-state values under reduced motion, and switches backdrop filters off under reduced transparency. +- [`base.video.css`](./src/styles/base.video.css) and [`base.audio.css`](./src/styles/base.audio.css) are the preset entries each skin stylesheet starts from. +- [`vars.ts`](./src/styles/vars.ts) classifies every token as public, runtime, or internal and feeds the registry docs. [`utilities.ts`](./src/styles/utilities.ts) describes every shared utility, variant, and computed theme key. + +## Tailwind entry files + +Three files in [`src/styles/`](./src/styles) chain together. Only the first ships to consumers. + +| File | Purpose | Used by | +| --------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------- | +| [`tailwind.css`](./src/styles/tailwind.css) | The design system: theme keys that alias `--media-*` tokens, shared `@utility` recipes, and `media-*` variants. No Tailwind import and no `@source`, so it works inside a consumer's own setup. | Both entries below, the registry theme item, the docs generator, and the catalog tests. | +| [`tailwind.compiler.css`](./src/styles/tailwind.compiler.css) | The build design system. Imports Tailwind, base, captions, presets, and the shared file, and aliases `--spacing` to the scaled media unit. No `@source`: the compiler applies class lists directly and never scans files. | [`build/transform.ts`](./build/transform.ts) and the registry theme test. | +| [`tailwind.dev.css`](./src/styles/tailwind.dev.css) | The playground entry. Extends the compiler entry, imports the plugin's candidate manifest through `vjsc:candidates`, and scans the dev TSX. The only place scanning happens. | [`dev/main.tsx`](./dev/main.tsx) in Tailwind mode. | + +Add a shared recipe to `tailwind.css` as a flat `@utility`, describe it in `utilities.ts`, and prefer a token plus theme key over a literal. The [component skill](../../.agents/skills/create-vjsc-component/SKILL.md) has the full rules. + +## Build outputs + +`generate` runs [`build/vite.config.ts`](./build/vite.config.ts) and writes three things: + +- Skin implementations into the ignored `packages/html/src/internal/skins/` and `packages/react/src/internal/skins/` folders, plus preset registrations and stylesheets under `packages/html/src/define/` and the background preset under `packages/react/src/presets/`, through [`build/packages/`](./build/packages). +- Shadcn source registries for React with Tailwind, React with CSS, and HTML into `dist/registry/source/r/`, from the items in [`build/registry/items/`](./build/registry/items) and the targets in [`build/registry/targets.ts`](./build/registry/targets.ts). +- The hosted registry in `dist/shadcn/` through `build:shadcn`, which [`netlify.toml`](./netlify.toml) publishes. + +## Commands + +Run these from `packages/skins`. + +```bash +pnpm dev # preview matrix +pnpm exec vp run generate # regenerate package inputs and registries +pnpm exec vp run validate:shadcn # schema and policy checks on the hosted registry +pnpm test # type check plus unit, build, and registry tests +``` ## License diff --git a/packages/skins/build/packages/html.ts b/packages/skins/build/packages/html.ts index 30cb38a856..e977b7b1d4 100644 --- a/packages/skins/build/packages/html.ts +++ b/packages/skins/build/packages/html.ts @@ -4,7 +4,7 @@ import type { Graph, GraphModule } from 'vjsc/graph'; import { bundleStyles, collectModules, renderHtml } from 'vjsc/graph'; import { isSkinName, type SkinMeta, type SkinModuleMeta, type SkinName } from '../../src/meta.ts'; -import { skinPreset, skinPresets, type SkinPreset } from '../skin.ts'; +import { skinBaseStylesheet, skinPreset, skinPresets, type SkinPreset } from '../skin.ts'; import type { GeneratedPackageFile } from './files.ts'; import { addCopiedFiles, addGenerated, generatedFiles, pascalCase } from './utils.ts'; @@ -47,7 +47,7 @@ export async function createHtmlPackageSkins( `${root}/skin.css`, await bundleStyles(graph, skin.modules, { label: name, - files: options.baseStyles ?? ['./styles/base.css'], + files: options.baseStyles ?? [`./styles/${skinBaseStylesheet(skin.preset)}`], }) ); } diff --git a/packages/skins/build/packages/react.ts b/packages/skins/build/packages/react.ts index aebe3e903e..0691b41c23 100644 --- a/packages/skins/build/packages/react.ts +++ b/packages/skins/build/packages/react.ts @@ -2,7 +2,7 @@ import type { Graph, GraphModule } from 'vjsc/graph'; import { bundleStyles, collectModules, relativeImport, rewriteImports, stripStyleImports } from 'vjsc/graph'; import { isSkinName, type SkinMeta, type SkinModuleMeta, type SkinName } from '../../src/meta.ts'; -import { skinPreset, skinPresets, type SkinPreset } from '../skin.ts'; +import { skinBaseStylesheet, skinPreset, skinPresets, type SkinPreset } from '../skin.ts'; import type { GeneratedPackageFile } from './files.ts'; import { addCopiedFiles, addGenerated, generatedFiles, pascalCase } from './utils.ts'; @@ -91,7 +91,7 @@ export async function createReactPackageSkins( `${publicRoot}/${publicName}.css`, await bundleStyles(graph, skin.modules, { label: `${skin.theme}-${skin.preset}`, - files: options.baseStyles ?? ['./styles/base.css'], + files: options.baseStyles ?? [`./styles/${skinBaseStylesheet(skin.preset)}`], }) ); } diff --git a/packages/skins/build/registry/items/skins.ts b/packages/skins/build/registry/items/skins.ts index 06db0d77c6..762f100e35 100644 --- a/packages/skins/build/registry/items/skins.ts +++ b/packages/skins/build/registry/items/skins.ts @@ -8,7 +8,7 @@ import { skinDirectory, skinPreset } from '../../skin.ts'; import type { VideojsRegistryMeta } from '../meta.ts'; import { packageRequirements, registryPaths, type RegistryTarget } from '../targets.ts'; import { exportedComponentName } from './components.ts'; -import { reactHelperDependency } from './support.ts'; +import { reactHelperDependency, themeStyleDependency } from './support.ts'; export async function htmlSkinItem( skin: RenderedHtmlSkin, @@ -22,9 +22,11 @@ export async function htmlSkinItem( const template = createSourceOwnedHtml(skin.template); const styleTarget = `${directory}/skin.css`; + const themeImport = relativeRegistryImport(`${directory}/skin.ts`, 'styles/theme.css'); const styleImport = relativeRegistryImport(`${directory}/skin.ts`, styleTarget); - const registration = `${`import '${styleImport}';`}\n\n${createHtmlSkinRegistration( + // The shared theme item must load before the skin's own scoped rules. + const registration = `import '${themeImport}';\nimport '${styleImport}';\n\n${createHtmlSkinRegistration( template, skin.modules, 'registry' @@ -47,10 +49,8 @@ export async function htmlSkinItem( path: 'skin.css', target: `${registryPaths.install}/${directory}/skin.css`, type: 'registry:style', - content: await bundleStyles(graph, skin.modules, { - label: name, - files: ['./styles/base.css'], - }), + // Theme tokens, resets, and presets ship once through the shared theme item. + content: await bundleStyles(graph, skin.modules, { label: name }), }, ]; @@ -62,7 +62,7 @@ export async function htmlSkinItem( categories: ['media', 'skins', skin.preset], docs: skinDocs(skin.root, meta, meta.name, target, directory), dependencies: ['@videojs/html'], - registryDependencies: [], + registryDependencies: [themeStyleDependency], files, meta: { role: 'skin', @@ -158,7 +158,7 @@ function skinDocs( const mediaEntry = preset.endsWith('audio') ? 'hls-audio' : 'hlsjs-video'; if (target.framework === 'html') { - return `Installs editable ${meta.title} source under \`${registryPaths.install}/${directory}\`. Requires \`${packageRequirements.html}\`; import the matching Player and media registrations before using the installed light-DOM template.`; + return `Installs editable ${meta.title} source under \`${registryPaths.install}/${directory}\` together with the shared theme stylesheet. Requires \`${packageRequirements.html}\`; import the matching Player and media registrations before using the installed light-DOM template.`; } return `Requires \`${packageRequirements.react}\`, which is installed with this item. diff --git a/packages/skins/build/registry/items/styles.ts b/packages/skins/build/registry/items/styles.ts index b6e38ed8e5..ebeb67e29b 100644 --- a/packages/skins/build/registry/items/styles.ts +++ b/packages/skins/build/registry/items/styles.ts @@ -1,10 +1,15 @@ +import { readFileSync } from 'node:fs'; +import { resolve } from 'node:path'; + import type { RegistryStylesOptions } from '../../../../vjsc/src/shadcn/index.ts'; +import { utilities } from '../../../src/styles/utilities.ts'; +import { vars } from '../../../src/styles/vars.ts'; import type { VideojsRegistryMeta } from '../meta.ts'; import type { RegistryTarget } from '../targets.ts'; -export function registryStyles(target: RegistryTarget): RegistryStylesOptions | undefined { - if (target.framework === 'html') return undefined; +const sharedTailwindSource = resolve(import.meta.dirname, '../../../src/styles/tailwind.css'); +export function registryStyles(target: RegistryTarget): RegistryStylesOptions { const meta = { role: 'support', framework: target.framework, @@ -15,13 +20,61 @@ export function registryStyles(target: RegistryTarget): RegistryStylesOptions | return { theme: { target: 'styles/theme.css', - include: ['./styles/base.css'], + include: ['./styles/base.css', './styles/captions.css', './styles/themes/video.css', './styles/themes/audio.css'], title: 'Video.js media theme', description: 'Scoped media tokens, resets, preferences, and Tailwind compiler integration.', - docs: 'Installed automatically with Video.js skins and UI components.', - tailwind: target.styling === 'tailwind' ? './styles/tailwind.shared.css' : undefined, + docs: themeDocs(target), + tailwind: target.styling === 'tailwind' ? './styles/tailwind.css' : undefined, meta, }, files: target.framework === 'react' && target.styling === 'css' ? 'styles' : undefined, }; } + +/** Human-readable catalog of the public tokens, Tailwind theme keys, utilities, and variants the theme ships. */ +function themeDocs(target: RegistryTarget): string { + const sections = [ + 'Installed automatically with Video.js skins and UI components. Import it before any skin or component stylesheet.', + section( + 'Customize with CSS variables', + Object.entries(vars) + .filter(([, variable]) => variable.kind === 'public') + .map(([name, variable]) => `\`${name}\`: ${variable.description}`) + ), + ]; + + if (target.styling === 'tailwind') { + const entries = Object.entries(utilities); + + sections.push( + section('Tailwind theme keys', [...themeAliasDocs(), ...docsOfKind(entries, 'theme')]), + section('Utilities', docsOfKind(entries, 'utility')), + section('Variants', docsOfKind(entries, 'variant')) + ); + } + + return sections.join('\n\n'); +} + +function section(title: string, lines: readonly string[]): string { + return `## ${title}\n\n${lines.map((line) => `- ${line}`).join('\n')}`; +} + +function docsOfKind(entries: Array<[string, { kind: string; description: string }]>, kind: string): string[] { + return entries.filter(([, rule]) => rule.kind === kind).map(([name, rule]) => `\`${name}\`: ${rule.description}`); +} + +/** Theme keys that alias a `--media-*` token inherit that token's description. */ +function themeAliasDocs(): string[] { + const source = readFileSync(sharedTailwindSource, 'utf8'); + const descriptions = new Map(Object.entries(vars).map(([name, variable]) => [name, variable.description])); + const docs: string[] = []; + + for (const [, key, alias] of source.matchAll(/^\s*(--[a-z-]+):\s*var\((--media-[a-z-]+)\);/gm)) { + const description = descriptions.get(alias!); + + if (description) docs.push(`\`${key}\`: ${description}`); + } + + return docs; +} diff --git a/packages/skins/build/registry/items/support.ts b/packages/skins/build/registry/items/support.ts index a2b08754d7..51ea922295 100644 --- a/packages/skins/build/registry/items/support.ts +++ b/packages/skins/build/registry/items/support.ts @@ -88,6 +88,9 @@ export function utilsItem(target: RegistryTarget): RegistryModuleItem> } | undefined; + readonly css?: Readonly> | undefined; +} + +describe('registry Tailwind theme', () => { + const theme = readThemeItem(); + const exported = Object.keys(theme.css ?? {}); + + it('exports every utility and custom variant declared in the shared Tailwind source', () => { + const declared = [...sharedSource.matchAll(/@(utility|custom-variant)\s+([^\s{(;]+)/g)].map( + ([, rule, name]) => `@${rule} ${name}` + ); + + expect(declared.length).toBeGreaterThan(5); + expect(exported).toEqual(expect.arrayContaining(declared)); + }); + + it('compiles every shipped Tailwind class with only the exported theme', async () => { + const full = await loadDesignSystem(compilerEntry); + const consumerEntry = writeConsumerEntry(theme); + const consumer = await loadDesignSystem(consumerEntry); + + rmSync(dirname(consumerEntry), { recursive: true, force: true }); + const candidates = shippedCandidates(full); + const unsupported = candidates.filter((candidate) => !consumer.recognizesCandidate(candidate)); + + expect(candidates.length).toBeGreaterThan(100); + expect(unsupported).toEqual([]); + }, 60_000); +}); + +function readThemeItem(): ThemeItem { + const registry: unknown = JSON.parse(readFileSync(resolve(registryDir, 'support/registry.json'), 'utf8')); + const items = isPlainObject(registry) && Array.isArray(registry.items) ? registry.items : []; + const theme = items.find((item): item is ThemeItem => isPlainObject(item) && item.name === '_style-theme'); + if (!theme) throw new Error('The registry has no `_style-theme` item. Run the skins generate task first.'); + + return theme; +} + +/** Recreate the stylesheet a Shadcn consumer receives: Tailwind plus the exported theme variables and css rules. */ +function writeConsumerEntry(theme: ThemeItem): string { + const variables = Object.entries(theme.cssVars?.theme ?? {}).map(([name, value]) => ` --${name}: ${value};`); + const source = [ + '@import "tailwindcss";', + `@theme inline {\n${variables.join('\n')}\n}`, + renderCss(theme.css ?? {}), + '', + ].join('\n\n'); + const directory = mkdtempSync(resolve(packageDir, 'dist/registry-theme-test-')); + const path = join(directory, 'consumer.css'); + + writeFileSync(path, source); + return path; +} + +function renderCss(entries: Readonly>, indent = ''): string { + return Object.entries(entries) + .map(([key, value]) => { + if (isString(value)) return `${indent}${key}: ${value};`; + + if (!isPlainObject(value)) throw new Error(`Unsupported registry css value for \`${key}\`.`); + + if (Object.keys(value).length === 0) return `${indent}${key};`; + + return `${indent}${key} {\n${renderCss(value, `${indent} `)}\n${indent}}`; + }) + .join('\n'); +} + +/** Collect every string token in the shipped Tailwind sources that the full design system recognizes as a class. */ +function shippedCandidates(design: DesignSystem): string[] { + const candidates = new Set(); + + for (const group of ['skins', 'ui']) { + const root = resolve(registryDir, group, 'files'); + + for (const entry of readdirSync(root, { recursive: true, withFileTypes: true })) { + if (!entry.isFile() || !entry.name.endsWith('.tsx')) continue; + + const source = readFileSync(resolve(entry.parentPath, entry.name), 'utf8'); + + for (const [, , literal] of source.matchAll(/(["'])((?:\\.|(?!\1)[^\\])*)\1/g)) { + for (const token of literal!.split(/\s+/)) { + if (token && design.recognizesCandidate(token)) candidates.add(token); + } + } + } + } + + return [...candidates].sort(); +} diff --git a/packages/skins/build/skin.ts b/packages/skins/build/skin.ts index 94c7fd0c98..26f037e5f4 100644 --- a/packages/skins/build/skin.ts +++ b/packages/skins/build/skin.ts @@ -20,6 +20,11 @@ export function skinDirectory(name: SkinName): string { return name.startsWith('minimal-') ? `skins/${preset}/minimal` : `skins/${preset}`; } +/** Runtime stylesheet entry carrying the shared tokens plus one preset's tokens, relative to `src/styles`. */ +export function skinBaseStylesheet(preset: SkinPreset): string { + return preset === 'audio' || preset === 'live-audio' ? 'base.audio.css' : 'base.video.css'; +} + export function isSkinPreset(value: string): value is SkinPreset { return skinPresets.some((preset) => preset === value); } diff --git a/packages/skins/build/tests/vite.test.ts b/packages/skins/build/tests/vite.test.ts index e4cc83d686..83fef3504f 100644 --- a/packages/skins/build/tests/vite.test.ts +++ b/packages/skins/build/tests/vite.test.ts @@ -36,15 +36,59 @@ const skins = [ 'minimal-audio', ] as const; const skinContracts = { - 'default-video': { exportName: 'DefaultVideoSkin', theme: 'default', preset: 'video' }, - 'minimal-video': { exportName: 'MinimalVideoSkin', theme: 'minimal', preset: 'video' }, - 'default-live-video': { exportName: 'DefaultLiveVideoSkin', theme: 'default', preset: 'live-video' }, - 'minimal-live-video': { exportName: 'MinimalLiveVideoSkin', theme: 'minimal', preset: 'live-video' }, - 'default-live-audio': { exportName: 'DefaultLiveAudioSkin', theme: 'default', preset: 'live-audio' }, - 'minimal-live-audio': { exportName: 'MinimalLiveAudioSkin', theme: 'minimal', preset: 'live-audio' }, - 'default-audio': { exportName: 'DefaultAudioSkin', theme: 'default', preset: 'audio' }, - 'minimal-audio': { exportName: 'MinimalAudioSkin', theme: 'minimal', preset: 'audio' }, -} as const satisfies Record<(typeof skins)[number], { exportName: string; theme: string; preset: string }>; + 'default-video': { + exportName: 'DefaultVideoSkin', + theme: 'default', + preset: 'video', + stylesheet: 'video/controls.css', + }, + 'minimal-video': { + exportName: 'MinimalVideoSkin', + theme: 'minimal', + preset: 'video', + stylesheet: 'video/controls.css', + }, + 'default-live-video': { + exportName: 'DefaultLiveVideoSkin', + theme: 'default', + preset: 'live-video', + stylesheet: 'live-video/controls.css', + }, + 'minimal-live-video': { + exportName: 'MinimalLiveVideoSkin', + theme: 'minimal', + preset: 'live-video', + stylesheet: 'live-video/controls.css', + }, + // Live audio controls are fully shared with the audio controls module. + 'default-live-audio': { + exportName: 'DefaultLiveAudioSkin', + theme: 'default', + preset: 'live-audio', + stylesheet: 'audio/controls.css', + }, + 'minimal-live-audio': { + exportName: 'MinimalLiveAudioSkin', + theme: 'minimal', + preset: 'live-audio', + stylesheet: 'audio/controls.css', + }, + 'default-audio': { + exportName: 'DefaultAudioSkin', + theme: 'default', + preset: 'audio', + stylesheet: 'audio/controls.css', + }, + 'minimal-audio': { + exportName: 'MinimalAudioSkin', + theme: 'minimal', + preset: 'audio', + stylesheet: 'audio/controls.css', + }, +} as const satisfies Record< + (typeof skins)[number], + { exportName: string; theme: string; preset: string; stylesheet: string } +>; const styles = ['css', 'tailwind'] as const; const variants = frameworks.flatMap((framework) => skins.flatMap((skin) => styles.map((style) => ({ framework, skin, style }))) @@ -94,7 +138,7 @@ describe('Skins Vite workflow', () => { for (const variant of variants) { const url = skinUrl(variant); const result = await server.transformRequest(url); - const { exportName: skinExport, theme, preset } = skinContracts[variant.skin]; + const { exportName: skinExport, theme, preset, stylesheet } = skinContracts[variant.skin]; expect(result?.code, url).toContain(skinExport); expect(result?.code, url).toContain('data-theme'); @@ -113,7 +157,7 @@ describe('Skins Vite workflow', () => { if (variant.style === 'css') { const code = controls?.code ?? ''; - const controlsStyle = encodeURIComponent(`${preset}/controls.css`); + const controlsStyle = encodeURIComponent(stylesheet); expect(code, url).toContain('virtual:vjsc/css'); expect(code, url).toContain('/base.css'); @@ -151,12 +195,13 @@ describe('Skins Vite workflow', () => { expect(code).not.toContain('media-menu-resizable-popup'); }, 30_000); - it('uses the captions button itself as the menu trigger', async () => { + it('uses the captions button itself as the menu trigger and labels its tooltip from it', async () => { const react = await server.transformRequest(reactCaptionsMenuUrl); const html = await server.transformRequest(htmlCaptionsMenuUrl); expect(react?.code).toMatch(/_jsxDEV\(MenuPrimitive\.Trigger, \{\s+render: .*_jsxDEV\(CaptionsButton/); - expect(react?.code).not.toContain('ButtonTooltip'); + expect(react?.code).toMatch(/_jsxDEV\(ButtonTooltip, \{[\s\S]*?_jsxDEV\(MenuPrimitive\.Trigger/); + expect(react?.code).not.toMatch(/_jsxDEV\(ButtonTooltip, \{\s+label:/); expect(html?.code).toMatch(/_jsxDEV\(CaptionsButton, \{\s+commandfor:[\s\S]*?className/); expect(html?.code).not.toContain('data-vjsc-render-captions-button'); }, 30_000); @@ -174,14 +219,16 @@ describe('Skins Vite workflow', () => { const css = isString(loaded) ? loaded : loaded?.code; if (isUndefined(css)) throw new Error(`Expected Vite to load \`${resolvedId}\`.`); - expect(css).toMatch(/\.media-play-button-restart-icon \{\s+opacity: 0;\s+scale: 0;/); + expect(css).toMatch( + /\.media-play-button-restart-icon \{\s+scale: var\(--media-hidden-icon-scale\) var\(--media-hidden-icon-scale\);\s+opacity: 0;/ + ); }, 30_000); it('includes Shadow DOM utilities only for HTML targets', async () => { const html = await server.transformRequest(htmlPosterUrl); const react = await server.transformRequest(reactPosterUrl); - expect(html?.code).toContain('[&>slot::slotted(img)]:absolute'); + expect(html?.code).toContain('[&>slot::slotted(img)]:layer-media'); expect(react?.code).not.toContain('::slotted'); }, 30_000); diff --git a/packages/skins/build/transform.ts b/packages/skins/build/transform.ts index 7a30f0bc2f..3bccb4bde1 100644 --- a/packages/skins/build/transform.ts +++ b/packages/skins/build/transform.ts @@ -5,6 +5,7 @@ import type { StyleTransformOptions } from 'vjsc/styles'; import type { ComponentTarget } from 'vjsc/target'; import { type SkinName, skinStyles } from '../src/meta.ts'; +import { skinBaseStylesheet } from './skin.ts'; import { createComponentTargets } from './target/index.ts'; const stylesDir = resolve(import.meta.dirname, '../src/styles'); @@ -63,7 +64,7 @@ export function createStyleOptions(config: SkinTransformConfig): StyleTransformO variants, stylesheet: { input: resolve(stylesDir, 'tailwind.compiler.css'), - base: resolve(stylesDir, 'base.css'), + base: resolve(stylesDir, skinBaseStylesheet(skin?.preset ?? 'video')), scope: skin?.scope ?? '.media-skin', }, }; diff --git a/packages/skins/dev/controls.ts b/packages/skins/dev/controls.ts index 7103996731..9732fe5a14 100644 --- a/packages/skins/dev/controls.ts +++ b/packages/skins/dev/controls.ts @@ -1,5 +1,6 @@ import { SOURCES } from '../../../apps/sandbox/app/shared/sources'; import { errorSource, mediaIds, previewWidth, type PreviewOptions } from './options'; +import { buildReport, createPreferenceBadges, formatRem } from './report'; export interface PreviewControls { readonly options: HTMLFormElement; @@ -54,7 +55,16 @@ function createOptions(preview: PreviewOptions): HTMLFormElement { ['single', 'Single track'], ['multiple', 'Multiple tracks'], ]), - createCopyButton(preview) + createSelect('dir', 'Direction', preview.direction, [ + ['ltr', 'Left to right'], + ['rtl', 'Right to left'], + ]), + createSelect('compare', 'Compare', preview.compare ? 'styles' : 'off', [ + ['off', 'Off'], + ['styles', 'CSS vs Tailwind'], + ]), + createReportControls(preview), + createPreferenceBadges() ); form.addEventListener('change', (event) => { if (!(event.target instanceof HTMLSelectElement)) return; @@ -126,39 +136,40 @@ function createWidthControl(initialWidth: number, setPlayerWidth: (width: number return section; } -function createCopyButton(preview: PreviewOptions): HTMLButtonElement { +/** + * Copies a markdown report for bug reports and shows it inline so it can be read or selected when the clipboard is + * unavailable. + */ +function createReportControls(preview: PreviewOptions): DocumentFragment { + const fragment = document.createDocumentFragment(); const button = document.createElement('button'); + const output = document.createElement('pre'); button.className = 'preview-copy'; button.type = 'button'; - button.textContent = 'Copy details'; + button.textContent = 'Copy report'; + output.className = 'preview-report'; + output.hidden = true; button.addEventListener('click', async () => { - const width = getPlayerWidth(); - const details = [ - 'Video.js skins preview', - `URL: ${location.href}`, - `framework=${preview.framework}`, - `skin=${preview.skin}`, - `style=${preview.styleMode}`, - `scheme=${preview.colorScheme}`, - `media=${preview.mediaId} (${preview.media.label})`, - `captions=${preview.captionsMode}`, - `width=${width}px (${formatRem(width)})`, - ].join('\n'); + const report = buildReport(preview, getPlayerWidth()); + + output.textContent = report; + output.hidden = false; try { - await navigator.clipboard.writeText(details); + await navigator.clipboard.writeText(report); button.textContent = 'Copied'; } catch { - button.textContent = 'Copy failed'; + button.textContent = 'Select the report below'; } setTimeout(() => { - button.textContent = 'Copy details'; + button.textContent = 'Copy report'; }, 3000); }); + fragment.append(button, output); - return button; + return fragment; } function createSelect( @@ -187,7 +198,3 @@ function getPlayerWidth(): number { return Number.parseInt(root?.style.getPropertyValue('--preview-player-width') ?? '', 10) || previewWidth.default; } - -function formatRem(width: number): string { - return `${Math.round((width / 16) * 100) / 100}rem`; -} diff --git a/packages/skins/dev/html-media.ts b/packages/skins/dev/html-media.ts index da93c25126..88cb65d2d8 100644 --- a/packages/skins/dev/html-media.ts +++ b/packages/skins/dev/html-media.ts @@ -59,16 +59,18 @@ export function renderHtmlMedia({ : '' }${storyboardTrack}`; - return `<${tag} id="preview-media"${sourceAttribute} playsinline crossorigin="anonymous">${videoTracks}${chapterTracks}`; + return `<${tag} data-preview-media${sourceAttribute} playsinline crossorigin="anonymous">${videoTracks}${chapterTracks}`; } export function assignHtmlMediaSource(root: ParentNode, source: SandboxSource['source']): void { if (!source) return; - const element = root.querySelector }>('#preview-media'); - if (!element) throw new Error('Expected the structured-source media element to exist.'); + const elements = root.querySelectorAll }>( + '[data-preview-media]' + ); + if (elements.length === 0) throw new Error('Expected the structured-source media element to exist.'); - element.source = source; + for (const element of elements) element.source = source; } function escapeAttribute(value: string): string { diff --git a/packages/skins/dev/main.tsx b/packages/skins/dev/main.tsx index b4ee030ee5..65d854f985 100644 --- a/packages/skins/dev/main.tsx +++ b/packages/skins/dev/main.tsx @@ -7,21 +7,33 @@ import { VideoPlayer } from '../../react/src/presets/video/player'; import { createPreviewControls } from './controls'; import { assignHtmlMediaSource, defineHtmlMedia, renderHtmlMedia } from './html-media'; import { loadSkin } from './loaders'; -import { readPreviewOptions } from './options'; +import { type PreviewOptions, readPreviewOptions, type StyleMode } from './options'; import { ReactPreviewMedia } from './react-media'; +import { installErrorLog } from './report'; import './styles.css'; +installErrorLog(); + const captions = new URL('./captions.vtt', import.meta.url).href; const preview = readPreviewOptions(); document.documentElement.dataset.colorScheme = preview.colorScheme; +document.documentElement.dir = preview.direction; -const Skin = await loadSkin(preview); +const variants: readonly PreviewOptions[] = preview.compare + ? [ + { ...preview, styleMode: 'css' }, + { ...preview, styleMode: 'tailwind' }, + ] + : [preview]; +const skins = await Promise.all(variants.map((variant) => loadSkin(variant))); -if (preview.styleMode === 'tailwind') await import('../src/styles/tailwind.compiler.css'); +if (variants.some((variant) => variant.styleMode === 'tailwind')) await import('../src/styles/tailwind.dev.css'); type PreviewRoot = HTMLElement & { __videojsSkinsReactRoot?: ReturnType }; +type ReactSkin = React.ComponentType>; +type HtmlSkin = (props?: { className?: string }) => { toString(): string }; const rootElement = document.getElementById('root'); if (!rootElement) throw new Error('Expected the skin preview root to exist.'); @@ -29,6 +41,9 @@ if (!rootElement) throw new Error('Expected the skin preview root to exist.'); const root: PreviewRoot = rootElement; root.dataset.mediaKind = preview.isAudio ? 'audio' : 'video'; + +if (preview.compare) root.dataset.compare = 'styles'; + root.__videojsSkinsReactRoot?.unmount(); delete root.__videojsSkinsReactRoot; @@ -39,8 +54,14 @@ const controls = createPreviewControls(preview, (width) => { root.before(controls.options, controls.width); if (preview.framework === 'react') { - // SAFETY: VJSC transforms the selected React target into a React component before the module loads. - renderReact(>} />); + renderReact( + <> + {variants.map((variant, index) => ( + // SAFETY: VJSC transforms the selected React target into a React component before the module loads. + + ))} + + ); } else { if (preview.isAudio && preview.isLive) await import('../../html/src/define/live-audio/player'); else if (preview.isAudio) await import('../../html/src/define/audio/player'); @@ -51,13 +72,7 @@ if (preview.framework === 'react') { await defineHtmlMedia(mediaOptions); - // SAFETY: VJSC transforms the selected HTML target into a string-rendering function before the module loads. - const render = Skin as (props?: { className?: string }) => { toString(): string }; const posterAttribute = preview.poster ? ` poster="${escapeAttribute(preview.poster)}"` : ''; - const output = String(render({ className: 'preview-player' })).replace( - '', - renderHtmlMedia(mediaOptions) - ); const playerTag = preview.isAudio ? preview.isLive ? 'live-audio-player' @@ -66,10 +81,43 @@ if (preview.framework === 'react') { ? 'live-video-player' : 'video-player'; - root.innerHTML = `<${playerTag}${posterAttribute}>${output}`; + root.innerHTML = variants + .map((variant, index) => { + // SAFETY: VJSC transforms the selected HTML target into a string-rendering function before the module loads. + const render = skins[index] as HtmlSkin; + const output = String(render({ className: 'preview-player' })).replace( + '', + renderHtmlMedia(mediaOptions) + ); + const player = `<${playerTag}${posterAttribute}>${output}`; + + return preview.compare ? compareSection(variant.styleMode, player) : player; + }) + .join(''); assignHtmlMediaSource(root, preview.media.source); } +function PreviewVariant({ variant, Skin }: { variant: PreviewOptions; Skin: ReactSkin }) { + const app = ; + + if (!preview.compare) return app; + + return ( +
+

{styleLabel(variant.styleMode)}

+ {app} +
+ ); +} + +function compareSection(styleMode: StyleMode, player: string): string { + return `

${styleLabel(styleMode)}

${player}
`; +} + +function styleLabel(styleMode: StyleMode): string { + return styleMode === 'css' ? 'CSS' : 'Tailwind'; +} + function App({ Skin }: { Skin: React.ComponentType> }) { const content = ( diff --git a/packages/skins/dev/options.ts b/packages/skins/dev/options.ts index cb455fce94..5267f56630 100644 --- a/packages/skins/dev/options.ts +++ b/packages/skins/dev/options.ts @@ -41,9 +41,14 @@ export type SkinName = | 'minimal-audio'; export type StyleMode = 'css' | 'tailwind'; +export type Direction = 'ltr' | 'rtl'; + export interface PreviewOptions { readonly captionsMode: CaptionsMode; readonly colorScheme: ColorScheme; + /** Render the CSS and Tailwind variants of the same skin together. */ + readonly compare: boolean; + readonly direction: Direction; readonly framework: Framework; readonly isAudio: boolean; readonly isLive: boolean; @@ -67,6 +72,8 @@ export function readPreviewOptions(search = location.search): PreviewOptions { const styleMode = params.get('style') === 'tailwind' ? 'tailwind' : 'css'; const captionsMode = params.get('captions') === 'multiple' ? 'multiple' : 'single'; const colorScheme = params.get('scheme') === 'light' ? 'light' : 'dark'; + const compare = params.get('compare') === 'styles'; + const direction = params.get('dir') === 'rtl' ? 'rtl' : 'ltr'; const requestedMedia = params.get('media'); const mediaId = isMediaId(requestedMedia) ? requestedMedia : isLive ? 'hls-live' : 'mp4-1'; const requestedWidth = Number.parseInt(params.get('width') ?? '', 10); @@ -79,6 +86,8 @@ export function readPreviewOptions(search = location.search): PreviewOptions { return { captionsMode, colorScheme, + compare, + direction, framework, isAudio, isLive, diff --git a/packages/skins/dev/report.ts b/packages/skins/dev/report.ts new file mode 100644 index 0000000000..4eae714563 --- /dev/null +++ b/packages/skins/dev/report.ts @@ -0,0 +1,101 @@ +import { isString } from '@videojs/utils/predicate'; + +import type { PreviewOptions } from './options'; + +const preferenceQueries = [ + ['reduced motion', '(prefers-reduced-motion: reduce)'], + ['reduced transparency', '(prefers-reduced-transparency: reduce)'], + ['more contrast', '(prefers-contrast: more)'], + ['forced colors', '(forced-colors: active)'], + ['hover', '(hover: hover)'], + ['coarse pointer', '(pointer: coarse)'], + ['dark scheme', '(prefers-color-scheme: dark)'], +] as const; + +const MAX_ERRORS = 10; +const errors: string[] = []; + +/** Record runtime errors so a copied report shows what failed, not only where. */ +export function installErrorLog(): void { + const consoleError = console.error.bind(console); + + window.addEventListener('error', (event) => record(event.message || describe(event.error))); + window.addEventListener('unhandledrejection', (event) => record(`Unhandled rejection: ${describe(event.reason)}`)); + console.error = (...args: unknown[]) => { + record(args.map(describe).join(' ')); + consoleError(...args); + }; +} + +/** Badges for the preferences the theme reacts to; DevTools rendering emulation flips them live. */ +export function createPreferenceBadges(): HTMLElement { + const section = document.createElement('section'); + const list = document.createElement('ul'); + const hint = document.createElement('p'); + + section.className = 'preview-preferences'; + section.ariaLabel = 'Detected preferences'; + hint.className = 'preview-preferences-hint'; + hint.textContent = 'Emulate these from the DevTools Rendering panel; the theme reads them through tokens.'; + list.append( + ...preferenceQueries.map(([name, query]) => { + const item = document.createElement('li'); + const media = matchMedia(query); + const update = () => { + item.dataset.active = String(media.matches); + item.textContent = `${name}: ${media.matches ? 'on' : 'off'}`; + }; + + media.addEventListener('change', update); + update(); + return item; + }) + ); + section.append(list, hint); + + return section; +} + +/** Markdown report of the current preview: URL, build, options, environment, preferences, and recent errors. */ +export function buildReport(preview: PreviewOptions, width: number): string { + const preferences = preferenceQueries + .map(([name, query]) => `${name} ${matchMedia(query).matches ? 'on' : 'off'}`) + .join(', '); + const options = [ + `framework=${preview.framework}`, + `skin=${preview.skin}`, + `style=${preview.compare ? 'css+tailwind' : preview.styleMode}`, + `scheme=${preview.colorScheme}`, + `dir=${preview.direction}`, + `media=${preview.mediaId} (${preview.media.label})`, + `captions=${preview.captionsMode}`, + `width=${width}px (${formatRem(width)})`, + ].join(', '); + + return [ + '## Video.js skins preview', + `- URL: ${location.href}`, + `- Build: ${__PREVIEW_BRANCH__} @ ${__PREVIEW_COMMIT__}`, + `- Options: ${options}`, + `- Browser: ${navigator.userAgent}`, + `- Viewport: ${innerWidth}x${innerHeight} @ ${devicePixelRatio}x`, + `- Preferences: ${preferences}`, + errors.length > 0 ? `- Errors:\n${errors.map((error) => ` - ${error}`).join('\n')}` : '- Errors: none', + ].join('\n'); +} + +export function formatRem(width: number): string { + return `${Math.round((width / 16) * 100) / 100}rem`; +} + +function record(message: string): void { + errors.push(`${new Date().toISOString().slice(11, 19)} ${message}`); + + if (errors.length > MAX_ERRORS) errors.shift(); +} + +function describe(value: unknown): string { + if (value instanceof Error) return `${value.name}: ${value.message}`; + + return isString(value) ? value : String(value); +} diff --git a/packages/skins/dev/styles.css b/packages/skins/dev/styles.css index 2f81bccbc1..fd971c8691 100644 --- a/packages/skins/dev/styles.css +++ b/packages/skins/dev/styles.css @@ -168,3 +168,76 @@ body { outline: 2px solid light-dark(#111, white); outline-offset: 2px; } + +.preview-report { + flex-basis: 100%; + margin: 0; + padding: 0.75rem; + overflow-x: auto; + color: light-dark(rgb(0 0 0 / 80%), rgb(255 255 255 / 80%)); + font-size: 0.75rem; + line-height: 1.5; + white-space: pre-wrap; + border: 1px solid light-dark(rgb(0 0 0 / 15%), rgb(255 255 255 / 15%)); + border-radius: 0.375rem; + background: light-dark(#f5f5f5, #111); +} + +.preview-preferences { + display: flex; + flex-basis: 100%; + flex-wrap: wrap; + gap: 0.375rem 0.75rem; + align-items: center; +} + +.preview-preferences ul { + display: flex; + flex-wrap: wrap; + gap: 0.375rem; + margin: 0; + padding: 0; + list-style: none; +} + +.preview-preferences li { + padding: 0.125rem 0.5rem; + color: light-dark(rgb(0 0 0 / 60%), rgb(255 255 255 / 60%)); + font-size: 0.75rem; + font-weight: 600; + border: 1px solid light-dark(rgb(0 0 0 / 15%), rgb(255 255 255 / 15%)); + border-radius: 9999px; +} + +.preview-preferences li[data-active="true"] { + color: #111; + border-color: #fcd34d; + background: #fcd34d; +} + +.preview-preferences-hint { + margin: 0; + color: light-dark(rgb(0 0 0 / 55%), rgb(255 255 255 / 55%)); + font-size: 0.75rem; +} + +#root[data-compare] { + grid-auto-flow: row; + gap: 1.5rem; + align-content: start; +} + +.preview-compare-item { + display: grid; + gap: 0.5rem; + justify-items: center; +} + +.preview-compare-item h2 { + margin: 0; + color: light-dark(rgb(0 0 0 / 60%), rgb(255 255 255 / 60%)); + font-size: 0.75rem; + font-weight: 650; + letter-spacing: 0.04em; + text-transform: uppercase; +} diff --git a/packages/skins/dev/vite-env.d.ts b/packages/skins/dev/vite-env.d.ts index 813b025fc8..4c6817df56 100644 --- a/packages/skins/dev/vite-env.d.ts +++ b/packages/skins/dev/vite-env.d.ts @@ -47,3 +47,6 @@ declare module '*&skin=minimal-audio' { | import('react').ComponentType> | ((props?: { className?: string }) => { toString(): string }); } + +declare const __PREVIEW_BRANCH__: string; +declare const __PREVIEW_COMMIT__: string; diff --git a/packages/skins/dev/vite.config.ts b/packages/skins/dev/vite.config.ts index 358c9790a0..11cd7ec369 100644 --- a/packages/skins/dev/vite.config.ts +++ b/packages/skins/dev/vite.config.ts @@ -1,3 +1,4 @@ +import { execFileSync } from 'node:child_process'; import { resolve } from 'node:path'; import tailwindcss from '@tailwindcss/vite'; @@ -10,6 +11,15 @@ import { vjscPlugin } from '../../vjsc/src/vite/index.ts'; import { resolveSkinComponents, resolveSkinStyles } from '../build/transform.ts'; const packageDir = resolve(import.meta.dirname, '..'); + +/** Branch and commit for the copied preview report; falls back when the checkout has no git metadata. */ +function describeGit(...args: string[]): string { + try { + return execFileSync('git', args, { cwd: packageDir, encoding: 'utf8' }).trim(); + } catch { + return 'unknown'; + } +} const reactSourceDir = normalizePath(resolve(packageDir, '../react/src')); const htmlDefineDir = normalizePath(resolve(packageDir, '../html/src/define')); const htmlIconDir = normalizePath(resolve(packageDir, '../html/src/icons')); @@ -19,6 +29,8 @@ export default defineConfig({ root: import.meta.dirname, define: { __DEV__: 'true', + __PREVIEW_BRANCH__: JSON.stringify(describeGit('rev-parse', '--abbrev-ref', 'HEAD')), + __PREVIEW_COMMIT__: JSON.stringify(describeGit('rev-parse', '--short', 'HEAD')), }, plugins: [ iconElementSourcePlugin(), @@ -27,9 +39,12 @@ export default defineConfig({ components: resolveSkinComponents, styles: resolveSkinStyles, }, + candidates: true, }), tailwindcss(), - react({ jsxImportSource: 'react' }), + // The entry mounts the preview with top-level awaits and must never hot swap. Without a refresh boundary, updates + // that reach it, such as rebuilt workspace dist files or context modules, fall through to a full reload. + react({ jsxImportSource: 'react', exclude: [/\/dev\/main\.tsx$/] }), ], resolve: { alias: [ diff --git a/packages/skins/package.json b/packages/skins/package.json index b7af566112..b8512e3b90 100644 --- a/packages/skins/package.json +++ b/packages/skins/package.json @@ -25,7 +25,6 @@ }, "devDependencies": { "@lit/context": "^1.1.0", - "@playwright/test": "^1.52.0", "@tailwindcss/vite": "^4.3.3", "@types/node": "^22.18.6", "@types/react": "^19.2.17", @@ -42,7 +41,6 @@ "react": "^19.2.8", "react-dom": "^19.2.8", "shadcn": "^4.19.0", - "tailwind-merge": "^3.5.0", "tailwindcss": "^4.3.3", "typescript": "^6.0.2", "vite": "^8.2.2", diff --git a/packages/skins/src/components/feedback/status-indicator.tsx b/packages/skins/src/components/feedback/status-indicator.tsx index d5d665b785..eba1416f94 100644 --- a/packages/skins/src/components/feedback/status-indicator.tsx +++ b/packages/skins/src/components/feedback/status-indicator.tsx @@ -13,6 +13,7 @@ import { import { Box, type Props } from 'vjsc/components'; import type { SkinComponentMeta } from '../../meta'; +import indicatorStyles from '../../styles/feedback/indicator.styles'; import playbackStyles from '../../styles/feedback/playback-status-indicator.styles'; import styles from '../../styles/feedback/status-indicator.styles'; @@ -22,8 +23,12 @@ const PLAYBACK_STATUS_ACTIONS = ['togglePaused'] as const; export function StatusIndicator({ className, ...props }: Props> = {}) { return ( - <$.StatusIndicator.Root actions={TOP_STATUS_ACTIONS} className={[styles.root, className]} {...props}> - + <$.StatusIndicator.Root + actions={TOP_STATUS_ACTIONS} + className={[indicatorStyles.root, styles.root, className]} + {...props} + > + diff --git a/packages/skins/src/components/feedback/volume-indicator.tsx b/packages/skins/src/components/feedback/volume-indicator.tsx index 00508c5d35..f56242cc01 100644 --- a/packages/skins/src/components/feedback/volume-indicator.tsx +++ b/packages/skins/src/components/feedback/volume-indicator.tsx @@ -4,12 +4,13 @@ import { VolumeHighIcon, VolumeLowIcon, VolumeOffIcon } from '@videojs/icons/vjs import type { Props } from 'vjsc/components'; import type { SkinComponentMeta } from '../../meta'; +import indicatorStyles from '../../styles/feedback/indicator.styles'; import styles from '../../styles/feedback/volume-indicator.styles'; export function VolumeIndicator({ className, ...props }: Props = {}) { return ( - <$.VolumeIndicator.Root className={[styles.root, className]} {...props}> - <$.VolumeIndicator.Fill className={styles.fill}> + <$.VolumeIndicator.Root className={[indicatorStyles.root, styles.root, className]} {...props}> + <$.VolumeIndicator.Fill className={[indicatorStyles.content, styles.fill]}> diff --git a/packages/skins/src/components/menus/captions-menu.tsx b/packages/skins/src/components/menus/captions-menu.tsx index 457180b84a..6d05fe6094 100644 --- a/packages/skins/src/components/menus/captions-menu.tsx +++ b/packages/skins/src/components/menus/captions-menu.tsx @@ -5,6 +5,7 @@ import { type Props, type PropsOf, Template } from 'vjsc/components'; import type { SkinComponentMeta } from '../../meta'; import styles from '../../styles/menus/menu.styles'; import popupStyles from '../../styles/popups/popup.styles'; +import { ButtonTooltip } from '../buttons/button-tooltip'; import { CaptionsButton } from '../buttons/captions-button'; import { RadioItem } from './radio-item'; @@ -16,7 +17,9 @@ export function CaptionsMenu({ className, ...props }: Props = return ( <$.Menu.Root side="top" align="center" boundary="viewport" {...props}> <$.CaptionsRadioGroup.Root> - <$.Menu.Trigger $render={CaptionsButton} className={className} /> + + <$.Menu.Trigger $render={CaptionsButton} className={className} /> + <$.Menu.Popup className={[popupStyles.popup, popupStyles.surface, styles.popup]}> <$.Menu.Content className={styles.content}> <$.CaptionsRadioGroup.Options className={styles.radioGroup}> diff --git a/packages/skins/src/skins/audio/error-dialog.styles.ts b/packages/skins/src/skins/audio/error-dialog.styles.ts index b19f7c2c0d..a2f76acabc 100644 --- a/packages/skins/src/skins/audio/error-dialog.styles.ts +++ b/packages/skins/src/skins/audio/error-dialog.styles.ts @@ -14,17 +14,16 @@ export default styles({ popup: { className: 'audio-dialog-popup', utilities: [ - 'absolute inset-0 z-50 flex h-full max-h-none w-full translate-none flex-row items-center rounded-[99px] py-0 pe-1 outline-hidden not-data-open:hidden', - 'bg-media-background text-media-controls-foreground backdrop-blur-lg backdrop-saturate-150', - 'transition-[opacity,filter] duration-250 ease-out', - 'data-starting-style:blur-xs data-starting-style:opacity-0', - 'data-ending-style:blur-xs data-ending-style:opacity-0', + 'absolute inset-0 z-50 flex h-full max-h-none w-full translate-none flex-row items-center rounded-media-pill py-0 pe-1 outline-hidden not-data-open:hidden', + 'bg-media-background text-media-controls-foreground backdrop-filter-media-dialog', + 'transition-[opacity,filter] duration-media-slower ease-out', + 'media-transitioning:blur-media-hidden-popup media-transitioning:opacity-0', ], variants: { default: 'gap-3 px-5', minimal: [ 'gap-4 px-3 backdrop-filter-none transition-[opacity,filter,scale]', - '[&:is([data-starting-style],[data-ending-style])]:[scale:.95]', + 'media-transitioning:scale-media-hidden-popup', ], }, }, diff --git a/packages/skins/src/skins/audio/play-button.styles.ts b/packages/skins/src/skins/audio/play-button.styles.ts index 07bf3f3077..0511091a74 100644 --- a/packages/skins/src/skins/audio/play-button.styles.ts +++ b/packages/skins/src/skins/audio/play-button.styles.ts @@ -9,7 +9,7 @@ export default styles({ }, bufferingIndicator: { className: 'audio-play-button-buffering-indicator', - utilities: ['z-20 rounded-media-control text-inherit! before:hidden!', 'data-visible:bg-media-controls'], + utilities: ['z-20 rounded-media-control text-inherit! before:hidden', 'data-visible:bg-media-controls'], }, }, }); diff --git a/packages/skins/src/skins/audio/skin.styles.ts b/packages/skins/src/skins/audio/skin.styles.ts index ba02ea036d..e5ce6c16ab 100644 --- a/packages/skins/src/skins/audio/skin.styles.ts +++ b/packages/skins/src/skins/audio/skin.styles.ts @@ -6,7 +6,7 @@ export default styles({ root: { className: 'audio-skin', scopeRoot: true, - utilities: 'h-auto! overflow-visible! bg-transparent! [container-type:inline-size]! after:hidden!', + utilities: 'h-auto! overflow-visible! bg-transparent! [container-type:inline-size]! after:hidden', }, }, }); diff --git a/packages/skins/src/skins/audio/time-slider.styles.ts b/packages/skins/src/skins/audio/time-slider.styles.ts index 4704e6a21d..db7e846488 100644 --- a/packages/skins/src/skins/audio/time-slider.styles.ts +++ b/packages/skins/src/skins/audio/time-slider.styles.ts @@ -14,10 +14,10 @@ export default styles({ }, previewContent: { className: 'audio-time-slider-preview-content', - utilities: 'bottom-[calc(100%+--spacing(10))] tabular-nums', + utilities: 'bottom-[calc(100%+var(--media-slider-preview-label-offset))] tabular-nums', variants: { default: 'left-1/2', - minimal: ['[left:var(--media-preview-left,var(--media-slider-pointer))]', 'after:hidden'], + minimal: '[left:var(--media-preview-left,var(--media-slider-pointer))]', }, }, value: { diff --git a/packages/skins/src/skins/default-audio/controls.styles.ts b/packages/skins/src/skins/default-audio/controls.styles.ts index 318d5e92b2..41f970a690 100644 --- a/packages/skins/src/skins/default-audio/controls.styles.ts +++ b/packages/skins/src/skins/default-audio/controls.styles.ts @@ -1,46 +1,19 @@ import { styles } from 'vjsc/styles'; -const timeButton = [ - 'cursor-pointer rounded-sm tabular-nums outline-2 -outline-offset-2 outline-transparent', - 'transition-[outline-color,outline-offset] duration-100 ease-out motion-reduce:duration-50', - 'focus-visible:outline-[var(--media-focus-ring-color)] focus-visible:outline-offset-2', -] as const; - export default styles({ file: 'audio/controls.css', rules: { - content: { - className: 'audio-controls-content', - utilities: [ - 'relative z-20 flex items-center rounded-media-control bg-media-controls p-1 text-media-controls-foreground', - 'text-shadow-media', - '[--media-popover-side-offset:--spacing(3)] [--media-tooltip-side-offset:var(--media-popover-side-offset)]', - '[--media-popover-boundary-offset:--spacing(2)] [--media-tooltip-boundary-offset:var(--media-popover-boundary-offset)]', - ], - }, - start: { - className: 'audio-controls-start', - utilities: 'flex items-center gap-px', - }, - end: { - className: 'audio-controls-end', - utilities: 'flex items-center gap-px', - }, seekButton: { className: 'audio-seek-button', - utilities: '@max-[32rem]/media-root:hidden', + utilities: 'media-max-compact:hidden', }, timeSliderGroup: { className: 'audio-time-slider-group', utilities: '@container/audio-time-controls flex min-w-0 flex-1 items-center gap-2.5 px-3', }, - currentValue: { - className: 'audio-time-current-value', - utilities: 'tabular-nums', - }, remainingValue: { className: 'audio-time-remaining-value', - utilities: [...timeButton, '@max-[16rem]/audio-time-controls:hidden'], + utilities: '@max-[16rem]/audio-time-controls:hidden', }, }, }); diff --git a/packages/skins/src/skins/default-audio/controls.tsx b/packages/skins/src/skins/default-audio/controls.tsx index feb43f676d..c330871fb4 100644 --- a/packages/skins/src/skins/default-audio/controls.tsx +++ b/packages/skins/src/skins/default-audio/controls.tsx @@ -4,7 +4,7 @@ import { ButtonTooltip } from '../../components/buttons/button-tooltip'; import { SeekButton } from '../../components/buttons/seek-button'; import { VolumePopover } from '../../components/controls/volume-popover'; import audioControlsStyles from '../../styles/layout/audio-controls.styles'; -import popupStyles from '../../styles/popups/popup.styles'; +import timeStyles from '../../styles/layout/time.styles'; import { AudioPlayButton } from '../audio/play-button'; import { AudioSettingsMenu } from '../audio/settings-menu'; import { AudioTimeSlider } from '../audio/time-slider'; @@ -13,9 +13,9 @@ import styles from './controls.styles'; export function DefaultAudioControls() { return ( <$.Controls.Root visibility="always"> - <$.Controls.Content className={[audioControlsStyles.root, popupStyles.surface, styles.content]}> + <$.Controls.Content className={[audioControlsStyles.root, audioControlsStyles.content]}> <$.Tooltip.Provider> - <$.Controls.Group className={styles.start}> + <$.Controls.Group className={audioControlsStyles.start}> @@ -26,12 +26,12 @@ export function DefaultAudioControls() { <$.Controls.Group className={styles.timeSliderGroup}> - <$.Time.Value className={styles.currentValue} type="current" /> + <$.Time.Value className={timeStyles.value} type="current" /> - <$.Time.Value className={styles.remainingValue} type="remaining" toggle /> + <$.Time.Value className={[timeStyles.toggle, styles.remainingValue]} type="remaining" toggle /> - <$.Controls.Group className={styles.end}> + <$.Controls.Group className={audioControlsStyles.end}> diff --git a/packages/skins/src/skins/default-live-audio/controls.styles.ts b/packages/skins/src/skins/default-live-audio/controls.styles.ts deleted file mode 100644 index 28122066cc..0000000000 --- a/packages/skins/src/skins/default-live-audio/controls.styles.ts +++ /dev/null @@ -1,28 +0,0 @@ -import { styles } from 'vjsc/styles'; - -export default styles({ - file: 'live-audio/controls.css', - rules: { - content: { - className: 'audio-controls-content', - utilities: [ - 'relative z-20 flex items-center rounded-media-control bg-media-controls p-1 text-media-controls-foreground', - 'text-shadow-media', - '[--media-popover-side-offset:--spacing(3)] [--media-tooltip-side-offset:var(--media-popover-side-offset)]', - '[--media-popover-boundary-offset:--spacing(2)] [--media-tooltip-boundary-offset:var(--media-popover-boundary-offset)]', - ], - }, - start: { - className: 'audio-controls-start', - utilities: 'flex items-center gap-px', - }, - end: { - className: 'audio-controls-end', - utilities: 'flex items-center gap-px', - }, - spacer: { - className: 'audio-controls-spacer', - utilities: 'flex-1', - }, - }, -}); diff --git a/packages/skins/src/skins/default-live-audio/controls.tsx b/packages/skins/src/skins/default-live-audio/controls.tsx index ad48659ec0..38f8e827d8 100644 --- a/packages/skins/src/skins/default-live-audio/controls.tsx +++ b/packages/skins/src/skins/default-live-audio/controls.tsx @@ -4,23 +4,21 @@ import { Box } from 'vjsc/components'; import { LiveButton } from '../../components/buttons/live-button'; import { VolumePopover } from '../../components/controls/volume-popover'; import audioControlsStyles from '../../styles/layout/audio-controls.styles'; -import popupStyles from '../../styles/popups/popup.styles'; import { AudioPlayButton } from '../audio/play-button'; -import styles from './controls.styles'; export function DefaultLiveAudioControls() { return ( <$.Controls.Root visibility="always"> - <$.Controls.Content className={[audioControlsStyles.root, popupStyles.surface, styles.content]}> + <$.Controls.Content className={[audioControlsStyles.root, audioControlsStyles.content]}> <$.Tooltip.Provider> - <$.Controls.Group className={styles.start}> + <$.Controls.Group className={audioControlsStyles.start}> -