Skip to content

Slider — live demo ​

This is the real @rozie-ui/slider-vue package running on this page (VitePress is itself a Vue app) — built from this repo's workspace, not npm: @rozie-ui/slider-vue has not published yet, only @rozie-ui/slider-solid has debuted so far. Drag a thumb with the mouse or touch, focus it and press the arrow / Home / End / PageUp keys, drag the two range thumbs past each other (they clamp and stay sorted), or tip the vertical one — then watch the two-way bound value update. The same Slider component, with the same API, ships for React, Vue, Svelte, Angular, Solid, and Lit. It's built on the browser's native <input type="range"> with no engine and no required CSS — the platform input behaviour, the cross-browser thumb styling, and a tokenised skin all ship inside the component.

value is two-way bound with v-model:value — the readout updates the instant you commit a value, and a consumer write flows back in. In single mode it's a scalar; with :range="true" it's a sorted [lo, hi] array (each thumb neighbour-clamped). The Single instance's buttons drive the imperative handle (increment(), decrement(), focus()) grabbed through Vue's ref. Flip orientation="vertical" to rotate the track (up = increase), pass :marks for tick marks, or :show-value for the value bubble — the same component, the same surface. See the full API for every prop, event, slot, and handle verb, plus theming and keyboard reference.

What ships for each framework ​

You author the component once as a .rozie file:

html
<!--
  Slider.rozie — a headless, WAI-ARIA accessible slider / range.

  The SECOND @rozie-ui family with NO third-party vanilla engine. Under
  Approach B the engine IS the browser's native <input type="range">: drag
  (mouse + touch), keyboard (arrows / Home / End), focus, role="slider",
  aria-value*, step / min / max, disabled, and RTL come from the platform for
  free. Rozie owns the author-side API, the two-way binding, the fill-var math,
  the range sort, the overlays, and a thin PageUp/PageDown step augment.

  Two overlapping transparent native inputs implement dual-thumb range
  (`range` prop). Vertical is a `transform: rotate(-90deg)` wrapper. The colored
  fill is a positioned <div> underlay driven by inline CSS custom properties
  (`--rozie-slider-fill-start` / `-end`) computed purely from value/min/max — no
  measured geometry, so ROZ123 ($refs only in $onMount) is never tripped.

  Authoring notes (collision classes — Phase 46 hardening makes natural names work):
    - The focus verb is `focus` — a DELIBERATE override of the inherited
      HTMLElement.focus on the Lit custom element. ROZ137 (EXPOSE_RESERVED_MEMBER)
      WARNS on this and does NOT auto-rename; the warn is accepted because the
      public `focus()` handle is intended. This INVERTS listbox's choice: listbox
      named its verb `focusControl` to AVOID the override; the slider accepts it
      (CONTEXT D-05). The accepted ROZ137 warn is documented here AND in every
      leaf README. `increment` / `decrement` are collision-safe (NOT host-element
      members), so they need no rename.
    - Every event fires through ONE wrapper fn (`fireChange`) so its
      prop-destructure hoists exactly once on React (a duplicate-emit-site would
      emit two `const … = props`). Phase 46 ITEM-1 also dedups, but the
      single-emit-site pattern is kept for clarity.
    - The fill is a `$computed` returning an object of inline CSS vars. Like every
      $computed it is read BARE in the template (`:style="fillStyle"`), never
      aliased in script — a $computed is a value on React but an accessor on
      Solid, so aliasing it diverges. Plain arithmetic (`pct`) stays a function.
    - The native value is read as `valueAsNumber` (a number), never bare `.value`
      (a string → arithmetic concatenation). The fresh number is used throughout
      a handler; $data is never re-read after a model write (ROZ138 staleness).

  Consumer example (single + range):

    <Slider r-model:value="$data.volume" :min="0" :max="100" :step="1" ariaLabel="Volume" />

    <Slider range r-model:value="$data.priceRange" :min="0" :max="500" ariaLabel="Price range" />
-->

<rozie name="Slider">

<props>
{
  // The current value (two-way). A scalar number in single mode; a sorted
  // [lo, hi] array in range mode. As the sole `model:true` prop it drives the
  // Angular ControlValueAccessor — a Slider IS a form control.
  value:       {
    type: null,
    default: null,
    model: true,
    docs: {
      description:
        'The current value (two-way `r-model`). A scalar number in single mode; a sorted `[lo, hi]` array in `range` mode, with each thumb neighbour-clamped so the pair stays sorted on every commit. As the sole `model: true` prop it drives the Angular `ControlValueAccessor`, so a Slider **is** a form control (`[(ngModel)]` / `[formControl]` bind directly).',
      example: '<Slider r-model:value="volume" :min="0" :max="100" :step="1" ariaLabel="Volume" />',
    },
  },

  // Range mode: `value` becomes a sorted [lo, hi] array driven by two
  // overlapping thumbs. Exact mirror of listbox's `multiple` (scalar↔array).
  // Bare-attr `<Slider range>` coerces to true (Phase 46 ITEM-2).
  range:       {
    type: Boolean,
    default: false,
    docs: {
      description:
        'Switch to dual-thumb range mode: `value` becomes a sorted `[lo, hi]` array driven by two overlapping native inputs. The exact analog of listbox\'s `multiple` (scalar↔array). A bare attribute (`<Slider range>`) coerces to `true`.',
    },
  },

  // Linear scale bounds + granularity. Forwarded to the native input as the
  // `min`/`max`/`step` attributes (NOT aria-valuemin/max — the browser derives
  // those; MDN slider role).
  min:         {
    type: Number,
    default: 0,
    docs: {
      description:
        'The lower bound of the scale, forwarded to the native input as the `min` attribute (the browser derives `aria-valuemin` from it — not set by hand, per MDN slider-role guidance).',
    },
  },
  max:         {
    type: Number,
    default: 100,
    docs: {
      description:
        'The upper bound of the scale, forwarded to the native input as the `max` attribute (the browser derives `aria-valuemax` from it — not set by hand, per MDN slider-role guidance).',
    },
  },
  step:        {
    type: Number,
    default: 1,
    docs: {
      description:
        'The granularity of the scale, forwarded as the native `step` attribute; every write-back is quantized to it.',
    },
  },

  // 'horizontal' (default) or 'vertical'. Vertical rotates the wrapper -90deg so
  // up = increase, and sets aria-orientation="vertical" explicitly.
  orientation: {
    type: String,
    default: 'horizontal',
    docs: {
      description:
        "Layout orientation — `'horizontal'` (default) or `'vertical'`. Vertical rotates the wrapper `-90deg` so up = increase and sets `aria-orientation=\"vertical\"` explicitly (a native range input always reports itself as horizontal even when visually rotated).",
    },
  },

  disabled:    {
    type: Boolean,
    default: false,
    docs: {
      description:
        'Disable the control — it becomes non-interactive and dimmed. Also sets the Angular `ControlValueAccessor` disabled state.',
    },
  },

  // Tick marks. A union: a bare value[] (positions only) OR a {value,label}[]
  // (positioned, labelled). The #mark scoped slot ({ value, label, position })
  // overrides the default rendering. Marks are a decorative overlay over the
  // track (siblings of the native input).
  marks:       {
    type: Array,
    default: () => [],
    docs: {
      description:
        'Tick marks over the track — either a bare `value[]` (positions only) or a `{ value, label }[]` (positioned and labelled). Rendered as a decorative overlay above the track; override per-mark rendering via the `mark` scoped slot (`{ value, label, position }`).',
    },
  },

  // Accessible name when there is no visible <label for>. Reflected onto each
  // native input's aria-label.
  ariaLabel:   {
    type: String,
    default: null,
    docs: {
      description:
        'Accessible name for each native input when there is no visible `<label for>`, reflected onto the input\'s `aria-label`.',
    },
  },

  // The PageUp/PageDown jump. null → step × 10. Applied by a thin @keydown
  // augment (arrows / Home / End stay native).
  pageStep:    {
    type: Number,
    default: null,
    docs: {
      description:
        'The jump applied on `PageUp` / `PageDown`. `null` falls back to `step × 10`. Applied by a thin `@keydown` augment so it honours this value (native browsers otherwise use their own large step); arrows / `Home` / `End` stay native.',
    },
  },

  // Formats the value shown in the #value bubble and aria-valuetext. null → the
  // raw value. Receives the numeric value, returns a string.
  formatValue: {
    type: Function,
    default: null,
    docs: {
      description:
        'A `(value) => string` formatter for the value shown in the `bubble` slot and surfaced as `aria-valuetext`. Receives the numeric value and returns a string; `null` uses the raw value.',
    },
  },

  // Render the #value bubble overlay (one per thumb in range mode). Headless:
  // no default-styled bubble — the slot stays opt-in.
  showValue:   {
    type: Boolean,
    default: false,
    docs: {
      description:
        'Render the value-bubble overlay (one bubble per thumb in range mode). Headless and opt-in — there is no default-styled bubble; supply the `bubble` slot to control its appearance.',
    },
  },
}
</props>

<data>
{
  // No measured geometry: the fill is pure value/min/max arithmetic (D-06), so
  // there is no transient state to track here.
}
</data>

<script>
// ---- numeric helpers ---------------------------------------------------
// A plain function (not `$computed`) so it reads uniformly across all six
// targets — it is called from both the fill $computed and the keyboard augment.
const pct = (v) => {
  const span = $props.max - $props.min
  if (span === 0) return 0
  const p = (v - $props.min) / span * 100
  if (p < 0) return 0
  if (p > 100) return 100
  return p
}

// The number of decimal places a finite number is written with (e.g. 0.1 -> 1,
// 5 -> 0, -1.25 -> 2). A plain string-based heuristic — every `step`/`min` a
// slider author passes is a simple decimal literal, never exponential
// notation, so this is exact for the values this component actually sees.
const decimalPlaces = (n) => {
  if (!Number.isFinite(n)) return 0
  const s = String(n)
  const i = s.indexOf('.')
  return i === -1 ? 0 : s.length - i - 1
}

// Clamp a raw number into [min,max] and quantize to `step` (guarding against a
// non-finite or zero step). Returns a finite number bounded by the scale.
//
// `$props.min + steps * step` is exact math but NOT exact floating point: e.g.
// step=0.1, steps=3 -> 0.30000000000000004 (binary floats can't represent
// 0.1 precisely). Round the result to the step's (and min's) own decimal
// precision — scaling by 10^places rather than `toFixed` so the model value
// and `aria-valuetext` both report the clean decimal a consumer configured,
// not a float artifact one ULP off.
const clampStep = (raw) => {
  if (!Number.isFinite(raw)) return $props.min
  let v = raw
  if (v < $props.min) v = $props.min
  if (v > $props.max) v = $props.max
  const step = $props.step
  if (Number.isFinite(step) && step > 0) {
    const steps = Math.round((v - $props.min) / step)
    v = $props.min + steps * step
    const places = Math.max(decimalPlaces(step), decimalPlaces($props.min))
    if (places > 0) {
      const factor = Math.pow(10, places)
      v = Math.round(v * factor) / factor
    }
    if (v < $props.min) v = $props.min
    if (v > $props.max) v = $props.max
  }
  return v
}

// The current range pair, defaulting to the full span when `value` is not yet a
// 2-tuple. Read into a stable local before destructuring — `$props.value`
// lowers to a `value()` accessor on Solid, so narrowing one local is uniform.
const rangePair = () => {
  const cur = $props.value
  if (Array.isArray(cur) && cur.length === 2) return [cur[0], cur[1]]
  return [$props.min, $props.max]
}

// The single (scalar) value, defaulting to min when not yet a number.
const singleValue = () => {
  const cur = $props.value
  return typeof cur === 'number' && Number.isFinite(cur) ? cur : $props.min
}

// ---- derived fill (pure $computed → inline CSS vars, D-06/D-07) ---------
// Read BARE in the template via :style="fillStyle". Returns the fill extent as a
// % of the track. The rotate-90 vertical wrapper maps X→Y, so the SAME
// start/end vars drive the (rotated) fill — no separate vertical math.
const fillStyle = $computed(() => {
  let start, end
  if ($props.range) {
    const arr = Array.isArray($props.value) && $props.value.length === 2
      ? $props.value
      : [$props.min, $props.max]
    start = pct(arr[0])
    end = pct(arr[1])
  } else {
    start = 0
    end = pct(typeof $props.value === 'number' && Number.isFinite($props.value) ? $props.value : $props.min)
  }
  return {
    '--rozie-slider-fill-start': start + '%',
    '--rozie-slider-fill-end': end + '%',
  }
})

// The marks list, normalised to { value, label } objects. A bare value[] entry
// becomes { value, label: String(value) }. A plain function (not $computed) so
// it reads uniformly and can be called in the r-for.
const normalizedMarks = () => {
  const list = Array.isArray($props.marks) ? $props.marks : []
  return list.map((m) => {
    if (m !== null && typeof m === 'object' && 'value' in m) {
      return { value: m.value, label: 'label' in m && m.label != null ? m.label : String(m.value) }
    }
    return { value: m, label: String(m) }
  })
}

// Format a value for the bubble / aria-valuetext. A plain function: `$props.x`
// reads uniformly inside it.
const display = (v) => {
  if ($props.formatValue !== null) return $props.formatValue(v)
  return String(v)
}

// ---- write-back (single emit funnel) -----------------------------------
// The SOLE `$emit('change')` site, called from every commit path so the React
// prop-destructure for `onChange` hoists exactly once.
const fireChange = (value) => $emit('change', { value })

// Single-mode commit: capture the fresh number, write the scalar, emit. Never
// re-read $data after the write (ROZ138: React setState is async).
const commitSingle = (raw) => {
  const v = clampStep(raw)
  $model.value = v
  fireChange(v)
}

// Range-mode commit: keep the [lo, hi] array SORTED and clamp each thumb at its
// neighbour, then write a FRESH array (in-place mutation is dropped on
// React/Solid/Lit/Angular change detectors — listbox precedent).
const commitRange = (which, raw) => {
  const pair = rangePair()
  let lo = pair[0]
  let hi = pair[1]
  const v = clampStep(raw)
  if (which === 'lo') lo = Math.min(v, hi)
  else hi = Math.max(v, lo)
  const next = [lo, hi]
  $model.value = next
  fireChange(next)
}

// ---- native input handlers ---------------------------------------------
// Single input. `valueAsNumber` is a number (never the string `.value`).
const onInputSingle = ($event) => commitSingle($event.target.valueAsNumber)
// Range inputs (lo / hi).
const onInputLo = ($event) => commitRange('lo', $event.target.valueAsNumber)
const onInputHi = ($event) => commitRange('hi', $event.target.valueAsNumber)

// ---- PageUp / PageDown augment (Open Q1 / RESEARCH A3) ------------------
// Native PageUp/PageDown uses the browser's default large step, which may not
// equal `pageStep`. Augment ONLY those two keys: apply ±pageStep (null → step×10),
// quantize + clamp via clampStep, write back. Arrows / Home / End stay native.
const effectivePageStep = () => {
  const ps = $props.pageStep
  if (Number.isFinite(ps) && ps > 0) return ps
  const step = Number.isFinite($props.step) && $props.step > 0 ? $props.step : 1
  return step * 10
}

const onKeyDownSingle = ($event) => {
  const key = $event.key
  if (key !== 'PageUp' && key !== 'PageDown') return
  $event.preventDefault()
  const delta = key === 'PageUp' ? effectivePageStep() : -effectivePageStep()
  commitSingle(singleValue() + delta)
}

const onKeyDownRange = (which, $event) => {
  const key = $event.key
  if (key !== 'PageUp' && key !== 'PageDown') return
  $event.preventDefault()
  const delta = key === 'PageUp' ? effectivePageStep() : -effectivePageStep()
  const pair = rangePair()
  const base = which === 'lo' ? pair[0] : pair[1]
  commitRange(which, base + delta)
}

// ---- imperative handle (D-05) ------------------------------------------
// `focus` reads $refs in a post-mount callback (called via the handle) — safe,
// never eager (ROZ123). It DELIBERATELY overrides HTMLElement.focus on Lit
// (ROZ137 warns; accepted — see header).
const focus = () => $refs.inputEl?.focus()

// Step a thumb by ±step. In range mode `thumb` selects 'lo' | 'hi' (default 'lo').
const increment = (thumb) => {
  if ($props.range) {
    const which = thumb === 'hi' ? 'hi' : 'lo'
    const pair = rangePair()
    const base = which === 'lo' ? pair[0] : pair[1]
    commitRange(which, base + $props.step)
  } else {
    commitSingle(singleValue() + $props.step)
  }
}
const decrement = (thumb) => {
  if ($props.range) {
    const which = thumb === 'hi' ? 'hi' : 'lo'
    const pair = rangePair()
    const base = which === 'lo' ? pair[0] : pair[1]
    commitRange(which, base - $props.step)
  } else {
    commitSingle(singleValue() - $props.step)
  }
}

// Shorthand keys (aliased `{ focus: fn }` keys are dropped by the React emitter)
// — every function is named exactly as its verb. `focus` triggers the accepted
// ROZ137 warn.
$expose({ focus, increment, decrement })
</script>

<template>
<div
  class="rozie-slider"
  :class="{ 'rozie-slider--vertical': $props.orientation === 'vertical', 'rozie-slider--horizontal': $props.orientation !== 'vertical', 'rozie-slider--range': $props.range, 'rozie-slider--disabled': $props.disabled }"
  :style="fillStyle"
>
  <!-- The track, with the colored filled-<div> underlay (D-07, z under thumbs).
       Both are positioned via the --rozie-slider-fill-* vars from fillStyle. -->
  <div class="rozie-slider-track" aria-hidden="true">
    <div class="rozie-slider-fill"></div>
  </div>

  <!-- Marks overlay layer (D-04). One decorative marker per mark; the #mark
       scoped slot ({ value, label, position }) overrides the default label.
       Mirrors the listbox #option scoped-slot shape. -->
  <div r-if="normalizedMarks().length > 0" class="rozie-slider-marks" aria-hidden="true">
    <!-- Loop var `mark` == the `#mark` slot name is now AUTO-FIXED by the Svelte
         emitter (Phase 73 item #1): `findRForSlotNameCollisions` detects the
         r-for-loop-var/slot-name shadow and renames the lowered snippet binding
         to `mark$$slot` (author's loop var + slot-arg values untouched), so the
         natural name is safe to author directly — no more hand-workaround. -->
    <div
      r-for="mark in normalizedMarks()"
      :key="mark.value"
      class="rozie-slider-mark"
      :style="{ left: pct(mark.value) + '%' }"
    >
      <slot name="mark" :value="mark.value" :label="mark.label" :position="pct(mark.value)">
        <span class="rozie-slider-mark-label">{{ mark.label }}</span>
      </slot>
    </div>
  </div>

  <!-- Value bubble overlay (D-03), gated by showValue. One bubble per thumb in
       range mode, each positioned at its fill var. Decorative siblings of the
       thumbs (content cannot project into the native thumb pseudo-element).
       The slot is named `bubble`, NOT `value` — a slot sharing a declared prop
       name (`value`) is a hard ROZ127 error (Svelte 5 unifies snippets + props
       into one $props namespace). It still exposes the per-thumb `value`. -->
  <div r-if="$props.showValue && !$props.range" class="rozie-slider-bubbles" aria-hidden="true">
    <div class="rozie-slider-bubble" :style="{ left: 'var(--rozie-slider-fill-end)' }">
      <slot name="bubble" :value="singleValue()">
        <span class="rozie-slider-bubble-text">{{ display(singleValue()) }}</span>
      </slot>
    </div>
  </div>
  <div r-if="$props.showValue && $props.range" class="rozie-slider-bubbles" aria-hidden="true">
    <div class="rozie-slider-bubble" :style="{ left: 'var(--rozie-slider-fill-start)' }">
      <slot name="bubble" :value="rangePair()[0]">
        <span class="rozie-slider-bubble-text">{{ display(rangePair()[0]) }}</span>
      </slot>
    </div>
    <div class="rozie-slider-bubble" :style="{ left: 'var(--rozie-slider-fill-end)' }">
      <slot name="bubble" :value="rangePair()[1]">
        <span class="rozie-slider-bubble-text">{{ display(rangePair()[1]) }}</span>
      </slot>
    </div>
  </div>

  <!-- Single-value native input. Drag / keyboard / role="slider" / aria-value*
       come from the platform; only aria-orientation is set explicitly when
       vertical (the input itself reports horizontal). min/max/step are native
       attrs (NOT aria-valuemin/max — MDN). -->
  <input
    r-if="!$props.range"
    ref="inputEl"
    class="rozie-slider-input"
    type="range"
    :min="$props.min"
    :max="$props.max"
    :step="$props.step"
    :value="singleValue()"
    :disabled="!!$props.disabled"
    :aria-label="$props.ariaLabel"
    :aria-orientation="$props.orientation === 'vertical' ? 'vertical' : 'horizontal'"
    :aria-valuetext="$props.formatValue !== null ? display(singleValue()) : null"
    @input="onInputSingle($event)"
    @keydown="onKeyDownSingle($event)"
  />

  <!-- Range: two overlapping native inputs (D-01 / Pattern 1). `inputEl` ref is
       on the lo thumb for the focus handle. -->
  <input
    r-if="$props.range"
    ref="inputEl"
    class="rozie-slider-input rozie-slider-input--lo"
    type="range"
    :min="$props.min"
    :max="$props.max"
    :step="$props.step"
    :value="rangePair()[0]"
    :disabled="!!$props.disabled"
    :aria-label="$props.ariaLabel"
    :aria-orientation="$props.orientation === 'vertical' ? 'vertical' : 'horizontal'"
    :aria-valuetext="$props.formatValue !== null ? display(rangePair()[0]) : null"
    @input="onInputLo($event)"
    @keydown="onKeyDownRange('lo', $event)"
  />
  <input
    r-if="$props.range"
    class="rozie-slider-input rozie-slider-input--hi"
    type="range"
    :min="$props.min"
    :max="$props.max"
    :step="$props.step"
    :value="rangePair()[1]"
    :disabled="!!$props.disabled"
    :aria-label="$props.ariaLabel"
    :aria-orientation="$props.orientation === 'vertical' ? 'vertical' : 'horizontal'"
    :aria-valuetext="$props.formatValue !== null ? display(rangePair()[1]) : null"
    @input="onInputHi($event)"
    @keydown="onKeyDownRange('hi', $event)"
  />
</div>
</template>

<style>
/*
  Fully token-driven. EVERY visual value is a `var(--rozie-slider-*, <fallback>)`,
  so the component renders with zero configuration yet is completely re-skinnable
  by setting tokens at any ancestor scope (`:root`, `.dark`, a wrapper, or the
  `.rozie-slider` element itself). The shipped `themes/*.css` presets map these
  tokens onto shadcn/Radix, Material 3, and Bootstrap 5.

  STRUCTURAL rules (Approach B overlap, the filled-<div> underlay, the rotate-90
  vertical wrapper, the per-vendor pseudo-elements) are behavior-critical (D-08):
  they compile per-leaf and are NOT consumer-overridable. Token DEFAULTS live in
  themes/base.css; only the cosmetic values flow through tokens.
*/

/* ---- wrapper + track ------------------------------------------------- */
.rozie-slider {
  position: relative;
  display: block;
  box-sizing: border-box;
  width: 100%;
  min-height: var(--rozie-slider-thumb-size, var(--rsl-thumb-size, 1rem));
  padding: var(--rozie-slider-pad, var(--rsl-pad, 0.5rem 0));
  font: var(--rozie-slider-font, inherit);
}

/* The track is a thin centered bar behind the thumbs. */
.rozie-slider-track {
  position: absolute;
  top: 50%;
  left: 0;
  right: 0;
  transform: translateY(-50%);
  height: var(--rozie-slider-track-height, var(--rsl-track-height, 0.375rem));
  border-radius: var(--rozie-slider-track-radius, var(--rsl-track-radius, 999px));
  background: var(--rozie-slider-track-bg, var(--rsl-track-bg, rgba(0, 0, 0, 0.18)));
  pointer-events: none;
}

/* The colored fill is a positioned <div> underlay (D-07), NOT a gradient track.
   It spans from --fill-start to --fill-end (both % of the track). */
.rozie-slider-fill {
  position: absolute;
  top: 0;
  bottom: 0;
  left: var(--rozie-slider-fill-start, 0%);
  right: calc(100% - var(--rozie-slider-fill-end, 0%));
  border-radius: inherit;
  background: var(--rozie-slider-fill-bg, var(--rozie-slider-accent, var(--rsl-fill-bg, var(--rsl-accent, #0066cc))));
}

/* ---- overlapping native inputs (D-08, Approach B) -------------------- */
/* Transparent tracks; clicks pass through the bar; only the thumb pseudo-
   elements catch pointer events; the focused input is raised so its thumb wins. */
.rozie-slider-input {
  -webkit-appearance: none;
  appearance: none;
  position: absolute;
  top: 50%;
  left: 0;
  transform: translateY(-50%);
  width: 100%;
  height: var(--rozie-slider-thumb-size, var(--rsl-thumb-size, 1rem));
  margin: 0;
  background: none;
  pointer-events: none;
  cursor: pointer;
  accent-color: var(--rozie-slider-accent, var(--rsl-accent, #0066cc));
}
.rozie-slider-input:focus { outline: none; z-index: 2; }
.rozie-slider--range .rozie-slider-input { pointer-events: none; }
.rozie-slider--disabled .rozie-slider-input { cursor: not-allowed; }
.rozie-slider--disabled { opacity: var(--rozie-slider-disabled-opacity, var(--rsl-disabled-opacity, 0.55)); }

/* Transparent native tracks — the filled <div> shows through. Each vendor
   pseudo-element is in its OWN rule block: comma-combining them drops the whole
   selector on both browsers (Pitfall 2). */
.rozie-slider-input::-webkit-slider-runnable-track {
  background: none;
  height: var(--rozie-slider-track-height, var(--rsl-track-height, 0.375rem));
}
.rozie-slider-input::-moz-range-track {
  background: none;
  height: var(--rozie-slider-track-height, var(--rsl-track-height, 0.375rem));
}
/* Firefox draws a progress fill on the native track — suppress it (we own the
   fill via the <div> underlay). */
.rozie-slider-input::-moz-range-progress {
  background: none;
}

/* Thumb pseudo-elements re-enable pointer events and carry the token styling.
   SEPARATE blocks per vendor (never comma-joined). WebKit needs an explicit
   -webkit-appearance:none and a margin-top to vertically center the thumb. */
.rozie-slider-input::-webkit-slider-thumb {
  -webkit-appearance: none;
  appearance: none;
  pointer-events: auto;
  width: var(--rozie-slider-thumb-size, var(--rsl-thumb-size, 1rem));
  height: var(--rozie-slider-thumb-size, var(--rsl-thumb-size, 1rem));
  border: var(--rozie-slider-thumb-border, var(--rsl-thumb-border, 2px solid #fff));
  border-radius: 50%;
  background: var(--rozie-slider-thumb-bg, var(--rozie-slider-accent, var(--rsl-thumb-bg, var(--rsl-accent, #0066cc))));
  box-shadow: var(--rozie-slider-thumb-shadow, var(--rsl-thumb-shadow, 0 1px 3px rgba(0, 0, 0, 0.3)));
  margin-top: var(--rozie-slider-thumb-offset, var(--rsl-thumb-offset, calc((0.375rem - 1rem) / 2)));
  cursor: pointer;
}
.rozie-slider-input::-moz-range-thumb {
  pointer-events: auto;
  width: var(--rozie-slider-thumb-size, var(--rsl-thumb-size, 1rem));
  height: var(--rozie-slider-thumb-size, var(--rsl-thumb-size, 1rem));
  border: var(--rozie-slider-thumb-border, var(--rsl-thumb-border, 2px solid #fff));
  border-radius: 50%;
  background: var(--rozie-slider-thumb-bg, var(--rozie-slider-accent, var(--rsl-thumb-bg, var(--rsl-accent, #0066cc))));
  box-shadow: var(--rozie-slider-thumb-shadow, var(--rsl-thumb-shadow, 0 1px 3px rgba(0, 0, 0, 0.3)));
  cursor: pointer;
}

/* ---- vertical (rotate-90 wrapper, Pattern 3) ------------------------- */
/* Swap the box to a tall, narrow column; rotate the (still-horizontal) input
   -90deg so up = increase. transform-origin centers the rotation in the column. */
.rozie-slider--vertical {
  width: var(--rozie-slider-thickness, var(--rsl-thickness, 2.5rem));
  height: var(--rozie-slider-length, var(--rsl-length, 12rem));
  padding: 0;
}
.rozie-slider--vertical .rozie-slider-track,
.rozie-slider--vertical .rozie-slider-input {
  top: 50%;
  left: 50%;
  width: var(--rozie-slider-length, var(--rsl-length, 12rem));
  transform: translate(-50%, -50%) rotate(-90deg);
  transform-origin: center center;
}
.rozie-slider--vertical .rozie-slider-fill {
  /* The fill still spans start→end along the (now rotated) input axis. */
}
.rozie-slider--vertical .rozie-slider-marks,
.rozie-slider--vertical .rozie-slider-bubbles {
  /* Overlays follow the rotated axis; left:%-of-length maps to the visual Y. */
  top: 50%;
  left: 50%;
  width: var(--rozie-slider-length, var(--rsl-length, 12rem));
  transform: translate(-50%, -50%) rotate(-90deg);
  transform-origin: center center;
}

/* ---- marks overlay --------------------------------------------------- */
.rozie-slider-marks {
  position: absolute;
  top: 50%;
  left: 0;
  right: 0;
  height: 0;
  pointer-events: none;
}
.rozie-slider-mark {
  position: absolute;
  top: 0;
  transform: translateX(-50%);
  color: var(--rozie-slider-mark-color, var(--rsl-mark-color, rgba(0, 0, 0, 0.55)));
}
.rozie-slider-mark-label {
  position: absolute;
  top: var(--rozie-slider-mark-offset, var(--rsl-mark-offset, 0.75rem));
  left: 50%;
  transform: translateX(-50%);
  font-size: var(--rozie-slider-mark-font-size, var(--rsl-mark-font-size, 0.6875rem));
  white-space: nowrap;
}

/* ---- value bubble overlay ------------------------------------------- */
.rozie-slider-bubbles {
  position: absolute;
  top: 0;
  left: 0;
  right: 0;
  height: 0;
  pointer-events: none;
}
.rozie-slider-bubble {
  position: absolute;
  top: var(--rozie-slider-bubble-offset, var(--rsl-bubble-offset, -1.25rem));
  transform: translateX(-50%);
}
.rozie-slider-bubble-text {
  display: inline-block;
  padding: var(--rozie-slider-bubble-padding, var(--rsl-bubble-padding, 0.0625rem 0.375rem));
  font-size: var(--rozie-slider-bubble-font-size, var(--rsl-bubble-font-size, 0.6875rem));
  color: var(--rozie-slider-bubble-fg, var(--rsl-bubble-fg, #fff));
  background: var(--rozie-slider-bubble-bg, var(--rozie-slider-accent, var(--rsl-bubble-bg, var(--rsl-accent, #0066cc))));
  border-radius: var(--rozie-slider-bubble-radius, var(--rsl-bubble-radius, 4px));
  white-space: nowrap;
}
</style>

</rozie>

…and Rozie compiles it to six framework-native components. Switch the tabs to see the actual generated output for each target (this is exactly what ships in @rozie-ui/slider-{react,vue,svelte,angular,solid,lit}):

tsx
import { forwardRef, useCallback, useImperativeHandle, useMemo, useRef, useState } from 'react';
import type { ReactNode } from 'react';
import { clsx, parseInlineStyle, rozieAttr, rozieDisplay, useControllableState } from '@rozie/runtime-react';
import './Slider.css';

interface MarkCtx { value: any; label: any; position: any; }

interface BubbleCtx { value: any; }

interface SliderProps extends Omit<import('react').ComponentPropsWithoutRef<'div'>, 'value' | 'defaultValue' | 'onValueChange' | 'range' | 'min' | 'max' | 'step' | 'orientation' | 'disabled' | 'marks' | 'ariaLabel' | 'pageStep' | 'formatValue' | 'showValue' | 'onChange' | 'renderMark' | 'renderBubble' | 'slots' | 'children' | 'dangerouslySetInnerHTML'> {
  /**
   * The current value (two-way `r-model`). A scalar number in single mode; a sorted `[lo, hi]` array in `range` mode, with each thumb neighbour-clamped so the pair stays sorted on every commit. As the sole `model: true` prop it drives the Angular `ControlValueAccessor`, so a Slider **is** a form control (`[(ngModel)]` / `[formControl]` bind directly).
   * @example
   * <Slider value={volume} onValueChange={setVolume} min={0} max={100} step={1} ariaLabel="Volume" />
   */
  value?: (unknown) | null;
  defaultValue?: (unknown) | null;
  onValueChange?: (value: (unknown) | null) => void;
  /**
   * Switch to dual-thumb range mode: `value` becomes a sorted `[lo, hi]` array driven by two overlapping native inputs. The exact analog of listbox's `multiple` (scalar↔array). A bare attribute (`<Slider range>`) coerces to `true`.
   */
  range?: boolean;
  /**
   * The lower bound of the scale, forwarded to the native input as the `min` attribute (the browser derives `aria-valuemin` from it — not set by hand, per MDN slider-role guidance).
   */
  min?: number;
  /**
   * The upper bound of the scale, forwarded to the native input as the `max` attribute (the browser derives `aria-valuemax` from it — not set by hand, per MDN slider-role guidance).
   */
  max?: number;
  /**
   * The granularity of the scale, forwarded as the native `step` attribute; every write-back is quantized to it.
   */
  step?: number;
  /**
   * Layout orientation — `'horizontal'` (default) or `'vertical'`. Vertical rotates the wrapper `-90deg` so up = increase and sets `aria-orientation="vertical"` explicitly (a native range input always reports itself as horizontal even when visually rotated).
   */
  orientation?: string;
  /**
   * Disable the control — it becomes non-interactive and dimmed. Also sets the Angular `ControlValueAccessor` disabled state.
   */
  disabled?: boolean;
  /**
   * Tick marks over the track — either a bare `value[]` (positions only) or a `{ value, label }[]` (positioned and labelled). Rendered as a decorative overlay above the track; override per-mark rendering via the `mark` scoped slot (`{ value, label, position }`).
   */
  marks?: any[];
  /**
   * Accessible name for each native input when there is no visible `<label for>`, reflected onto the input's `aria-label`.
   */
  ariaLabel?: (string) | null;
  /**
   * The jump applied on `PageUp` / `PageDown`. `null` falls back to `step × 10`. Applied by a thin `@keydown` augment so it honours this value (native browsers otherwise use their own large step); arrows / `Home` / `End` stay native.
   */
  pageStep?: (number) | null;
  /**
   * A `(value) => string` formatter for the value shown in the `bubble` slot and surfaced as `aria-valuetext`. Receives the numeric value and returns a string; `null` uses the raw value.
   */
  formatValue?: ((...args: any[]) => any) | null;
  /**
   * Render the value-bubble overlay (one bubble per thumb in range mode). Headless and opt-in — there is no default-styled bubble; supply the `bubble` slot to control its appearance.
   */
  showValue?: boolean;
  onChange?: (...args: any[]) => void;
  renderMark?: (ctx: MarkCtx) => ReactNode;
  renderBubble?: (ctx: BubbleCtx) => ReactNode;
  slots?: Record<string, () => import('react').ReactNode>;
}

export interface SliderHandle {
  focus: (...args: any[]) => any;
  increment: (...args: any[]) => any;
  decrement: (...args: any[]) => any;
}

const Slider = forwardRef<SliderHandle, SliderProps>(function Slider(_props: SliderProps, ref): JSX.Element {
  const __defaultMarks = useState(() => (() => [])())[0];
  const props: Omit<SliderProps, 'range' | 'min' | 'max' | 'step' | 'orientation' | 'disabled' | 'marks' | 'ariaLabel' | 'pageStep' | 'formatValue' | 'showValue'> & { range: boolean; min: number; max: number; step: number; orientation: string; disabled: boolean; marks: any[]; ariaLabel: (string) | null; pageStep: (number) | null; formatValue: ((...args: any[]) => any) | null; showValue: boolean } = {
    ..._props,
    range: _props.range ?? false,
    min: _props.min ?? 0,
    max: _props.max ?? 100,
    step: _props.step ?? 1,
    orientation: _props.orientation ?? 'horizontal',
    disabled: _props.disabled ?? false,
    marks: _props.marks ?? __defaultMarks,
    ariaLabel: _props.ariaLabel ?? null,
    pageStep: _props.pageStep ?? null,
    formatValue: _props.formatValue ?? null,
    showValue: _props.showValue ?? false,
  };
  const attrs: Record<string, unknown> = (() => {
    const { value, range, min, max, step, orientation, disabled, marks, ariaLabel, pageStep, formatValue, showValue, defaultValue, onValueChange, onChange, ...rest } = _props as SliderProps & Record<string, unknown>;
    void value; void range; void min; void max; void step; void orientation; void disabled; void marks; void ariaLabel; void pageStep; void formatValue; void showValue; void defaultValue; void onValueChange; void onChange;
    return rest;
  })();
  const [value, setValue] = useControllableState({
    value: props.value,
    defaultValue: props.defaultValue ?? null,
    onValueChange: props.onValueChange,
  });
  const inputEl = useRef<HTMLInputElement | null>(null);
  const fillStyle = useMemo(() => {
    let start, end;
    if (props.range) {
      const arr = Array.isArray(value) && value.length === 2 ? value : [props.min, props.max];
      start = pct(arr[0]);
      end = pct(arr[1]);
    } else {
      start = 0;
      end = pct(typeof value === 'number' && Number.isFinite(value) ? value : props.min);
    }
    return {
      '--rozie-slider-fill-start': start + '%',
      '--rozie-slider-fill-end': end + '%'
    };
  }, [Array, Number, pct, props.max, props.min, props.range, value]);

  // ---- numeric helpers ---------------------------------------------------
  // A plain function (not `$computed`) so it reads uniformly across all six
  // targets — it is called from both the fill $computed and the keyboard augment.
  function pct(v: any) {
    const span = props.max - props.min;
    if (span === 0) return 0;
    const p = (v - props.min) / span * 100;
    if (p < 0) return 0;
    if (p > 100) return 100;
    return p;
  }

  // The number of decimal places a finite number is written with (e.g. 0.1 -> 1,
  // 5 -> 0, -1.25 -> 2). A plain string-based heuristic — every `step`/`min` a
  // slider author passes is a simple decimal literal, never exponential
  // notation, so this is exact for the values this component actually sees.
  function decimalPlaces(n: any) {
    if (!Number.isFinite(n)) return 0;
    const s = String(n);
    const i = s.indexOf('.');
    return i === -1 ? 0 : s.length - i - 1;
  }

  // Clamp a raw number into [min,max] and quantize to `step` (guarding against a
  // non-finite or zero step). Returns a finite number bounded by the scale.
  //
  // `$props.min + steps * step` is exact math but NOT exact floating point: e.g.
  // step=0.1, steps=3 -> 0.30000000000000004 (binary floats can't represent
  // 0.1 precisely). Round the result to the step's (and min's) own decimal
  // precision — scaling by 10^places rather than `toFixed` so the model value
  // and `aria-valuetext` both report the clean decimal a consumer configured,
  // not a float artifact one ULP off.
  function clampStep(raw: any) {
    if (!Number.isFinite(raw)) return props.min;
    let v = raw;
    if (v < props.min) v = props.min;
    if (v > props.max) v = props.max;
    const step = props.step;
    if (Number.isFinite(step) && step > 0) {
      const steps = Math.round((v - props.min) / step);
      v = props.min + steps * step;
      const places = Math.max(decimalPlaces(step), decimalPlaces(props.min));
      if (places > 0) {
        const factor = Math.pow(10, places);
        v = Math.round(v * factor) / factor;
      }
      if (v < props.min) v = props.min;
      if (v > props.max) v = props.max;
    }
    return v;
  }

  // The current range pair, defaulting to the full span when `value` is not yet a
  // 2-tuple. Read into a stable local before destructuring — `$props.value`
  // lowers to a `value()` accessor on Solid, so narrowing one local is uniform.
  function rangePair() {
    const cur = value;
    if (Array.isArray(cur) && cur.length === 2) return [cur[0], cur[1]];
    return [props.min, props.max];
  }

  // The single (scalar) value, defaulting to min when not yet a number.
  function singleValue() {
    const cur = value;
    return typeof cur === 'number' && Number.isFinite(cur) ? cur : props.min;
  }

  // ---- derived fill (pure $computed → inline CSS vars, D-06/D-07) ---------
  // Read BARE in the template via :style="fillStyle". Returns the fill extent as a
  // % of the track. The rotate-90 vertical wrapper maps X→Y, so the SAME
  // start/end vars drive the (rotated) fill — no separate vertical math.
  // The marks list, normalised to { value, label } objects. A bare value[] entry
  // becomes { value, label: String(value) }. A plain function (not $computed) so
  // it reads uniformly and can be called in the r-for.
  function normalizedMarks() {
    const list = Array.isArray(props.marks) ? props.marks : [];
    return list.map((m: any) => {
      if (m !== null && typeof m === 'object' && 'value' in m) {
        return {
          value: m.value,
          label: 'label' in m && m.label != null ? m.label : String(m.value)
        };
      }
      return {
        value: m,
        label: String(m)
      };
    });
  }

  // Format a value for the bubble / aria-valuetext. A plain function: `$props.x`
  // reads uniformly inside it.
  function display(v: any) {
    if (props.formatValue !== null) return props.formatValue(v);
    return String(v);
  }

  // ---- write-back (single emit funnel) -----------------------------------
  // The SOLE `$emit('change')` site, called from every commit path so the React
  // prop-destructure for `onChange` hoists exactly once.
  function fireChange(value: any) {
    return props.onChange && props.onChange({
      value
    });
  }

  // Single-mode commit: capture the fresh number, write the scalar, emit. Never
  // re-read $data after the write (ROZ138: React setState is async).
  function commitSingle(raw: any) {
    const v = clampStep(raw);
    setValue(v);
    fireChange(v);
  }

  // Range-mode commit: keep the [lo, hi] array SORTED and clamp each thumb at its
  // neighbour, then write a FRESH array (in-place mutation is dropped on
  // React/Solid/Lit/Angular change detectors — listbox precedent).
  function commitRange(which: any, raw: any) {
    const pair = rangePair();
    let lo = pair[0];
    let hi = pair[1];
    const v = clampStep(raw);
    if (which === 'lo') lo = Math.min(v, hi);else hi = Math.max(v, lo);
    const next = [lo, hi];
    setValue(next);
    fireChange(next);
  }

  // ---- native input handlers ---------------------------------------------
  // Single input. `valueAsNumber` is a number (never the string `.value`).
  const onInputSingle = useCallback(($event: any) => commitSingle($event.target.valueAsNumber), [commitSingle]);
  // Range inputs (lo / hi).
  const onInputLo = useCallback(($event: any) => commitRange('lo', $event.target.valueAsNumber), [commitRange]);
  const onInputHi = useCallback(($event: any) => commitRange('hi', $event.target.valueAsNumber), [commitRange]);
  // ---- PageUp / PageDown augment (Open Q1 / RESEARCH A3) ------------------
  // Native PageUp/PageDown uses the browser's default large step, which may not
  // equal `pageStep`. Augment ONLY those two keys: apply ±pageStep (null → step×10),
  // quantize + clamp via clampStep, write back. Arrows / Home / End stay native.
  function effectivePageStep() {
    const ps = props.pageStep;
    if (Number.isFinite(ps) && ps > 0) return ps;
    const step = Number.isFinite(props.step) && props.step > 0 ? props.step : 1;
    return step * 10;
  }
  const onKeyDownSingle = useCallback(($event: any) => {
    const key = $event.key;
    if (key !== 'PageUp' && key !== 'PageDown') return;
    $event.preventDefault();
    const delta = key === 'PageUp' ? effectivePageStep() : -effectivePageStep();
    commitSingle(singleValue() + delta);
  }, [commitSingle, effectivePageStep, singleValue]);
  const onKeyDownRange = useCallback((which: any, $event: any) => {
    const key = $event.key;
    if (key !== 'PageUp' && key !== 'PageDown') return;
    $event.preventDefault();
    const delta = key === 'PageUp' ? effectivePageStep() : -effectivePageStep();
    const pair = rangePair();
    const base = which === 'lo' ? pair[0] : pair[1];
    commitRange(which, base + delta);
  }, [commitRange, effectivePageStep, rangePair]);
  // ---- imperative handle (D-05) ------------------------------------------
  // `focus` reads $refs in a post-mount callback (called via the handle) — safe,
  // never eager (ROZ123). It DELIBERATELY overrides HTMLElement.focus on Lit
  // (ROZ137 warns; accepted — see header).
  function focus() {
    return inputEl.current?.focus();
  }

  // Step a thumb by ±step. In range mode `thumb` selects 'lo' | 'hi' (default 'lo').
  function increment(thumb: any) {
    if (props.range) {
      const which = thumb === 'hi' ? 'hi' : 'lo';
      const pair = rangePair();
      const base = which === 'lo' ? pair[0] : pair[1];
      commitRange(which, base + props.step);
    } else {
      commitSingle(singleValue() + props.step);
    }
  }
  function decrement(thumb: any) {
    if (props.range) {
      const which = thumb === 'hi' ? 'hi' : 'lo';
      const pair = rangePair();
      const base = which === 'lo' ? pair[0] : pair[1];
      commitRange(which, base - props.step);
    } else {
      commitSingle(singleValue() - props.step);
    }
  }

  // Shorthand keys (aliased `{ focus: fn }` keys are dropped by the React emitter)
  // — every function is named exactly as its verb. `focus` triggers the accepted
  // ROZ137 warn.

  const _rozieExposeRef = useRef({ focus, increment, decrement });
  _rozieExposeRef.current = { focus, increment, decrement };
  useImperativeHandle(ref, () => ({ focus: (...args: Parameters<typeof focus>): ReturnType<typeof focus> => _rozieExposeRef.current.focus(...args), increment: (...args: Parameters<typeof increment>): ReturnType<typeof increment> => _rozieExposeRef.current.increment(...args), decrement: (...args: Parameters<typeof decrement>): ReturnType<typeof decrement> => _rozieExposeRef.current.decrement(...args) }), []);

  return (
    <>
    <div style={parseInlineStyle(fillStyle)} {...attrs} className={clsx(clsx("rozie-slider", { "rozie-slider--vertical": props.orientation === 'vertical', "rozie-slider--horizontal": props.orientation !== 'vertical', "rozie-slider--range": props.range, "rozie-slider--disabled": props.disabled }), (attrs.className as string | undefined))} data-rozie-s-4e6f0be6="">
      
      <div className={"rozie-slider-track"} aria-hidden="true" data-rozie-s-4e6f0be6="">
        <div className={"rozie-slider-fill"} data-rozie-s-4e6f0be6="" />
      </div>

      
      {!!(normalizedMarks().length > 0) && <div className={"rozie-slider-marks"} aria-hidden="true" data-rozie-s-4e6f0be6="">
        
        {normalizedMarks().map((mark) => <div key={mark.value} className={"rozie-slider-mark"} style={{ left: pct(mark.value) + '%' }} data-rozie-s-4e6f0be6="">
          {(props.renderMark ?? props.slots?.['mark']) ? ((props.renderMark ?? props.slots?.['mark']) as Function)({ value: mark.value, label: mark.label, position: pct(mark.value) }) : <span className={"rozie-slider-mark-label"} data-rozie-s-4e6f0be6="">{rozieDisplay(mark.label)}</span>}
        </div>)}
      </div>}{!!(props.showValue && !props.range) && <div className={"rozie-slider-bubbles"} aria-hidden="true" data-rozie-s-4e6f0be6="">
        <div className={"rozie-slider-bubble"} style={{ left: 'var(--rozie-slider-fill-end)' }} data-rozie-s-4e6f0be6="">
          {(props.renderBubble ?? props.slots?.['bubble']) ? ((props.renderBubble ?? props.slots?.['bubble']) as Function)({ value: singleValue() }) : <span className={"rozie-slider-bubble-text"} data-rozie-s-4e6f0be6="">{rozieDisplay(display(singleValue()))}</span>}
        </div>
      </div>}{!!(props.showValue && props.range) && <div className={"rozie-slider-bubbles"} aria-hidden="true" data-rozie-s-4e6f0be6="">
        <div className={"rozie-slider-bubble"} style={{ left: 'var(--rozie-slider-fill-start)' }} data-rozie-s-4e6f0be6="">
          {(props.renderBubble ?? props.slots?.['bubble']) ? ((props.renderBubble ?? props.slots?.['bubble']) as Function)({ value: rangePair()[0] }) : <span className={"rozie-slider-bubble-text"} data-rozie-s-4e6f0be6="">{rozieDisplay(display(rangePair()[0]))}</span>}
        </div>
        <div className={"rozie-slider-bubble"} style={{ left: 'var(--rozie-slider-fill-end)' }} data-rozie-s-4e6f0be6="">
          {(props.renderBubble ?? props.slots?.['bubble']) ? ((props.renderBubble ?? props.slots?.['bubble']) as Function)({ value: rangePair()[1] }) : <span className={"rozie-slider-bubble-text"} data-rozie-s-4e6f0be6="">{rozieDisplay(display(rangePair()[1]))}</span>}
        </div>
      </div>}{!!(!props.range) && <input ref={inputEl} className={"rozie-slider-input"} type="range" min={props.min} max={props.max} step={props.step} value={singleValue()} disabled={!!props.disabled} aria-label={rozieAttr(props.ariaLabel)} aria-orientation={rozieAttr(props.orientation === 'vertical' ? 'vertical' : 'horizontal')} aria-valuetext={rozieAttr(props.formatValue !== null ? display(singleValue()) : undefined)} onInput={($event) => { onInputSingle($event); }} onKeyDown={($event) => { onKeyDownSingle($event); }} data-rozie-s-4e6f0be6="" />}{!!(props.range) && <input ref={inputEl} className={"rozie-slider-input rozie-slider-input--lo"} type="range" min={props.min} max={props.max} step={props.step} value={rangePair()[0]} disabled={!!props.disabled} aria-label={rozieAttr(props.ariaLabel)} aria-orientation={rozieAttr(props.orientation === 'vertical' ? 'vertical' : 'horizontal')} aria-valuetext={rozieAttr(props.formatValue !== null ? display(rangePair()[0]) : undefined)} onInput={($event) => { onInputLo($event); }} onKeyDown={($event) => { onKeyDownRange('lo', $event); }} data-rozie-s-4e6f0be6="" />}{!!(props.range) && <input className={"rozie-slider-input rozie-slider-input--hi"} type="range" min={props.min} max={props.max} step={props.step} value={rangePair()[1]} disabled={!!props.disabled} aria-label={rozieAttr(props.ariaLabel)} aria-orientation={rozieAttr(props.orientation === 'vertical' ? 'vertical' : 'horizontal')} aria-valuetext={rozieAttr(props.formatValue !== null ? display(rangePair()[1]) : undefined)} onInput={($event) => { onInputHi($event); }} onKeyDown={($event) => { onKeyDownRange('hi', $event); }} data-rozie-s-4e6f0be6="" />}</div>
    </>
  );
});
export default Slider;
vue
<template>

<div :class="['rozie-slider', { 'rozie-slider--vertical': props.orientation === 'vertical', 'rozie-slider--horizontal': props.orientation !== 'vertical', 'rozie-slider--range': props.range, 'rozie-slider--disabled': props.disabled }]" :style="fillStyle" v-bind="$attrs">
  
  <div class="rozie-slider-track" aria-hidden="true">
    <div class="rozie-slider-fill"></div>
  </div>

  
  <div v-if="normalizedMarks().length > 0" class="rozie-slider-marks" aria-hidden="true">
    
    <div v-for="mark in normalizedMarks()" :key="mark.value" class="rozie-slider-mark" :style="{ left: pct(mark.value) + '%' }">
      <slot name="mark" :value="mark.value" :label="mark.label" :position="pct(mark.value)">
        <span class="rozie-slider-mark-label">{{ mark.label }}</span>
      </slot>
    </div>
  </div><div v-if="props.showValue && !props.range" class="rozie-slider-bubbles" aria-hidden="true">
    <div class="rozie-slider-bubble" :style="{ left: 'var(--rozie-slider-fill-end)' }">
      <slot name="bubble" :value="singleValue()">
        <span class="rozie-slider-bubble-text">{{ display(singleValue()) }}</span>
      </slot>
    </div>
  </div><div v-if="props.showValue && props.range" class="rozie-slider-bubbles" aria-hidden="true">
    <div class="rozie-slider-bubble" :style="{ left: 'var(--rozie-slider-fill-start)' }">
      <slot name="bubble" :value="rangePair()[0]">
        <span class="rozie-slider-bubble-text">{{ display(rangePair()[0]) }}</span>
      </slot>
    </div>
    <div class="rozie-slider-bubble" :style="{ left: 'var(--rozie-slider-fill-end)' }">
      <slot name="bubble" :value="rangePair()[1]">
        <span class="rozie-slider-bubble-text">{{ display(rangePair()[1]) }}</span>
      </slot>
    </div>
  </div><input v-if="!props.range" ref="inputElRef" class="rozie-slider-input" type="range" :min="props.min" :max="props.max" :step="props.step" :value="singleValue()" :disabled="!!props.disabled" :aria-label="props.ariaLabel" :aria-orientation="props.orientation === 'vertical' ? 'vertical' : 'horizontal'" :aria-valuetext="props.formatValue !== null ? display(singleValue()) : undefined" @input="onInputSingle($event)" @keydown="onKeyDownSingle($event)" /><input v-if="props.range" ref="inputElRef" class="rozie-slider-input rozie-slider-input--lo" type="range" :min="props.min" :max="props.max" :step="props.step" :value="rangePair()[0]" :disabled="!!props.disabled" :aria-label="props.ariaLabel" :aria-orientation="props.orientation === 'vertical' ? 'vertical' : 'horizontal'" :aria-valuetext="props.formatValue !== null ? display(rangePair()[0]) : undefined" @input="onInputLo($event)" @keydown="onKeyDownRange('lo', $event)" /><input v-if="props.range" class="rozie-slider-input rozie-slider-input--hi" type="range" :min="props.min" :max="props.max" :step="props.step" :value="rangePair()[1]" :disabled="!!props.disabled" :aria-label="props.ariaLabel" :aria-orientation="props.orientation === 'vertical' ? 'vertical' : 'horizontal'" :aria-valuetext="props.formatValue !== null ? display(rangePair()[1]) : undefined" @input="onInputHi($event)" @keydown="onKeyDownRange('hi', $event)" /></div>

</template>

<script setup lang="ts">
import { computed, ref } from 'vue';

const props = withDefaults(
  defineProps<{
    /**
     * Switch to dual-thumb range mode: `value` becomes a sorted `[lo, hi]` array driven by two overlapping native inputs. The exact analog of listbox's `multiple` (scalar↔array). A bare attribute (`<Slider range>`) coerces to `true`.
     */
    range?: boolean;
    /**
     * The lower bound of the scale, forwarded to the native input as the `min` attribute (the browser derives `aria-valuemin` from it — not set by hand, per MDN slider-role guidance).
     */
    min?: number;
    /**
     * The upper bound of the scale, forwarded to the native input as the `max` attribute (the browser derives `aria-valuemax` from it — not set by hand, per MDN slider-role guidance).
     */
    max?: number;
    /**
     * The granularity of the scale, forwarded as the native `step` attribute; every write-back is quantized to it.
     */
    step?: number;
    /**
     * Layout orientation — `'horizontal'` (default) or `'vertical'`. Vertical rotates the wrapper `-90deg` so up = increase and sets `aria-orientation="vertical"` explicitly (a native range input always reports itself as horizontal even when visually rotated).
     */
    orientation?: string;
    /**
     * Disable the control — it becomes non-interactive and dimmed. Also sets the Angular `ControlValueAccessor` disabled state.
     */
    disabled?: boolean;
    /**
     * Tick marks over the track — either a bare `value[]` (positions only) or a `{ value, label }[]` (positioned and labelled). Rendered as a decorative overlay above the track; override per-mark rendering via the `mark` scoped slot (`{ value, label, position }`).
     */
    marks?: any[];
    /**
     * Accessible name for each native input when there is no visible `<label for>`, reflected onto the input's `aria-label`.
     */
    ariaLabel?: string | null;
    /**
     * The jump applied on `PageUp` / `PageDown`. `null` falls back to `step × 10`. Applied by a thin `@keydown` augment so it honours this value (native browsers otherwise use their own large step); arrows / `Home` / `End` stay native.
     */
    pageStep?: number | null;
    /**
     * A `(value) => string` formatter for the value shown in the `bubble` slot and surfaced as `aria-valuetext`. Receives the numeric value and returns a string; `null` uses the raw value.
     */
    formatValue?: ((...args: any[]) => any) | null;
    /**
     * Render the value-bubble overlay (one bubble per thumb in range mode). Headless and opt-in — there is no default-styled bubble; supply the `bubble` slot to control its appearance.
     */
    showValue?: boolean;
  }>(),
  { range: false, min: 0, max: 100, step: 1, orientation: 'horizontal', disabled: false, marks: () => [], ariaLabel: null, pageStep: null, formatValue: null, showValue: false }
);

/**
 * The current value (two-way `r-model`). A scalar number in single mode; a sorted `[lo, hi]` array in `range` mode, with each thumb neighbour-clamped so the pair stays sorted on every commit. As the sole `model: true` prop it drives the Angular `ControlValueAccessor`, so a Slider **is** a form control (`[(ngModel)]` / `[formControl]` bind directly).
 * @example
 * <Slider v-model:value="volume" :min="0" :max="100" :step="1" ariaLabel="Volume" />
 */
const value = defineModel<unknown | null>('value', { default: null });

const emit = defineEmits<{
  change: [...args: any[]];
}>();

defineSlots<{
  mark(props: { value: any; label: any; position: any }): any;
  bubble(props: { value: any }): any;
  bubble(props: { value: any }): any;
  bubble(props: { value: any }): any;
}>();

const inputElRef = ref<HTMLInputElement>();

const fillStyle = computed(() => {
  let start, end;
  if (props.range) {
    const arr = Array.isArray(value.value) && value.value.length === 2 ? value.value : [props.min, props.max];
    start = pct(arr[0]);
    end = pct(arr[1]);
  } else {
    start = 0;
    end = pct(typeof value.value === 'number' && Number.isFinite(value.value) ? value.value : props.min);
  }
  return {
    '--rozie-slider-fill-start': start + '%',
    '--rozie-slider-fill-end': end + '%'
  };
});

// ---- numeric helpers ---------------------------------------------------
// A plain function (not `$computed`) so it reads uniformly across all six
// targets — it is called from both the fill $computed and the keyboard augment.
const pct = (v: any) => {
  const span = props.max - props.min;
  if (span === 0) return 0;
  const p = (v - props.min) / span * 100;
  if (p < 0) return 0;
  if (p > 100) return 100;
  return p;
};
// The number of decimal places a finite number is written with (e.g. 0.1 -> 1,
// 5 -> 0, -1.25 -> 2). A plain string-based heuristic — every `step`/`min` a
// slider author passes is a simple decimal literal, never exponential
// notation, so this is exact for the values this component actually sees.
const decimalPlaces = (n: any) => {
  if (!Number.isFinite(n)) return 0;
  const s = String(n);
  const i = s.indexOf('.');
  return i === -1 ? 0 : s.length - i - 1;
};
// Clamp a raw number into [min,max] and quantize to `step` (guarding against a
// non-finite or zero step). Returns a finite number bounded by the scale.
//
// `$props.min + steps * step` is exact math but NOT exact floating point: e.g.
// step=0.1, steps=3 -> 0.30000000000000004 (binary floats can't represent
// 0.1 precisely). Round the result to the step's (and min's) own decimal
// precision — scaling by 10^places rather than `toFixed` so the model value
// and `aria-valuetext` both report the clean decimal a consumer configured,
// not a float artifact one ULP off.
const clampStep = (raw: any) => {
  if (!Number.isFinite(raw)) return props.min;
  let v = raw;
  if (v < props.min) v = props.min;
  if (v > props.max) v = props.max;
  const step = props.step;
  if (Number.isFinite(step) && step > 0) {
    const steps = Math.round((v - props.min) / step);
    v = props.min + steps * step;
    const places = Math.max(decimalPlaces(step), decimalPlaces(props.min));
    if (places > 0) {
      const factor = Math.pow(10, places);
      v = Math.round(v * factor) / factor;
    }
    if (v < props.min) v = props.min;
    if (v > props.max) v = props.max;
  }
  return v;
};
// The current range pair, defaulting to the full span when `value` is not yet a
// 2-tuple. Read into a stable local before destructuring — `$props.value`
// lowers to a `value()` accessor on Solid, so narrowing one local is uniform.
const rangePair = () => {
  const cur = value.value;
  if (Array.isArray(cur) && cur.length === 2) return [cur[0], cur[1]];
  return [props.min, props.max];
};
// The single (scalar) value, defaulting to min when not yet a number.
const singleValue = () => {
  const cur = value.value;
  return typeof cur === 'number' && Number.isFinite(cur) ? cur : props.min;
};

// ---- derived fill (pure $computed → inline CSS vars, D-06/D-07) ---------
// Read BARE in the template via :style="fillStyle". Returns the fill extent as a
// % of the track. The rotate-90 vertical wrapper maps X→Y, so the SAME
// start/end vars drive the (rotated) fill — no separate vertical math.
// The marks list, normalised to { value, label } objects. A bare value[] entry
// becomes { value, label: String(value) }. A plain function (not $computed) so
// it reads uniformly and can be called in the r-for.
const normalizedMarks = () => {
  const list = Array.isArray(props.marks) ? props.marks : [];
  return list.map((m: any) => {
    if (m !== null && typeof m === 'object' && 'value' in m) {
      return {
        value: m.value,
        label: 'label' in m && m.label != null ? m.label : String(m.value)
      };
    }
    return {
      value: m,
      label: String(m)
    };
  });
};
// Format a value for the bubble / aria-valuetext. A plain function: `$props.x`
// reads uniformly inside it.
const display = (v: any) => {
  if (props.formatValue !== null) return props.formatValue(v);
  return String(v);
};
// ---- write-back (single emit funnel) -----------------------------------
// The SOLE `$emit('change')` site, called from every commit path so the React
// prop-destructure for `onChange` hoists exactly once.
const fireChange = (value: any) => emit('change', {
  value
});
// Single-mode commit: capture the fresh number, write the scalar, emit. Never
// re-read $data after the write (ROZ138: React setState is async).
const commitSingle = (raw: any) => {
  const v = clampStep(raw);
  value.value = v;
  fireChange(v);
};
// Range-mode commit: keep the [lo, hi] array SORTED and clamp each thumb at its
// neighbour, then write a FRESH array (in-place mutation is dropped on
// React/Solid/Lit/Angular change detectors — listbox precedent).
const commitRange = (which: any, raw: any) => {
  const pair = rangePair();
  let lo = pair[0];
  let hi = pair[1];
  const v = clampStep(raw);
  if (which === 'lo') lo = Math.min(v, hi);else hi = Math.max(v, lo);
  const next = [lo, hi];
  value.value = next;
  fireChange(next);
};
// ---- native input handlers ---------------------------------------------
// Single input. `valueAsNumber` is a number (never the string `.value`).
const onInputSingle = ($event: any) => commitSingle($event.target.valueAsNumber);
// Range inputs (lo / hi).
const onInputLo = ($event: any) => commitRange('lo', $event.target.valueAsNumber);
const onInputHi = ($event: any) => commitRange('hi', $event.target.valueAsNumber);
// ---- PageUp / PageDown augment (Open Q1 / RESEARCH A3) ------------------
// Native PageUp/PageDown uses the browser's default large step, which may not
// equal `pageStep`. Augment ONLY those two keys: apply ±pageStep (null → step×10),
// quantize + clamp via clampStep, write back. Arrows / Home / End stay native.
const effectivePageStep = () => {
  const ps = props.pageStep;
  if (Number.isFinite(ps) && ps > 0) return ps;
  const step = Number.isFinite(props.step) && props.step > 0 ? props.step : 1;
  return step * 10;
};
const onKeyDownSingle = ($event: any) => {
  const key = $event.key;
  if (key !== 'PageUp' && key !== 'PageDown') return;
  $event.preventDefault();
  const delta = key === 'PageUp' ? effectivePageStep() : -effectivePageStep();
  commitSingle(singleValue() + delta);
};
const onKeyDownRange = (which: any, $event: any) => {
  const key = $event.key;
  if (key !== 'PageUp' && key !== 'PageDown') return;
  $event.preventDefault();
  const delta = key === 'PageUp' ? effectivePageStep() : -effectivePageStep();
  const pair = rangePair();
  const base = which === 'lo' ? pair[0] : pair[1];
  commitRange(which, base + delta);
};
// ---- imperative handle (D-05) ------------------------------------------
// `focus` reads $refs in a post-mount callback (called via the handle) — safe,
// never eager (ROZ123). It DELIBERATELY overrides HTMLElement.focus on Lit
// (ROZ137 warns; accepted — see header).
const focus = () => inputElRef.value?.focus();
// Step a thumb by ±step. In range mode `thumb` selects 'lo' | 'hi' (default 'lo').
const increment = (thumb: any) => {
  if (props.range) {
    const which = thumb === 'hi' ? 'hi' : 'lo';
    const pair = rangePair();
    const base = which === 'lo' ? pair[0] : pair[1];
    commitRange(which, base + props.step);
  } else {
    commitSingle(singleValue() + props.step);
  }
};
const decrement = (thumb: any) => {
  if (props.range) {
    const which = thumb === 'hi' ? 'hi' : 'lo';
    const pair = rangePair();
    const base = which === 'lo' ? pair[0] : pair[1];
    commitRange(which, base - props.step);
  } else {
    commitSingle(singleValue() - props.step);
  }
};

// Shorthand keys (aliased `{ focus: fn }` keys are dropped by the React emitter)
// — every function is named exactly as its verb. `focus` triggers the accepted
// ROZ137 warn.

defineExpose({ focus, increment, decrement });
</script>

<style scoped>
.rozie-slider {
  position: relative;
  display: block;
  box-sizing: border-box;
  width: 100%;
  min-height: var(--rozie-slider-thumb-size, var(--rsl-thumb-size, 1rem));
  padding: var(--rozie-slider-pad, var(--rsl-pad, 0.5rem 0));
  font: var(--rozie-slider-font, inherit);
}
.rozie-slider-track {
  position: absolute;
  top: 50%;
  left: 0;
  right: 0;
  transform: translateY(-50%);
  height: var(--rozie-slider-track-height, var(--rsl-track-height, 0.375rem));
  border-radius: var(--rozie-slider-track-radius, var(--rsl-track-radius, 999px));
  background: var(--rozie-slider-track-bg, var(--rsl-track-bg, rgba(0, 0, 0, 0.18)));
  pointer-events: none;
}
.rozie-slider-fill {
  position: absolute;
  top: 0;
  bottom: 0;
  left: var(--rozie-slider-fill-start, 0%);
  right: calc(100% - var(--rozie-slider-fill-end, 0%));
  border-radius: inherit;
  background: var(--rozie-slider-fill-bg, var(--rozie-slider-accent, var(--rsl-fill-bg, var(--rsl-accent, #0066cc))));
}
.rozie-slider-input {
  -webkit-appearance: none;
  appearance: none;
  position: absolute;
  top: 50%;
  left: 0;
  transform: translateY(-50%);
  width: 100%;
  height: var(--rozie-slider-thumb-size, var(--rsl-thumb-size, 1rem));
  margin: 0;
  background: none;
  pointer-events: none;
  cursor: pointer;
  accent-color: var(--rozie-slider-accent, var(--rsl-accent, #0066cc));
}
.rozie-slider-input:focus { outline: none; z-index: 2; }
.rozie-slider--range .rozie-slider-input { pointer-events: none; }
.rozie-slider--disabled .rozie-slider-input { cursor: not-allowed; }
.rozie-slider--disabled { opacity: var(--rozie-slider-disabled-opacity, var(--rsl-disabled-opacity, 0.55)); }
.rozie-slider-input::-webkit-slider-runnable-track {
  background: none;
  height: var(--rozie-slider-track-height, var(--rsl-track-height, 0.375rem));
}
.rozie-slider-input::-moz-range-track {
  background: none;
  height: var(--rozie-slider-track-height, var(--rsl-track-height, 0.375rem));
}
.rozie-slider-input::-moz-range-progress {
  background: none;
}
.rozie-slider-input::-webkit-slider-thumb {
  -webkit-appearance: none;
  appearance: none;
  pointer-events: auto;
  width: var(--rozie-slider-thumb-size, var(--rsl-thumb-size, 1rem));
  height: var(--rozie-slider-thumb-size, var(--rsl-thumb-size, 1rem));
  border: var(--rozie-slider-thumb-border, var(--rsl-thumb-border, 2px solid #fff));
  border-radius: 50%;
  background: var(--rozie-slider-thumb-bg, var(--rozie-slider-accent, var(--rsl-thumb-bg, var(--rsl-accent, #0066cc))));
  box-shadow: var(--rozie-slider-thumb-shadow, var(--rsl-thumb-shadow, 0 1px 3px rgba(0, 0, 0, 0.3)));
  margin-top: var(--rozie-slider-thumb-offset, var(--rsl-thumb-offset, calc((0.375rem - 1rem) / 2)));
  cursor: pointer;
}
.rozie-slider-input::-moz-range-thumb {
  pointer-events: auto;
  width: var(--rozie-slider-thumb-size, var(--rsl-thumb-size, 1rem));
  height: var(--rozie-slider-thumb-size, var(--rsl-thumb-size, 1rem));
  border: var(--rozie-slider-thumb-border, var(--rsl-thumb-border, 2px solid #fff));
  border-radius: 50%;
  background: var(--rozie-slider-thumb-bg, var(--rozie-slider-accent, var(--rsl-thumb-bg, var(--rsl-accent, #0066cc))));
  box-shadow: var(--rozie-slider-thumb-shadow, var(--rsl-thumb-shadow, 0 1px 3px rgba(0, 0, 0, 0.3)));
  cursor: pointer;
}
.rozie-slider--vertical {
  width: var(--rozie-slider-thickness, var(--rsl-thickness, 2.5rem));
  height: var(--rozie-slider-length, var(--rsl-length, 12rem));
  padding: 0;
}
.rozie-slider--vertical .rozie-slider-track,
.rozie-slider--vertical .rozie-slider-input {
  top: 50%;
  left: 50%;
  width: var(--rozie-slider-length, var(--rsl-length, 12rem));
  transform: translate(-50%, -50%) rotate(-90deg);
  transform-origin: center center;
}
.rozie-slider--vertical .rozie-slider-fill {
  /* The fill still spans start→end along the (now rotated) input axis. */
}
.rozie-slider--vertical .rozie-slider-marks,
.rozie-slider--vertical .rozie-slider-bubbles {
  /* Overlays follow the rotated axis; left:%-of-length maps to the visual Y. */
  top: 50%;
  left: 50%;
  width: var(--rozie-slider-length, var(--rsl-length, 12rem));
  transform: translate(-50%, -50%) rotate(-90deg);
  transform-origin: center center;
}
.rozie-slider-marks {
  position: absolute;
  top: 50%;
  left: 0;
  right: 0;
  height: 0;
  pointer-events: none;
}
.rozie-slider-mark {
  position: absolute;
  top: 0;
  transform: translateX(-50%);
  color: var(--rozie-slider-mark-color, var(--rsl-mark-color, rgba(0, 0, 0, 0.55)));
}
.rozie-slider-mark-label {
  position: absolute;
  top: var(--rozie-slider-mark-offset, var(--rsl-mark-offset, 0.75rem));
  left: 50%;
  transform: translateX(-50%);
  font-size: var(--rozie-slider-mark-font-size, var(--rsl-mark-font-size, 0.6875rem));
  white-space: nowrap;
}
.rozie-slider-bubbles {
  position: absolute;
  top: 0;
  left: 0;
  right: 0;
  height: 0;
  pointer-events: none;
}
.rozie-slider-bubble {
  position: absolute;
  top: var(--rozie-slider-bubble-offset, var(--rsl-bubble-offset, -1.25rem));
  transform: translateX(-50%);
}
.rozie-slider-bubble-text {
  display: inline-block;
  padding: var(--rozie-slider-bubble-padding, var(--rsl-bubble-padding, 0.0625rem 0.375rem));
  font-size: var(--rozie-slider-bubble-font-size, var(--rsl-bubble-font-size, 0.6875rem));
  color: var(--rozie-slider-bubble-fg, var(--rsl-bubble-fg, #fff));
  background: var(--rozie-slider-bubble-bg, var(--rozie-slider-accent, var(--rsl-bubble-bg, var(--rsl-accent, #0066cc))));
  border-radius: var(--rozie-slider-bubble-radius, var(--rsl-bubble-radius, 4px));
  white-space: nowrap;
}
</style>
svelte
<script lang="ts">
import { applyListeners, rozieAttr, rozieDisplay, rozieStyle } from '@rozie/runtime-svelte';

import type { Snippet } from 'svelte';

interface Props extends Omit<import('svelte/elements').SvelteHTMLElements['div'], 'value' | 'range' | 'min' | 'max' | 'step' | 'orientation' | 'disabled' | 'marks' | 'ariaLabel' | 'pageStep' | 'formatValue' | 'showValue' | 'mark' | 'bubble' | 'snippets' | 'onchange' | 'children'> {
  /**
   * The current value (two-way `r-model`). A scalar number in single mode; a sorted `[lo, hi]` array in `range` mode, with each thumb neighbour-clamped so the pair stays sorted on every commit. As the sole `model: true` prop it drives the Angular `ControlValueAccessor`, so a Slider **is** a form control (`[(ngModel)]` / `[formControl]` bind directly).
   * @example
   * <Slider bind:value={volume} min={0} max={100} step={1} ariaLabel="Volume" />
   */
  value?: (unknown) | null;
  /**
   * Switch to dual-thumb range mode: `value` becomes a sorted `[lo, hi]` array driven by two overlapping native inputs. The exact analog of listbox's `multiple` (scalar↔array). A bare attribute (`<Slider range>`) coerces to `true`.
   */
  range?: boolean;
  /**
   * The lower bound of the scale, forwarded to the native input as the `min` attribute (the browser derives `aria-valuemin` from it — not set by hand, per MDN slider-role guidance).
   */
  min?: number;
  /**
   * The upper bound of the scale, forwarded to the native input as the `max` attribute (the browser derives `aria-valuemax` from it — not set by hand, per MDN slider-role guidance).
   */
  max?: number;
  /**
   * The granularity of the scale, forwarded as the native `step` attribute; every write-back is quantized to it.
   */
  step?: number;
  /**
   * Layout orientation — `'horizontal'` (default) or `'vertical'`. Vertical rotates the wrapper `-90deg` so up = increase and sets `aria-orientation="vertical"` explicitly (a native range input always reports itself as horizontal even when visually rotated).
   */
  orientation?: string;
  /**
   * Disable the control — it becomes non-interactive and dimmed. Also sets the Angular `ControlValueAccessor` disabled state.
   */
  disabled?: boolean;
  /**
   * Tick marks over the track — either a bare `value[]` (positions only) or a `{ value, label }[]` (positioned and labelled). Rendered as a decorative overlay above the track; override per-mark rendering via the `mark` scoped slot (`{ value, label, position }`).
   */
  marks?: any[];
  /**
   * Accessible name for each native input when there is no visible `<label for>`, reflected onto the input's `aria-label`.
   */
  ariaLabel?: (string) | null;
  /**
   * The jump applied on `PageUp` / `PageDown`. `null` falls back to `step × 10`. Applied by a thin `@keydown` augment so it honours this value (native browsers otherwise use their own large step); arrows / `Home` / `End` stay native.
   */
  pageStep?: (number) | null;
  /**
   * A `(value) => string` formatter for the value shown in the `bubble` slot and surfaced as `aria-valuetext`. Receives the numeric value and returns a string; `null` uses the raw value.
   */
  formatValue?: ((...args: any[]) => any) | null;
  /**
   * Render the value-bubble overlay (one bubble per thumb in range mode). Headless and opt-in — there is no default-styled bubble; supply the `bubble` slot to control its appearance.
   */
  showValue?: boolean;
  mark?: Snippet<[{ value: any; label: any; position: any }]>;
  bubble?: Snippet<[{ value: any }]>;
  snippets?: Record<string, any>;
  onchange?: (...args: any[]) => void;
}

let __defaultMarks = (() => [])();

let {
  value = $bindable(null),
  range = false,
  min = 0,
  max = 100,
  step = 1,
  orientation = 'horizontal',
  disabled = false,
  marks = __defaultMarks,
  ariaLabel = null,
  pageStep = null,
  formatValue = null,
  showValue = false,
  mark: __markProp,
  bubble: __bubbleProp,
  snippets,
  onchange,
  ...__rozieAttrs
}: Props = $props();

const mark$$slot = $derived(__markProp ?? snippets?.mark);
const bubble = $derived(__bubbleProp ?? snippets?.bubble);

let inputEl = $state<HTMLInputElement | undefined>(undefined);

// ---- numeric helpers ---------------------------------------------------
// A plain function (not `$computed`) so it reads uniformly across all six
// targets — it is called from both the fill $computed and the keyboard augment.
const pct = (v: any) => {
  const span = max - min;
  if (span === 0) return 0;
  const p = (v - min) / span * 100;
  if (p < 0) return 0;
  if (p > 100) return 100;
  return p;
};
// The number of decimal places a finite number is written with (e.g. 0.1 -> 1,
// 5 -> 0, -1.25 -> 2). A plain string-based heuristic — every `step`/`min` a
// slider author passes is a simple decimal literal, never exponential
// notation, so this is exact for the values this component actually sees.
const decimalPlaces = (n: any) => {
  if (!Number.isFinite(n)) return 0;
  const s = String(n);
  const i = s.indexOf('.');
  return i === -1 ? 0 : s.length - i - 1;
};
// Clamp a raw number into [min,max] and quantize to `step` (guarding against a
// non-finite or zero step). Returns a finite number bounded by the scale.
//
// `$props.min + steps * step` is exact math but NOT exact floating point: e.g.
// step=0.1, steps=3 -> 0.30000000000000004 (binary floats can't represent
// 0.1 precisely). Round the result to the step's (and min's) own decimal
// precision — scaling by 10^places rather than `toFixed` so the model value
// and `aria-valuetext` both report the clean decimal a consumer configured,
// not a float artifact one ULP off.
const clampStep = (raw: any) => {
  if (!Number.isFinite(raw)) return min;
  let v = raw;
  if (v < min) v = min;
  if (v > max) v = max;
  const step$local = step;
  if (Number.isFinite(step$local) && step$local > 0) {
    const steps = Math.round((v - min) / step$local);
    v = min + steps * step$local;
    const places = Math.max(decimalPlaces(step$local), decimalPlaces(min));
    if (places > 0) {
      const factor = Math.pow(10, places);
      v = Math.round(v * factor) / factor;
    }
    if (v < min) v = min;
    if (v > max) v = max;
  }
  return v;
};
// The current range pair, defaulting to the full span when `value` is not yet a
// 2-tuple. Read into a stable local before destructuring — `$props.value`
// lowers to a `value()` accessor on Solid, so narrowing one local is uniform.
const rangePair = () => {
  const cur = value;
  if (Array.isArray(cur) && cur.length === 2) return [cur[0], cur[1]];
  return [min, max];
};
// The single (scalar) value, defaulting to min when not yet a number.
const singleValue = () => {
  const cur = value;
  return typeof cur === 'number' && Number.isFinite(cur) ? cur : min;
};

// ---- derived fill (pure $computed → inline CSS vars, D-06/D-07) ---------
// Read BARE in the template via :style="fillStyle". Returns the fill extent as a
// % of the track. The rotate-90 vertical wrapper maps X→Y, so the SAME
// start/end vars drive the (rotated) fill — no separate vertical math.
// The marks list, normalised to { value, label } objects. A bare value[] entry
// becomes { value, label: String(value) }. A plain function (not $computed) so
// it reads uniformly and can be called in the r-for.
const normalizedMarks = () => {
  const list = Array.isArray(marks) ? marks : [];
  return list.map((m: any) => {
    if (m !== null && typeof m === 'object' && 'value' in m) {
      return {
        value: m.value,
        label: 'label' in m && m.label != null ? m.label : String(m.value)
      };
    }
    return {
      value: m,
      label: String(m)
    };
  });
};
// Format a value for the bubble / aria-valuetext. A plain function: `$props.x`
// reads uniformly inside it.
const display = (v: any) => {
  if (formatValue !== null) return formatValue(v);
  return String(v);
};
// ---- write-back (single emit funnel) -----------------------------------
// The SOLE `$emit('change')` site, called from every commit path so the React
// prop-destructure for `onChange` hoists exactly once.
const fireChange = (value: any) => onchange?.({
  value
});
// Single-mode commit: capture the fresh number, write the scalar, emit. Never
// re-read $data after the write (ROZ138: React setState is async).
const commitSingle = (raw: any) => {
  const v = clampStep(raw);
  value = v;
  fireChange(v);
};
// Range-mode commit: keep the [lo, hi] array SORTED and clamp each thumb at its
// neighbour, then write a FRESH array (in-place mutation is dropped on
// React/Solid/Lit/Angular change detectors — listbox precedent).
const commitRange = (which: any, raw: any) => {
  const pair = rangePair();
  let lo = pair[0];
  let hi = pair[1];
  const v = clampStep(raw);
  if (which === 'lo') lo = Math.min(v, hi);else hi = Math.max(v, lo);
  const next = [lo, hi];
  value = next;
  fireChange(next);
};
// ---- native input handlers ---------------------------------------------
// Single input. `valueAsNumber` is a number (never the string `.value`).
const onInputSingle = ($event: any) => commitSingle($event.target.valueAsNumber);
// Range inputs (lo / hi).
const onInputLo = ($event: any) => commitRange('lo', $event.target.valueAsNumber);
const onInputHi = ($event: any) => commitRange('hi', $event.target.valueAsNumber);
// ---- PageUp / PageDown augment (Open Q1 / RESEARCH A3) ------------------
// Native PageUp/PageDown uses the browser's default large step, which may not
// equal `pageStep`. Augment ONLY those two keys: apply ±pageStep (null → step×10),
// quantize + clamp via clampStep, write back. Arrows / Home / End stay native.
const effectivePageStep = () => {
  const ps = pageStep;
  if (Number.isFinite(ps) && ps > 0) return ps;
  const step$local = Number.isFinite(step) && step > 0 ? step : 1;
  return step$local * 10;
};
const onKeyDownSingle = ($event: any) => {
  const key = $event.key;
  if (key !== 'PageUp' && key !== 'PageDown') return;
  $event.preventDefault();
  const delta = key === 'PageUp' ? effectivePageStep() : -effectivePageStep();
  commitSingle(singleValue() + delta);
};
const onKeyDownRange = (which: any, $event: any) => {
  const key = $event.key;
  if (key !== 'PageUp' && key !== 'PageDown') return;
  $event.preventDefault();
  const delta = key === 'PageUp' ? effectivePageStep() : -effectivePageStep();
  const pair = rangePair();
  const base = which === 'lo' ? pair[0] : pair[1];
  commitRange(which, base + delta);
};
// ---- imperative handle (D-05) ------------------------------------------
// `focus` reads $refs in a post-mount callback (called via the handle) — safe,
// never eager (ROZ123). It DELIBERATELY overrides HTMLElement.focus on Lit
// (ROZ137 warns; accepted — see header).
export const focus = () => inputEl?.focus();
// Step a thumb by ±step. In range mode `thumb` selects 'lo' | 'hi' (default 'lo').
export const increment = (thumb: any) => {
  if (range) {
    const which = thumb === 'hi' ? 'hi' : 'lo';
    const pair = rangePair();
    const base = which === 'lo' ? pair[0] : pair[1];
    commitRange(which, base + step);
  } else {
    commitSingle(singleValue() + step);
  }
};
export const decrement = (thumb: any) => {
  if (range) {
    const which = thumb === 'hi' ? 'hi' : 'lo';
    const pair = rangePair();
    const base = which === 'lo' ? pair[0] : pair[1];
    commitRange(which, base - step);
  } else {
    commitSingle(singleValue() - step);
  }
};

// Shorthand keys (aliased `{ focus: fn }` keys are dropped by the React emitter)
// — every function is named exactly as its verb. `focus` triggers the accepted
// ROZ137 warn.

const fillStyle = $derived.by(() => {
  let start, end;
  if (range) {
    const arr = Array.isArray(value) && value.length === 2 ? value : [min, max];
    start = pct(arr[0]);
    end = pct(arr[1]);
  } else {
    start = 0;
    end = pct(typeof value === 'number' && Number.isFinite(value) ? value : min);
  }
  return {
    '--rozie-slider-fill-start': start + '%',
    '--rozie-slider-fill-end': end + '%'
  };
});
</script>

<div style={rozieStyle(fillStyle)} {...__rozieAttrs} class={["rozie-slider", { 'rozie-slider--vertical': orientation === 'vertical', 'rozie-slider--horizontal': orientation !== 'vertical', 'rozie-slider--range': range, 'rozie-slider--disabled': disabled }, (__rozieAttrs)?.class]} use:applyListeners={__rozieAttrs} data-rozie-s-4e6f0be6><div class="rozie-slider-track" aria-hidden="true" data-rozie-s-4e6f0be6><div class="rozie-slider-fill" data-rozie-s-4e6f0be6></div></div>{#if normalizedMarks().length > 0}<div class="rozie-slider-marks" aria-hidden="true" data-rozie-s-4e6f0be6>{#each normalizedMarks() as mark (mark.value)}<div class="rozie-slider-mark" style:left={pct(mark.value) + '%'} data-rozie-s-4e6f0be6>{#if mark$$slot}{@render mark$$slot({ value: mark.value, label: mark.label, position: pct(mark.value) })}{:else}<span class="rozie-slider-mark-label" data-rozie-s-4e6f0be6>{rozieDisplay(mark.label)}</span>{/if}</div>{/each}</div>{/if}{#if showValue && !range}<div class="rozie-slider-bubbles" aria-hidden="true" data-rozie-s-4e6f0be6><div class="rozie-slider-bubble" style:left={'var(--rozie-slider-fill-end)'} data-rozie-s-4e6f0be6>{#if bubble}{@render bubble({ value: singleValue() })}{:else}<span class="rozie-slider-bubble-text" data-rozie-s-4e6f0be6>{rozieDisplay(display(singleValue()))}</span>{/if}</div></div>{/if}{#if showValue && range}<div class="rozie-slider-bubbles" aria-hidden="true" data-rozie-s-4e6f0be6><div class="rozie-slider-bubble" style:left={'var(--rozie-slider-fill-start)'} data-rozie-s-4e6f0be6>{#if bubble}{@render bubble({ value: rangePair()[0] })}{:else}<span class="rozie-slider-bubble-text" data-rozie-s-4e6f0be6>{rozieDisplay(display(rangePair()[0]))}</span>{/if}</div><div class="rozie-slider-bubble" style:left={'var(--rozie-slider-fill-end)'} data-rozie-s-4e6f0be6>{#if bubble}{@render bubble({ value: rangePair()[1] })}{:else}<span class="rozie-slider-bubble-text" data-rozie-s-4e6f0be6>{rozieDisplay(display(rangePair()[1]))}</span>{/if}</div></div>{/if}{#if !range}<input bind:this={inputEl} class="rozie-slider-input" type="range" min={min} max={max} step={step} value={singleValue()} disabled={!!disabled} aria-label={ariaLabel} aria-orientation={rozieAttr(orientation === 'vertical' ? 'vertical' : 'horizontal')} aria-valuetext={rozieAttr(formatValue !== null ? display(singleValue()) : null)} oninput={($event) => { onInputSingle($event); }} onkeydown={($event) => { onKeyDownSingle($event); }} data-rozie-s-4e6f0be6 />{/if}{#if range}<input bind:this={inputEl} class="rozie-slider-input rozie-slider-input--lo" type="range" min={min} max={max} step={step} value={rangePair()[0]} disabled={!!disabled} aria-label={ariaLabel} aria-orientation={rozieAttr(orientation === 'vertical' ? 'vertical' : 'horizontal')} aria-valuetext={rozieAttr(formatValue !== null ? display(rangePair()[0]) : null)} oninput={($event) => { onInputLo($event); }} onkeydown={($event) => { onKeyDownRange('lo', $event); }} data-rozie-s-4e6f0be6 />{/if}{#if range}<input class="rozie-slider-input rozie-slider-input--hi" type="range" min={min} max={max} step={step} value={rangePair()[1]} disabled={!!disabled} aria-label={ariaLabel} aria-orientation={rozieAttr(orientation === 'vertical' ? 'vertical' : 'horizontal')} aria-valuetext={rozieAttr(formatValue !== null ? display(rangePair()[1]) : null)} oninput={($event) => { onInputHi($event); }} onkeydown={($event) => { onKeyDownRange('hi', $event); }} data-rozie-s-4e6f0be6 />{/if}</div>

<style>
:global {
  .rozie-slider[data-rozie-s-4e6f0be6] {
    position: relative;
    display: block;
    box-sizing: border-box;
    width: 100%;
    min-height: var(--rozie-slider-thumb-size, var(--rsl-thumb-size, 1rem));
    padding: var(--rozie-slider-pad, var(--rsl-pad, 0.5rem 0));
    font: var(--rozie-slider-font, inherit);
  }
  .rozie-slider-track[data-rozie-s-4e6f0be6] {
    position: absolute;
    top: 50%;
    left: 0;
    right: 0;
    transform: translateY(-50%);
    height: var(--rozie-slider-track-height, var(--rsl-track-height, 0.375rem));
    border-radius: var(--rozie-slider-track-radius, var(--rsl-track-radius, 999px));
    background: var(--rozie-slider-track-bg, var(--rsl-track-bg, rgba(0, 0, 0, 0.18)));
    pointer-events: none;
  }
  .rozie-slider-fill[data-rozie-s-4e6f0be6] {
    position: absolute;
    top: 0;
    bottom: 0;
    left: var(--rozie-slider-fill-start, 0%);
    right: calc(100% - var(--rozie-slider-fill-end, 0%));
    border-radius: inherit;
    background: var(--rozie-slider-fill-bg, var(--rozie-slider-accent, var(--rsl-fill-bg, var(--rsl-accent, #0066cc))));
  }
  .rozie-slider-input[data-rozie-s-4e6f0be6] {
    -webkit-appearance: none;
    appearance: none;
    position: absolute;
    top: 50%;
    left: 0;
    transform: translateY(-50%);
    width: 100%;
    height: var(--rozie-slider-thumb-size, var(--rsl-thumb-size, 1rem));
    margin: 0;
    background: none;
    pointer-events: none;
    cursor: pointer;
    accent-color: var(--rozie-slider-accent, var(--rsl-accent, #0066cc));
  }
  .rozie-slider-input[data-rozie-s-4e6f0be6]:focus { outline: none; z-index: 2; }
  .rozie-slider--range[data-rozie-s-4e6f0be6] .rozie-slider-input[data-rozie-s-4e6f0be6] { pointer-events: none; }
  .rozie-slider--disabled[data-rozie-s-4e6f0be6] .rozie-slider-input[data-rozie-s-4e6f0be6] { cursor: not-allowed; }
  .rozie-slider--disabled[data-rozie-s-4e6f0be6] { opacity: var(--rozie-slider-disabled-opacity, var(--rsl-disabled-opacity, 0.55)); }
  .rozie-slider-input[data-rozie-s-4e6f0be6]::-webkit-slider-runnable-track {
    background: none;
    height: var(--rozie-slider-track-height, var(--rsl-track-height, 0.375rem));
  }
  .rozie-slider-input[data-rozie-s-4e6f0be6]::-moz-range-track {
    background: none;
    height: var(--rozie-slider-track-height, var(--rsl-track-height, 0.375rem));
  }
  .rozie-slider-input[data-rozie-s-4e6f0be6]::-moz-range-progress {
    background: none;
  }
  .rozie-slider-input[data-rozie-s-4e6f0be6]::-webkit-slider-thumb {
    -webkit-appearance: none;
    appearance: none;
    pointer-events: auto;
    width: var(--rozie-slider-thumb-size, var(--rsl-thumb-size, 1rem));
    height: var(--rozie-slider-thumb-size, var(--rsl-thumb-size, 1rem));
    border: var(--rozie-slider-thumb-border, var(--rsl-thumb-border, 2px solid #fff));
    border-radius: 50%;
    background: var(--rozie-slider-thumb-bg, var(--rozie-slider-accent, var(--rsl-thumb-bg, var(--rsl-accent, #0066cc))));
    box-shadow: var(--rozie-slider-thumb-shadow, var(--rsl-thumb-shadow, 0 1px 3px rgba(0, 0, 0, 0.3)));
    margin-top: var(--rozie-slider-thumb-offset, var(--rsl-thumb-offset, calc((0.375rem - 1rem) / 2)));
    cursor: pointer;
  }
  .rozie-slider-input[data-rozie-s-4e6f0be6]::-moz-range-thumb {
    pointer-events: auto;
    width: var(--rozie-slider-thumb-size, var(--rsl-thumb-size, 1rem));
    height: var(--rozie-slider-thumb-size, var(--rsl-thumb-size, 1rem));
    border: var(--rozie-slider-thumb-border, var(--rsl-thumb-border, 2px solid #fff));
    border-radius: 50%;
    background: var(--rozie-slider-thumb-bg, var(--rozie-slider-accent, var(--rsl-thumb-bg, var(--rsl-accent, #0066cc))));
    box-shadow: var(--rozie-slider-thumb-shadow, var(--rsl-thumb-shadow, 0 1px 3px rgba(0, 0, 0, 0.3)));
    cursor: pointer;
  }
  .rozie-slider--vertical[data-rozie-s-4e6f0be6] {
    width: var(--rozie-slider-thickness, var(--rsl-thickness, 2.5rem));
    height: var(--rozie-slider-length, var(--rsl-length, 12rem));
    padding: 0;
  }
  .rozie-slider--vertical[data-rozie-s-4e6f0be6] .rozie-slider-track[data-rozie-s-4e6f0be6],
  .rozie-slider--vertical[data-rozie-s-4e6f0be6] .rozie-slider-input[data-rozie-s-4e6f0be6] {
    top: 50%;
    left: 50%;
    width: var(--rozie-slider-length, var(--rsl-length, 12rem));
    transform: translate(-50%, -50%) rotate(-90deg);
    transform-origin: center center;
  }
  .rozie-slider--vertical[data-rozie-s-4e6f0be6] .rozie-slider-fill[data-rozie-s-4e6f0be6] {
    /* The fill still spans start→end along the (now rotated) input axis. */
  }
  .rozie-slider--vertical[data-rozie-s-4e6f0be6] .rozie-slider-marks[data-rozie-s-4e6f0be6],
  .rozie-slider--vertical[data-rozie-s-4e6f0be6] .rozie-slider-bubbles[data-rozie-s-4e6f0be6] {
    /* Overlays follow the rotated axis; left:%-of-length maps to the visual Y. */
    top: 50%;
    left: 50%;
    width: var(--rozie-slider-length, var(--rsl-length, 12rem));
    transform: translate(-50%, -50%) rotate(-90deg);
    transform-origin: center center;
  }
  .rozie-slider-marks[data-rozie-s-4e6f0be6] {
    position: absolute;
    top: 50%;
    left: 0;
    right: 0;
    height: 0;
    pointer-events: none;
  }
  .rozie-slider-mark[data-rozie-s-4e6f0be6] {
    position: absolute;
    top: 0;
    transform: translateX(-50%);
    color: var(--rozie-slider-mark-color, var(--rsl-mark-color, rgba(0, 0, 0, 0.55)));
  }
  .rozie-slider-mark-label[data-rozie-s-4e6f0be6] {
    position: absolute;
    top: var(--rozie-slider-mark-offset, var(--rsl-mark-offset, 0.75rem));
    left: 50%;
    transform: translateX(-50%);
    font-size: var(--rozie-slider-mark-font-size, var(--rsl-mark-font-size, 0.6875rem));
    white-space: nowrap;
  }
  .rozie-slider-bubbles[data-rozie-s-4e6f0be6] {
    position: absolute;
    top: 0;
    left: 0;
    right: 0;
    height: 0;
    pointer-events: none;
  }
  .rozie-slider-bubble[data-rozie-s-4e6f0be6] {
    position: absolute;
    top: var(--rozie-slider-bubble-offset, var(--rsl-bubble-offset, -1.25rem));
    transform: translateX(-50%);
  }
  .rozie-slider-bubble-text[data-rozie-s-4e6f0be6] {
    display: inline-block;
    padding: var(--rozie-slider-bubble-padding, var(--rsl-bubble-padding, 0.0625rem 0.375rem));
    font-size: var(--rozie-slider-bubble-font-size, var(--rsl-bubble-font-size, 0.6875rem));
    color: var(--rozie-slider-bubble-fg, var(--rsl-bubble-fg, #fff));
    background: var(--rozie-slider-bubble-bg, var(--rozie-slider-accent, var(--rsl-bubble-bg, var(--rsl-accent, #0066cc))));
    border-radius: var(--rozie-slider-bubble-radius, var(--rsl-bubble-radius, 4px));
    white-space: nowrap;
  }
}
</style>
ts
import { Component, ContentChild, DestroyRef, ElementRef, Renderer2, TemplateRef, ViewEncapsulation, afterRenderEffect, computed, contentChildren, effect, forwardRef, inject, input, model, output, signal, viewChild } from '@angular/core';
import { NgClass, NgTemplateOutlet } from '@angular/common';
import { NG_VALUE_ACCESSOR } from '@angular/forms';
import { RozieSlot, createRozieAttrApplier, createRozieHostAttrsReader, rozieAttr as __rozieAttr, rozieDisplay as __rozieDisplay } from '@rozie/runtime-angular';

interface MarkCtx {
  $implicit: { value: any; label: any; position: any };
  value: any;
  label: any;
  position: any;
}

interface BubbleCtx {
  $implicit: { value: any };
  value: any;
}

@Component({
  selector: 'rozie-slider',
  standalone: true,
  imports: [NgTemplateOutlet, NgClass],
  template: `

    <div class="rozie-slider" [ngClass]="{ 'rozie-slider--vertical': orientation() === 'vertical', 'rozie-slider--horizontal': orientation() !== 'vertical', 'rozie-slider--range': range(), 'rozie-slider--disabled': (disabled() || this.__rozieCvaDisabled()) }" [style]="fillStyle()" #rozieSpread_0 #rozieListenersTarget_1>
      
      <div class="rozie-slider-track" aria-hidden="true">
        <div class="rozie-slider-fill"></div>
      </div>

      
      @if (normalizedMarks().length > 0) {
    <div class="rozie-slider-marks" aria-hidden="true">
        
        @for (mark of normalizedMarks(); track mark.value) {
    <div class="rozie-slider-mark" [style]="{ left: pct(mark.value) + '%' }">
          @if ((markTpl ?? __rozieFillMap()['mark'] ?? templates()?.['mark'])) {
    <ng-container *ngTemplateOutlet="(markTpl ?? __rozieFillMap()['mark'] ?? templates()?.['mark']); context: { $implicit: { value: mark.value, label: mark.label, position: pct(mark.value) }, value: mark.value, label: mark.label, position: pct(mark.value) }" />
    } @else {

            <span class="rozie-slider-mark-label">{{ rozieDisplay(mark.label) }}</span>
          
    }
        </div>
    }
      </div>
    }@if (showValue() && !range()) {
    <div class="rozie-slider-bubbles" aria-hidden="true">
        <div class="rozie-slider-bubble" [style]="{ left: 'var(--rozie-slider-fill-end)' }">
          @if ((bubbleTpl ?? __rozieFillMap()['bubble'] ?? templates()?.['bubble'])) {
    <ng-container *ngTemplateOutlet="(bubbleTpl ?? __rozieFillMap()['bubble'] ?? templates()?.['bubble']); context: { $implicit: { value: singleValue() }, value: singleValue() }" />
    } @else {

            <span class="rozie-slider-bubble-text">{{ rozieDisplay(display(singleValue())) }}</span>
          
    }
        </div>
      </div>
    }@if (showValue() && range()) {
    <div class="rozie-slider-bubbles" aria-hidden="true">
        <div class="rozie-slider-bubble" [style]="{ left: 'var(--rozie-slider-fill-start)' }">
          @if ((bubbleTpl ?? __rozieFillMap()['bubble'] ?? templates()?.['bubble'])) {
    <ng-container *ngTemplateOutlet="(bubbleTpl ?? __rozieFillMap()['bubble'] ?? templates()?.['bubble']); context: { $implicit: { value: rangePair()[0] }, value: rangePair()[0] }" />
    } @else {

            <span class="rozie-slider-bubble-text">{{ rozieDisplay(display(rangePair()[0])) }}</span>
          
    }
        </div>
        <div class="rozie-slider-bubble" [style]="{ left: 'var(--rozie-slider-fill-end)' }">
          @if ((bubbleTpl ?? __rozieFillMap()['bubble'] ?? templates()?.['bubble'])) {
    <ng-container *ngTemplateOutlet="(bubbleTpl ?? __rozieFillMap()['bubble'] ?? templates()?.['bubble']); context: { $implicit: { value: rangePair()[1] }, value: rangePair()[1] }" />
    } @else {

            <span class="rozie-slider-bubble-text">{{ rozieDisplay(display(rangePair()[1])) }}</span>
          
    }
        </div>
      </div>
    }@if (!range()) {
    <input #inputEl class="rozie-slider-input" type="range" [min]="min()" [max]="max()" [step]="step()" [value]="singleValue()" [disabled]="!!(disabled() || this.__rozieCvaDisabled())" [attr.aria-label]="rozieAttr(ariaLabel())" [attr.aria-orientation]="rozieAttr(orientation() === 'vertical' ? 'vertical' : 'horizontal')" [attr.aria-valuetext]="rozieAttr(formatValue() !== null ? display(singleValue()) : null)" (input)="onInputSingle($event)" (keydown)="onKeyDownSingle($event)" />
    }@if (range()) {
    <input #inputEl class="rozie-slider-input rozie-slider-input--lo" type="range" [min]="min()" [max]="max()" [step]="step()" [value]="rangePair()[0]" [disabled]="!!(disabled() || this.__rozieCvaDisabled())" [attr.aria-label]="rozieAttr(ariaLabel())" [attr.aria-orientation]="rozieAttr(orientation() === 'vertical' ? 'vertical' : 'horizontal')" [attr.aria-valuetext]="rozieAttr(formatValue() !== null ? display(rangePair()[0]) : null)" (input)="onInputLo($event)" (keydown)="onKeyDownRange('lo', $event)" />
    }@if (range()) {
    <input class="rozie-slider-input rozie-slider-input--hi" type="range" [min]="min()" [max]="max()" [step]="step()" [value]="rangePair()[1]" [disabled]="!!(disabled() || this.__rozieCvaDisabled())" [attr.aria-label]="rozieAttr(ariaLabel())" [attr.aria-orientation]="rozieAttr(orientation() === 'vertical' ? 'vertical' : 'horizontal')" [attr.aria-valuetext]="rozieAttr(formatValue() !== null ? display(rangePair()[1]) : null)" (input)="onInputHi($event)" (keydown)="onKeyDownRange('hi', $event)" />
    }</div>

  `,
  styles: [`
    :host(rozie-slider) { display: contents; }
    .rozie-slider {
      position: relative;
      display: block;
      box-sizing: border-box;
      width: 100%;
      min-height: var(--rozie-slider-thumb-size, var(--rsl-thumb-size, 1rem));
      padding: var(--rozie-slider-pad, var(--rsl-pad, 0.5rem 0));
      font: var(--rozie-slider-font, inherit);
    }
    .rozie-slider-track {
      position: absolute;
      top: 50%;
      left: 0;
      right: 0;
      transform: translateY(-50%);
      height: var(--rozie-slider-track-height, var(--rsl-track-height, 0.375rem));
      border-radius: var(--rozie-slider-track-radius, var(--rsl-track-radius, 999px));
      background: var(--rozie-slider-track-bg, var(--rsl-track-bg, rgba(0, 0, 0, 0.18)));
      pointer-events: none;
    }
    .rozie-slider-fill {
      position: absolute;
      top: 0;
      bottom: 0;
      left: var(--rozie-slider-fill-start, 0%);
      right: calc(100% - var(--rozie-slider-fill-end, 0%));
      border-radius: inherit;
      background: var(--rozie-slider-fill-bg, var(--rozie-slider-accent, var(--rsl-fill-bg, var(--rsl-accent, #0066cc))));
    }
    .rozie-slider-input {
      -webkit-appearance: none;
      appearance: none;
      position: absolute;
      top: 50%;
      left: 0;
      transform: translateY(-50%);
      width: 100%;
      height: var(--rozie-slider-thumb-size, var(--rsl-thumb-size, 1rem));
      margin: 0;
      background: none;
      pointer-events: none;
      cursor: pointer;
      accent-color: var(--rozie-slider-accent, var(--rsl-accent, #0066cc));
    }
    .rozie-slider-input:focus { outline: none; z-index: 2; }
    .rozie-slider--range .rozie-slider-input { pointer-events: none; }
    .rozie-slider--disabled .rozie-slider-input { cursor: not-allowed; }
    .rozie-slider--disabled { opacity: var(--rozie-slider-disabled-opacity, var(--rsl-disabled-opacity, 0.55)); }
    .rozie-slider-input::-webkit-slider-runnable-track {
      background: none;
      height: var(--rozie-slider-track-height, var(--rsl-track-height, 0.375rem));
    }
    .rozie-slider-input::-moz-range-track {
      background: none;
      height: var(--rozie-slider-track-height, var(--rsl-track-height, 0.375rem));
    }
    .rozie-slider-input::-moz-range-progress {
      background: none;
    }
    .rozie-slider-input::-webkit-slider-thumb {
      -webkit-appearance: none;
      appearance: none;
      pointer-events: auto;
      width: var(--rozie-slider-thumb-size, var(--rsl-thumb-size, 1rem));
      height: var(--rozie-slider-thumb-size, var(--rsl-thumb-size, 1rem));
      border: var(--rozie-slider-thumb-border, var(--rsl-thumb-border, 2px solid #fff));
      border-radius: 50%;
      background: var(--rozie-slider-thumb-bg, var(--rozie-slider-accent, var(--rsl-thumb-bg, var(--rsl-accent, #0066cc))));
      box-shadow: var(--rozie-slider-thumb-shadow, var(--rsl-thumb-shadow, 0 1px 3px rgba(0, 0, 0, 0.3)));
      margin-top: var(--rozie-slider-thumb-offset, var(--rsl-thumb-offset, calc((0.375rem - 1rem) / 2)));
      cursor: pointer;
    }
    .rozie-slider-input::-moz-range-thumb {
      pointer-events: auto;
      width: var(--rozie-slider-thumb-size, var(--rsl-thumb-size, 1rem));
      height: var(--rozie-slider-thumb-size, var(--rsl-thumb-size, 1rem));
      border: var(--rozie-slider-thumb-border, var(--rsl-thumb-border, 2px solid #fff));
      border-radius: 50%;
      background: var(--rozie-slider-thumb-bg, var(--rozie-slider-accent, var(--rsl-thumb-bg, var(--rsl-accent, #0066cc))));
      box-shadow: var(--rozie-slider-thumb-shadow, var(--rsl-thumb-shadow, 0 1px 3px rgba(0, 0, 0, 0.3)));
      cursor: pointer;
    }
    .rozie-slider--vertical {
      width: var(--rozie-slider-thickness, var(--rsl-thickness, 2.5rem));
      height: var(--rozie-slider-length, var(--rsl-length, 12rem));
      padding: 0;
    }
    .rozie-slider--vertical .rozie-slider-track,
    .rozie-slider--vertical .rozie-slider-input {
      top: 50%;
      left: 50%;
      width: var(--rozie-slider-length, var(--rsl-length, 12rem));
      transform: translate(-50%, -50%) rotate(-90deg);
      transform-origin: center center;
    }
    .rozie-slider--vertical .rozie-slider-fill {
      /* The fill still spans start→end along the (now rotated) input axis. */
    }
    .rozie-slider--vertical .rozie-slider-marks,
    .rozie-slider--vertical .rozie-slider-bubbles {
      /* Overlays follow the rotated axis; left:%-of-length maps to the visual Y. */
      top: 50%;
      left: 50%;
      width: var(--rozie-slider-length, var(--rsl-length, 12rem));
      transform: translate(-50%, -50%) rotate(-90deg);
      transform-origin: center center;
    }
    .rozie-slider-marks {
      position: absolute;
      top: 50%;
      left: 0;
      right: 0;
      height: 0;
      pointer-events: none;
    }
    .rozie-slider-mark {
      position: absolute;
      top: 0;
      transform: translateX(-50%);
      color: var(--rozie-slider-mark-color, var(--rsl-mark-color, rgba(0, 0, 0, 0.55)));
    }
    .rozie-slider-mark-label {
      position: absolute;
      top: var(--rozie-slider-mark-offset, var(--rsl-mark-offset, 0.75rem));
      left: 50%;
      transform: translateX(-50%);
      font-size: var(--rozie-slider-mark-font-size, var(--rsl-mark-font-size, 0.6875rem));
      white-space: nowrap;
    }
    .rozie-slider-bubbles {
      position: absolute;
      top: 0;
      left: 0;
      right: 0;
      height: 0;
      pointer-events: none;
    }
    .rozie-slider-bubble {
      position: absolute;
      top: var(--rozie-slider-bubble-offset, var(--rsl-bubble-offset, -1.25rem));
      transform: translateX(-50%);
    }
    .rozie-slider-bubble-text {
      display: inline-block;
      padding: var(--rozie-slider-bubble-padding, var(--rsl-bubble-padding, 0.0625rem 0.375rem));
      font-size: var(--rozie-slider-bubble-font-size, var(--rsl-bubble-font-size, 0.6875rem));
      color: var(--rozie-slider-bubble-fg, var(--rsl-bubble-fg, #fff));
      background: var(--rozie-slider-bubble-bg, var(--rozie-slider-accent, var(--rsl-bubble-bg, var(--rsl-accent, #0066cc))));
      border-radius: var(--rozie-slider-bubble-radius, var(--rsl-bubble-radius, 4px));
      white-space: nowrap;
    }
  `],
  providers: [
    {
      provide: NG_VALUE_ACCESSOR,
      useExisting: forwardRef(() => Slider),
      multi: true,
    },
  ],
  host: { '(focusout)': '__rozieCvaOnTouched()' },
})
export class Slider {
  /**
   * The current value (two-way `r-model`). A scalar number in single mode; a sorted `[lo, hi]` array in `range` mode, with each thumb neighbour-clamped so the pair stays sorted on every commit. As the sole `model: true` prop it drives the Angular `ControlValueAccessor`, so a Slider **is** a form control (`[(ngModel)]` / `[formControl]` bind directly).
   * @example
   * <rozie-slider [(value)]="volume" [min]="0" [max]="100" [step]="1" ariaLabel="Volume" />
   */
  value = model<(unknown) | null>(null);
  /**
   * Switch to dual-thumb range mode: `value` becomes a sorted `[lo, hi]` array driven by two overlapping native inputs. The exact analog of listbox's `multiple` (scalar↔array). A bare attribute (`<Slider range>`) coerces to `true`.
   */
  range = input<boolean>(false);
  /**
   * The lower bound of the scale, forwarded to the native input as the `min` attribute (the browser derives `aria-valuemin` from it — not set by hand, per MDN slider-role guidance).
   */
  min = input<number>(0);
  /**
   * The upper bound of the scale, forwarded to the native input as the `max` attribute (the browser derives `aria-valuemax` from it — not set by hand, per MDN slider-role guidance).
   */
  max = input<number>(100);
  /**
   * The granularity of the scale, forwarded as the native `step` attribute; every write-back is quantized to it.
   */
  step = input<number>(1);
  /**
   * Layout orientation — `'horizontal'` (default) or `'vertical'`. Vertical rotates the wrapper `-90deg` so up = increase and sets `aria-orientation="vertical"` explicitly (a native range input always reports itself as horizontal even when visually rotated).
   */
  orientation = input<string>('horizontal');
  /**
   * Disable the control — it becomes non-interactive and dimmed. Also sets the Angular `ControlValueAccessor` disabled state.
   */
  disabled = input<boolean>(false);
  /**
   * Tick marks over the track — either a bare `value[]` (positions only) or a `{ value, label }[]` (positioned and labelled). Rendered as a decorative overlay above the track; override per-mark rendering via the `mark` scoped slot (`{ value, label, position }`).
   */
  marks = input<any[]>((() => [])());
  /**
   * Accessible name for each native input when there is no visible `<label for>`, reflected onto the input's `aria-label`.
   */
  ariaLabel = input<(string) | null>(null);
  /**
   * The jump applied on `PageUp` / `PageDown`. `null` falls back to `step × 10`. Applied by a thin `@keydown` augment so it honours this value (native browsers otherwise use their own large step); arrows / `Home` / `End` stay native.
   */
  pageStep = input<(number) | null>(null);
  /**
   * A `(value) => string` formatter for the value shown in the `bubble` slot and surfaced as `aria-valuetext`. Receives the numeric value and returns a string; `null` uses the raw value.
   */
  formatValue = input<((...args: any[]) => any) | null>(null);
  /**
   * Render the value-bubble overlay (one bubble per thumb in range mode). Headless and opt-in — there is no default-styled bubble; supply the `bubble` slot to control its appearance.
   */
  showValue = input<boolean>(false);
  inputEl = viewChild<ElementRef<HTMLInputElement>>('inputEl');
  change = output<unknown>();
  @ContentChild('mark', { read: TemplateRef }) markTpl?: TemplateRef<MarkCtx>;
  @ContentChild('bubble', { read: TemplateRef }) bubbleTpl?: TemplateRef<BubbleCtx>;
  templates = input<Record<string, TemplateRef<unknown>> | undefined>(undefined);
  __rozieFills = contentChildren(RozieSlot, { descendants: true });
  __rozieFillMap = computed(() => {
    const map = Object.create(null) as Record<string, TemplateRef<unknown>>;
    for (const f of this.__rozieFills()) {
      const k = f.rozieSlot();
      if (k == null) continue;
      if (k === '__proto__' || k === 'constructor' || k === 'prototype') continue;
      map[k === '' ? 'defaultSlot' : k] = f.templateRef;
    }
    return map;
  });

  fillStyle = computed(() => {
    const __value = this.value();
    const __min = this.min();
    let start, end;
    if (this.range()) {
      const arr = Array.isArray(__value) && __value.length === 2 ? __value : [__min, this.max()];
      start = this.pct(arr[0]);
      end = this.pct(arr[1]);
    } else {
      start = 0;
      const v = __value;
      end = this.pct(typeof v === 'number' && Number.isFinite(v) ? v : __min);
    }
    return {
      '--rozie-slider-fill-start': start + '%',
      '--rozie-slider-fill-end': end + '%'
    };
  });

  // ---- numeric helpers ---------------------------------------------------
  // A plain function (not `$computed`) so it reads uniformly across all six
  // targets — it is called from both the fill $computed and the keyboard augment.
  pct = (v: any) => {
    const __min = this.min();
    const span = this.max() - __min;
    if (span === 0) return 0;
    const p = (v - __min) / span * 100;
    if (p < 0) return 0;
    if (p > 100) return 100;
    return p;
  };
  // The number of decimal places a finite number is written with (e.g. 0.1 -> 1,
  // 5 -> 0, -1.25 -> 2). A plain string-based heuristic — every `step`/`min` a
  // slider author passes is a simple decimal literal, never exponential
  // notation, so this is exact for the values this component actually sees.
  decimalPlaces = (n: any) => {
    if (!Number.isFinite(n)) return 0;
    const s = String(n);
    const i = s.indexOf('.');
    return i === -1 ? 0 : s.length - i - 1;
  };
  // Clamp a raw number into [min,max] and quantize to `step` (guarding against a
  // non-finite or zero step). Returns a finite number bounded by the scale.
  //
  // `$props.min + steps * step` is exact math but NOT exact floating point: e.g.
  // step=0.1, steps=3 -> 0.30000000000000004 (binary floats can't represent
  // 0.1 precisely). Round the result to the step's (and min's) own decimal
  // precision — scaling by 10^places rather than `toFixed` so the model value
  // and `aria-valuetext` both report the clean decimal a consumer configured,
  // not a float artifact one ULP off.
  clampStep = (raw: any) => {
    const __min = this.min();
    const __max = this.max();
    if (!Number.isFinite(raw)) return __min;
    let v = raw;
    if (v < __min) v = __min;
    if (v > __max) v = __max;
    const step = this.step();
    if (Number.isFinite(step) && step > 0) {
      const steps = Math.round((v - __min) / step);
      v = __min + steps * step;
      const places = Math.max(this.decimalPlaces(step), this.decimalPlaces(__min));
      if (places > 0) {
        const factor = Math.pow(10, places);
        v = Math.round(v * factor) / factor;
      }
      if (v < __min) v = __min;
      if (v > __max) v = __max;
    }
    return v;
  };
  // The current range pair, defaulting to the full span when `value` is not yet a
  // 2-tuple. Read into a stable local before destructuring — `$props.value`
  // lowers to a `value()` accessor on Solid, so narrowing one local is uniform.
  rangePair = () => {
    const cur = this.value();
    if (Array.isArray(cur) && cur.length === 2) return [cur[0], cur[1]];
    return [this.min(), this.max()];
  };
  // The single (scalar) value, defaulting to min when not yet a number.
  singleValue = () => {
    const cur = this.value();
    return typeof cur === 'number' && Number.isFinite(cur) ? cur : this.min();
  };
  // The marks list, normalised to { value, label } objects. A bare value[] entry
  // becomes { value, label: String(value) }. A plain function (not $computed) so
  // it reads uniformly and can be called in the r-for.
  normalizedMarks = () => {
    const __marks = this.marks();
    const list = Array.isArray(__marks) ? __marks : [];
    return list.map((m: any) => {
      if (m !== null && typeof m === 'object' && 'value' in m) {
        return {
          value: m.value,
          label: 'label' in m && m.label != null ? m.label : String(m.value)
        };
      }
      return {
        value: m,
        label: String(m)
      };
    });
  };
  // Format a value for the bubble / aria-valuetext. A plain function: `$props.x`
  // reads uniformly inside it.
  display = (v: any) => {
    const __formatValue = this.formatValue();
    if (__formatValue !== null) return __formatValue(v);
    return String(v);
  };
  // ---- write-back (single emit funnel) -----------------------------------
  // The SOLE `$emit('change')` site, called from every commit path so the React
  // prop-destructure for `onChange` hoists exactly once.
  fireChange = (value: any) => this.change.emit({
    value: this.value()
  });
  // Single-mode commit: capture the fresh number, write the scalar, emit. Never
  // re-read $data after the write (ROZ138: React setState is async).
  commitSingle = (raw: any) => {
    const v = this.clampStep(raw);
    this.value.set(v), this.__rozieCvaOnChange(v);
    this.fireChange(v);
  };
  // Range-mode commit: keep the [lo, hi] array SORTED and clamp each thumb at its
  // neighbour, then write a FRESH array (in-place mutation is dropped on
  // React/Solid/Lit/Angular change detectors — listbox precedent).
  commitRange = (which: any, raw: any) => {
    const pair = this.rangePair();
    let lo = pair[0];
    let hi = pair[1];
    const v = this.clampStep(raw);
    if (which === 'lo') lo = Math.min(v, hi);else hi = Math.max(v, lo);
    const next = [lo, hi];
    this.value.set(next), this.__rozieCvaOnChange(next);
    this.fireChange(next);
  };
  // ---- native input handlers ---------------------------------------------
  // Single input. `valueAsNumber` is a number (never the string `.value`).
  onInputSingle = ($event: any) => this.commitSingle($event.target.valueAsNumber);
  // Range inputs (lo / hi).
  onInputLo = ($event: any) => this.commitRange('lo', $event.target.valueAsNumber);
  onInputHi = ($event: any) => this.commitRange('hi', $event.target.valueAsNumber);
  // ---- PageUp / PageDown augment (Open Q1 / RESEARCH A3) ------------------
  // Native PageUp/PageDown uses the browser's default large step, which may not
  // equal `pageStep`. Augment ONLY those two keys: apply ±pageStep (null → step×10),
  // quantize + clamp via clampStep, write back. Arrows / Home / End stay native.
  effectivePageStep = () => {
    const __step = this.step();
    const ps = this.pageStep();
    if (Number.isFinite(ps) && ps > 0) return ps;
    const step = Number.isFinite(__step) && __step > 0 ? __step : 1;
    return step * 10;
  };
  onKeyDownSingle = ($event: any) => {
    const key = $event.key;
    if (key !== 'PageUp' && key !== 'PageDown') return;
    $event.preventDefault();
    const delta = key === 'PageUp' ? this.effectivePageStep() : -this.effectivePageStep();
    this.commitSingle(this.singleValue() + delta);
  };
  onKeyDownRange = (which: any, $event: any) => {
    const key = $event.key;
    if (key !== 'PageUp' && key !== 'PageDown') return;
    $event.preventDefault();
    const delta = key === 'PageUp' ? this.effectivePageStep() : -this.effectivePageStep();
    const pair = this.rangePair();
    const base = which === 'lo' ? pair[0] : pair[1];
    this.commitRange(which, base + delta);
  };
  // ---- imperative handle (D-05) ------------------------------------------
  // `focus` reads $refs in a post-mount callback (called via the handle) — safe,
  // never eager (ROZ123). It DELIBERATELY overrides HTMLElement.focus on Lit
  // (ROZ137 warns; accepted — see header).
  focus = () => this.inputEl()?.nativeElement?.focus();
  // Step a thumb by ±step. In range mode `thumb` selects 'lo' | 'hi' (default 'lo').
  increment = (thumb: any) => {
    const __step = this.step();
    if (this.range()) {
      const which = thumb === 'hi' ? 'hi' : 'lo';
      const pair = this.rangePair();
      const base = which === 'lo' ? pair[0] : pair[1];
      this.commitRange(which, base + __step);
    } else {
      this.commitSingle(this.singleValue() + __step);
    }
  };
  decrement = (thumb: any) => {
    const __step = this.step();
    if (this.range()) {
      const which = thumb === 'hi' ? 'hi' : 'lo';
      const pair = this.rangePair();
      const base = which === 'lo' ? pair[0] : pair[1];
      this.commitRange(which, base - __step);
    } else {
      this.commitSingle(this.singleValue() - __step);
    }
  };

  private __rozieCvaOnChange: (v: unknown) => void = () => {};
  private __rozieCvaOnTouchedFn: () => void = () => {};
  protected __rozieCvaDisabled = signal(false);

  writeValue(v: unknown | null): void {
    this.value.set(v ?? null);
  }
  registerOnChange(fn: (v: unknown) => void): void {
    this.__rozieCvaOnChange = fn;
  }
  registerOnTouched(fn: () => void): void {
    this.__rozieCvaOnTouchedFn = fn;
  }
  setDisabledState(isDisabled: boolean): void {
    this.__rozieCvaDisabled.set(isDisabled);
  }
  __rozieCvaOnTouched(): void {
    this.__rozieCvaOnTouchedFn();
  }

  static ngTemplateContextGuard(
    _dir: Slider,
    _ctx: unknown,
  ): _ctx is MarkCtx | BubbleCtx {
    return true;
  }

  private __rozieDestroyRef = inject(DestroyRef);

  private rozieSpread_0 = viewChild<ElementRef>('rozieSpread_0');

  private __rozieApplyAttrs = createRozieAttrApplier(inject(Renderer2));

  private __rozieGetHostAttrs = createRozieHostAttrsReader(inject(ElementRef));

  private __rozieSpread_0_effect = afterRenderEffect(() => {
    const el = this.rozieSpread_0()?.nativeElement;
    if (!el) return;
    this.__rozieApplyAttrs(el, this.__rozieGetHostAttrs());
  });

  private rozieListenersTarget_1 = viewChild<ElementRef>('rozieListenersTarget_1');

  private __rozieListenersRenderer = inject(Renderer2);

  private __rozieListenersDisposers_1: Array<() => void> = [];

  private __rozieListenersDestroyRegistered_1 = false;

  private __rozieListenersEffect_1 = effect(() => {
    const el = this.rozieListenersTarget_1()?.nativeElement;
    if (!el) return;
    for (const off of this.__rozieListenersDisposers_1) off();
    this.__rozieListenersDisposers_1 = [];
    const obj: Record<string, unknown> = {};
    for (const [k, v] of Object.entries(obj)) {
      if (k === '__proto__' || k === 'constructor' || k === 'prototype') continue;
      if (typeof v !== 'function') continue;
      const norm = k.startsWith('on') ? k.slice(2).toLowerCase() : k;
      const dispose = this.__rozieListenersRenderer.listen(el, norm, v as EventListener);
      this.__rozieListenersDisposers_1.push(dispose);
    }
    if (!this.__rozieListenersDestroyRegistered_1) {
      this.__rozieListenersDestroyRegistered_1 = true;
      this.__rozieDestroyRef.onDestroy(() => {
        for (const off of this.__rozieListenersDisposers_1) off();
        this.__rozieListenersDisposers_1 = [];
      });
    }
  });

  rozieDisplay(v: unknown): string { return __rozieDisplay(v); }

  rozieAttr(v: unknown): string | null { return __rozieAttr(v); }
}

export default Slider;
tsx
import type { JSX } from 'solid-js';
import { Show, createMemo, mergeProps, onMount, splitProps } from 'solid-js';
import { Key } from '@solid-primitives/keyed';
import { __rozieInjectStyle, createControllableSignal, parseInlineStyle, rozieAttr, rozieClass, rozieDisplay } from '@rozie/runtime-solid';

__rozieInjectStyle('Slider-4e6f0be6', `.rozie-slider[data-rozie-s-4e6f0be6] {
  position: relative;
  display: block;
  box-sizing: border-box;
  width: 100%;
  min-height: var(--rozie-slider-thumb-size, var(--rsl-thumb-size, 1rem));
  padding: var(--rozie-slider-pad, var(--rsl-pad, 0.5rem 0));
  font: var(--rozie-slider-font, inherit);
}
.rozie-slider-track[data-rozie-s-4e6f0be6] {
  position: absolute;
  top: 50%;
  left: 0;
  right: 0;
  transform: translateY(-50%);
  height: var(--rozie-slider-track-height, var(--rsl-track-height, 0.375rem));
  border-radius: var(--rozie-slider-track-radius, var(--rsl-track-radius, 999px));
  background: var(--rozie-slider-track-bg, var(--rsl-track-bg, rgba(0, 0, 0, 0.18)));
  pointer-events: none;
}
.rozie-slider-fill[data-rozie-s-4e6f0be6] {
  position: absolute;
  top: 0;
  bottom: 0;
  left: var(--rozie-slider-fill-start, 0%);
  right: calc(100% - var(--rozie-slider-fill-end, 0%));
  border-radius: inherit;
  background: var(--rozie-slider-fill-bg, var(--rozie-slider-accent, var(--rsl-fill-bg, var(--rsl-accent, #0066cc))));
}
.rozie-slider-input[data-rozie-s-4e6f0be6] {
  -webkit-appearance: none;
  appearance: none;
  position: absolute;
  top: 50%;
  left: 0;
  transform: translateY(-50%);
  width: 100%;
  height: var(--rozie-slider-thumb-size, var(--rsl-thumb-size, 1rem));
  margin: 0;
  background: none;
  pointer-events: none;
  cursor: pointer;
  accent-color: var(--rozie-slider-accent, var(--rsl-accent, #0066cc));
}
.rozie-slider-input[data-rozie-s-4e6f0be6]:focus { outline: none; z-index: 2; }
.rozie-slider--range[data-rozie-s-4e6f0be6] .rozie-slider-input[data-rozie-s-4e6f0be6] { pointer-events: none; }
.rozie-slider--disabled[data-rozie-s-4e6f0be6] .rozie-slider-input[data-rozie-s-4e6f0be6] { cursor: not-allowed; }
.rozie-slider--disabled[data-rozie-s-4e6f0be6] { opacity: var(--rozie-slider-disabled-opacity, var(--rsl-disabled-opacity, 0.55)); }
.rozie-slider-input[data-rozie-s-4e6f0be6]::-webkit-slider-runnable-track {
  background: none;
  height: var(--rozie-slider-track-height, var(--rsl-track-height, 0.375rem));
}
.rozie-slider-input[data-rozie-s-4e6f0be6]::-moz-range-track {
  background: none;
  height: var(--rozie-slider-track-height, var(--rsl-track-height, 0.375rem));
}
.rozie-slider-input[data-rozie-s-4e6f0be6]::-moz-range-progress {
  background: none;
}
.rozie-slider-input[data-rozie-s-4e6f0be6]::-webkit-slider-thumb {
  -webkit-appearance: none;
  appearance: none;
  pointer-events: auto;
  width: var(--rozie-slider-thumb-size, var(--rsl-thumb-size, 1rem));
  height: var(--rozie-slider-thumb-size, var(--rsl-thumb-size, 1rem));
  border: var(--rozie-slider-thumb-border, var(--rsl-thumb-border, 2px solid #fff));
  border-radius: 50%;
  background: var(--rozie-slider-thumb-bg, var(--rozie-slider-accent, var(--rsl-thumb-bg, var(--rsl-accent, #0066cc))));
  box-shadow: var(--rozie-slider-thumb-shadow, var(--rsl-thumb-shadow, 0 1px 3px rgba(0, 0, 0, 0.3)));
  margin-top: var(--rozie-slider-thumb-offset, var(--rsl-thumb-offset, calc((0.375rem - 1rem) / 2)));
  cursor: pointer;
}
.rozie-slider-input[data-rozie-s-4e6f0be6]::-moz-range-thumb {
  pointer-events: auto;
  width: var(--rozie-slider-thumb-size, var(--rsl-thumb-size, 1rem));
  height: var(--rozie-slider-thumb-size, var(--rsl-thumb-size, 1rem));
  border: var(--rozie-slider-thumb-border, var(--rsl-thumb-border, 2px solid #fff));
  border-radius: 50%;
  background: var(--rozie-slider-thumb-bg, var(--rozie-slider-accent, var(--rsl-thumb-bg, var(--rsl-accent, #0066cc))));
  box-shadow: var(--rozie-slider-thumb-shadow, var(--rsl-thumb-shadow, 0 1px 3px rgba(0, 0, 0, 0.3)));
  cursor: pointer;
}
.rozie-slider--vertical[data-rozie-s-4e6f0be6] {
  width: var(--rozie-slider-thickness, var(--rsl-thickness, 2.5rem));
  height: var(--rozie-slider-length, var(--rsl-length, 12rem));
  padding: 0;
}
.rozie-slider--vertical[data-rozie-s-4e6f0be6] .rozie-slider-track[data-rozie-s-4e6f0be6],
.rozie-slider--vertical[data-rozie-s-4e6f0be6] .rozie-slider-input[data-rozie-s-4e6f0be6] {
  top: 50%;
  left: 50%;
  width: var(--rozie-slider-length, var(--rsl-length, 12rem));
  transform: translate(-50%, -50%) rotate(-90deg);
  transform-origin: center center;
}
.rozie-slider--vertical[data-rozie-s-4e6f0be6] .rozie-slider-fill[data-rozie-s-4e6f0be6] {
  /* The fill still spans start→end along the (now rotated) input axis. */
}
.rozie-slider--vertical[data-rozie-s-4e6f0be6] .rozie-slider-marks[data-rozie-s-4e6f0be6],
.rozie-slider--vertical[data-rozie-s-4e6f0be6] .rozie-slider-bubbles[data-rozie-s-4e6f0be6] {
  /* Overlays follow the rotated axis; left:%-of-length maps to the visual Y. */
  top: 50%;
  left: 50%;
  width: var(--rozie-slider-length, var(--rsl-length, 12rem));
  transform: translate(-50%, -50%) rotate(-90deg);
  transform-origin: center center;
}
.rozie-slider-marks[data-rozie-s-4e6f0be6] {
  position: absolute;
  top: 50%;
  left: 0;
  right: 0;
  height: 0;
  pointer-events: none;
}
.rozie-slider-mark[data-rozie-s-4e6f0be6] {
  position: absolute;
  top: 0;
  transform: translateX(-50%);
  color: var(--rozie-slider-mark-color, var(--rsl-mark-color, rgba(0, 0, 0, 0.55)));
}
.rozie-slider-mark-label[data-rozie-s-4e6f0be6] {
  position: absolute;
  top: var(--rozie-slider-mark-offset, var(--rsl-mark-offset, 0.75rem));
  left: 50%;
  transform: translateX(-50%);
  font-size: var(--rozie-slider-mark-font-size, var(--rsl-mark-font-size, 0.6875rem));
  white-space: nowrap;
}
.rozie-slider-bubbles[data-rozie-s-4e6f0be6] {
  position: absolute;
  top: 0;
  left: 0;
  right: 0;
  height: 0;
  pointer-events: none;
}
.rozie-slider-bubble[data-rozie-s-4e6f0be6] {
  position: absolute;
  top: var(--rozie-slider-bubble-offset, var(--rsl-bubble-offset, -1.25rem));
  transform: translateX(-50%);
}
.rozie-slider-bubble-text[data-rozie-s-4e6f0be6] {
  display: inline-block;
  padding: var(--rozie-slider-bubble-padding, var(--rsl-bubble-padding, 0.0625rem 0.375rem));
  font-size: var(--rozie-slider-bubble-font-size, var(--rsl-bubble-font-size, 0.6875rem));
  color: var(--rozie-slider-bubble-fg, var(--rsl-bubble-fg, #fff));
  background: var(--rozie-slider-bubble-bg, var(--rozie-slider-accent, var(--rsl-bubble-bg, var(--rsl-accent, #0066cc))));
  border-radius: var(--rozie-slider-bubble-radius, var(--rsl-bubble-radius, 4px));
  white-space: nowrap;
}`);

interface MarkSlotCtx { value: any; label: any; position: any; }

interface BubbleSlotCtx { value: any; }

interface SliderProps extends Omit<import('solid-js').ComponentProps<'div'>, 'value' | 'defaultValue' | 'onValueChange' | 'range' | 'min' | 'max' | 'step' | 'orientation' | 'disabled' | 'marks' | 'ariaLabel' | 'pageStep' | 'formatValue' | 'showValue' | 'onChange' | 'markSlot' | 'bubbleSlot' | 'slots' | 'ref' | 'children' | 'innerHTML' | 'innerText' | 'textContent'> {
  /**
   * The current value (two-way `r-model`). A scalar number in single mode; a sorted `[lo, hi]` array in `range` mode, with each thumb neighbour-clamped so the pair stays sorted on every commit. As the sole `model: true` prop it drives the Angular `ControlValueAccessor`, so a Slider **is** a form control (`[(ngModel)]` / `[formControl]` bind directly).
   * @example
   * <Slider value={volume()} onValueChange={setVolume} min={0} max={100} step={1} ariaLabel="Volume" />
   */
  value?: (unknown) | null;
  defaultValue?: (unknown) | null;
  onValueChange?: (value: (unknown) | null) => void;
  /**
   * Switch to dual-thumb range mode: `value` becomes a sorted `[lo, hi]` array driven by two overlapping native inputs. The exact analog of listbox's `multiple` (scalar↔array). A bare attribute (`<Slider range>`) coerces to `true`.
   */
  range?: boolean;
  /**
   * The lower bound of the scale, forwarded to the native input as the `min` attribute (the browser derives `aria-valuemin` from it — not set by hand, per MDN slider-role guidance).
   */
  min?: number;
  /**
   * The upper bound of the scale, forwarded to the native input as the `max` attribute (the browser derives `aria-valuemax` from it — not set by hand, per MDN slider-role guidance).
   */
  max?: number;
  /**
   * The granularity of the scale, forwarded as the native `step` attribute; every write-back is quantized to it.
   */
  step?: number;
  /**
   * Layout orientation — `'horizontal'` (default) or `'vertical'`. Vertical rotates the wrapper `-90deg` so up = increase and sets `aria-orientation="vertical"` explicitly (a native range input always reports itself as horizontal even when visually rotated).
   */
  orientation?: string;
  /**
   * Disable the control — it becomes non-interactive and dimmed. Also sets the Angular `ControlValueAccessor` disabled state.
   */
  disabled?: boolean;
  /**
   * Tick marks over the track — either a bare `value[]` (positions only) or a `{ value, label }[]` (positioned and labelled). Rendered as a decorative overlay above the track; override per-mark rendering via the `mark` scoped slot (`{ value, label, position }`).
   */
  marks?: any[];
  /**
   * Accessible name for each native input when there is no visible `<label for>`, reflected onto the input's `aria-label`.
   */
  ariaLabel?: (string) | null;
  /**
   * The jump applied on `PageUp` / `PageDown`. `null` falls back to `step × 10`. Applied by a thin `@keydown` augment so it honours this value (native browsers otherwise use their own large step); arrows / `Home` / `End` stay native.
   */
  pageStep?: (number) | null;
  /**
   * A `(value) => string` formatter for the value shown in the `bubble` slot and surfaced as `aria-valuetext`. Receives the numeric value and returns a string; `null` uses the raw value.
   */
  formatValue?: ((...args: any[]) => any) | null;
  /**
   * Render the value-bubble overlay (one bubble per thumb in range mode). Headless and opt-in — there is no default-styled bubble; supply the `bubble` slot to control its appearance.
   */
  showValue?: boolean;
  onChange?: (...args: any[]) => void;
  markSlot?: (ctx: MarkSlotCtx) => JSX.Element;
  bubbleSlot?: (ctx: BubbleSlotCtx) => JSX.Element;
  slots?: Record<string, (ctx: any) => JSX.Element>;
  ref?: (h: SliderHandle) => void;
}

export interface SliderHandle {
  focus: (...args: any[]) => any;
  increment: (...args: any[]) => any;
  decrement: (...args: any[]) => any;
}

export default function Slider(_props: SliderProps): JSX.Element {
  const _merged = mergeProps({ range: false, min: 0, max: 100, step: 1, orientation: 'horizontal', disabled: false, marks: (() => [])() as any[], ariaLabel: null, pageStep: null, formatValue: null, showValue: false }, _props);
  const [local, attrs] = splitProps(_merged, ['value', 'range', 'min', 'max', 'step', 'orientation', 'disabled', 'marks', 'ariaLabel', 'pageStep', 'formatValue', 'showValue', 'ref', 'onChange']);
  onMount(() => { local.ref?.({ focus, increment, decrement }); });

  const [value, setValue] = createControllableSignal<unknown>(_props as unknown as Record<string, unknown>, 'value', null);
  const fillStyle = createMemo(() => {
    let start, end;
    if (local.range) {
      const arr = Array.isArray(value()) && (value() as any).length === 2 ? value() : [local.min, local.max];
      start = pct(arr[0]);
      end = pct(arr[1]);
    } else {
      start = 0;
      const v = value();
      end = pct(typeof v === 'number' && Number.isFinite(v) ? v : local.min);
    }
    return {
      '--rozie-slider-fill-start': start + '%',
      '--rozie-slider-fill-end': end + '%'
    };
  });
  let inputElRef: HTMLElement | null = null;

  // ---- numeric helpers ---------------------------------------------------
  // A plain function (not `$computed`) so it reads uniformly across all six
  // targets — it is called from both the fill $computed and the keyboard augment.
  function pct(v: any) {
    const span = local.max - local.min;
    if (span === 0) return 0;
    const p = (v - local.min) / span * 100;
    if (p < 0) return 0;
    if (p > 100) return 100;
    return p;
  }

  // The number of decimal places a finite number is written with (e.g. 0.1 -> 1,
  // 5 -> 0, -1.25 -> 2). A plain string-based heuristic — every `step`/`min` a
  // slider author passes is a simple decimal literal, never exponential
  // notation, so this is exact for the values this component actually sees.
  function decimalPlaces(n: any) {
    if (!Number.isFinite(n)) return 0;
    const s = String(n);
    const i = s.indexOf('.');
    return i === -1 ? 0 : s.length - i - 1;
  }

  // Clamp a raw number into [min,max] and quantize to `step` (guarding against a
  // non-finite or zero step). Returns a finite number bounded by the scale.
  //
  // `$props.min + steps * step` is exact math but NOT exact floating point: e.g.
  // step=0.1, steps=3 -> 0.30000000000000004 (binary floats can't represent
  // 0.1 precisely). Round the result to the step's (and min's) own decimal
  // precision — scaling by 10^places rather than `toFixed` so the model value
  // and `aria-valuetext` both report the clean decimal a consumer configured,
  // not a float artifact one ULP off.
  function clampStep(raw: any) {
    if (!Number.isFinite(raw)) return local.min;
    let v = raw;
    if (v < local.min) v = local.min;
    if (v > local.max) v = local.max;
    const step$local = local.step;
    if (Number.isFinite(step$local) && step$local > 0) {
      const steps = Math.round((v - local.min) / step$local);
      v = local.min + steps * step$local;
      const places = Math.max(decimalPlaces(step$local), decimalPlaces(local.min));
      if (places > 0) {
        const factor = Math.pow(10, places);
        v = Math.round(v * factor) / factor;
      }
      if (v < local.min) v = local.min;
      if (v > local.max) v = local.max;
    }
    return v;
  }

  // The current range pair, defaulting to the full span when `value` is not yet a
  // 2-tuple. Read into a stable local before destructuring — `$props.value`
  // lowers to a `value()` accessor on Solid, so narrowing one local is uniform.
  function rangePair() {
    const cur = value();
    if (Array.isArray(cur) && cur.length === 2) return [cur[0], cur[1]];
    return [local.min, local.max];
  }

  // The single (scalar) value, defaulting to min when not yet a number.
  function singleValue() {
    const cur = value();
    return typeof cur === 'number' && Number.isFinite(cur) ? cur : local.min;
  }

  // ---- derived fill (pure $computed → inline CSS vars, D-06/D-07) ---------
  // Read BARE in the template via :style="fillStyle". Returns the fill extent as a
  // % of the track. The rotate-90 vertical wrapper maps X→Y, so the SAME
  // start/end vars drive the (rotated) fill — no separate vertical math.

  // The marks list, normalised to { value, label } objects. A bare value[] entry
  // becomes { value, label: String(value) }. A plain function (not $computed) so
  // it reads uniformly and can be called in the r-for.
  function normalizedMarks() {
    const list = Array.isArray(local.marks) ? local.marks : [];
    return list.map((m: any) => {
      if (m !== null && typeof m === 'object' && 'value' in m) {
        return {
          value: m.value,
          label: 'label' in m && m.label != null ? m.label : String(m.value)
        };
      }
      return {
        value: m,
        label: String(m)
      };
    });
  }

  // Format a value for the bubble / aria-valuetext. A plain function: `$props.x`
  // reads uniformly inside it.
  function display(v: any) {
    if (local.formatValue !== null) return local.formatValue(v);
    return String(v);
  }

  // ---- write-back (single emit funnel) -----------------------------------
  // The SOLE `$emit('change')` site, called from every commit path so the React
  // prop-destructure for `onChange` hoists exactly once.
  function fireChange(value: any) {
    return _props.onChange?.({
      value
    });
  }

  // Single-mode commit: capture the fresh number, write the scalar, emit. Never
  // re-read $data after the write (ROZ138: React setState is async).
  function commitSingle(raw: any) {
    const v = clampStep(raw);
    setValue(v);
    fireChange(v);
  }

  // Range-mode commit: keep the [lo, hi] array SORTED and clamp each thumb at its
  // neighbour, then write a FRESH array (in-place mutation is dropped on
  // React/Solid/Lit/Angular change detectors — listbox precedent).
  function commitRange(which: any, raw: any) {
    const pair = rangePair();
    let lo = pair[0];
    let hi = pair[1];
    const v = clampStep(raw);
    if (which === 'lo') lo = Math.min(v, hi);else hi = Math.max(v, lo);
    const next = [lo, hi];
    setValue(next);
    fireChange(next);
  }

  // ---- native input handlers ---------------------------------------------
  // Single input. `valueAsNumber` is a number (never the string `.value`).
  function onInputSingle($event: any) {
    return commitSingle($event.target.valueAsNumber);
  }
  // Range inputs (lo / hi).
  function onInputLo($event: any) {
    return commitRange('lo', $event.target.valueAsNumber);
  }
  function onInputHi($event: any) {
    return commitRange('hi', $event.target.valueAsNumber);
  }

  // ---- PageUp / PageDown augment (Open Q1 / RESEARCH A3) ------------------
  // Native PageUp/PageDown uses the browser's default large step, which may not
  // equal `pageStep`. Augment ONLY those two keys: apply ±pageStep (null → step×10),
  // quantize + clamp via clampStep, write back. Arrows / Home / End stay native.
  function effectivePageStep() {
    const ps = local.pageStep;
    if (Number.isFinite(ps) && ps > 0) return ps;
    const step$local = Number.isFinite(local.step) && local.step > 0 ? local.step : 1;
    return step$local * 10;
  }
  function onKeyDownSingle($event: any) {
    const key = $event.key;
    if (key !== 'PageUp' && key !== 'PageDown') return;
    $event.preventDefault();
    const delta = key === 'PageUp' ? effectivePageStep() : -effectivePageStep();
    commitSingle(singleValue() + delta);
  }
  function onKeyDownRange(which: any, $event: any) {
    const key = $event.key;
    if (key !== 'PageUp' && key !== 'PageDown') return;
    $event.preventDefault();
    const delta = key === 'PageUp' ? effectivePageStep() : -effectivePageStep();
    const pair = rangePair();
    const base = which === 'lo' ? pair[0] : pair[1];
    commitRange(which, base + delta);
  }

  // ---- imperative handle (D-05) ------------------------------------------
  // `focus` reads $refs in a post-mount callback (called via the handle) — safe,
  // never eager (ROZ123). It DELIBERATELY overrides HTMLElement.focus on Lit
  // (ROZ137 warns; accepted — see header).
  function focus() {
    return inputElRef?.focus();
  }

  // Step a thumb by ±step. In range mode `thumb` selects 'lo' | 'hi' (default 'lo').
  function increment(thumb: any) {
    if (local.range) {
      const which = thumb === 'hi' ? 'hi' : 'lo';
      const pair = rangePair();
      const base = which === 'lo' ? pair[0] : pair[1];
      commitRange(which, base + local.step);
    } else {
      commitSingle(singleValue() + local.step);
    }
  }
  function decrement(thumb: any) {
    if (local.range) {
      const which = thumb === 'hi' ? 'hi' : 'lo';
      const pair = rangePair();
      const base = which === 'lo' ? pair[0] : pair[1];
      commitRange(which, base - local.step);
    } else {
      commitSingle(singleValue() - local.step);
    }
  }

  // Shorthand keys (aliased `{ focus: fn }` keys are dropped by the React emitter)
  // — every function is named exactly as its verb. `focus` triggers the accepted
  // ROZ137 warn.

  return (
    <>
    <div {...attrs} class={"rozie-slider" + " " + rozieClass({ 'rozie-slider--vertical': local.orientation === 'vertical', 'rozie-slider--horizontal': local.orientation !== 'vertical', 'rozie-slider--range': local.range, 'rozie-slider--disabled': local.disabled }) + (((attrs as unknown as Record<string, unknown>).class as string | undefined) ? " " + ((attrs as unknown as Record<string, unknown>).class as string | undefined) : "")} style={parseInlineStyle([parseInlineStyle(fillStyle()), ((attrs as unknown as Record<string, unknown>).style as string | JSX.CSSProperties | undefined)])} data-rozie-s-4e6f0be6="">
      
      <div class={"rozie-slider-track"} aria-hidden="true" data-rozie-s-4e6f0be6="">
        <div class={"rozie-slider-fill"} data-rozie-s-4e6f0be6="" />
      </div>

      
      {<Show when={normalizedMarks().length > 0}><div class={"rozie-slider-marks"} aria-hidden="true" data-rozie-s-4e6f0be6="">
        
        <Key each={normalizedMarks() as readonly any[]} by={(mark) => mark.value}>{(mark) => <div class={"rozie-slider-mark"} style={{ left: pct(mark().value) + '%' }} data-rozie-s-4e6f0be6="">
          {(_props.markSlot ?? _props.slots?.['mark'])?.({ get value() { return mark().value; }, get label() { return mark().label; }, get position() { return pct(mark().value); } }) ?? <span class={"rozie-slider-mark-label"} data-rozie-s-4e6f0be6="">{rozieDisplay(mark().label)}</span>}
        </div>}</Key>
      </div></Show>}{<Show when={local.showValue && !local.range}><div class={"rozie-slider-bubbles"} aria-hidden="true" data-rozie-s-4e6f0be6="">
        <div class={"rozie-slider-bubble"} style={{ left: 'var(--rozie-slider-fill-end)' }} data-rozie-s-4e6f0be6="">
          {(_props.bubbleSlot ?? _props.slots?.['bubble'])?.({ get value() { return singleValue(); } }) ?? <span class={"rozie-slider-bubble-text"} data-rozie-s-4e6f0be6="">{rozieDisplay(display(singleValue()))}</span>}
        </div>
      </div></Show>}{<Show when={local.showValue && local.range}><div class={"rozie-slider-bubbles"} aria-hidden="true" data-rozie-s-4e6f0be6="">
        <div class={"rozie-slider-bubble"} style={{ left: 'var(--rozie-slider-fill-start)' }} data-rozie-s-4e6f0be6="">
          {(_props.bubbleSlot ?? _props.slots?.['bubble'])?.({ get value() { return rangePair()[0]; } }) ?? <span class={"rozie-slider-bubble-text"} data-rozie-s-4e6f0be6="">{rozieDisplay(display(rangePair()[0]))}</span>}
        </div>
        <div class={"rozie-slider-bubble"} style={{ left: 'var(--rozie-slider-fill-end)' }} data-rozie-s-4e6f0be6="">
          {(_props.bubbleSlot ?? _props.slots?.['bubble'])?.({ get value() { return rangePair()[1]; } }) ?? <span class={"rozie-slider-bubble-text"} data-rozie-s-4e6f0be6="">{rozieDisplay(display(rangePair()[1]))}</span>}
        </div>
      </div></Show>}{<Show when={!local.range}><input type="range" aria-label={rozieAttr(local.ariaLabel)} aria-orientation={rozieAttr(local.orientation === 'vertical' ? 'vertical' : 'horizontal')} aria-valuetext={rozieAttr(local.formatValue !== null ? display(singleValue()) : null)} ref={(el) => { inputElRef = el as HTMLElement; }} class={"rozie-slider-input"} min={local.min} max={local.max} step={local.step} value={singleValue()} disabled={!!local.disabled} onInput={($event: InputEvent & { currentTarget: HTMLInputElement; target: Element }) => { onInputSingle($event); }} onKeyDown={($event: KeyboardEvent & { currentTarget: HTMLInputElement; target: Element }) => { onKeyDownSingle($event); }} data-rozie-s-4e6f0be6="" /></Show>}{<Show when={local.range}><input type="range" aria-label={rozieAttr(local.ariaLabel)} aria-orientation={rozieAttr(local.orientation === 'vertical' ? 'vertical' : 'horizontal')} aria-valuetext={rozieAttr(local.formatValue !== null ? display(rangePair()[0]) : null)} ref={(el) => { inputElRef = el as HTMLElement; }} class={"rozie-slider-input rozie-slider-input--lo"} min={local.min} max={local.max} step={local.step} value={rangePair()[0]} disabled={!!local.disabled} onInput={($event: InputEvent & { currentTarget: HTMLInputElement; target: Element }) => { onInputLo($event); }} onKeyDown={($event: KeyboardEvent & { currentTarget: HTMLInputElement; target: Element }) => { onKeyDownRange('lo', $event); }} data-rozie-s-4e6f0be6="" /></Show>}{<Show when={local.range}><input type="range" aria-label={rozieAttr(local.ariaLabel)} aria-orientation={rozieAttr(local.orientation === 'vertical' ? 'vertical' : 'horizontal')} aria-valuetext={rozieAttr(local.formatValue !== null ? display(rangePair()[1]) : null)} class={"rozie-slider-input rozie-slider-input--hi"} min={local.min} max={local.max} step={local.step} value={rangePair()[1]} disabled={!!local.disabled} onInput={($event: InputEvent & { currentTarget: HTMLInputElement; target: Element }) => { onInputHi($event); }} onKeyDown={($event: KeyboardEvent & { currentTarget: HTMLInputElement; target: Element }) => { onKeyDownRange('hi', $event); }} data-rozie-s-4e6f0be6="" /></Show>}</div>
    </>
  );
}
ts
import { LitElement, css, html, nothing } from 'lit';
import { customElement, property, query, queryAssignedElements, state } from 'lit/decorators.js';
import { SignalWatcher } from '@lit-labs/preact-signals';
import { RozieSlotDistributor, createLitControllableProperty, rozieAttr, rozieDisplay, rozieListeners, rozieSpread, rozieStyle } from '@rozie/runtime-lit';
import { repeat } from 'lit/directives/repeat.js';
import { styleMap } from 'lit/directives/style-map.js';

interface RozieMarkSlotCtx {
  value: any;
  label: any;
  position: any;
}

interface RozieBubbleSlotCtx {
  value: any;
}

@customElement('rozie-slider')
export default class Slider extends SignalWatcher(LitElement) {
  static shadowRootOptions: ShadowRootInit = { ...LitElement.shadowRootOptions, slotAssignment: 'manual' };

  static styles = css`
:host{display:contents}
.rozie-slider[data-rozie-s-4e6f0be6] {
  position: relative;
  display: block;
  box-sizing: border-box;
  width: 100%;
  min-height: var(--rozie-slider-thumb-size, var(--rsl-thumb-size, 1rem));
  padding: var(--rozie-slider-pad, var(--rsl-pad, 0.5rem 0));
  font: var(--rozie-slider-font, inherit);
}
.rozie-slider-track[data-rozie-s-4e6f0be6] {
  position: absolute;
  top: 50%;
  left: 0;
  right: 0;
  transform: translateY(-50%);
  height: var(--rozie-slider-track-height, var(--rsl-track-height, 0.375rem));
  border-radius: var(--rozie-slider-track-radius, var(--rsl-track-radius, 999px));
  background: var(--rozie-slider-track-bg, var(--rsl-track-bg, rgba(0, 0, 0, 0.18)));
  pointer-events: none;
}
.rozie-slider-fill[data-rozie-s-4e6f0be6] {
  position: absolute;
  top: 0;
  bottom: 0;
  left: var(--rozie-slider-fill-start, 0%);
  right: calc(100% - var(--rozie-slider-fill-end, 0%));
  border-radius: inherit;
  background: var(--rozie-slider-fill-bg, var(--rozie-slider-accent, var(--rsl-fill-bg, var(--rsl-accent, #0066cc))));
}
.rozie-slider-input[data-rozie-s-4e6f0be6] {
  -webkit-appearance: none;
  appearance: none;
  position: absolute;
  top: 50%;
  left: 0;
  transform: translateY(-50%);
  width: 100%;
  height: var(--rozie-slider-thumb-size, var(--rsl-thumb-size, 1rem));
  margin: 0;
  background: none;
  pointer-events: none;
  cursor: pointer;
  accent-color: var(--rozie-slider-accent, var(--rsl-accent, #0066cc));
}
.rozie-slider-input[data-rozie-s-4e6f0be6]:focus { outline: none; z-index: 2; }
.rozie-slider--range[data-rozie-s-4e6f0be6] .rozie-slider-input[data-rozie-s-4e6f0be6] { pointer-events: none; }
.rozie-slider--disabled[data-rozie-s-4e6f0be6] .rozie-slider-input[data-rozie-s-4e6f0be6] { cursor: not-allowed; }
.rozie-slider--disabled[data-rozie-s-4e6f0be6] { opacity: var(--rozie-slider-disabled-opacity, var(--rsl-disabled-opacity, 0.55)); }
.rozie-slider-input[data-rozie-s-4e6f0be6]::-webkit-slider-runnable-track {
  background: none;
  height: var(--rozie-slider-track-height, var(--rsl-track-height, 0.375rem));
}
.rozie-slider-input[data-rozie-s-4e6f0be6]::-moz-range-track {
  background: none;
  height: var(--rozie-slider-track-height, var(--rsl-track-height, 0.375rem));
}
.rozie-slider-input[data-rozie-s-4e6f0be6]::-moz-range-progress {
  background: none;
}
.rozie-slider-input[data-rozie-s-4e6f0be6]::-webkit-slider-thumb {
  -webkit-appearance: none;
  appearance: none;
  pointer-events: auto;
  width: var(--rozie-slider-thumb-size, var(--rsl-thumb-size, 1rem));
  height: var(--rozie-slider-thumb-size, var(--rsl-thumb-size, 1rem));
  border: var(--rozie-slider-thumb-border, var(--rsl-thumb-border, 2px solid #fff));
  border-radius: 50%;
  background: var(--rozie-slider-thumb-bg, var(--rozie-slider-accent, var(--rsl-thumb-bg, var(--rsl-accent, #0066cc))));
  box-shadow: var(--rozie-slider-thumb-shadow, var(--rsl-thumb-shadow, 0 1px 3px rgba(0, 0, 0, 0.3)));
  margin-top: var(--rozie-slider-thumb-offset, var(--rsl-thumb-offset, calc((0.375rem - 1rem) / 2)));
  cursor: pointer;
}
.rozie-slider-input[data-rozie-s-4e6f0be6]::-moz-range-thumb {
  pointer-events: auto;
  width: var(--rozie-slider-thumb-size, var(--rsl-thumb-size, 1rem));
  height: var(--rozie-slider-thumb-size, var(--rsl-thumb-size, 1rem));
  border: var(--rozie-slider-thumb-border, var(--rsl-thumb-border, 2px solid #fff));
  border-radius: 50%;
  background: var(--rozie-slider-thumb-bg, var(--rozie-slider-accent, var(--rsl-thumb-bg, var(--rsl-accent, #0066cc))));
  box-shadow: var(--rozie-slider-thumb-shadow, var(--rsl-thumb-shadow, 0 1px 3px rgba(0, 0, 0, 0.3)));
  cursor: pointer;
}
.rozie-slider--vertical[data-rozie-s-4e6f0be6] {
  width: var(--rozie-slider-thickness, var(--rsl-thickness, 2.5rem));
  height: var(--rozie-slider-length, var(--rsl-length, 12rem));
  padding: 0;
}
.rozie-slider--vertical[data-rozie-s-4e6f0be6] .rozie-slider-track[data-rozie-s-4e6f0be6],
.rozie-slider--vertical[data-rozie-s-4e6f0be6] .rozie-slider-input[data-rozie-s-4e6f0be6] {
  top: 50%;
  left: 50%;
  width: var(--rozie-slider-length, var(--rsl-length, 12rem));
  transform: translate(-50%, -50%) rotate(-90deg);
  transform-origin: center center;
}
.rozie-slider--vertical[data-rozie-s-4e6f0be6] .rozie-slider-fill[data-rozie-s-4e6f0be6] {
  /* The fill still spans start→end along the (now rotated) input axis. */
}
.rozie-slider--vertical[data-rozie-s-4e6f0be6] .rozie-slider-marks[data-rozie-s-4e6f0be6],
.rozie-slider--vertical[data-rozie-s-4e6f0be6] .rozie-slider-bubbles[data-rozie-s-4e6f0be6] {
  /* Overlays follow the rotated axis; left:%-of-length maps to the visual Y. */
  top: 50%;
  left: 50%;
  width: var(--rozie-slider-length, var(--rsl-length, 12rem));
  transform: translate(-50%, -50%) rotate(-90deg);
  transform-origin: center center;
}
.rozie-slider-marks[data-rozie-s-4e6f0be6] {
  position: absolute;
  top: 50%;
  left: 0;
  right: 0;
  height: 0;
  pointer-events: none;
}
.rozie-slider-mark[data-rozie-s-4e6f0be6] {
  position: absolute;
  top: 0;
  transform: translateX(-50%);
  color: var(--rozie-slider-mark-color, var(--rsl-mark-color, rgba(0, 0, 0, 0.55)));
}
.rozie-slider-mark-label[data-rozie-s-4e6f0be6] {
  position: absolute;
  top: var(--rozie-slider-mark-offset, var(--rsl-mark-offset, 0.75rem));
  left: 50%;
  transform: translateX(-50%);
  font-size: var(--rozie-slider-mark-font-size, var(--rsl-mark-font-size, 0.6875rem));
  white-space: nowrap;
}
.rozie-slider-bubbles[data-rozie-s-4e6f0be6] {
  position: absolute;
  top: 0;
  left: 0;
  right: 0;
  height: 0;
  pointer-events: none;
}
.rozie-slider-bubble[data-rozie-s-4e6f0be6] {
  position: absolute;
  top: var(--rozie-slider-bubble-offset, var(--rsl-bubble-offset, -1.25rem));
  transform: translateX(-50%);
}
.rozie-slider-bubble-text[data-rozie-s-4e6f0be6] {
  display: inline-block;
  padding: var(--rozie-slider-bubble-padding, var(--rsl-bubble-padding, 0.0625rem 0.375rem));
  font-size: var(--rozie-slider-bubble-font-size, var(--rsl-bubble-font-size, 0.6875rem));
  color: var(--rozie-slider-bubble-fg, var(--rsl-bubble-fg, #fff));
  background: var(--rozie-slider-bubble-bg, var(--rozie-slider-accent, var(--rsl-bubble-bg, var(--rsl-accent, #0066cc))));
  border-radius: var(--rozie-slider-bubble-radius, var(--rsl-bubble-radius, 4px));
  white-space: nowrap;
}
`;

  /**
   * The current value (two-way `r-model`). A scalar number in single mode; a sorted `[lo, hi]` array in `range` mode, with each thumb neighbour-clamped so the pair stays sorted on every commit. As the sole `model: true` prop it drives the Angular `ControlValueAccessor`, so a Slider **is** a form control (`[(ngModel)]` / `[formControl]` bind directly).
   * @example
   * <rozie-slider .value=${volume} @value-change=${…} .min=${0} .max=${100} .step=${1} ariaLabel="Volume"></rozie-slider>
   */
  @property({ type: Object, attribute: 'value' }) _value_attr: unknown = null;
  private _valueControllable = createLitControllableProperty<unknown>({ host: this, eventName: 'value-change', defaultValue: null, initialControlledValue: undefined });
  /**
   * Switch to dual-thumb range mode: `value` becomes a sorted `[lo, hi]` array driven by two overlapping native inputs. The exact analog of listbox's `multiple` (scalar↔array). A bare attribute (`<Slider range>`) coerces to `true`.
   */
  @property({ type: Boolean, reflect: true }) range: boolean = false;
  /**
   * The lower bound of the scale, forwarded to the native input as the `min` attribute (the browser derives `aria-valuemin` from it — not set by hand, per MDN slider-role guidance).
   */
  @property({ type: Number, reflect: true }) min: number = 0;
  /**
   * The upper bound of the scale, forwarded to the native input as the `max` attribute (the browser derives `aria-valuemax` from it — not set by hand, per MDN slider-role guidance).
   */
  @property({ type: Number, reflect: true }) max: number = 100;
  /**
   * The granularity of the scale, forwarded as the native `step` attribute; every write-back is quantized to it.
   */
  @property({ type: Number, reflect: true }) step: number = 1;
  /**
   * Layout orientation — `'horizontal'` (default) or `'vertical'`. Vertical rotates the wrapper `-90deg` so up = increase and sets `aria-orientation="vertical"` explicitly (a native range input always reports itself as horizontal even when visually rotated).
   */
  @property({ type: String, reflect: true }) orientation: string = 'horizontal';
  /**
   * Disable the control — it becomes non-interactive and dimmed. Also sets the Angular `ControlValueAccessor` disabled state.
   */
  @property({ type: Boolean, reflect: true }) disabled: boolean = false;
  /**
   * Tick marks over the track — either a bare `value[]` (positions only) or a `{ value, label }[]` (positioned and labelled). Rendered as a decorative overlay above the track; override per-mark rendering via the `mark` scoped slot (`{ value, label, position }`).
   */
  @property({ type: Array }) marks: any[] = [];
  /**
   * Accessible name for each native input when there is no visible `<label for>`, reflected onto the input's `aria-label`.
   */
  @property({ type: String, reflect: true, attribute: 'aria-label' }) ariaLabel: string | null = null;
  /**
   * The jump applied on `PageUp` / `PageDown`. `null` falls back to `step × 10`. Applied by a thin `@keydown` augment so it honours this value (native browsers otherwise use their own large step); arrows / `Home` / `End` stay native.
   */
  @property({ type: Number, reflect: true, attribute: 'page-step' }) pageStep: number | null = null;
  /**
   * A `(value) => string` formatter for the value shown in the `bubble` slot and surfaced as `aria-valuetext`. Receives the numeric value and returns a string; `null` uses the raw value.
   */
  @property({ type: Function, attribute: 'format-value' }) formatValue: ((...args: any[]) => any) | null = null;
  /**
   * Render the value-bubble overlay (one bubble per thumb in range mode). Headless and opt-in — there is no default-styled bubble; supply the `bubble` slot to control its appearance.
   */
  @property({ type: Boolean, reflect: true, attribute: 'show-value' }) showValue: boolean = false;
  @query('[data-rozie-ref="inputEl"]') private _refInputEl!: HTMLElement;

  private _rozieSlotDistributor = new RozieSlotDistributor(this);

  @state() private _hasSlotMark = false;
  @queryAssignedElements({ slot: 'mark', flatten: true }) private _slotMarkElements!: Element[];
  @property({ attribute: false }) mark?: (scope: { value: any; label: any; position: any }) => unknown;
  @state() private _hasSlotBubble = false;
  @queryAssignedElements({ slot: 'bubble', flatten: true }) private _slotBubbleElements!: Element[];
  @property({ attribute: false }) bubble?: (scope: { value: any }) => unknown;
  // Phase 79 Plan 08 (R4) contract for 79-09: the record intake for
  // record-routed slot fills. 79-09's consumer-side emitSlotFiller
  // accumulates an object literal onto the SAME `.rozieSlots=${{ ... }}`
  // open-tag binding; the KEY is the fill's authored (possibly
  // non-identifier) name and the VALUE is a scope-taking render
  // function. `rozieSlots?.[name]` must be checked BEFORE the legacy
  // named function-prop / <slot> fallback (AC-9). Attribute
  // deserialization is disabled — this is a function-valued record,
  // never reflected to/from an HTML attribute.
  @property({ attribute: false }) rozieSlots?: Record<string, (scope: any) => unknown>;

  private _disconnectCleanups: Array<() => void> = [];
  // Re-parenting guard: set true once the deferred teardown has actually
  // run (a genuine un-mount), so a subsequent reconnect knows to re-arm.
  private _rozieTornDown = false;

  private _armListeners(): void {
    {
      const slotEl = this.shadowRoot?.querySelector('slot[name="mark"]');
      if (slotEl !== null && slotEl !== undefined) {
        const update = () => { this._hasSlotMark = this._slotMarkElements.length > 0; };
        slotEl.addEventListener('slotchange', update);
        // CR-05 fix: push cleanup so the listener is removed on disconnectedCallback.
        this._disconnectCleanups.push(() => slotEl.removeEventListener('slotchange', update));
        update();
      }
    }

    {
      const slotEl = this.shadowRoot?.querySelector('slot[name="bubble"]');
      if (slotEl !== null && slotEl !== undefined) {
        const update = () => { this._hasSlotBubble = this._slotBubbleElements.length > 0; };
        slotEl.addEventListener('slotchange', update);
        // CR-05 fix: push cleanup so the listener is removed on disconnectedCallback.
        this._disconnectCleanups.push(() => slotEl.removeEventListener('slotchange', update));
        update();
      }
    }
  }

  connectedCallback(): void {
    // Phase 07.3.1 D-LIT-15 — pre-seed _hasSlot<X> from light DOM so first render isn't deadlocked.
    this._hasSlotMark = Array.from(this.children).some((el) => el.getAttribute('slot') === 'mark');
    this._hasSlotBubble = Array.from(this.children).some((el) => el.getAttribute('slot') === 'bubble');
    super.connectedCallback();
    if (this.hasUpdated && this._rozieTornDown) { this._rozieTornDown = false; this._armListeners(); }
  }

  firstUpdated(): void {
    this._armListeners();
  }

  disconnectedCallback(): void {
    super.disconnectedCallback();
    queueMicrotask(() => {
      if (this.isConnected || this._rozieTornDown) return;
      this._rozieTornDown = true;
      for (const fn of this._disconnectCleanups) fn();
      this._disconnectCleanups = [];
    });
  }

  attributeChangedCallback(name: string, old: string | null, value: string | null): void {
    super.attributeChangedCallback(name, old, value);
    if (name === 'value') this._valueControllable.notifyAttributeChange(value as unknown as unknown);
  }

  render() {
    return html`
<div class="${Object.entries({ "rozie-slider": true, 'rozie-slider--vertical': this.orientation === 'vertical', 'rozie-slider--horizontal': this.orientation !== 'vertical', 'rozie-slider--range': this.range, 'rozie-slider--disabled': this.disabled }).filter(([, v]) => v).map(([k]) => k).join(' ')}" style=${rozieStyle(this.fillStyle)} ${rozieSpread(this.$attrs)} ${rozieListeners(this.$listeners)} data-rozie-s-4e6f0be6>
  
  <div class="rozie-slider-track" aria-hidden="true" data-rozie-s-4e6f0be6>
    <div class="rozie-slider-fill" data-rozie-s-4e6f0be6></div>
  </div>

  
  ${this.normalizedMarks().length > 0 ? html`<div class="rozie-slider-marks" aria-hidden="true" data-rozie-s-4e6f0be6>
    
    ${repeat<any>(this.normalizedMarks(), (mark, _idx) => mark.value, (mark, _idx) => html`<div class="rozie-slider-mark" style=${styleMap({ left: this.pct(mark.value) + '%' })} data-rozie-s-4e6f0be6>
      ${this.mark !== undefined ? this.mark({value: mark.value, label: mark.label, position: this.pct(mark.value)}) : html`<slot name="mark" data-rozie-params=${(() => { try { return JSON.stringify({value: mark.value, label: mark.label, position: this.pct(mark.value)}); } catch { return '{}'; } })()}>
        <span class="rozie-slider-mark-label" data-rozie-s-4e6f0be6>${rozieDisplay(mark.label)}</span>
      </slot>`}
    </div>`)}
  </div>` : nothing}${this.showValue && !this.range ? html`<div class="rozie-slider-bubbles" aria-hidden="true" data-rozie-s-4e6f0be6>
    <div class="rozie-slider-bubble" style=${styleMap({ left: 'var(--rozie-slider-fill-end)' })} data-rozie-s-4e6f0be6>
      ${this.bubble !== undefined ? this.bubble({value: this.singleValue()}) : html`<slot name="bubble" data-rozie-params=${(() => { try { return JSON.stringify({value: this.singleValue()}); } catch { return '{}'; } })()}>
        <span class="rozie-slider-bubble-text" data-rozie-s-4e6f0be6>${rozieDisplay(this.display(this.singleValue()))}</span>
      </slot>`}
    </div>
  </div>` : nothing}${this.showValue && this.range ? html`<div class="rozie-slider-bubbles" aria-hidden="true" data-rozie-s-4e6f0be6>
    <div class="rozie-slider-bubble" style=${styleMap({ left: 'var(--rozie-slider-fill-start)' })} data-rozie-s-4e6f0be6>
      ${this.bubble !== undefined ? this.bubble({value: this.rangePair()[0]}) : html`<slot name="bubble" data-rozie-params=${(() => { try { return JSON.stringify({value: this.rangePair()[0]}); } catch { return '{}'; } })()}>
        <span class="rozie-slider-bubble-text" data-rozie-s-4e6f0be6>${rozieDisplay(this.display(this.rangePair()[0]))}</span>
      </slot>`}
    </div>
    <div class="rozie-slider-bubble" style=${styleMap({ left: 'var(--rozie-slider-fill-end)' })} data-rozie-s-4e6f0be6>
      ${this.bubble !== undefined ? this.bubble({value: this.rangePair()[1]}) : html`<slot name="bubble" data-rozie-params=${(() => { try { return JSON.stringify({value: this.rangePair()[1]}); } catch { return '{}'; } })()}>
        <span class="rozie-slider-bubble-text" data-rozie-s-4e6f0be6>${rozieDisplay(this.display(this.rangePair()[1]))}</span>
      </slot>`}
    </div>
  </div>` : nothing}${!this.range ? html`<input class="rozie-slider-input" type="range" min=${this.min} max=${this.max} step=${this.step} .value=${this.singleValue()} ?disabled=${!!this.disabled} aria-label=${rozieAttr(this.ariaLabel)} aria-orientation=${rozieAttr(this.orientation === 'vertical' ? 'vertical' : 'horizontal')} aria-valuetext=${rozieAttr(this.formatValue !== null ? this.display(this.singleValue()) : null)} @input=${($event: InputEvent & { currentTarget: HTMLInputElement; target: HTMLInputElement }) => { this.onInputSingle($event); }} @keydown=${($event: KeyboardEvent & { currentTarget: HTMLInputElement; target: HTMLInputElement }) => { this.onKeyDownSingle($event); }} data-rozie-ref="inputEl" data-rozie-s-4e6f0be6 />` : nothing}${this.range ? html`<input class="rozie-slider-input rozie-slider-input--lo" type="range" min=${this.min} max=${this.max} step=${this.step} .value=${this.rangePair()[0]} ?disabled=${!!this.disabled} aria-label=${rozieAttr(this.ariaLabel)} aria-orientation=${rozieAttr(this.orientation === 'vertical' ? 'vertical' : 'horizontal')} aria-valuetext=${rozieAttr(this.formatValue !== null ? this.display(this.rangePair()[0]) : null)} @input=${($event: InputEvent & { currentTarget: HTMLInputElement; target: HTMLInputElement }) => { this.onInputLo($event); }} @keydown=${($event: KeyboardEvent & { currentTarget: HTMLInputElement; target: HTMLInputElement }) => { this.onKeyDownRange('lo', $event); }} data-rozie-ref="inputEl" data-rozie-s-4e6f0be6 />` : nothing}${this.range ? html`<input class="rozie-slider-input rozie-slider-input--hi" type="range" min=${this.min} max=${this.max} step=${this.step} .value=${this.rangePair()[1]} ?disabled=${!!this.disabled} aria-label=${rozieAttr(this.ariaLabel)} aria-orientation=${rozieAttr(this.orientation === 'vertical' ? 'vertical' : 'horizontal')} aria-valuetext=${rozieAttr(this.formatValue !== null ? this.display(this.rangePair()[1]) : null)} @input=${($event: InputEvent & { currentTarget: HTMLInputElement; target: HTMLInputElement }) => { this.onInputHi($event); }} @keydown=${($event: KeyboardEvent & { currentTarget: HTMLInputElement; target: HTMLInputElement }) => { this.onKeyDownRange('hi', $event); }} data-rozie-s-4e6f0be6 />` : nothing}</div>
`;
  }

  // ---- numeric helpers ---------------------------------------------------
  // A plain function (not `$computed`) so it reads uniformly across all six
  // targets — it is called from both the fill $computed and the keyboard augment.
  pct = (v: any) => {
  const span = this.max - this.min;
  if (span === 0) return 0;
  const p = (v - this.min) / span * 100;
  if (p < 0) return 0;
  if (p > 100) return 100;
  return p;
};

  // The number of decimal places a finite number is written with (e.g. 0.1 -> 1,
  // 5 -> 0, -1.25 -> 2). A plain string-based heuristic — every `step`/`min` a
  // slider author passes is a simple decimal literal, never exponential
  // notation, so this is exact for the values this component actually sees.
  decimalPlaces = (n: any) => {
  if (!Number.isFinite(n)) return 0;
  const s = String(n);
  const i = s.indexOf('.');
  return i === -1 ? 0 : s.length - i - 1;
};

  // Clamp a raw number into [min,max] and quantize to `step` (guarding against a
  // non-finite or zero step). Returns a finite number bounded by the scale.
  //
  // `$props.min + steps * step` is exact math but NOT exact floating point: e.g.
  // step=0.1, steps=3 -> 0.30000000000000004 (binary floats can't represent
  // 0.1 precisely). Round the result to the step's (and min's) own decimal
  // precision — scaling by 10^places rather than `toFixed` so the model value
  // and `aria-valuetext` both report the clean decimal a consumer configured,
  // not a float artifact one ULP off.
  clampStep = (raw: any) => {
  if (!Number.isFinite(raw)) return this.min;
  let v = raw;
  if (v < this.min) v = this.min;
  if (v > this.max) v = this.max;
  const step = this.step;
  if (Number.isFinite(step) && step > 0) {
    const steps = Math.round((v - this.min) / step);
    v = this.min + steps * step;
    const places = Math.max(this.decimalPlaces(step), this.decimalPlaces(this.min));
    if (places > 0) {
      const factor = Math.pow(10, places);
      v = Math.round(v * factor) / factor;
    }
    if (v < this.min) v = this.min;
    if (v > this.max) v = this.max;
  }
  return v;
};

  // The current range pair, defaulting to the full span when `value` is not yet a
  // 2-tuple. Read into a stable local before destructuring — `$props.value`
  // lowers to a `value()` accessor on Solid, so narrowing one local is uniform.
  rangePair = () => {
  const cur = this.value;
  if (Array.isArray(cur) && cur.length === 2) return [cur[0], cur[1]];
  return [this.min, this.max];
};

  // The single (scalar) value, defaulting to min when not yet a number.
  singleValue = () => {
  const cur = this.value;
  return typeof cur === 'number' && Number.isFinite(cur) ? cur : this.min;
};

  // ---- derived fill (pure $computed → inline CSS vars, D-06/D-07) ---------
  // Read BARE in the template via :style="fillStyle". Returns the fill extent as a
  // % of the track. The rotate-90 vertical wrapper maps X→Y, so the SAME
  // start/end vars drive the (rotated) fill — no separate vertical math.
  get fillStyle() {
    let start, end;
    if (this.range) {
      const arr = Array.isArray(this.value) && this.value.length === 2 ? this.value : [this.min, this.max];
      start = this.pct(arr[0]);
      end = this.pct(arr[1]);
    } else {
      start = 0;
      end = this.pct(typeof this.value === 'number' && Number.isFinite(this.value) ? this.value : this.min);
    }
    return {
      '--rozie-slider-fill-start': start + '%',
      '--rozie-slider-fill-end': end + '%'
    };
  }

  // The marks list, normalised to { value, label } objects. A bare value[] entry
  // becomes { value, label: String(value) }. A plain function (not $computed) so
  // it reads uniformly and can be called in the r-for.
  normalizedMarks = () => {
  const list = Array.isArray(this.marks) ? this.marks : [];
  return list.map((m: any) => {
    if (m !== null && typeof m === 'object' && 'value' in m) {
      return {
        value: m.value,
        label: 'label' in m && m.label != null ? m.label : String(m.value)
      };
    }
    return {
      value: m,
      label: String(m)
    };
  });
};

  // Format a value for the bubble / aria-valuetext. A plain function: `$props.x`
  // reads uniformly inside it.
  display = (v: any) => {
  if (this.formatValue !== null) return this.formatValue(v);
  return String(v);
};

  // ---- write-back (single emit funnel) -----------------------------------
  // The SOLE `$emit('change')` site, called from every commit path so the React
  // prop-destructure for `onChange` hoists exactly once.
  fireChange = (value: any) => this.dispatchEvent(new CustomEvent("change", {
  detail: {
    value
  },
  bubbles: true,
  composed: true
}));

  // Single-mode commit: capture the fresh number, write the scalar, emit. Never
  // re-read $data after the write (ROZ138: React setState is async).
  commitSingle = (raw: any) => {
  const v = this.clampStep(raw);
  this._valueControllable.write(v);
  this.fireChange(v);
};

  // Range-mode commit: keep the [lo, hi] array SORTED and clamp each thumb at its
  // neighbour, then write a FRESH array (in-place mutation is dropped on
  // React/Solid/Lit/Angular change detectors — listbox precedent).
  commitRange = (which: any, raw: any) => {
  const pair = this.rangePair();
  let lo = pair[0];
  let hi = pair[1];
  const v = this.clampStep(raw);
  if (which === 'lo') lo = Math.min(v, hi);else hi = Math.max(v, lo);
  const next = [lo, hi];
  this._valueControllable.write(next);
  this.fireChange(next);
};

  // ---- native input handlers ---------------------------------------------
  // Single input. `valueAsNumber` is a number (never the string `.value`).
  onInputSingle = ($event: any) => this.commitSingle($event.target.valueAsNumber);

  // Range inputs (lo / hi).
  onInputLo = ($event: any) => this.commitRange('lo', $event.target.valueAsNumber);

  onInputHi = ($event: any) => this.commitRange('hi', $event.target.valueAsNumber);

  // ---- PageUp / PageDown augment (Open Q1 / RESEARCH A3) ------------------
  // Native PageUp/PageDown uses the browser's default large step, which may not
  // equal `pageStep`. Augment ONLY those two keys: apply ±pageStep (null → step×10),
  // quantize + clamp via clampStep, write back. Arrows / Home / End stay native.
  effectivePageStep = () => {
  const ps = this.pageStep;
  if (Number.isFinite(ps) && ps > 0) return ps;
  const step = Number.isFinite(this.step) && this.step > 0 ? this.step : 1;
  return step * 10;
};

  onKeyDownSingle = ($event: any) => {
  const key = $event.key;
  if (key !== 'PageUp' && key !== 'PageDown') return;
  $event.preventDefault();
  const delta = key === 'PageUp' ? this.effectivePageStep() : -this.effectivePageStep();
  this.commitSingle(this.singleValue() + delta);
};

  onKeyDownRange = (which: any, $event: any) => {
  const key = $event.key;
  if (key !== 'PageUp' && key !== 'PageDown') return;
  $event.preventDefault();
  const delta = key === 'PageUp' ? this.effectivePageStep() : -this.effectivePageStep();
  const pair = this.rangePair();
  const base = which === 'lo' ? pair[0] : pair[1];
  this.commitRange(which, base + delta);
};

  // ---- imperative handle (D-05) ------------------------------------------
  // `focus` reads $refs in a post-mount callback (called via the handle) — safe,
  // never eager (ROZ123). It DELIBERATELY overrides HTMLElement.focus on Lit
  // (ROZ137 warns; accepted — see header).
  focus = () => this._refInputEl?.focus();

  // Step a thumb by ±step. In range mode `thumb` selects 'lo' | 'hi' (default 'lo').
  increment = (thumb: any) => {
  if (this.range) {
    const which = thumb === 'hi' ? 'hi' : 'lo';
    const pair = this.rangePair();
    const base = which === 'lo' ? pair[0] : pair[1];
    this.commitRange(which, base + this.step);
  } else {
    this.commitSingle(this.singleValue() + this.step);
  }
};

  decrement = (thumb: any) => {
  if (this.range) {
    const which = thumb === 'hi' ? 'hi' : 'lo';
    const pair = this.rangePair();
    const base = which === 'lo' ? pair[0] : pair[1];
    this.commitRange(which, base - this.step);
  } else {
    this.commitSingle(this.singleValue() - this.step);
  }
};

  get value(): unknown { return this._valueControllable.read(); }
  set value(v: unknown) { this._valueControllable.notifyPropertyWrite(v); }

  /**
   * Plan 14-05 — cross-framework attribute fallthrough source. Reads the
   * host custom element's attributes on each call so a consumer-side bound
   * attribute flows through on every render. The `rozieSpread` directive
   * (D-02) does the cross-render diff downstream.
   *
   * Phase 15 follow-up Bug A — declared-prop attribute names are filtered
   * out so `$attrs` returns "rest after declared props" (semantic parity
   * with React/Vue/Svelte/Solid/Angular). Both Lit attribute-naming
   * forms are folded into the skip set: kebab-case for model props
   * (explicit `attribute:`) AND lowercased property name (Lit's default).
   *
   * command-palette-per-level-virtual / portal-through-portal cluster —
   * `data-rozie-ref` is ALWAYS skipped too (a reserved compiler bookkeeping
   * attribute, never a consumer prop) so a parent-assigned `ref=` on this
   * component's own host tag can never clobber this component's OWN
   * internal `data-rozie-ref` ref markers via fallthrough re-application.
   */
  private get $attrs(): Record<string, string> {
    const __skip = new Set<string>(['data-rozie-ref', 'value', 'range', 'min', 'max', 'step', 'orientation', 'disabled', 'marks', 'aria-label', 'arialabel', 'page-step', 'pagestep', 'format-value', 'formatvalue', 'show-value', 'showvalue']);
    const out: Record<string, string> = {};
    for (const a of Array.from(this.attributes)) {
      if (__skip.has(a.name)) continue;
      out[a.name] = a.value;
    }
    return out;
  }

  /**
   * Phase 15 D-19 — consumer-passed listener cluster placeholder.
   * Lit attaches event listeners directly on the host element via
   * `addEventListener` (no per-instance prop rest binding), so the
   * runtime value is undefined; the `rozieListeners` directive's
   * nullish coercion (`obj ?? {}`) handles the no-op cleanly.
   * The declaration exists to satisfy `tsc --noEmit` on consumer
   * projects with strict mode — bare `$listeners` in `render()`
   * would otherwise raise TS2304 (Cannot find name).
   */
  private get $listeners(): Record<string, EventListener> | undefined {
    return undefined;
  }
}

Each is a real component for its framework — React forwardRef + hooks, Vue <script setup> + defineModel, Svelte 5 runes, an Angular standalone component (with ControlValueAccessor), a Solid component, and a Lit custom element. Same props, same change event, same two-way value, same scoped slots, same imperative handle — identical on every target, built on the native <input type="range"> with no third-party engine behind it.

See also ​

Pre-1.0 — APIs may change between minor versions.