// shortplay-players.ts — THE PUBLIC PLAYERS CONTRACT (the SDK types module). // // ════════════════════════════════════════════════════════════════════════════════════════════ // FROZEN v1 (2026-10-02, CPO+Studio review) — Mega 1, cards pair-unit-transport-and-rooms / // feat-per-player-input-slots-for-phone-controller-multiplayer / // spec-tv-player-setup-and-sdk-managed-pairing-games-own-the-lobby. Studio reviewed this against // Downhill Dragons (a one-button HOLD game: press DURATION is gameplay); the seven review items are // folded in below (raw start/end edges, `cancel`, device-side `t`/`heldMs`, one-snapshot onPlayers, // `colorName`, keyboard `code`; item 7, a game-side room lock, is deferred to v1.1 — card // feat-sdk-game-side-room-lock-v1-1). From here: ADDITIVE changes only. // ════════════════════════════════════════════════════════════════════════════════════════════ // // This module is STANDALONE (like shortplay-sdk.ts): it imports nothing from the app's `src/` graph // and is consumed THREE ways — by the SDK (`ShortPlay.players()` etc.), by the platform's runtime // protocol (the parent→game `type:'players'` message carries a `PlayersSnapshot`), and by the host // hook that builds that snapshot from the relay's `room-state.slots`. // // SHAPE (from Design's lobby board, design/pairing-sdk-managed-lobby-join-board @ 0d529b5bb, with // the CPO's two corrections 2026-10-01): a game that sets `managesPlayers` OWNS the lobby — the // platform draws NOTHING over it. The SDK hands the game (a) the four seats with a THREE-state // `state` (empty / joined / held — held = the 2-minute hold after a drop, rejoinable even in a // locked room), (b) the join QR as an SVG string + the typed code (the lobby floor is QR + code), // and (c) every controller input tagged with the sender's slot. /** The four seats. P1 is first-come; a held seat keeps its id until the hold lapses. */ export const PLAYER_SLOT_IDS = ['P1', 'P2', 'P3', 'P4'] as const; export type PlayerSlotId = (typeof PLAYER_SLOT_IDS)[number]; /** The phone controller layouts (Mega 2, ADDITIVE on frozen v1). A game declares the ones it accepts * (manifest `controllerLayouts`, best fit FIRST); the paired phone offers only those, defaults to the * first, and the player can switch. `retro` (d-pad + A/B) and `twin-stick` contribute the `gamepad` * token; `touch` / `touch+dpad` keep the pointer / dpad tokens. */ export const CONTROLLER_LAYOUTS = ['touch', 'touch+dpad', 'retro', 'twin-stick'] as const; export type ControllerLayout = (typeof CONTROLLER_LAYOUTS)[number]; /** SDK D2b (R1): `move-and-aim` is a manifest / GameDefinition ALIAS for the `twin-stick` layout. It is * accepted ONLY at those two declaration points and normalised in memory by `normalizeLayout()`; the * relay wire value and the `players()` snapshot say `twin-stick` PERMANENTLY (an old SDK's validator * drops a snapshot with an unknown layout). The UI label is "Move + aim". */ export const CONTROLLER_LAYOUT_ALIASES = Object.freeze({ 'move-and-aim': 'twin-stick' } as const); export type ControllerLayoutInput = ControllerLayout | keyof typeof CONTROLLER_LAYOUT_ALIASES; /** The ONE normaliser: a layout name or alias -> the internal layout, or null when unknown. */ export function normalizeLayout(raw: unknown): ControllerLayout | null { if (typeof raw !== 'string') return null; if ((CONTROLLER_LAYOUTS as readonly string[]).includes(raw)) return raw as ControllerLayout; const alias = (CONTROLLER_LAYOUT_ALIASES as Record)[raw]; return alias ?? null; } /** `normalizeLayout` over a declared list: aliases resolved, unknowns dropped, duplicates removed (order kept). */ export function normalizeLayouts(raw: readonly unknown[] | undefined): ControllerLayout[] { const out: ControllerLayout[] = []; for (const item of raw ?? []) { const l = normalizeLayout(item); if (l && !out.includes(l)) out.push(l); } return out; } /** `empty` nobody; `joined` a live controller; `held` the controller dropped and the seat is * reserved for that same device until `heldUntil` (then it becomes `empty`). */ export type PlayerState = 'empty' | 'joined' | 'held'; export interface PlayerInfo { slot: PlayerSlotId; state: PlayerState; /** The design-system custom property for this seat's color (`--sp-player-1..4`). A sandboxed * game document does NOT carry the platform stylesheet, so `color` below is the resolved value. */ colorToken: `--sp-player-${1 | 2 | 3 | 4}`; /** The seat's resolved fill color (hex) — use this inside the game. */ color: string; /** The literal label to draw ("P1"). Color is never the only cue. */ label: PlayerSlotId; /** The color's name ("Sky") for first-learn copy / accessible names. */ colorName: string; /** Epoch ms the hold lapses — only when `state === 'held'`, else null. */ heldUntil: number | null; /** Mega 2 (ADDITIVE): present only when ONE phone holds this seat AND another (split halves) — * which half of the phone is this seat. Absent for an ordinary one-seat device. */ half?: 'left' | 'right'; /** ADDITIVE (integration, AMENDMENT-3 item D): what kind of device holds this seat — a phone, a * tablet, or the TV's own remote. Absent for an empty seat (and for a device kind not listed). */ device?: PlayerDevice; /** ADDITIVE (integration, AMENDMENT-3 item D): the controller layout that seat's phone is showing * RIGHT NOW. Re-sent when the player switches layout. Absent until the phone reports one, and * always absent for the remote. */ layout?: ControllerLayout; /** ADDITIVE (SDK D1, ruling R6): what the player is actually HOLDING, derived by the SDK from the * device type + layout the relay already sends — pick the glyphs/copy to show from this. Absent * for an empty seat (and for a device type this SDK does not know). See docs/maker/controllers.md. */ deviceKind?: PlayerDeviceKind; } /** ADDITIVE: the values of `PlayerInfo.deviceKind`. */ export const PLAYER_DEVICE_KINDS = ['remote', 'phone-classic', 'phone-move-aim', 'phone-touch', 'gamepad', 'keyboard'] as const; export type PlayerDeviceKind = (typeof PLAYER_DEVICE_KINDS)[number]; /** ADDITIVE: the device kinds a game is told about (`PlayerInfo.device`). */ export const PLAYER_DEVICES = ['phone', 'tablet', 'remote'] as const; export type PlayerDevice = (typeof PLAYER_DEVICES)[number]; /** What the game draws as its own lobby floor. `null` until the room is open. */ export interface PlayersLobby { /** The join QR as an `` string (draw it at >= the ten-foot floor; the platform does not). */ qrSvg: string | null; /** The typed join code ("ABCD"), the fallback beside the QR. */ code: string | null; /** True while the room is locked to NEW devices (a held seat can still rejoin). */ locked: boolean; } /** The full players state — what `snapshot()` returns, `onPlayers` delivers, and the parent pushes. */ export interface PlayersSnapshot { players: PlayerInfo[]; lobby: PlayersLobby; } /** One controller input, tagged with its sender's seat. `slot` is null only for an input with no * seat (e.g. the TV remote / an unseated device). Normalised coords are 0..1 within the stage. * * TIMING CONTRACT (frozen v1; a timing game like Downhill Dragons depends on every line): * - `start` fires at touch-down / key-down / button-down, BEFORE any gesture classification, and * `end` fires on release. A tap is a `start` then an `end` — never a batched pair delivered after * the platform decided it was a tap. `token` on a raw press may be a provisional label ('tap'); * do not branch on it for press/release logic, branch on `phase`. * - `cancel` ends a press that will never get an `end`: the controller dropped (the seat goes * `held`) or the OS cancelled the touch. Treat it as "released, do not score as a press". * - `t` is monotonic ms on the CONTROLLER's own clock (performance.now() there). Compare `t` * values from the SAME slot only — clocks are not comparable across devices. Wi-Fi jitter of * 30-100ms per edge is in arrival time, never in `t`. * - `heldMs` (on `end` only) is the press duration measured ON THE DEVICE (end `t` - start `t`). * Prefer it over differencing arrival times. * If a frame reaches the SDK with no device clock (an older controller), `t` is the SDK's own * arrival time (`performance.now()` in the game frame) and `heldMs` is absent. */ export interface SlotInputEvent { slot: PlayerSlotId | null; token: 'tap' | 'hold' | 'drag' | 'swipe' | 'multitouch' | 'keyboard' | 'mouse' | 'tilt' | 'gamepad' | 'dpad' | 'remote'; phase: 'start' | 'move' | 'end' | 'cancel'; /** Monotonic ms on the sender's own clock (see the timing contract above). */ t: number; /** Press duration in ms, measured on the device. Present on `end` when the controller measured it. */ heldMs?: number; x?: number; y?: number; dx?: number; dy?: number; dpadDirection?: 'up' | 'down' | 'left' | 'right' | 'select'; /** `KeyboardEvent.code` ("Space", "KeyA") — only on `token: 'keyboard'` events. */ code?: string; /** Mega 2 (ADDITIVE): the phone layout that produced this event (absent from older controllers). */ layout?: ControllerLayout; /** Mega 2 (ADDITIVE): which control produced a `gamepad` token — the retro A/B buttons or a * twin-stick zone (`dx`/`dy` carry the stick vector, -1..1 of the zone radius). */ control?: 'A' | 'B' | 'Select' | 'Start' | 'stick-left' | 'stick-right'; /** Mega 2 + Mega 4 (ADDITIVE): the W3C Gamepad "standard" mapping button index (0..16) this edge is * for — A=0, B=1, Select=8, Start=9, d-pad up/down/left/right=12/13/14/15. What the phone's retro * pad sends on `gamepad` / `dpad` edges. The Gamepad shim consumes it when present and falls back * to mapping `dpadDirection`/`token` when absent. */ button?: number; /** Mega 2 + Mega 4 (ADDITIVE): the W3C standard axes (-1..1) — `[lx, ly]`, or * `[leftX, leftY, rightX, rightY]` twin-stick (max 4; the full state of both zones). */ axes?: number[]; } /** Mega 4: the SHARED field-name/limit home for the Gamepad shim wire fields (`button`, `axes`). The * phone (Mega 2) writes them, the relay + host bridge pass them, the shim reads them. */ export const GAMEPAD_BUTTON_MAX = 16; export const GAMEPAD_AXES_MAX = 4; /** The SDK surface, available on `ShortPlay` for a game that opts in with `managesPlayers: true`. */ export interface PlayersApi { /** The four seats right now (always length 4, P1..P4). Convenience for `snapshot().players`. */ players(): PlayerInfo[]; /** The lobby floor: QR svg + typed code (+ lock state). Convenience for `snapshot().lobby`. */ lobby(): PlayersLobby; /** Players + lobby read together, consistent with each other. */ snapshot(): PlayersSnapshot; /** Fires with ONE consistent snapshot (players + lobby) every time anything changes: a seat * joins / drops / is held / lapses, or the join code / QR / lock changes. Returns an unsubscribe. */ onPlayers(cb: (snapshot: PlayersSnapshot) => void): () => void; /** Fires for every controller input, tagged with `slot`. For a `managesPlayers` game this is the * ONLY input channel (the untagged synthetic-DOM path is off). Returns an unsubscribe. */ onInput(cb: (event: SlotInputEvent) => void): () => void; } /** The seat table — one place for label / color token / resolved fill / name. Fills mirror * `--sp-player-1..4` in the app's tokens.css (kept in step by a unit test). */ export const PLAYER_SLOT_TABLE: Readonly< Record > = Object.freeze({ P1: { token: '--sp-player-1', color: '#5ac8fa', colorName: 'Sky' }, P2: { token: '--sp-player-2', color: '#ff7a59', colorName: 'Coral' }, P3: { token: '--sp-player-3', color: '#fff02e', colorName: 'Yellow' }, P4: { token: '--sp-player-4', color: '#8266ff', colorName: 'Violet' }, }); /** A seat as the relay reports it (`room-state.slots[i]`) — the subset this module reads. */ export interface RelaySlotView { slot: PlayerSlotId; state: PlayerState; heldUntil: number | null; half?: 'left' | 'right' | null; /** ADDITIVE: the relay's seat device type (`phone` / `tablet` / `remote` / other) and its phone's * reported layout — mapped to `PlayerInfo.device` / `PlayerInfo.layout`. */ deviceType?: string | null; layout?: ControllerLayout | null; } /** relay `slots` -> the public `PlayerInfo[]` (always the four seats, in order, even if the relay * reports fewer — a missing seat reads `empty`). */ export function playersFromRelaySlots(slots: readonly RelaySlotView[] | undefined): PlayerInfo[] { return PLAYER_SLOT_IDS.map((slot): PlayerInfo => { const view = slots?.find((s) => s.slot === slot); const spec = PLAYER_SLOT_TABLE[slot]; const state: PlayerState = view?.state ?? 'empty'; return { slot, state, colorToken: spec.token, color: spec.color, label: slot, colorName: spec.colorName, heldUntil: state === 'held' ? (view?.heldUntil ?? null) : null, ...(view?.half ? { half: view.half } : {}), ...seatDevice(state, view), }; }); } /** ADDITIVE: `device` / `layout` for a seat somebody holds. Fail closed: an unknown device kind or * layout is simply left out, and the remote never carries a layout. */ function seatDevice(state: PlayerState, view: RelaySlotView | undefined): Pick { if (state === 'empty' || !view) return {}; // `device` keeps its three legacy values: an OLD game-side SDK's validator rejects the whole snapshot // on any other `device`, so a gamepad/keyboard seat is expressed ONLY through the new `deviceKind`. const device = (PLAYER_DEVICES as readonly string[]).includes(view.deviceType ?? '') ? (view.deviceType as PlayerDevice) : undefined; const layout = device !== 'remote' && (CONTROLLER_LAYOUTS as readonly string[]).includes(view.layout ?? '') ? (view.layout as ControllerLayout) : undefined; const deviceKind = deriveDeviceKind(view.deviceType, view.layout); return { ...(device ? { device } : {}), ...(layout ? { layout } : {}), ...(deviceKind ? { deviceKind } : {}) }; } /** R6: the relay's deviceType + layout -> `deviceKind`. `twin-stick` stays the wire layout (R1); the * kind says `phone-move-aim`. Unknown device types derive nothing (fail closed). */ function deriveDeviceKind(deviceType: string | null | undefined, layout: string | null | undefined): PlayerDeviceKind | undefined { switch (deviceType) { case 'remote': return 'remote'; case 'gamepad': return 'gamepad'; case 'keyboard': return 'keyboard'; case 'phone': case 'tablet': return layout === 'retro' ? 'phone-classic' : layout === 'twin-stick' ? 'phone-move-aim' : 'phone-touch'; default: return undefined; } } /** An all-empty seat list — what `players()` returns before the first snapshot arrives. */ export function emptyPlayers(): PlayerInfo[] { return playersFromRelaySlots(undefined); } const PLAYERS_STRING_MAX = 65_536; /** Structural validator for a parent→game `PlayersSnapshot` payload (adversarial input: the SDK runs * in a sandbox and must not trust a malformed frame). */ export function isPlayersSnapshot(x: unknown): x is PlayersSnapshot { if (typeof x !== 'object' || x === null) return false; const rec = x as Record; if (!Array.isArray(rec.players) || rec.players.length !== PLAYER_SLOT_IDS.length) return false; for (let i = 0; i < PLAYER_SLOT_IDS.length; i++) { const p = rec.players[i] as Record | null; if (typeof p !== 'object' || p === null) return false; if (p.slot !== PLAYER_SLOT_IDS[i]) return false; if (p.state !== 'empty' && p.state !== 'joined' && p.state !== 'held') return false; if (p.heldUntil !== null && (typeof p.heldUntil !== 'number' || !Number.isFinite(p.heldUntil))) return false; // ADDITIVE optional fields: absent is fine, a present value must be a known one (fail closed). if (p.device !== undefined && !(PLAYER_DEVICES as readonly unknown[]).includes(p.device)) return false; if (p.layout !== undefined && !(CONTROLLER_LAYOUTS as readonly unknown[]).includes(p.layout)) return false; // FORWARD-compatible (AMENDMENT-1): a non-string still rejects, but an UNKNOWN string must not drop // the whole snapshot — a newer host may add kinds. `normalizePlayersSnapshot` strips it at the boundary. if (p.deviceKind !== undefined && typeof p.deviceKind !== 'string') return false; } const lobby = rec.lobby as Record | null; if (typeof lobby !== 'object' || lobby === null) return false; for (const f of ['qrSvg', 'code'] as const) { const v = lobby[f]; if (v !== null && (typeof v !== 'string' || v.length > PLAYERS_STRING_MAX)) return false; } return typeof lobby.locked === 'boolean'; } /** Boundary normaliser for a VALIDATED snapshot: a seat whose `deviceKind` this SDK does not know (a * newer host) reaches the game with `deviceKind` ABSENT, everything else intact. Builds new objects; * never mutates the caller's. Keeps `PlayerInfo.deviceKind` honest (only `PlayerDeviceKind` values). */ export function normalizePlayersSnapshot(snap: PlayersSnapshot): PlayersSnapshot { const known = PLAYER_DEVICE_KINDS as readonly unknown[]; return { ...snap, players: snap.players.map((p) => { if (p.deviceKind === undefined || known.includes(p.deviceKind)) return p; const { deviceKind: _unknown, ...rest } = p; return rest; }), }; } // ----- controller labels (SDK D2a) -------------------------------------------------------- // // A game may NAME its pad's face keys and stick captions. Keys are the W3C button index as a string // ('0' = A, '1' = B, '8' = Select, '9' = Start) plus the twin-stick captions 'move' / 'aim'. // Select/Start ('8'/'9') are accepted and validated but not yet drawn (D2b: needs a no-truncate MenuItem). // MAX 8 characters, a DEV-TIME error at declare time: a label is NEVER truncated. Whether a label FITS // the 64px key is a WIDTH question the phone answers at render time (fit ladder, then default fallback). export const CONTROLLER_LABEL_KEYS = ['0', '1', '8', '9', 'move', 'aim'] as const; export type ControllerLabelKey = (typeof CONTROLLER_LABEL_KEYS)[number]; export type ControllerLabels = Partial>; export const CONTROLLER_LABEL_MAX = 8; const isLabelKey = (k: string): k is ControllerLabelKey => (CONTROLLER_LABEL_KEYS as readonly string[]).includes(k); const labelLength = (s: string): number => Array.from(s).length; /** Control (Cc), format (Cf), line (Zl) and paragraph (Zp) separators: never legal in a label (R8). */ const UNSAFE_LABEL_CHARS = /[\p{Cc}\p{Cf}\p{Zl}\p{Zp}]/u; /** R8 sanitising, the ONE rule (mirrored in api/src/tv/protocol.ts and tested against the same table, * app/tests/games-sdk/controllerLabelTable.ts): NFC, trim, then DROP (null) a non-string, an empty label, * one holding a Cc/Cf/Zl/Zp character, or (only when `maxChars` is given) one longer than `maxChars` * code points. Never truncates, never throws. */ export function sanitizeControllerLabel(raw: unknown, maxChars?: number): string | null { if (typeof raw !== 'string') return null; const v = raw.normalize('NFC').trim(); if (v.length === 0 || UNSAFE_LABEL_CHARS.test(v)) return null; if (maxChars !== undefined && labelLength(v) > maxChars) return null; return v; } /** DECLARE-TIME validation (the game's own bundle). Throws on a known key whose label is empty, * non-string or longer than 8 characters. An unknown key is ignored (a newer SDK vocabulary). */ export function validateControllerLabels(labels: ControllerLabels): void { for (const [key, value] of Object.entries(labels)) { if (!isLabelKey(key)) continue; if (typeof value !== 'string' || value.length === 0) { throw new Error(`controllerLabels['${key}'] must be a non-empty string.`); } if (UNSAFE_LABEL_CHARS.test(value)) { throw new Error(`controllerLabels['${key}'] contains a control or format character; use plain printable text.`); } if (labelLength(value) > CONTROLLER_LABEL_MAX) { throw new Error( `controllerLabels['${key}'] = '${value}' is ${labelLength(value)} characters; the maximum is ${CONTROLLER_LABEL_MAX}. Labels are never truncated.`, ); } } } /** RECEIVE-SIDE boundary (phone / old client): keeps only the keys + values this client understands and * DROPS the rest. Never throws and never rejects the carrying frame. An over-long known label is kept * as received: the pad's render-time fit falls back to the key's default. */ export function normalizeControllerLabels(raw: unknown): ControllerLabels { if (typeof raw !== 'object' || raw === null || Array.isArray(raw)) return {}; const out: ControllerLabels = {}; for (const [key, value] of Object.entries(raw)) { if (!isLabelKey(key)) continue; const clean = sanitizeControllerLabel(value); if (clean !== null) out[key] = clean; } return out; } // ----- slot style (SDK D2b) ---------------------------------------------------------------- // // A game may set a seat's LABEL and COLOUR: `ShortPlay.setSlotStyle('P1', { label: 'Red Team', color: 3 })`. // `color` is a slot-token reference ONLY (1-4 or 'P1'-'P4', resolved through PLAYER_SLOT_TABLE) - never a // literal colour (R4). The seat's "P" badge ALWAYS renders (RULING 5). Max 12 chars, printable, no URLs; // a label that fails falls back to "P" with a dev-console warning (RULING 3). export const SLOT_LABEL_MAX = 12; export type SlotColorToken = 1 | 2 | 3 | 4; export interface SlotStyleInput { label?: string; /** A slot token: 1-4, or 'P1'-'P4'. */ color?: SlotColorToken | PlayerSlotId; } export interface SlotStyle { label?: string; color?: SlotColorToken; } export type SlotStyles = Partial>; const SLOT_URL_LIKE = /(?:[a-z][a-z0-9+.-]*:\/\/|www\.|[a-z0-9]\.(?:com|net|org|io|gg|app|dev|co|me|ly|xyz|tv|info|ru|cn)(?![a-z0-9]))/i; /** The seat label rule (R8 floor + RULING 3): NFC/trim, no Cc/Cf/Zl/Zp, max 12, no URLs. null = invalid. */ export function sanitizeSlotLabel(raw: unknown): string | null { const clean = sanitizeControllerLabel(raw, SLOT_LABEL_MAX); return clean === null || SLOT_URL_LIKE.test(clean) ? null : clean; } /** A slot-token reference (1-4 or 'P1'-'P4') -> 1-4; anything else (a hex string, 5, 'red') -> null. */ export function slotColorToken(raw: unknown): SlotColorToken | null { if (raw === 1 || raw === 2 || raw === 3 || raw === 4) return raw; if (typeof raw === 'string' && (PLAYER_SLOT_IDS as readonly string[]).includes(raw)) return Number(raw.slice(1)) as SlotColorToken; return null; } /** The resolved fill for a slot token, through PLAYER_SLOT_TABLE. */ export function slotColorFill(token: SlotColorToken): string { return PLAYER_SLOT_TABLE[`P${token}` as PlayerSlotId].color; } /** Validate a game's style for `slot`. `warn` gets one dev-console line per rejected part; a rejected label * or colour is simply left out (the seat shows "P" / its own colour). */ export function validateSlotStyle(slot: unknown, style: unknown, warn: (message: string) => void): { slot: PlayerSlotId; style: SlotStyle } | null { if (typeof slot !== 'string' || !(PLAYER_SLOT_IDS as readonly string[]).includes(slot)) { warn(`setSlotStyle: unknown slot ${JSON.stringify(slot)}; use 'P1'..'P4'.`); return null; } const id = slot as PlayerSlotId; const rec = typeof style === 'object' && style !== null ? (style as Record) : {}; const out: SlotStyle = {}; if (rec.label !== undefined) { const label = sanitizeSlotLabel(rec.label); if (label === null) warn(`setSlotStyle: label ${JSON.stringify(rec.label)} for ${id} is not allowed (max ${SLOT_LABEL_MAX} printable characters, no URLs); showing "${id}".`); else out.label = label; } if (rec.color !== undefined) { const color = slotColorToken(rec.color); if (color === null) warn(`setSlotStyle: color ${JSON.stringify(rec.color)} for ${id} is not a slot token; use 1-4 or 'P1'-'P4' (never a literal colour).`); else out.color = color; } return { slot: id, style: out }; } /** RECEIVE-SIDE (phone / old client): a relay `slotStyles` value -> only the seats / fields this client * understands. Never throws; an unknown seat, a bad label or a non-token colour is dropped. */ export function normalizeSlotStyles(raw: unknown): SlotStyles { if (typeof raw !== 'object' || raw === null || Array.isArray(raw)) return {}; const out: SlotStyles = {}; for (const id of PLAYER_SLOT_IDS) { const v = (raw as Record)[id]; if (typeof v !== 'object' || v === null) continue; const label = sanitizeSlotLabel((v as Record).label); const color = slotColorToken((v as Record).color); if (label !== null || color !== null) out[id] = { ...(label === null ? {} : { label }), ...(color === null ? {} : { color }) }; } return out; }