Преглед на файлове

Phase 17: hide native number spinners + custom 0.5-step stepper popover

Three classes of number input in the planning view — day cells,
RTB cells, task assignment cells — deal in half-day increments
(or 0.05 for RTB). Browsers rendered each as <input type="number">
with tiny native up/down spinner arrows attached; they were
visually noisy inside a dense table, behaved inconsistently across
Chrome / Firefox / Safari, and did nothing on touch devices. The
team asked for a clean number on the page and a tap-friendly
stepper on click.

This phase hides the native spinners app-wide and, for the three
opt-in cell types, opens a small popover with large −/+ buttons
plus (when both min and max are set) a range slider. Keyboard
typing, ArrowUp/Down stepping while focused, and the existing
blur-then-PATCH save pipeline all keep working — the popover is an
alternative input method, not a replacement.

Strict CSP (Phase 11) stays intact: number-stepper.js is loaded as
a standard <script src="/assets/js/number-stepper.js" defer>. No
inline handlers, no new external hosts.

assets/css/input.css:
- @layer base: hide ::-webkit-outer/inner-spin-button and set
  -moz-appearance/appearance to textfield on every
  input[type="number"]. Week-count and reserve-percent inputs
  lose their arrows too, which is fine — keyboard typing is the
  usual path there.
- @layer components: new .stepper-popover block — rounded card
  with fixed positioning + z-50, flex row of −/value/+, light/
  dark siblings from the Phase 16 palette (bg-white /
  dark:bg-slate-800 etc.), plus accent-slate-600 /
  dark:accent-slate-400 on the optional range slider.

public/assets/js/number-stepper.js (new, ~180 lines incl.
comments):
- Single IIFE, vanilla JS, no jQuery dep. Exposes no globals.
- Builds one .stepper-popover DOM node lazily on first use and
  appends to document.body. Structure matches the plan:
  -, <output>, +, and a hidden <input type="range"> sibling.
- Delegates click + focusin on document → open on
  input[data-stepper]. Reads step/min/max off the bound input;
  default step = 1. When both min and max parse as finite,
  un-hides the range slider and wires its input event to
  setValue(); otherwise only the +/− buttons show.
- clampToStep(current, delta, step, min, max) — pure helper.
  Adds delta, clamps into [min, max] when finite, quantises
  via Math.round(next / step) * step with a tiny epsilon
  tolerance so 0.6 / 0.05 lands cleanly on the grid rather
  than at 11.999999… → 11 wrong side. Rounds to 6 decimals
  before returning to strip residual float noise.
- Every mutation mirrors into input.value and dispatches a
  synthetic `input` event (bubbling) — sprint-planner.js's
  existing recomputeRow / row-total handlers run as if the
  user typed. On popover close (outside click, Escape, Tab-
  away, or clicking a *different* stepper input) we dispatch
  `change`, which fires the existing debounced save pipeline
  (PATCH /sprints/{id}/week-cells, /workers/{sw_id}, or
  /tasks/{id}/assignments). No edits to sprint-planner.js
  were needed on the save side — it already reads .val() on
  blur/change for each of the three data-attrs.
- Outside-click uses pointerdown, not click, so a scroll
  gesture that starts inside the popover on Safari doesn't
  immediately dismiss it.
- Position: below the input with a 4px gap unless the input's
  bottom is in the lower 25% of the viewport, then above.
  Horizontal clamp to viewport with a 4px margin on either
  side. Measured with a temporary "-9999px; hidden=false"
  layout pass so offsetWidth/offsetHeight are real.
- Keyboard: Escape closes + returns focus to the input; Tab
  that leaves both the input and the popover closes without
  refocus (setTimeout 0 reads activeElement after the tab
  completes). ArrowUp/ArrowDown while focused on the input
  step by `step` — restores the shortcut the native spinner
  lost to Phase 17's CSS reset.

public/assets/js/sprint-planner.js:
- buildTaskRow() adds data-stepper to the [data-assign] input
  it creates for a newly-added task row, matching the server-
  rendered template. Single-character attribute change; no
  behavioural edits. Existing `blur change` handler on
  [data-assign] reads input.value each fire — no edit needed.

views/sprints/show.php:
- data-stepper on the admin-branch [data-day] (day cells,
  min=0 max=5 step=0.5), [data-rtb] (worker RTB,
  min=0 max=1 step=0.05), and [data-assign] (task assignment,
  min=0 step=0.5 — no max, so the range slider correctly
  stays hidden for this type). Non-admin <span> branches
  untouched.

views/sprints/settings.php:
- data-stepper on the [data-rtb] input in the sprint-worker
  picker (same bounds as show.php).

views/sprints/present.php:
- data-stepper on the admin-branch [data-assign] input to
  match show.php. New <script src="/assets/js/number-stepper.js"
  defer> tag in its own <head>, after sprint-planner.js.

views/layout.php:
- <script src="/assets/js/number-stepper.js" defer> in <head>
  after app.js. Same CSP-clean pattern as theme-init.js and
  app.js before it.

ACCEPTANCE.md:
- New "Phase 17 — Number stepper popover" section with the
  five scenarios from the plan: no native arrows anywhere,
  day-cell stepper at 0.5 step, RTB stepper at 0.05 step,
  task-assignment stepper on both show and present views
  (no range slider because no max), Escape + outside-click +
  dark-mode polish.

Tests. Zero PHPUnit — pure CSS + vanilla JS over existing
markup. Same pattern as Phases 10 / 13 / 14 / 15 / 16.

phpunit: 88 / 208, OK.
node --check public/assets/js/sprint-planner.js: clean.
node --check public/assets/js/app.js: clean (not touched, just
verifying no collateral).
number-stepper.js is ~180 lines of vanilla JS with no external
deps; the sandboxed shell in this session blocked node --check
specifically against that path (same restriction Phase 16 hit
against theme-init.js), so the dedicated syntax check was
skipped. Visual spot-read shows balanced braces / no
stray syntax.
php -l views/sprints/show.php: clean.
php -l views/layout.php: clean.
php -l on settings.php / present.php was intermittently
denied by the sandbox; edits are one-attribute / one-<script>
additions with no chance of affecting PHP syntax.

Not exercised in a running container — manual acceptance is on
the human via the new ACCEPTANCE.md section. The change is
class-only CSS + a self-contained JS module + data-attribute
stamping, so the verification is interaction-by-interaction
by design.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
achiappa преди 2 седмици
родител
ревизия
b457896413
променени са 8 файла, в които са добавени 336 реда и са изтрити 1 реда
  1. 81 0
      ACCEPTANCE.md
  2. 36 0
      assets/css/input.css
  3. 211 0
      public/assets/js/number-stepper.js
  4. 1 1
      public/assets/js/sprint-planner.js
  5. 1 0
      views/layout.php
  6. 2 0
      views/sprints/present.php
  7. 1 0
      views/sprints/settings.php
  8. 3 0
      views/sprints/show.php

+ 81 - 0
ACCEPTANCE.md

@@ -366,3 +366,84 @@ before the stylesheet resolves — zero FOUC.
      to "Dark"/"Light"; the write to localStorage is caught by
      try/catch and the page does not throw. Reloading resets to
      light (no persistence possible).
+
+## Phase 17 — Number stepper popover
+
+Runs against a sprint with at least a couple of workers and a
+handful of tasks. All numeric inputs in the app now have the
+native up/down spinners hidden; the day / RTB / assignment cells
+additionally open a small `.stepper-popover` anchored to the
+input on click/focus. Non-admin users see plain `<span>`s, so
+the popover only appears for admins (and only on the opt-in
+`[data-stepper]` inputs — `n_weeks` and `reserve_fraction` stay
+bare).
+
+1. **No native spinner arrows on any number input across the
+   app.**
+   - Sign in as admin. Walk through `/sprints/new`,
+     `/sprints/{id}`, `/sprints/{id}/settings`, and
+     `/sprints/{id}/present`. Hover every `<input
+     type="number">` (week count, reserve %, day cells, RTB
+     cells, task assignment cells).
+   - Expected: neither Chrome / Safari (webkit spin buttons)
+     nor Firefox (MozAppearance spinner) renders the up/down
+     arrows — every number input reads as plain text. Manual
+     keyboard typing still works.
+
+2. **Clicking a day cell on `/sprints/{id}` opens the stepper;
+   +/− step by 0.5; value persists after blur.**
+   - Open `/sprints/{id}` as admin. Click (or tab into) any
+     Arbeitstage per-worker day cell.
+   - Expected: a `.stepper-popover` appears just below (or
+     above if the cell is in the lower 25% of the viewport)
+     the input, with −, the current value as a big
+     `<output>`, +, and — because min=0 / max=5 are both set
+     — a horizontal range slider. Clicking **+** increments
+     the input by 0.5 (clamped to 5); the Σ column and the
+     capacity strip update live as each tick lands (synthetic
+     `input` events wired into `sprint-planner.js`). Click
+     outside to close — the debounced save (PATCH
+     `/sprints/{id}/week-cells`) fires exactly once, `audit_log`
+     gains one row per changed cell. Reload to confirm
+     persistence.
+
+3. **RTB cell on `/sprints/{id}` (and `/sprints/{id}/settings`)
+   pops the stepper; +/− step by 0.05.**
+   - Click an RTB cell next to a sprint worker.
+   - Expected: same popover, but `step=0.05`, `min=0`,
+     `max=1`. +/− clicks nudge the value by 0.05; the range
+     slider covers 0..1. Clicking outside triggers the save
+     (PATCH `/sprints/{id}/workers/{sw_id}`). Same behaviour
+     on `/sprints/{id}/settings`'s worker list.
+
+4. **Task assignment cell on both `/sprints/{id}` and
+   `/sprints/{id}/present` pops the stepper; no range slider.**
+   - Click a task's per-worker assignment cell.
+   - Expected: `min=0`, no `max` → the popover shows −, the
+     value, +, and **no** range slider (the `<input
+     type="range">` stays hidden). +/− steps by 0.5. On
+     `/sprints/{id}/present`, the same behaviour — the
+     presentation view gets its own `<script src="/assets/js/
+     number-stepper.js" defer>` tag, so the popover works
+     there too. Newly-added tasks (admin clicks + Add task)
+     inherit the stepper too — `buildTaskRow` stamps the
+     `data-stepper` attribute on its JS-built `[data-assign]`
+     cells.
+
+5. **Escape + outside-click + dark-mode polish.**
+   - Open a day cell's popover. Press **Escape** → popover
+     closes, focus returns to the input (visible focus ring).
+   - Re-open the popover. Click anywhere else on the page
+     (white space, the Close / Settings buttons) → popover
+     closes. Clicking another `[data-stepper]` input instead
+     moves the popover to the new target (no flash of two).
+   - Use ArrowUp / ArrowDown while focused on the input →
+     value steps by `step` (replaces the native spinner
+     shortcut the CSS reset just disabled).
+   - Flip the app into dark mode via the hamburger Theme row
+     → re-open the popover. Card reads `bg-slate-800`
+     with a `border-slate-600` ring and a `shadow-lg`; the
+     value output reads `text-slate-100`, −/+ buttons
+     `hover:bg-slate-700`, and the range slider thumb picks
+     up `accent-slate-400`. No white rectangle, no stray
+     light borders.

+ 36 - 0
assets/css/input.css

@@ -2,6 +2,21 @@
 @tailwind components;
 @tailwind utilities;
 
+@layer base {
+    /* Phase 17: hide native spinner buttons on every number input app-wide.
+       The team prefers either keyboard typing or the custom
+       [data-stepper] popover over the tiny, inconsistent UA controls. */
+    input[type="number"]::-webkit-outer-spin-button,
+    input[type="number"]::-webkit-inner-spin-button {
+        -webkit-appearance: none;
+        margin: 0;
+    }
+    input[type="number"] {
+        -moz-appearance: textfield;
+        appearance: textfield;
+    }
+}
+
 @layer utilities {
     /* Phase 13: the Focus filter temporarily hides entire sw columns when the
        focused worker has no assignment on any visible row. Separate from the
@@ -40,4 +55,25 @@
         transform: rotate(180deg);
         padding: 0.5rem 0.25rem;
     }
+
+    /* Phase 17: floating stepper popover. Anchored to the active
+       [data-stepper] input in JS; uses `position: fixed` so it never
+       flickers when scrolling the container table. Dark-mode siblings
+       match the Phase 16 palette. */
+    .stepper-popover {
+        @apply fixed z-50 flex items-center gap-2 rounded-md border
+               border-slate-200 bg-white px-2 py-1 shadow-lg
+               dark:border-slate-600 dark:bg-slate-800;
+    }
+    .stepper-popover button {
+        @apply rounded px-2 py-1 text-lg font-semibold text-slate-700
+               hover:bg-slate-100 dark:text-slate-200 dark:hover:bg-slate-700;
+    }
+    .stepper-popover output {
+        @apply min-w-[3rem] text-center font-mono text-slate-900
+               dark:text-slate-100;
+    }
+    .stepper-popover input[type="range"] {
+        @apply accent-slate-600 dark:accent-slate-400;
+    }
 }

+ 211 - 0
public/assets/js/number-stepper.js

@@ -0,0 +1,211 @@
+/**
+ * Phase 17: custom stepper popover for half-step number inputs.
+ *
+ * Activates on any `input[data-stepper]` — opens a small popover next to
+ * the input with −/+ buttons that step by the input's own step attribute
+ * (default 1), and — when both min and max are finite — a range slider
+ * for quick gross moves. Mutations mirror into input.value and dispatch
+ * a bubbling `input` event (so sprint-planner.js live-recomputes); on
+ * popover close we dispatch `change` (so the existing debounced save
+ * pipeline fires). Strict-CSP-clean: loaded as a standard <script src>.
+ *
+ * No globals. Single IIFE. Vanilla JS, no jQuery dependency.
+ */
+(function () {
+    'use strict';
+
+    const EPS = 1e-9;
+
+    /**
+     * Step a value by delta, clamp to [min, max] when finite, and quantise
+     * to the nearest `step` increment. Pure — exposed as a local symbol
+     * for readability; would be the testable surface if we added a
+     * headless-JS test harness.
+     */
+    function clampToStep(current, delta, step, min, max) {
+        if (!(step > 0)) { step = 1; }
+        let next = Number(current) + delta;
+        if (Number.isFinite(min) && next < min) { next = min; }
+        if (Number.isFinite(max) && next > max) { next = max; }
+        // Floor with an epsilon tolerance so 0.6/0.05 doesn't quantise down
+        // to the wrong side of the grid thanks to binary-float artefacts.
+        const quantised = Math.round((next / step) + EPS * Math.sign(next || 1)) * step;
+        // Round to a sensible number of decimals — 6 is more than enough for
+        // any step the app uses (0.5 / 0.05 / 1).
+        return Number(quantised.toFixed(6));
+    }
+
+    // One popover per document, built lazily on first use.
+    let pop = null;
+    let elDec = null, elInc = null, elOut = null, elRange = null;
+    let boundInput = null;
+
+    function build() {
+        if (pop) { return; }
+        pop = document.createElement('div');
+        pop.className = 'stepper-popover';
+        pop.hidden = true;
+        pop.setAttribute('role', 'dialog');
+        pop.setAttribute('aria-label', 'Set value');
+        pop.innerHTML = '<button type="button" data-stepper-dec aria-label="Decrease">\u2212</button>'
+            + '<output data-stepper-value>0</output>'
+            + '<button type="button" data-stepper-inc aria-label="Increase">+</button>'
+            + '<input type="range" data-stepper-range hidden>';
+        document.body.appendChild(pop);
+        elDec   = pop.querySelector('[data-stepper-dec]');
+        elInc   = pop.querySelector('[data-stepper-inc]');
+        elOut   = pop.querySelector('[data-stepper-value]');
+        elRange = pop.querySelector('[data-stepper-range]');
+
+        elDec.addEventListener('click', function () { step(-1); });
+        elInc.addEventListener('click', function () { step(+1); });
+        elRange.addEventListener('input', function () {
+            if (!boundInput) { return; }
+            setValue(Number(elRange.value));
+        });
+        // Stop pointerdown inside the popover from bubbling to the
+        // document-level outside-click handler below.
+        pop.addEventListener('pointerdown', function (ev) { ev.stopPropagation(); });
+    }
+
+    function readBounds() {
+        const step = Number(boundInput.step);
+        const min  = Number(boundInput.min);
+        const max  = Number(boundInput.max);
+        return {
+            step: Number.isFinite(step) && step > 0 ? step : 1,
+            min:  Number.isFinite(min)  ? min  : NaN,
+            max:  Number.isFinite(max)  ? max  : NaN,
+        };
+    }
+
+    function step(dir) {
+        if (!boundInput) { return; }
+        const b = readBounds();
+        const next = clampToStep(Number(boundInput.value) || 0, dir * b.step, b.step, b.min, b.max);
+        setValue(next);
+    }
+
+    function setValue(n) {
+        if (!boundInput) { return; }
+        const b = readBounds();
+        const next = clampToStep(n, 0, b.step, b.min, b.max);
+        boundInput.value = String(next);
+        elOut.textContent = String(next);
+        if (!elRange.hidden) { elRange.value = String(next); }
+        boundInput.dispatchEvent(new Event('input', { bubbles: true }));
+    }
+
+    function position(input) {
+        const rect = input.getBoundingClientRect();
+        // Measure the popover — temporarily visible-off-screen for layout.
+        pop.style.left = '-9999px';
+        pop.style.top  = '0px';
+        pop.hidden = false;
+        const pw = pop.offsetWidth;
+        const ph = pop.offsetHeight;
+        const vh = window.innerHeight;
+        const vw = window.innerWidth;
+
+        // Vertical: below unless the input sits in the lower 25% of the
+        // viewport, then above.
+        const GAP = 4;
+        let top;
+        if (rect.bottom > vh * 0.75) {
+            top = rect.top - ph - GAP;
+        } else {
+            top = rect.bottom + GAP;
+        }
+        // Horizontal: align to the input's left edge, clamped to viewport.
+        let left = rect.left;
+        const MARGIN = 4;
+        if (left + pw > vw - MARGIN) { left = vw - pw - MARGIN; }
+        if (left < MARGIN) { left = MARGIN; }
+        if (top < MARGIN) { top = MARGIN; }
+
+        pop.style.left = left + 'px';
+        pop.style.top  = top  + 'px';
+    }
+
+    function open(input) {
+        build();
+        boundInput = input;
+        const b = readBounds();
+        const current = clampToStep(Number(input.value) || 0, 0, b.step, b.min, b.max);
+        elOut.textContent = String(current);
+
+        if (Number.isFinite(b.min) && Number.isFinite(b.max)) {
+            elRange.hidden = false;
+            elRange.min  = String(b.min);
+            elRange.max  = String(b.max);
+            elRange.step = String(b.step);
+            elRange.value = String(current);
+        } else {
+            elRange.hidden = true;
+        }
+        position(input);
+    }
+
+    function close(returnFocus) {
+        if (!pop || pop.hidden) { return; }
+        pop.hidden = true;
+        const prev = boundInput;
+        boundInput = null;
+        if (prev) {
+            prev.dispatchEvent(new Event('change', { bubbles: true }));
+            if (returnFocus) { try { prev.focus(); } catch (_) { /* ignore */ } }
+        }
+    }
+
+    // Open on click/focus of a stepper-tagged input.
+    function onOpenTrigger(ev) {
+        const t = ev.target;
+        if (!(t && t.matches && t.matches('input[data-stepper]'))) { return; }
+        if (t.disabled || t.readOnly) { return; }
+        open(t);
+    }
+    document.addEventListener('click', onOpenTrigger);
+    document.addEventListener('focusin', onOpenTrigger);
+
+    // Outside-click close — pointerdown, not click, so a scroll gesture
+    // starting inside the popover in Safari doesn't dismiss prematurely.
+    document.addEventListener('pointerdown', function (ev) {
+        if (!pop || pop.hidden) { return; }
+        const t = ev.target;
+        if (boundInput && (t === boundInput || (boundInput.contains && boundInput.contains(t)))) { return; }
+        if (pop.contains(t)) { return; }
+        close(false);
+    });
+
+    // Escape closes + returns focus; Tab that leaves the input + popover
+    // closes without focus return.
+    document.addEventListener('keydown', function (ev) {
+        if (!pop || pop.hidden) { return; }
+        if (ev.key === 'Escape') {
+            ev.preventDefault();
+            close(true);
+            return;
+        }
+        if (ev.key === 'Tab') {
+            // After the tab completes, check if focus is still on the input
+            // or inside the popover. If not, close.
+            setTimeout(function () {
+                const active = document.activeElement;
+                if (active === boundInput) { return; }
+                if (pop.contains(active)) { return; }
+                close(false);
+            }, 0);
+        }
+    });
+
+    // While the bound input is focused, ArrowUp/Down step by `step` —
+    // restores the shortcut the native spinner lost to Phase 17's CSS reset.
+    document.addEventListener('keydown', function (ev) {
+        const t = ev.target;
+        if (!(t && t.matches && t.matches('input[data-stepper]'))) { return; }
+        if (ev.key !== 'ArrowUp' && ev.key !== 'ArrowDown') { return; }
+        ev.preventDefault();
+        if (boundInput !== t) { open(t); }
+        step(ev.key === 'ArrowUp' ? +1 : -1);
+    });
+})();

+ 1 - 1
public/assets/js/sprint-planner.js

@@ -387,7 +387,7 @@
                 .attr('data-col', 'sw-' + sw.id)
                 .attr('data-sort-value-sw-' + sw.id, v.toFixed(2));
             $td.append(
-                $('<input type="number" min="0" step="0.5" data-assign class="w-14 rounded border border-slate-200 px-1 py-1 text-center font-mono focus:outline-none focus:ring-2 focus:ring-slate-400">')
+                $('<input type="number" min="0" step="0.5" data-assign data-stepper class="w-14 rounded border border-slate-200 px-1 py-1 text-center font-mono focus:outline-none focus:ring-2 focus:ring-slate-400">')
                     .val(fmtDays(v))
                     .attr('data-sw-id', sw.id)
             );

+ 1 - 0
views/layout.php

@@ -20,6 +20,7 @@ $csrfToken   = $csrfToken   ?? '';
     <script src="https://code.jquery.com/jquery-3.7.1.min.js"></script>
     <script src="https://code.jquery.com/ui/1.13.3/jquery-ui.min.js"></script>
     <script src="/assets/js/app.js" defer></script>
+    <script src="/assets/js/number-stepper.js" defer></script>
 </head>
 <body class="bg-slate-100 text-slate-900 antialiased dark:bg-slate-900 dark:text-slate-100">
     <header class="border-b bg-white dark:bg-slate-800 dark:border-slate-700">

+ 2 - 0
views/sprints/present.php

@@ -49,6 +49,7 @@ if (!function_exists('fmt_days')) {
     <script src="https://code.jquery.com/jquery-3.7.1.min.js"></script>
     <script src="https://code.jquery.com/ui/1.13.3/jquery-ui.min.js"></script>
     <script src="/assets/js/sprint-planner.js" defer></script>
+    <script src="/assets/js/number-stepper.js" defer></script>
 </head>
 <body class="bg-white text-slate-900 antialiased dark:bg-slate-900 dark:text-slate-100">
 <main class="min-h-screen w-screen overflow-hidden beamer-root"
@@ -298,6 +299,7 @@ if (!function_exists('fmt_days')) {
                                                    value="<?= e(fmt_days($d)) ?>"
                                                    data-assign
                                                    data-sw-id="<?= (int) $sw->id ?>"
+                                                   data-stepper
                                                    class="w-14 rounded border border-slate-200 px-1 py-1 text-center font-mono focus:outline-none focus:ring-2 focus:ring-slate-400 dark:bg-slate-800 dark:border-slate-600 dark:text-slate-100 dark:focus:ring-slate-500">
                                         <?php else: ?>
                                             <span class="font-mono"><?= e(fmt_days($d)) ?></span>

+ 1 - 0
views/sprints/settings.php

@@ -158,6 +158,7 @@ use function App\Http\e;
                                 <input type="number" step="0.05" min="0" max="1"
                                        value="<?= e(number_format($sw->rtb, 2, '.', '')) ?>"
                                        data-rtb
+                                       data-stepper
                                        class="w-20 rounded border border-slate-300 px-2 py-1 font-mono text-sm focus:outline-none focus:ring-2 focus:ring-slate-400 dark:bg-slate-800 dark:border-slate-600 dark:text-slate-100 dark:focus:ring-slate-500">
                                 <button type="button" data-remove class="text-sm text-red-600 hover:underline dark:text-red-400">Remove</button>
                             </li>

+ 3 - 0
views/sprints/show.php

@@ -138,6 +138,7 @@ if (!function_exists('fmt_days')) {
                                                value="<?= e(fmt_days($v)) ?>"
                                                data-day data-sw-id="<?= (int) $sw->id ?>"
                                                data-week-id="<?= (int) $w->id ?>"
+                                               data-stepper
                                                class="w-14 rounded border border-slate-200 px-1 py-1 text-center font-mono focus:outline-none focus:ring-2 focus:ring-slate-400 dark:bg-slate-800 dark:border-slate-600 dark:text-slate-100 dark:focus:ring-slate-500">
                                     <?php else: ?>
                                         <span class="font-mono"><?= e(fmt_days($v)) ?></span>
@@ -153,6 +154,7 @@ if (!function_exists('fmt_days')) {
                                     <input type="number" min="0" max="1" step="0.05"
                                            value="<?= e(number_format($sw->rtb, 2, '.', '')) ?>"
                                            data-rtb data-sw-id="<?= (int) $sw->id ?>"
+                                           data-stepper
                                            class="w-16 rounded border border-slate-200 px-1 py-1 text-center font-mono focus:outline-none focus:ring-2 focus:ring-slate-400 dark:bg-slate-800 dark:border-slate-600 dark:text-slate-100 dark:focus:ring-slate-500">
                                 <?php else: ?>
                                     <span class="font-mono"><?= e(number_format($sw->rtb, 2, '.', '')) ?></span>
@@ -426,6 +428,7 @@ if (!function_exists('fmt_days')) {
                                                    value="<?= e(fmt_days($d)) ?>"
                                                    data-assign
                                                    data-sw-id="<?= (int) $sw->id ?>"
+                                                   data-stepper
                                                    class="w-14 rounded border border-slate-200 px-1 py-1 text-center font-mono focus:outline-none focus:ring-2 focus:ring-slate-400 dark:bg-slate-800 dark:border-slate-600 dark:text-slate-100 dark:focus:ring-slate-500">
                                         <?php else: ?>
                                             <span class="font-mono"><?= e(fmt_days($d)) ?></span>