diff --git a/.claude/plans/skin-jsx-migration.md b/.claude/plans/skin-jsx-migration.md
index 81fa8d2a..3610df14 100644
--- a/.claude/plans/skin-jsx-migration.md
+++ b/.claude/plans/skin-jsx-migration.md
@@ -109,7 +109,7 @@ Steps:
- Import Tailwind token objects from existing `packages/skins/src/default/tailwind/video.tailwind.ts`.
- Keep skin source target-neutral:
- no `div`, `span`, `button`, `sup`
- - no `render`
+ - no React `render` props; the only exception is the restricted settings radio-group children template documented below
- no `aria-*`, `data-*`, `id`, `role`, `tabIndex`, or `style`
- Replace obvious wrappers with semantic Core parts where they already exist.
- Capture every missing semantic wrapper/lowering need in a gap list before inventing new API.
@@ -333,6 +333,244 @@ Next implementation sequence:
- Add `default/audio.skin.tsx`, `minimal/video.skin.tsx`, and `minimal/audio.skin.tsx`.
- Defer live/background variants until base audio/video generation is stable.
+### Current Default Video Parity Decisions
+
+This section records the current direction for closing the remaining gap between `packages/skins/src/default/video.skin.tsx` and the manual React Tailwind skin.
+
+Baseline decisions:
+- Source skin stays target-neutral: no lowercase intrinsic elements (`div`, `span`, `sup`, etc.) and no React hooks/render props. The settings radio-group children template is a compile-time template exception, not a general React escape hatch.
+- Source skin owns styling decisions and token placement. React lowering should not hide major styling/markup decisions in opaque helper components unless the behavior is inherently React-specific.
+- React lowering may still own React-specific mechanics: `render` props, `type="button"`, DOM attribute names, rest props, and imports.
+- Do not chase the current manual skin exactly when the manual shape only exists because older code needed wrappers. Prefer the simpler source shape when it preserves behavior and styling.
+
+Buffering indicator:
+- Do not keep the manual inner buffering `container` wrapper.
+- Move any still-needed `bufferingIndicator.container` styles into `bufferingIndicator.root`.
+- Generated/source target shape:
+
+```tsx
+
+
+
+```
+
+Error dialog:
+- Keep the simplified generated/source structure.
+- Do not add `ErrorDialog.Dialog`, `ErrorDialog.Content`, or `ErrorDialog.Actions` solely to match the current manual React skin.
+- Generated/source target shape:
+
+```tsx
+
+
+ Something went wrong.
+
+ OK
+
+
+```
+
+Seek buttons:
+- Keep the visible seek label, but do not use an extra icon-container wrapper span.
+- Add a source-neutral `Text` primitive that React lowers to `span`.
+- Move `iconContainer`-style grid/relative placement onto the seek button token.
+- Source target shape:
+
+```tsx
+
+
+ {SEEK_TIME}
+
+
+
+
+ {SEEK_TIME}
+
+```
+
+React generated shape:
+
+```tsx
+
+
+ {SEEK_TIME}
+
+```
+
+Thumbnail preview:
+- We want nested thumbnail parts because the current thumbnail implementation already has root/image semantics internally.
+- Desired source/API shape:
+
+```tsx
+
+
+
+
+
+```
+
+- This requires nested component part support in the manifest/generated component system. Current manifests only support one level like `Slider.Thumbnail`.
+- Keep existing `` behavior as an alias to the image part if possible.
+
+Volume popover:
+- Use a special `VolumePopover.Root`, but reuse regular `Popover` parts as children.
+- The root owns volume availability/null behavior, similar to how `VolumeSlider.Root` owns the volume feature dependency.
+- Source target shape:
+
+```tsx
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+```
+
+- React lowering can continue converting `Popover.Trigger` children into `render` props.
+
+Settings menu:
+- The settings menu must remain style-owned by source. Do not solve it with an opaque React helper that contains all the styling/markup.
+- The radio groups are the right data boundary: `QualityRadioGroup`, `PlaybackRateRadioGroup`, and `CaptionsRadioGroup` already have Core state and React hooks that produce option records.
+- Do not add tailored compound parts like `QualityRadioGroup.Label`, `QualityRadioGroup.Tier`, or `QualityRadioGroup.Badge`. Those values are already returned by the React hooks as fields on each option.
+- Use a restricted source-level children render function as the option-row template. The group owns the hook data and the generated `map(...)`; the source owns the row markup/styling.
+- Treat the render function as a compile-time template language, not as a general React render prop in source. React can execute the generated render body, but HTML lowering must statically translate it to `` placeholders.
+- Do not use `Menu.ItemIndicator` for generated settings rows. `Menu.RadioItem` / `` already own checked state via `aria-checked`; the check icon should receive `menu.indicator` directly and use parent `group-aria-checked/menu-item` styles.
+- Keep a generic availability boundary around each setting section so React can conditionally render the whole submenu section while HTML can rely on existing `type="quality|playback-rate|captions"` / `data-availability` behavior. Placeholder name: `Menu.Setting`; final naming still needs confirmation.
+
+Source-template target shape:
+
+```tsx
+
+
+
+
+ Quality
+
+
+
+
+
+
+
+
+
+ Quality
+
+
+
+
+
+ {({ option }) => (
+
+
+ {option.label}
+ {option.tier ? {option.tier} : null}
+
+ {option.badge ? {option.badge} : null}
+
+
+ )}
+
+
+
+
+```
+
+React lowering target concept:
+
+```tsx
+const quality = useQualityOptions();
+
+{quality?.state.availability === 'available' ? (
+
+ {/* source-owned trigger/content markup */}
+
+ {quality.options.map((option) => (
+
+
+ {option.label}
+ {option.tier ? {option.tier} : null}
+
+ {option.badge ? {option.badge} : null}
+
+
+ ))}
+
+
+) : null}
+```
+
+HTML lowering target concept:
+
+```html
+
+
+
+
+
+
+
+
+
+
+
+
+```
+
+Template-lowering constraints:
+- Allow a restricted children render function only as the direct child of `QualityRadioGroup`, `PlaybackRateRadioGroup`, and `CaptionsRadioGroup`.
+- The render function returns one `Menu.RadioItem` row template. `value={option.value}` and `disabled={option.disabled}` are allowed for React lowering and dropped for HTML template lowering because the HTML group sets those on cloned items.
+- Allow static JSX, static props, source-neutral `Text`, icons, `className` values, and `className` arrays.
+- Allow direct option field reads: `{option.label}`, `{option.tier}`, `{option.badge}`, `{option.value}`, and `{option.disabled}`.
+- Allow simple optional field guards such as `{option.tier ? {option.tier} : null}` and `{option.badge ? {option.badge} : null}`.
+- Disallow `options.map(...)` in source, function calls inside the template, spreads, event handlers, refs, hooks, computed expressions, dynamic component selection, and arbitrary `state` usage until a concrete need appears.
+- Unsupported template expressions should produce compiler diagnostics rather than silently generating incorrect React or HTML output.
+
+Input feedback:
+- Avoid adding `InputFeedback.Root` if we can move wrapper positioning styles onto the actual indicator roots.
+- Current root styles own absolute placement/grid. Move equivalent placement into `inputFeedback.island.*` and `inputFeedback.bubble.*` tokens.
+- Source target shape:
+
+```tsx
+
+ {/* fill/value/icon children */}
+
+
+
+ {/* status icon/value children */}
+
+
+
+ {/* seek icon/value children */}
+
+
+
+ {/* play/pause icon children */}
+
+```
+
+### Revised Near-Term Order
+
+1. Add `Text` primitive and React lowering to `span`.
+2. Update seek labels to use `Text`, and move `iconContainer` styles onto the seek button token.
+3. Simplify buffering token/source shape by merging container styles into root.
+4. Add poster class parity.
+5. Move input-feedback root positioning onto indicator roots.
+6. Add nested component part support for `Slider.Thumbnail.Root` / `Slider.Thumbnail.Image`.
+7. Add `VolumePopover.Root` and source volume popover structure.
+8. Implement settings menu render-function template lowering for React first, then HTML `` lowering using the validated subset above.
+
## Phase 3 - Expand React Tailwind Coverage
Purpose: move from one slice to all supported React Tailwind skins.
@@ -403,4 +641,4 @@ Gate question:
## Immediate Next Step
-Continue expanding `default/video` source parity one structural gap at a time. The next likely gap is overlay after deciding whether the missing shell belongs in Core parts, source abstractions, or React lowering.
+Continue expanding `default/video` source parity one structural gap at a time. The next implementation chunk should be `Text` plus seek-label parity, followed by buffering/poster simplification. Settings menu lowering now has a recorded direction: restricted render-function option templates, React hook/map generation, HTML `` placeholder generation, and no `Menu.ItemIndicator` wrapper.
diff --git a/packages/core/src/core/ui/components.generated.ts b/packages/core/src/core/ui/components.generated.ts
index cf0fe218..1fb01a6b 100644
--- a/packages/core/src/core/ui/components.generated.ts
+++ b/packages/core/src/core/ui/components.generated.ts
@@ -29,6 +29,7 @@ import SeekIndicatorDef from './seek-indicator/seek-indicator-component';
import SliderDef from './slider/slider-component';
import StatusAnnouncerDef from './status-announcer/status-announcer-component';
import StatusIndicatorDef from './status-indicator/status-indicator-component';
+import TextDef from './text/text-component';
import ThumbnailDef from './thumbnail/thumbnail-component';
import TimeDef from './time/time-component';
import TimeSliderDef from './time-slider/time-slider-component';
@@ -64,6 +65,7 @@ export const SeekIndicator = createComponent(SeekIndicatorDef);
export const Slider = createComponent(SliderDef);
export const StatusAnnouncer = createComponent(StatusAnnouncerDef);
export const StatusIndicator = createComponent(StatusIndicatorDef);
+export const Text = createComponent(TextDef);
export const Thumbnail = createComponent(ThumbnailDef);
export const Time = createComponent(TimeDef);
export const TimeSlider = createComponent(TimeSliderDef);
@@ -100,6 +102,7 @@ export const COMPONENTS = {
Slider: SliderDef,
StatusAnnouncer: StatusAnnouncerDef,
StatusIndicator: StatusIndicatorDef,
+ Text: TextDef,
Thumbnail: ThumbnailDef,
Time: TimeDef,
TimeSlider: TimeSliderDef,
diff --git a/packages/core/src/core/ui/text/text-component.ts b/packages/core/src/core/ui/text/text-component.ts
new file mode 100644
index 00000000..bfdad25d
--- /dev/null
+++ b/packages/core/src/core/ui/text/text-component.ts
@@ -0,0 +1,5 @@
+import { defineComponent } from '../manifest';
+
+export default defineComponent()({
+ name: 'Text',
+});
diff --git a/packages/react/skins.compiler.config.ts b/packages/react/skins.compiler.config.ts
index e8b56337..adb9c7c4 100644
--- a/packages/react/skins.compiler.config.ts
+++ b/packages/react/skins.compiler.config.ts
@@ -50,6 +50,10 @@ export default defineConfig({
when: code.match.jsx.tag('Container'),
transform: code.edit.jsx.addPropsSpread('rest'),
}),
+ code.edit.jsx.element({
+ when: code.match.jsx.tag('Text'),
+ transform: code.edit.jsx.replaceTag('span'),
+ }),
code.edit.jsx.element({
when: code.match.jsx.tag('Poster'),
transform: () => {
diff --git a/packages/react/src/presets/tests/skins-compiler-config.test.ts b/packages/react/src/presets/tests/skins-compiler-config.test.ts
index df662fa5..4c6c3592 100644
--- a/packages/react/src/presets/tests/skins-compiler-config.test.ts
+++ b/packages/react/src/presets/tests/skins-compiler-config.test.ts
@@ -28,6 +28,7 @@ describe('skins compiler config', () => {
expect(code).toContain('from "@/ui/pip-button"');
expect(code).toContain('from "@/ui/play-button"');
expect(code).toContain('from "@/ui/poster"');
+ expect(code).not.toContain('from "@/ui/text"');
expect(code).not.toContain('from "@/ui/slider"');
expect(code).toContain('from "@/utils/use-render"');
expect(code).toContain('from "@videojs/utils/predicate"');
@@ -69,8 +70,16 @@ describe('skins compiler config', () => {
expect(compactCode).toContain(compact(''));
expect(compactCode).toContain(
- compact('')
+ compact('')
);
+ expect(compactCode).toContain(compact(''));
+ expect(compactCode).toContain(compact('{SEEK_TIME}'));
+ expect(compactCode).toContain(
+ compact('')
+ );
+ expect(compactCode).toContain(compact('{SEEK_TIME}'));
+ expect(compactCode).not.toContain('iconContainer');
+ expect(compactCode).not.toContain('}'));
expect(compactCode).toContain(compact(''));
expect(compactCode).toContain(compact(''));
diff --git a/packages/react/src/presets/video/default-video.generated.tailwind.tsx b/packages/react/src/presets/video/default-video.generated.tailwind.tsx
index c8ce16be..18d26791 100644
--- a/packages/react/src/presets/video/default-video.generated.tailwind.tsx
+++ b/packages/react/src/presets/video/default-video.generated.tailwind.tsx
@@ -12,12 +12,12 @@ import {
error,
fullscreenIcon,
icon,
- iconContainer,
iconFlipped,
overlay,
pipIcon,
playIcon,
popup,
+ seek,
slider,
time,
} from '@videojs/skins/default/tailwind/video.tailwind';
@@ -112,8 +112,9 @@ export function DefaultVideoSkin({ className, children, poster, ...rest }: Defau
-
+
+
+ {SEEK_TIME}
}
/>
@@ -126,8 +127,9 @@ export function DefaultVideoSkin({ className, children, poster, ...rest }: Defau
-
+
+
+ {SEEK_TIME}
}
/>
diff --git a/packages/skins/src/default/tailwind/components/seek.ts b/packages/skins/src/default/tailwind/components/seek.ts
index f4b14949..26007c87 100644
--- a/packages/skins/src/default/tailwind/components/seek.ts
+++ b/packages/skins/src/default/tailwind/components/seek.ts
@@ -1,4 +1,5 @@
export const seek = {
+ button: 'relative grid',
label: 'text-[10px] font-medium tracking-tighter tabular-nums',
labelForward: 'absolute -right-px -bottom-0.75',
labelBackward: 'absolute -left-px -bottom-0.75',
diff --git a/packages/skins/src/default/video.skin.tsx b/packages/skins/src/default/video.skin.tsx
index 08564be5..9113b4fd 100644
--- a/packages/skins/src/default/video.skin.tsx
+++ b/packages/skins/src/default/video.skin.tsx
@@ -14,6 +14,7 @@ import {
Poster,
SeekButton,
StatusAnnouncer,
+ Text,
Time,
TimeSlider,
Tooltip,
@@ -45,12 +46,12 @@ import {
error,
fullscreenIcon,
icon,
- iconContainer,
iconFlipped,
overlay,
pipIcon,
playIcon,
popup,
+ seek,
slider,
time,
} from './tailwind/video.tailwind';
@@ -101,8 +102,9 @@ export function DefaultVideoSkin({ className, children }: DefaultVideoSkinProps)
-
-
+
+
+ {SEEK_TIME}
@@ -113,8 +115,9 @@ export function DefaultVideoSkin({ className, children }: DefaultVideoSkinProps)
-
-
+
+
+ {SEEK_TIME}
diff --git a/packages/skins/src/minimal/tailwind/components/seek.ts b/packages/skins/src/minimal/tailwind/components/seek.ts
index f4b14949..26007c87 100644
--- a/packages/skins/src/minimal/tailwind/components/seek.ts
+++ b/packages/skins/src/minimal/tailwind/components/seek.ts
@@ -1,4 +1,5 @@
export const seek = {
+ button: 'relative grid',
label: 'text-[10px] font-medium tracking-tighter tabular-nums',
labelForward: 'absolute -right-px -bottom-0.75',
labelBackward: 'absolute -left-px -bottom-0.75',