Browse Source

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 weeks ago
parent
commit
b457896413

+ 81 - 0
ACCEPTANCE.md

@@ -366,3 +366,84 @@ before the stylesheet resolves — zero FOUC.
      to "Dark"/"Light"; the write to localStorage is caught by
      to "Dark"/"Light"; the write to localStorage is caught by
      try/catch and the page does not throw. Reloading resets to
      try/catch and the page does not throw. Reloading resets to
      light (no persistence possible).
      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 components;
 @tailwind utilities;
 @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 {
 @layer utilities {
     /* Phase 13: the Focus filter temporarily hides entire sw columns when the
     /* Phase 13: the Focus filter temporarily hides entire sw columns when the
        focused worker has no assignment on any visible row. Separate from the
        focused worker has no assignment on any visible row. Separate from the
@@ -40,4 +55,25 @@
         transform: rotate(180deg);
         transform: rotate(180deg);
         padding: 0.5rem 0.25rem;
         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-col', 'sw-' + sw.id)
                 .attr('data-sort-value-sw-' + sw.id, v.toFixed(2));
                 .attr('data-sort-value-sw-' + sw.id, v.toFixed(2));
             $td.append(
             $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))
                     .val(fmtDays(v))
                     .attr('data-sw-id', sw.id)
                     .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/jquery-3.7.1.min.js"></script>
     <script src="https://code.jquery.com/ui/1.13.3/jquery-ui.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/app.js" defer></script>
+    <script src="/assets/js/number-stepper.js" defer></script>
 </head>
 </head>
 <body class="bg-slate-100 text-slate-900 antialiased dark:bg-slate-900 dark:text-slate-100">
 <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">
     <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/jquery-3.7.1.min.js"></script>
     <script src="https://code.jquery.com/ui/1.13.3/jquery-ui.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/sprint-planner.js" defer></script>
+    <script src="/assets/js/number-stepper.js" defer></script>
 </head>
 </head>
 <body class="bg-white text-slate-900 antialiased dark:bg-slate-900 dark:text-slate-100">
 <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"
 <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)) ?>"
                                                    value="<?= e(fmt_days($d)) ?>"
                                                    data-assign
                                                    data-assign
                                                    data-sw-id="<?= (int) $sw->id ?>"
                                                    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">
                                                    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: ?>
                                         <?php else: ?>
                                             <span class="font-mono"><?= e(fmt_days($d)) ?></span>
                                             <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"
                                 <input type="number" step="0.05" min="0" max="1"
                                        value="<?= e(number_format($sw->rtb, 2, '.', '')) ?>"
                                        value="<?= e(number_format($sw->rtb, 2, '.', '')) ?>"
                                        data-rtb
                                        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">
                                        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>
                                 <button type="button" data-remove class="text-sm text-red-600 hover:underline dark:text-red-400">Remove</button>
                             </li>
                             </li>

+ 3 - 0
views/sprints/show.php

@@ -138,6 +138,7 @@ if (!function_exists('fmt_days')) {
                                                value="<?= e(fmt_days($v)) ?>"
                                                value="<?= e(fmt_days($v)) ?>"
                                                data-day data-sw-id="<?= (int) $sw->id ?>"
                                                data-day data-sw-id="<?= (int) $sw->id ?>"
                                                data-week-id="<?= (int) $w->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">
                                                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: ?>
                                     <?php else: ?>
                                         <span class="font-mono"><?= e(fmt_days($v)) ?></span>
                                         <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"
                                     <input type="number" min="0" max="1" step="0.05"
                                            value="<?= e(number_format($sw->rtb, 2, '.', '')) ?>"
                                            value="<?= e(number_format($sw->rtb, 2, '.', '')) ?>"
                                            data-rtb data-sw-id="<?= (int) $sw->id ?>"
                                            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">
                                            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: ?>
                                 <?php else: ?>
                                     <span class="font-mono"><?= e(number_format($sw->rtb, 2, '.', '')) ?></span>
                                     <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)) ?>"
                                                    value="<?= e(fmt_days($d)) ?>"
                                                    data-assign
                                                    data-assign
                                                    data-sw-id="<?= (int) $sw->id ?>"
                                                    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">
                                                    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: ?>
                                         <?php else: ?>
                                             <span class="font-mono"><?= e(fmt_days($d)) ?></span>
                                             <span class="font-mono"><?= e(fmt_days($d)) ?></span>