diff --git a/docs/decisions.md b/docs/decisions.md
index b01205535..74ccb02c0 100644
--- a/docs/decisions.md
+++ b/docs/decisions.md
@@ -99,6 +99,7 @@ in-file entries.
- [2026-07-18 — Unsupported PlaybackOrder is loud at build time; a declared support matrix and tripwire test make new orders safe by construction (#403)](#2026-07-18--unsupported-playbackorder-is-loud-at-build-time-a-declared-support-matrix-and-tripwire-test-make-new-orders-safe-by-construction-403)
- [2026-07-18 — CI build-once was measured and rejected; keep the #420 tree-skip](#2026-07-18--ci-build-once-was-measured-and-rejected-keep-the-420-tree-skip)
- [2026-07-18 — Never-scanned `LastScan` surfaces as null at the API boundary, not the 0001-01-01 MinValue sentinel (#409)](#2026-07-18--never-scanned-lastscan-surfaces-as-null-at-the-api-boundary-not-the-0001-01-01-minvalue-sentinel-409)
+- [2026-07-19 — WeightedShuffle SPA: weights edited on the multi-collection, order offered only on classic MultiCollection schedule items; fair-share is a reset not a mode (#404)](#2026-07-19--weightedshuffle-spa-weights-edited-on-the-multi-collection-order-offered-only-on-classic-multicollection-schedule-items-fair-share-is-a-reset-not-a-mode-404)
---
@@ -1942,3 +1943,42 @@ internal scanner code that coalesces to `SystemTime.MinValueUtc` for its own non
`LibrariesScreen.tsx` was removed — both call sites revert to a plain null/truthy check now that the API
is honest. (Follow-up option, not done here: have `MediaSourceRepository` write `null` instead of
`MinValue` so the data is clean at rest too; the read coercion makes that non-urgent.)
+
+## 2026-07-19 — WeightedShuffle SPA: weights edited on the multi-collection, order offered only on classic MultiCollection schedule items; fair-share is a reset not a mode (#404)
+
+The UI half of #70 (backend + API shipped in PR #402). No new endpoint or DTO — `weight` was already on
+`MultiCollectionItemRequest`/`…ResponseModel` and `WeightedShuffle` already in the `PlaybackOrder` enum; this
+is purely SPA (+ docs).
+
+- **Two surfaces, split by where each datum lives.** The per-source *weights* are a property of the
+ `MultiCollection`, so they are edited in the multi-collection editor (`/app/multi-collections`), not on the
+ schedule item. The *order* (`WeightedShuffle`) is a property of the schedule item, so it is added to the
+ classic schedule item's Playback Order options. Putting weights on the schedule item was rejected: the same
+ multi-collection can be referenced by many schedule items, and weights are shared, not per-reference.
+- **`WeightedShuffle` is offered ONLY for `collectionType === 'MultiCollection'`** (`itemRules.ts`
+ `MULTI_COLLECTION_ORDERS`) — a *meaningfulness* scope, not a rejection mirror. Weights only exist on
+ multi-collection members, so the order only does something with 2+ weighted sources; on a single collection
+ the classic engine still **accepts** it (verified by live-E2E: `PUT /schedules/{id}/items` with a plain
+ Collection + `WeightedShuffle` → 200) but it degrades to fair-share (≈ `Shuffle`), a confusing no-op — so
+ the SPA doesn't offer it there. The *rejection* the #70 entry describes is on the separate **playlist and
+ block** write paths (different engines); the playlist/block editors keep their own order lists (not
+ `MULTI_COLLECTION_ORDERS`) and already omit `WeightedShuffle`, so nothing extra was needed there.
+- **`WeightedShuffle` joins `ShuffleInOrder` in the `fillWithGroup` exclusion.** `fillWithGroupModeEligible`
+ already excluded `ShuffleInOrder`; WeightedShuffle is excluded for the same structural reason —
+ `PlayoutBuilder` splits a fill-with-group item into per-group enumerators scheduled one group at a time,
+ which is incompatible with WeightedShuffle's *whole-multi-collection* per-source share of airtime. Offering
+ the combination would silently not do what the name says.
+- **Fair-share is a "Reset to fair share" button, not a stateful toggle.** decisions.md 2026-07-17 established
+ that fair-share is not a separate mode — it is `WeightedShuffle` with all weights left at the default 1. So
+ there is nothing to toggle *on*: the button just sets every source's weight to 1 (disabled when already
+ there). A persistent toggle was rejected because "fair share" has no stored representation distinct from
+ "the weights all happen to be 1".
+- **Weights round-trip through the editor draft or they silently reset.** The multi-collection PUT replaces
+ the whole item list, so `weight` is read in `itemsFromMultiCollection` and written in `toItemRequest`;
+ dropping it would reset every source to 1 on the next unrelated save (rename, add a source). Covered by a
+ round-trip test. This is the general replace-all-DTO trap now recorded in `spa-conventions.md` §4.
+- **Percentages are display-only; the wire format is the integer weight.** A 3:1 weight shows as 75% / 25%,
+ computed from the clamped weights; the request always carries the integer relative share (1..1000). The
+ weight field is held as a string in the editor draft so it edits smoothly, clamped to the API validator's
+ 1..1000 on blur and again at save — an out-of-range value never reaches the server as a raw 400. `Input`
+ gained `min`/`max`/`inputMode`/`onBlur` passthroughs for this (reusable by #425's weight UI).
diff --git a/docs/domain-model.md b/docs/domain-model.md
index 593a1a9fa..01ee195a6 100644
--- a/docs/domain-model.md
+++ b/docs/domain-model.md
@@ -66,11 +66,11 @@ Channel (1) ──< Playout (0..N per channel; ChannelPlayoutSource distinguishe
| **FillerPreset** | A reusable filler definition: `FillerKind` (PreRoll/MidRoll/PostRoll/Tail/Fallback; also `GuideMode=99`, `DecoDefault=100`) × `FillerMode` (None/Duration/Count/Pad/RandomCount) over a collection/media-item/multi-collection/smart-collection/playlist source, with an optional `Expression` (NCalc). Referenced from `ProgramScheduleItem` (Pre/Mid/Post/Tail/FallbackFillerId) and `Channel.FallbackFillerId`. | `FillerPreset`, `FillerKind`, `FillerMode` | `/app/filler-presets` |
| **Clock-boundary padding** (#77) | Snapping a schedule to clean `:00/:15/:30`-style guide times is **not a separate feature** — it is `FillerMode.Pad` + `PadToNearestMinute` on a `FillerPreset` (Classic), or the `pad_to_next`/`pad_until` YAML instructions (Sequential). Block playouts are inherently clock-anchored via `TemplateItem.StartTime`. The EPG reflects the padded boundary automatically (`ChannelGuideProjector` coalesces trailing filler into the programme window). No one-click per-channel toggle yet — deferred to a UI follow-up blocked on #388. See `decisions.md` 2026-07-17. | `FillerPreset` (`Pad`) | `/app/filler-presets` |
| **Seasonal / date-conditional scheduling** (#73) | Holiday/seasonal channels are **not a separate feature** — they are the existing date predicate on `IAlternateScheduleItem`, implemented by `ProgramScheduleAlternate` (Classic) and `PlayoutTemplate` (Block), evaluated by `AlternateScheduleSelector.GetScheduleForDate` (first match by `Index`, catch-all last). **Leaving `StartYear`/`EndYear` empty makes the range repeat every year** — the "set once, works every December" switch; explicit years (required in pairs) mean a one-off window and disable wrap-around detection. Wrap-around (Nov→Feb) and invalid/leap dates (Feb 31) are handled. No *soft* prioritization primitive exists (binary first-match-wins); that ask belongs to #70's weighting work. See `channels.md` → "Recipe: seasonal / holiday programming" and `decisions.md` 2026-07-17. | `IAlternateScheduleItem`, `ProgramScheduleAlternate`, `PlayoutTemplate` | `/app/playouts/{id}/alternate-schedules`, `/app/playouts/{id}/templates` |
-| **Playback order** | How a schedule item's source(s) are sequenced (`PlaybackOrder`). Note three that are easily confused: **`Shuffle`** is Fisher–Yates over the flattened items, so airtime is implicitly proportional to collection size (a 200-episode show swamps a 20-episode one). **`ShuffleInOrder`** is a balanced shuffle (keyj) that pads sources to equal length with non-emitting spacers — it plays every item exactly once per cycle, so it prevents *clumping* but leaves airtime proportional to size; it is **not** fair-share. **`WeightedShuffle`** (#70) picks a *source* by smooth weighted round-robin then takes its next item, so each source's `Weight` is its share of airtime — equal weights (the default) mean equal airtime regardless of library size, with small sources looping. Classic engine only; rejected at the write path for playlist/block items. See `decisions.md` 2026-07-17. | `PlaybackOrder`, `MultiCollectionItem.Weight`, `MultiCollectionSmartItem.Weight` | `/app/schedules` (weight UI pending, gated on #388) |
+| **Playback order** | How a schedule item's source(s) are sequenced (`PlaybackOrder`). Note three that are easily confused: **`Shuffle`** is Fisher–Yates over the flattened items, so airtime is implicitly proportional to collection size (a 200-episode show swamps a 20-episode one). **`ShuffleInOrder`** is a balanced shuffle (keyj) that pads sources to equal length with non-emitting spacers — it plays every item exactly once per cycle, so it prevents *clumping* but leaves airtime proportional to size; it is **not** fair-share. **`WeightedShuffle`** (#70) picks a *source* by smooth weighted round-robin then takes its next item, so each source's `Weight` is its share of airtime — equal weights (the default) mean equal airtime regardless of library size, with small sources looping. Classic engine only; rejected at the write path for playlist/block items. See `decisions.md` 2026-07-17. | `PlaybackOrder`, `MultiCollectionItem.Weight`, `MultiCollectionSmartItem.Weight` | `WeightedShuffle` is offered as a Playback Order **only** on classic schedule items whose source is a MultiCollection (`web/src/schedules/itemRules.ts`, #404); the per-source weights themselves are edited at `/app/multi-collections` |
| **Watermark** | `ChannelWatermark` image overlay; attached at channel, schedule-item, block-item, deco, or playout-item level with position/size/opacity. | `ChannelWatermark`, `DecoWatermark`, `BlockItemWatermark`, `ProgramScheduleItemWatermark` | `/app/watermarks` |
| **Collection** | Manual list of media items (`CollectionItem`). | `Collection` | `/app/collections` |
| **SmartCollection** | Saved search — a `Query` string, no static item list. | `SmartCollection` | `/app/collections` |
-| **MultiCollection** | Combines multiple `Collection`s and/or `SmartCollection`s (with grouping via `MultiCollectionItem`/`MultiCollectionSmartItem`). Both join entities carry a per-source `Weight` (default 1) used by `PlaybackOrder.WeightedShuffle` (#70) and ignored by every other order — the two are mirrors, so a change to one belongs on the other. | `MultiCollection` | `/app/multi-collections` (#151) |
+| **MultiCollection** | Combines multiple `Collection`s and/or `SmartCollection`s (with grouping via `MultiCollectionItem`/`MultiCollectionSmartItem`). Both join entities carry a per-source `Weight` (default 1) used by `PlaybackOrder.WeightedShuffle` (#70) and ignored by every other order — the two are mirrors, so a change to one belongs on the other. The editor exposes a per-source weight input (1..1000, mirroring the API validator) with a computed % share and a "Reset to fair share" action; it round-trips `weight` from the GET because the PUT replaces the item list (#404). | `MultiCollection` | `/app/multi-collections` (#151, weight UI #404) |
| **RerunCollection** | One source (collection/media item/multi/smart) with separate `FirstRunPlaybackOrder` vs `RerunPlaybackOrder`. | `RerunCollection` | `/app/rerun-collections` (#152) |
| **Playlist** / **PlaylistGroup** | Ordered `PlaylistItem`s; `IsSystem` flag marks built-in/non-deletable playlists and groups. | `Playlist`, `PlaylistGroup`, `PlaylistItem` | `/app/playlists` |
| **Media kinds** | `CollectionType` enum distinguishes container kinds (Collection/TelevisionShow/TelevisionSeason/Artist/MultiCollection/SmartCollection/Playlist/RerunFirstRun/RerunRerun/SearchQuery) from leaf media kinds (Movie/Episode/MusicVideo/OtherVideo/Song/Image/RemoteStream) plus synthetic `FakeCollection`/`FakePlaylistItem`. Concrete media entities: `Movie`, `Show`/`Season`/`Episode`, `Artist`/`MusicVideo`/`Song`, `OtherVideo`, `Image`, `RemoteStream` (`ErsatzTV.Core/Domain/MediaItem/`). | `MediaItem` subclasses | `/app/media?kind=...` |
diff --git a/docs/spa-conventions.md b/docs/spa-conventions.md
index 9414579b1..ed585a12c 100644
--- a/docs/spa-conventions.md
+++ b/docs/spa-conventions.md
@@ -172,6 +172,18 @@ fresher edit:
ETag and the draft data come from one read and stay consistent (the fail-safe ordering; don't pair a
list-row draft with a separately-fetched ETag). Editors that navigate back to the list on save (Playout,
Multi/Rerun, Collection reorder) need only the 412 branch — no post-save ETag rotation.
+- **Replace-all PUT → round-trip every persisted field, even ones the editor doesn't visibly edit.** A
+ replace-all update (Multi/Rerun collection, schedule items, block items) submits the *whole* item list,
+ so any field the draft mapping drops is silently reset to its default server-side. The multi-collection
+ editor's per-source `weight` (#404) is the canonical trap: it must be read in `itemsFromMultiCollection`
+ and written in `toItemRequest`, or every source resets to weight 1 on the next save of an unrelated edit
+ (rename, add a source). When you add a field to a replace-all DTO, add it to **both** the response→draft
+ and draft→request mappings and cover the round-trip in a test.
+- **Numeric inputs with API bounds**: hold the value as a string in the draft so the field edits smoothly
+ (transient empty/partial), pass `type="number"` + `min`/`max`/`inputMode="numeric"` to `Input` for the
+ native stepper, clamp to the API's validator range on `onBlur` (immediate feedback) **and** again at save
+ (authoritative) — never let an out-of-range value reach the server and surface a raw 400. Mirror the
+ server's bound as a shared const (e.g. multi-collection weight `WEIGHT_MIN`/`WEIGHT_MAX` = 1..1000, #404).
## 5. Artwork rendering
diff --git a/web/src/components/forms.tsx b/web/src/components/forms.tsx
index 831836409..f96049bf2 100644
--- a/web/src/components/forms.tsx
+++ b/web/src/components/forms.tsx
@@ -1,4 +1,4 @@
-import type { ChangeEvent, CSSProperties, MouseEvent, ReactNode } from 'react';
+import type { ChangeEvent, CSSProperties, FocusEvent, MouseEvent, ReactNode } from 'react';
import { Check, ChevronDown, Minus } from 'lucide-react';
import { Spinner } from './feedback';
@@ -112,6 +112,13 @@ export interface InputProps {
type?: string;
fullWidth?: boolean;
style?: CSSProperties;
+ // Native passthroughs — added for numeric fields (e.g. the multi-collection weight input,
+ // #404): min/max bound the stepper, inputMode hints the mobile keyboard, onBlur normalizes on commit.
+ min?: number;
+ max?: number;
+ inputMode?: 'numeric' | 'decimal' | 'text';
+ onBlur?: (e: FocusEvent) => void;
+ ariaLabel?: string;
}
export function Input({
@@ -126,7 +133,12 @@ export function Input({
size = 'md',
type = 'text',
fullWidth = true,
- style
+ style,
+ min,
+ max,
+ inputMode,
+ onBlur,
+ ariaLabel
}: InputProps) {
return (