// KueSync — Companion feedbacks
// © 2026 Krometis Analytics. All rights reserved.

import { combineRgb } from '@companion-module/base'

const WHITE = combineRgb(255, 255, 255)
const GREEN = combineRgb(0, 150, 0)
const RED = combineRgb(180, 0, 0)
const AMBER = combineRgb(180, 120, 0)
const BLUE = combineRgb(0, 80, 200)

// 0.12.10: resolve an at-zero colour token (curated name or #rrggbb) to a button bgcolor for the timer feedback.
function zeroColorRgb(tok) {
  const s = String(tok || 'red').trim().toLowerCase()
  const named = { red: combineRgb(255, 91, 94), amber: combineRgb(255, 204, 0), white: WHITE, green: combineRgb(47, 210, 79), blue: combineRgb(92, 155, 255) }
  if (named[s]) return named[s]
  const m = /^#([0-9a-f]{2})([0-9a-f]{2})([0-9a-f]{2})$/.exec(s)
  if (m) return combineRgb(parseInt(m[1], 16), parseInt(m[2], 16), parseInt(m[3], 16))
  return named.red
}

const primaryState = (self) => (self.primary && self.primary() ? self.primary().state : { slide: 0, playing: false })

// 0.12.49.1: map a surface background token to a button swatch colour so a "cycle background" key shows the
// current look. Green/blue mirror the chroma-key shades used on the full-screen pages.
function bgRgb(tok) {
  const s = String(tok || 'black').trim().toLowerCase()
  const m = { black: combineRgb(0, 0, 0), white: WHITE, green: combineRgb(0, 177, 64), blue: combineRgb(0, 90, 200) }
  return m[s] || m.black
}

// True when the timer source is NOT live — a dropped or stale leader whose clock we would only be
// extrapolating. The hardware-driving feedbacks gate on this so a GPI/switcher/lighting cue never fires off a
// dead connection. Defensive by construction: if _timerLive is absent (unit-test stubs that inject a timer
// directly), we do NOT block — those tests assert the state logic, and the liveness gate is exercised
// separately. In the real module _timerLive always exists.
function notLive(self) { return typeof self._timerLive === 'function' && !self._timerLive() }

export function getFeedbacks(self) {
  return {
    // 0.9.6: one feedback that paints a follower button green when Active, red when Inactive (parked),
    // by machine number. Pair it with the Activate / Deactivate / Toggle actions for a button that both
    // controls AND shows state at a glance. Unknown / not-yet-reporting machine → left unstyled.
    follower_active: {
      type: 'advanced',
      name: 'Follower Active (green) / Inactive (red) — pick the follower',
      options: [
        { type: 'dropdown', id: 'machine', label: 'Follower (by name · IP)', default: (self.followerChoices ? (self.followerChoices()[0] || {}).id : '1') || '1', choices: self.followerChoices ? self.followerChoices() : [{ id: '1', label: 'Machine 1' }], allowCustom: true, tooltip: 'Pick the machine by its name (or type a machine number / IP).' },
        { type: 'colorpicker', id: 'activeColor', label: 'Active background', default: GREEN },
        { type: 'colorpicker', id: 'inactiveColor', label: 'Inactive background', default: RED },
      ],
      callback: (fb) => {
        const t = self.targetFor ? self.targetFor(String(fb.options.machine || '1').trim()) : null
        if (!t) return {}
        const active = t.state ? t.state.followerActive !== false : true
        return { bgcolor: active ? (fb.options.activeColor ?? GREEN) : (fb.options.inactiveColor ?? RED), color: WHITE }
      },
    },
    // 0.12.53: lit when the picked follower is a SLIDE DRIVER (its USB/keystrokes drive the fleet via the Leader).
    follower_slidedriver: {
      type: 'advanced',
      name: 'Follower Slide driver (on / off) — pick the follower',
      options: [
        { type: 'dropdown', id: 'machine', label: 'Follower (by name · IP)', default: (self.followerChoices ? (self.followerChoices()[0] || {}).id : '1') || '1', choices: self.followerChoices ? self.followerChoices() : [{ id: '1', label: 'Machine 1' }], allowCustom: true, tooltip: 'Pick the machine by its name (or type a machine number / IP).' },
        { type: 'colorpicker', id: 'driverColor', label: 'Slide-driver background', default: GREEN },
        { type: 'colorpicker', id: 'offColor', label: 'Not-a-driver background', default: 0x1b2733 },
      ],
      callback: (fb) => {
        const t = self.targetFor ? self.targetFor(String(fb.options.machine || '1').trim()) : null
        if (!t) return {}
        const driver = t.state ? t.state.slideDriver === true : false
        return { bgcolor: driver ? (fb.options.driverColor ?? GREEN) : (fb.options.offColor ?? 0x1b2733), color: WHITE }
      },
    },
    // 0.12.55: lights amber when ANY follower is connected on a DIFFERENT subnet than the Leader's primary
    // (still working on the best available path — just a heads-up the fleet isn't all on one subnet).
    fleet_offsubnet: {
      type: 'advanced',
      name: 'Fleet: a follower is on a different subnet (amber)',
      options: [
        { type: 'colorpicker', id: 'onColor', label: 'When a follower is off-subnet', default: combineRgb(150, 108, 30) },
      ],
      callback: () => {
        const lead = self.leaderTarget ? self.leaderTarget() : null
        const ff = lead && lead.state && Array.isArray(lead.state.fleetFollowers) ? lead.state.fleetFollowers : []
        const off = ff.some((f) => f && f.offSubnet)
        return off ? { bgcolor: combineRgb(150, 108, 30), color: WHITE } : {}
      },
    },
    connected: {
      type: 'boolean', name: 'At least one machine connected',
      defaultStyle: { bgcolor: GREEN, color: WHITE }, options: [],
      callback: () => (self.connectedCount ? self.connectedCount() > 0 : false),
    },
    all_connected: {
      type: 'boolean', name: 'All machines connected',
      defaultStyle: { bgcolor: GREEN, color: WHITE }, options: [],
      callback: () => self.targets.length > 0 && self.connectedCount() === self.targets.length,
    },
    all_healthy: {
      // Green only when every configured machine is HEALTHY (reporting, not just socket-open). This
      // is the one to trust for redundancy — a wedged/frozen backup with an open socket is NOT green.
      type: 'boolean', name: 'All machines HEALTHY (reporting, not stale)',
      defaultStyle: { bgcolor: GREEN, color: WHITE }, options: [],
      callback: () => self.targets.length > 0 && self.healthyCount() === self.targets.length,
    },
    playing: {
      type: 'boolean', name: 'Slideshow playing (primary)',
      defaultStyle: { bgcolor: BLUE, color: WHITE }, options: [],
      callback: () => primaryState(self).playing === true,
    },
    on_slide: {
      type: 'boolean', name: 'On a specific slide (primary)',
      defaultStyle: { bgcolor: BLUE, color: WHITE },
      options: [{ type: 'number', id: 'slide', label: 'Slide number', default: 1, min: 1, max: 9999 }],
      callback: (fb) => primaryState(self).slide === Number(fb.options.slide),
    },
    cue_running: {
      type: 'boolean', name: 'Cue running',
      defaultStyle: { bgcolor: GREEN, color: WHITE }, options: [],
      callback: () => self.cue.running && !self.cue.paused,
    },
    fleet_in_sync: {
      // Goes RED when any connected machine is on a different slide than the rest — your real-time
      // confidence indicator for the redundant fleet (esp. in relative/+builds mode).
      type: 'boolean', name: 'Fleet OUT of sync (any machine on a different slide)',
      defaultStyle: { bgcolor: RED, color: WHITE }, options: [],
      callback: () => {
        const p = self.primary && self.primary()
        if (!(p && p.state && p.state.playing)) return false   // no show running → 'paused', not drift
        return self.fleetSync ? !self.fleetSync().inSync : false
      },
    },
    leader_known: {
      // GREEN when a healthy Leader is driving the fleet; AMBER when a Leader is reported but stale
      // (mid-failover); default (no colour) when there is no Leader (Companion-only / pre-v6 agents).
      type: 'advanced', name: 'Current Leader colour (healthy=green, stale=amber, none=neutral)', options: [],
      callback: () => {
        const ld = self.currentLeader ? self.currentLeader() : { present: false, healthy: false }
        if (ld.present && ld.healthy) return { bgcolor: GREEN, color: WHITE }
        if (ld.present) return { bgcolor: AMBER, color: WHITE }
        return {}
      },
    },
    media_ending: {
      // Turns the button amber, then red, as a playing video nears its end — so the operator sees the
      // wrap coming. Only fires while a video clock is actually running (not when armed/idle).
      type: 'advanced', name: 'Video ending soon (amber, then red in the final seconds)',
      options: [
        { type: 'number', id: 'amberAt', label: 'Amber at (seconds remaining)', default: 30, min: 1, max: 3600 },
        { type: 'number', id: 'redAt', label: 'Red at (seconds remaining)', default: 10, min: 1, max: 3600 },
      ],
      callback: (fb) => {
        if (!(self.videoActive && self.videoActive())) return {}
        const left = self.videoRemainingSecs ? self.videoRemainingSecs() : 999999
        const red = Number(fb.options.redAt), amber = Number(fb.options.amberAt)
        if (left <= red) return { bgcolor: RED, color: WHITE }
        if (left <= amber) return { bgcolor: AMBER, color: WHITE }
        return {}
      },
    },
    cue_light: {
      // hardware-style lamp: GREEN flashes on a forward move, RED on a backward one, mirroring
      // the presenter's clicker. Output only. Put it on one key, or tile it across many keys to build a
      // big cue light. "Go only" / "Back only" build a two-zone panel (green half, red half).
      type: 'advanced', name: 'Cue light (cue lamp — green forward / red back)',
      options: [
        { type: 'dropdown', id: 'lamp', label: 'Lamp', default: 'active', choices: [
          { id: 'active', label: 'Active — green on forward, red on back' },
          { id: 'go', label: 'Go lamp — green on forward only' },
          { id: 'back', label: 'Back lamp — red on back only' },
        ] },
        { type: 'colorpicker', id: 'goColor', label: 'Go colour (forward)', default: combineRgb(47, 180, 107) },
        { type: 'colorpicker', id: 'backColor', label: 'Back colour (reverse)', default: combineRgb(229, 72, 77) },
        { type: 'colorpicker', id: 'idleColor', label: 'Idle colour (between cues)', default: combineRgb(14, 20, 25) },
        { type: 'colorpicker', id: 'textColor', label: 'Text colour', default: WHITE },
      ],
      // 0.10.19: evaluate the moment a button gets this feedback (add OR config load) so the lamp lights
      // immediately — no more "remove + re-add to make it work". Deferred so the instance is registered first.
      subscribe: () => { try { setTimeout(() => self.checkFeedbacks('cue_light'), 0) } catch {} },
      callback: (fb) => {
        const st = self.cueState ? self.cueState() : 'idle'
        const lamp = fb.options.lamp || 'active'
        // Resolve EVERY colour with a ?? fallback (matches the kuetimer feedback). A button added under an
        // older module — before these colour options existed — has fb.options.*Color === undefined; reading
        // them raw returned { bgcolor: undefined } and the lamp painted NOTHING, so it looked dead until a
        // manual re-add seeded the new defaults. With fallbacks the lamp lights correctly with no re-add.
        const goC = fb.options.goColor ?? combineRgb(47, 180, 107)
        const backC = fb.options.backColor ?? combineRgb(229, 72, 77)
        const idleC = fb.options.idleColor ?? combineRgb(14, 20, 25)
        let bg = idleC
        if ((lamp === 'active' || lamp === 'go') && st === 'go') bg = goC
        else if ((lamp === 'active' || lamp === 'back') && st === 'back') bg = backC
        return { bgcolor: bg, color: fb.options.textColor ?? WHITE }
      },
    },
    // 0.12.1 CUE LAMP (BOOLEAN). The advanced `cue_light` above is provably correct but Companion 4.3.4 won't
    // re-render a PLACED advanced feedback after an app/connection reload (the lamp went dead until a manual
    // re-add). These BOOLEAN feedbacks are rendered by CORE Companion (it owns the style), which survives a
    // reload. Stack BOTH on one key for a green-forward / red-back lamp, or use one alone for a single-colour
    // zone. The colour is the feedback's editable style, so an operator can recolour without touching the module.
    lamp_go: {
      type: 'boolean', name: 'Cue lamp — GREEN on a forward move',
      description: 'Turns the key green when the fleet advances (flash or latch per the connection’s Cue light mode). Stack with the RED lamp to build a two-colour cue light.',
      defaultStyle: { bgcolor: combineRgb(47, 180, 107), color: WHITE },
      options: [],
      callback: () => (self.cueState ? self.cueState() === 'go' : false),
    },
    lamp_back: {
      type: 'boolean', name: 'Cue lamp — RED on a back move',
      description: 'Turns the key red when the fleet goes back.',
      defaultStyle: { bgcolor: combineRgb(229, 72, 77), color: WHITE },
      options: [],
      callback: () => (self.cueState ? self.cueState() === 'back' : false),
    },
    // ── 0.12.15 TRIGGER SURFACE ──────────────────────────────────────────────────────────────────────────
    // These exist so Companion can drive the REST of the rig off KueSync state. A Companion trigger watching
    // one of these BOOLEAN feedbacks can fire anything Companion can reach — an ATEM cut, a lighting cue, a
    // media server, a GPO relay, an HTTP call to a control system.
    //
    // Booleans (not 'advanced') on purpose: Companion triggers can use a boolean feedback directly as their
    // condition, whereas an advanced feedback only paints a button. That distinction is the whole point of
    // this block.
    cue_on_air: {
      type: 'boolean', name: 'Cue is ON AIR (program) — fires when a specific cue goes live',
      description: 'True while the named cue is the program cue. Use as a trigger condition to fire other hardware when a given cue starts.',
      defaultStyle: { bgcolor: combineRgb(150, 30, 30), color: WHITE },
      options: [
        { type: 'number', id: 'cue', label: 'Cue number (1-based; 0 = any cue on air)', default: 0, min: 0, max: 300 },
      ],
      callback: (fb) => {
        const k = self.kuetimer ? self.kuetimer() : {}
        const idx = (k.pgmIndex != null) ? k.pgmIndex : -1
        const want = Number(fb.options.cue) || 0
        if (idx < 0) return false
        return want === 0 ? true : (idx + 1) === want
      },
    },
    cue_armed: {
      type: 'boolean', name: 'Cue is ARMED (preview)',
      description: 'True while the named cue is armed in preview. Useful for pre-rolling other systems before the TAKE.',
      defaultStyle: { bgcolor: combineRgb(31, 82, 52), color: WHITE },
      options: [
        { type: 'number', id: 'cue', label: 'Cue number (1-based; 0 = anything armed)', default: 0, min: 0, max: 300 },
      ],
      callback: (fb) => {
        const k = self.kuetimer ? self.kuetimer() : {}
        const idx = (k.pvwIndex != null) ? k.pvwIndex : -1
        const want = Number(fb.options.cue) || 0
        if (idx < 0) return false
        return want === 0 ? true : (idx + 1) === want
      },
    },
    cue_warp_active: {
      type: 'boolean', name: 'Presenter pacing is active (presenter clock differs from real time)',
      description: 'True while the presenter countdown is being paced to land on the out-time, so the presenter monitor is not showing real time. Handy to light a warning tally at the operator position.',
      defaultStyle: { bgcolor: combineRgb(42, 28, 51), color: combineRgb(217, 169, 240) },
      options: [],
      callback: () => { const k = self.kuetimer ? self.kuetimer() : {}; return !!k.warp },
    },
    cue_running_behind: {
      type: 'boolean', name: 'Show is running BEHIND by more than N seconds',
      description: 'True once the live over/under passes your threshold. Trigger a stage-manager alert, a chase light, or an automated "wrap it up" message to the speaker.',
      defaultStyle: { bgcolor: AMBER, color: combineRgb(0, 0, 0) },
      options: [
        { type: 'number', id: 'secs', label: 'Behind by at least (seconds)', default: 60, min: 0, max: 7200 },
      ],
      callback: (fb) => {
        if (notLive(self)) return false
        const k = self.kuetimer ? self.kuetimer() : {}
        const behind = Math.round((k.cueDeltaMs || 0) / 1000)
        return behind >= (Number(fb.options.secs) || 0) && behind > 0
      },
    },
    cue_timer_zero: {
      type: 'boolean', name: 'Timer has reached zero / is in overtime',
      description: 'True at and past 0:00. Trigger a hard out — roll credits, take a break slide, cut to a holding graphic.',
      defaultStyle: { bgcolor: RED, color: WHITE },
      options: [],
      callback: () => { if (notLive(self)) return false; const k = self.kuetimer ? self.kuetimer() : {}; return k.state === 'overtime' },
    },
    cue_timer_warn: {
      type: 'boolean', name: 'Timer is inside the WARN window',
      description: 'True from the warn threshold down to zero. Trigger a two-minute warning light or an automated speaker prompt.',
      defaultStyle: { bgcolor: AMBER, color: combineRgb(0, 0, 0) },
      options: [],
      callback: () => { if (notLive(self)) return false; const k = self.kuetimer ? self.kuetimer() : {}; return k.state === 'warning' },
    },
    // 0.12.16 VIDEO COUNT-OUT (agent-fanned, honesty-gated). ksVideo() returns { show:false } unless a source is
    // live and accurate, so these are automatically safe off a dead leader — nothing to fire off a frozen clip.
    video_active: {
      type: 'boolean', name: 'A video count-out is ACTIVE (a clip is playing)',
      description: 'True while an embedded or external (Mitti/PlaybackPro) clip is counting out. Light a tally, arm a chase, or gate an action to "video is rolling".',
      defaultStyle: { bgcolor: combineRgb(42, 28, 51), color: combineRgb(217, 169, 240) },
      options: [],
      callback: () => { const v = self.ksVideo ? self.ksVideo() : { show: false }; return !!v.show },
    },
    video_ending_below: {
      type: 'boolean', name: 'Video time remaining is BELOW a threshold (with hysteresis)',
      description: 'True once the playing clip reaches your threshold, held steady past it. Drive a GPI/lighting cue a fixed time before a video ends — a "coming back to stage" warning.',
      defaultStyle: { bgcolor: AMBER, color: combineRgb(0, 0, 0) },
      options: [
        { type: 'number', id: 'secs', label: 'Below (seconds remaining)', default: 10, min: 0, max: 86400 },
        { type: 'number', id: 'rearm', label: 'Re-arm margin (seconds) — prevents flicker at the boundary', default: 2, min: 0, max: 3600 },
      ],
      callback: (fb) => {
        const v = self.ksVideo ? self.ksVideo() : { show: false }
        if (!v.show) { self._vidBelowLatch && self._vidBelowLatch.delete(fb.id); return false }
        const remS = Math.round((Number(v.remainingMs) || 0) / 1000)
        const on = Number(fb.options.secs) || 0
        const rearm = Math.max(0, Number(fb.options.rearm) || 0)
        if (!self._vidBelowLatch) self._vidBelowLatch = new Map()
        const prev = self._vidBelowLatch.get(fb.id) || false
        const now = prev ? (remS <= on + rearm) : (remS <= on)
        self._vidBelowLatch.set(fb.id, now)
        return now
      },
    },
    cue_time_remaining_below: {
      // 0.12.15: an ARBITRARY threshold with HYSTERESIS. cue_timer_warn is fixed to the timer's own warn point;
      // this lets an integrator light a cue at any number they choose (30s to a GPI, 5:00 to a stage-manager
      // tally) without touching the show's warn setting. It is evaluated on the 4Hz edge tick, so the crossing
      // is caught within 250ms. The re-arm margin is the hysteresis: once lit at N it stays lit until the timer
      // climbs back above N + margin, so a cue nudged or adjusted right on the boundary cannot chatter the
      // output — which for a relay or a lighting cue means a real audible/visible flicker.
      type: 'boolean', name: 'Time remaining is BELOW a threshold (with hysteresis)',
      description: 'True once the countdown reaches your threshold, held steady past it. Drive a chase light, a GPI, or an automated prompt at any time you choose — independent of the show’s own warn point.',
      defaultStyle: { bgcolor: AMBER, color: combineRgb(0, 0, 0) },
      options: [
        { type: 'number', id: 'secs', label: 'Below (seconds remaining)', default: 30, min: 0, max: 86400 },
        { type: 'number', id: 'rearm', label: 'Re-arm margin (seconds) — prevents flicker at the boundary', default: 2, min: 0, max: 3600 },
        { type: 'checkbox', id: 'runningOnly', label: 'Only while the timer is running', default: true },
      ],
      callback: (fb) => {
        const k = self.kuetimer ? self.kuetimer() : {}
        // Never fire off a dead/stale leader (kuetimer extrapolates a frozen clock — see main._timerLive).
        if (notLive(self)) { self._belowLatch && self._belowLatch.delete(fb.id); return false }
        // Only meaningful for a countdown. Count-up / time-of-day have no "remaining" to fall below.
        if (!k.present || k.mode !== 'down') { self._belowLatch && self._belowLatch.delete(fb.id); return false }
        if (fb.options.runningOnly && !k.running) { self._belowLatch && self._belowLatch.delete(fb.id); return false }
        const remS = Math.round((k.remainingMs || 0) / 1000)
        const on = Number(fb.options.secs) || 0
        const rearm = Math.max(0, Number(fb.options.rearm) || 0)
        const prev = self._belowLatch ? (self._belowLatch.get(fb.id) || false) : false
        // Arm at/below the threshold; once armed, only disarm after climbing back above N + margin.
        const now = prev ? (remS <= on + rearm) : (remS <= on)
        if (self._belowLatch) self._belowLatch.set(fb.id, now)
        return now
      },
    },
    kuetimer: {
      // KueSync Timer button colour: green while running, amber in the warning window, red at/past zero (overtime).
      // Pair with the $(kuetimer:kuetimer_time) variable for a live timer button on a stream deck.
      type: 'advanced', name: 'KueSync Timer status colour (running=green, warning=amber, overtime=red)',
      options: [
        { type: 'colorpicker', id: 'runColor', label: 'Running', default: GREEN },
        { type: 'colorpicker', id: 'warnColor', label: 'Warning', default: AMBER },
        { type: 'colorpicker', id: 'overColor', label: 'Overtime / time up', default: RED },
        // 0.12.49.1: a distinct colour while the timer is LOADED (armed with a limit but not started) so an
        // operator can see a cue is ready before the show. Blue by default; separate from idle (no colour).
        { type: 'colorpicker', id: 'loadedColor', label: 'Loaded (armed, not started)', default: BLUE },
        { type: 'colorpicker', id: 'textColor', label: 'Text colour', default: WHITE },
        { type: 'checkbox', id: 'overUsesZeroColor', label: 'At Hold/Blink 0:00, use the timer’s at-zero colour instead of the Overtime colour above', default: false },
      ],
      callback: (fb) => {
        const k = self.kuetimer ? self.kuetimer() : { state: 'idle' }
        const txt = fb.options.textColor ?? WHITE
        // LOADED: a countdown that is set/armed but not yet running (state 'set'). Painted distinctly so a caller
        // sees "cue ready" vs. a blank idle key.
        if (k.state === 'set') return { bgcolor: fb.options.loadedColor ?? BLUE, color: txt }
        if (k.state === 'overtime') {
          // Parity with the on-screen clock: when the timer holds/blinks 0:00 with a chosen at-zero colour, the
          // button can mirror it. Count-overtime (end=up) always stays the Overtime colour (it's the red count).
          if (fb.options.overUsesZeroColor && k.endAction !== 'up') return { bgcolor: zeroColorRgb(k.zeroColor), color: txt }
          return { bgcolor: fb.options.overColor ?? RED, color: txt }
        }
        if (k.state === 'warning') return { bgcolor: fb.options.warnColor ?? AMBER, color: txt }
        if (k.state === 'running' || k.state === 'countup' || k.state === 'tod') return { bgcolor: fb.options.runColor ?? GREEN, color: txt }
        return {}
      },
    },
    kuetimer_message_active: {
      // Lights when a one-line on-screen message is currently showing on the clock/clickers (auto-clears after 30s).
      type: 'boolean', name: 'KueSync Timer: on-screen message showing',
      defaultStyle: { bgcolor: BLUE, color: WHITE }, options: [],
      callback: () => { const k = self.kuetimer ? self.kuetimer() : { message: '' }; return !!(k.message && k.message.trim()) },
    },
    license_state: {
      type: 'advanced', name: 'License status colour (trial=amber, expired=red)', options: [],
      callback: () => {
        const st = (self.fleetEntitlement ? self.fleetEntitlement() : self.license.evaluate()).state
        if (st === 'expired') return { bgcolor: RED, color: WHITE }
        if (st === 'trial') return { bgcolor: AMBER, color: WHITE }
        return {}
      },
    },
    // Fleet version awareness — a mixed-version fleet is a real sync risk; surface it (and any
    // published update) BEFORE the show. Amber = agents disagree; blue = a newer KueSync exists.
    fleet_version: {
      type: 'advanced', name: 'Fleet version status (amber = mixed versions, blue = update available)', options: [],
      callback: () => {
        const ts = self.targets || []
        const vers = new Set(ts.map((t) => t && t.state && t.state.agentVersion).filter(Boolean))
        const upd = ts.map((t) => t && t.state && t.state.updateVersion).find((v) => v)
        if (vers.size > 1) return { bgcolor: AMBER, color: WHITE }
        if (upd) return { bgcolor: combineRgb(61, 139, 255), color: WHITE }
        return {}
      },
    },

    // ── 0.12.49.1 NOTES SOURCE + LOOK (mirror of the leader's config) ────────────────────────────────────────
    // These read self.leaderCfg (refreshed ~4s via GET /leader/config). Pair each with its set/toggle action so a
    // single button both drives AND shows the current state.
    notes_is_source: {
      type: 'boolean', name: 'KueSync Notes: a specific machine IS the current source',
      description: 'Green when the chosen machine (or AUTO / Leader) is the pinned notes source right now.',
      defaultStyle: { bgcolor: GREEN, color: WHITE },
      options: [
        { type: 'dropdown', id: 'source', label: 'Source', default: '', choices: self.sourceChoices ? self.sourceChoices() : [{ id: '', label: 'AUTO' }], allowCustom: true },
      ],
      callback: (fb) => {
        const want = String(fb.options.source || '').trim().toLowerCase()
        const cur = String(self.notesSourceHost ? self.notesSourceHost() : '').trim().toLowerCase()
        if (want === '' || want === 'self') return cur === want
        return self.normHost ? self.normHost(cur) === self.normHost(want) : cur === want
      },
    },
    notes_backup_set: {
      type: 'boolean', name: 'KueSync Notes: a BACKUP source is configured',
      description: 'Green while a failover notes source is set on the leader.',
      defaultStyle: { bgcolor: BLUE, color: WHITE }, options: [],
      callback: () => !!(self.notesFailoverHost && self.notesFailoverHost()),
    },
    clock_bg_swatch: {
      type: 'advanced', name: 'Look: Timer/Clock background — paint the key its current colour', options: [
        { type: 'colorpicker', id: 'textColor', label: 'Text colour', default: WHITE },
      ],
      callback: (fb) => ({ bgcolor: bgRgb(self.lookClockBg ? self.lookClockBg() : 'black'), color: fb.options.textColor ?? WHITE }),
    },
    notes_bg_swatch: {
      type: 'advanced', name: 'Look: Notes background — paint the key its current (effective) colour', options: [
        { type: 'colorpicker', id: 'textColor', label: 'Text colour', default: WHITE },
      ],
      callback: (fb) => {
        const raw = self.lookNotesBgRaw ? self.lookNotesBgRaw() : ''
        const eff = raw || (self.lookClockBg ? self.lookClockBg() : 'black')
        return { bgcolor: bgRgb(eff), color: fb.options.textColor ?? WHITE }
      },
    },
    clock_pip_on: {
      type: 'boolean', name: 'Look: Kue-Light pip on the Timer is ON',
      defaultStyle: { bgcolor: GREEN, color: WHITE }, options: [],
      callback: () => !!(self.lookClockPip && self.lookClockPip()),
    },
    notes_pip_on: {
      type: 'boolean', name: 'Look: Kue-Light pips on the Notes screen are ON',
      defaultStyle: { bgcolor: GREEN, color: WHITE }, options: [],
      callback: () => !!(self.lookNotesPip && self.lookNotesPip()),
    },
    clock_chroma_black: {
      type: 'boolean', name: 'Look: Timer/Clock chroma text is BLACK (else white)',
      defaultStyle: { bgcolor: AMBER, color: combineRgb(0, 0, 0) }, options: [],
      callback: () => (self.lookClockChroma ? self.lookClockChroma() : 'white') === 'black',
    },
    notes_chroma_black: {
      type: 'boolean', name: 'Look: Notes chroma text is BLACK (else white)',
      defaultStyle: { bgcolor: AMBER, color: combineRgb(0, 0, 0) }, options: [],
      callback: () => (self.lookNotesChroma ? self.lookNotesChroma() : 'white') === 'black',
    },
  }
}
