feat: FASE 3 - Flusso MeasurementTec (selezione ricetta, esecuzione misure, riepilogo)

Implementazione completa del flusso operativo per il ruolo MeasurementTec:

Blueprint measure.py:
- select_recipe: selezione ricetta con ricerca e barcode
- task_list: lista task con conteggi subtask e allegati
- task_execute: esecuzione misure con numpad, calibro USB, feedback real-time
- task_complete: riepilogo con statistiche pass/fail e export CSV
- API AJAX: lookup-barcode, save-traceability, save-measurement
- Autorizzazione role_required("MeasurementTec") su tutte le route

Componenti riutilizzabili:
- numpad.html/js/css: tastierino numerico touch-friendly con keyboard support
- caliper_status.html + caliper.js: integrazione calibro USB via Web Serial API
- barcode_scanner.html + barcode.js: scansione barcode con html5-qrcode
- measurement_feedback.html: feedback visivo pass/warning/fail in tempo reale
- next_measurement.html: indicatore prossima misurazione
- annotation-viewer.js: visualizzatore canvas con marker su disegni tecnici
- csv-export.js: export CSV con locale italiano (delimitatore ;, decimale ,)

Sicurezza:
- Decoratore role_required(*roles) per autorizzazione basata su ruoli
- CSRF token su tutti i POST AJAX
- |tojson per prevenire XSS su annotations_json
- Validazione input lato client e server

i18n: 23+ nuove chiavi tradotte IT/EN per tutti i template FASE 3

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Adriano
2026-02-07 08:40:58 +01:00
parent edd4580a5a
commit a386986c17
19 changed files with 3905 additions and 16 deletions
@@ -0,0 +1,151 @@
<!--
Barcode Scanner Modal Component
Camera-based barcode/QR code scanner
Include in pages where barcode scanning is needed
-->
<div x-data="barcodeScanner()"
@barcode-use.window="stopScan(); clear()"
x-init="$watch('scanning', val => console.log('Scanning:', val))"
x-cloak>
<!-- Trigger button (styled slot - customize per use case) -->
<slot name="trigger">
<button @click="startScan()"
type="button"
class="inline-flex items-center gap-2 px-4 py-2.5 bg-white dark:bg-slate-800 border border-slate-300 dark:border-slate-600 rounded-lg text-sm font-medium text-slate-700 dark:text-slate-300 hover:bg-slate-50 dark:hover:bg-slate-700 transition-colors duration-200 shadow-sm hover:shadow-md">
<!-- Barcode icon -->
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v1m6 11h2m-6 0h-2v4m0-11v3m0 0h.01M12 12h4.01M16 20h4M4 12h4m12 0h.01M5 8h2a1 1 0 001-1V5a1 1 0 00-1-1H5a1 1 0 00-1 1v2a1 1 0 001 1zm12 0h2a1 1 0 001-1V5a1 1 0 00-1-1h-2a1 1 0 00-1 1v2a1 1 0 001 1zM5 20h2a1 1 0 001-1v-2a1 1 0 00-1-1H5a1 1 0 00-1 1v2a1 1 0 001 1z"/>
</svg>
<span>{{ _('Scansiona Barcode') }}</span>
</button>
</slot>
<!-- Modal overlay -->
<div x-show="scanning || result"
x-transition:enter="transition ease-out duration-200"
x-transition:enter-start="opacity-0"
x-transition:enter-end="opacity-100"
x-transition:leave="transition ease-in duration-150"
x-transition:leave-start="opacity-100"
x-transition:leave-end="opacity-0"
class="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm p-4"
style="display: none;">
<div @click.outside="scanning && !result ? null : (stopScan(), clear())"
x-transition:enter="transition ease-out duration-200"
x-transition:enter-start="opacity-0 scale-95"
x-transition:enter-end="opacity-100 scale-100"
x-transition:leave="transition ease-in duration-150"
x-transition:leave-start="opacity-100 scale-100"
x-transition:leave-end="opacity-0 scale-95"
class="bg-white dark:bg-slate-800 rounded-2xl shadow-2xl max-w-md w-full overflow-hidden">
<!-- Header -->
<div class="flex justify-between items-center px-6 py-4 border-b border-slate-200 dark:border-slate-700">
<div class="flex items-center gap-3">
<div class="w-10 h-10 bg-primary/10 rounded-lg flex items-center justify-center">
<svg class="w-5 h-5 text-primary" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v1m6 11h2m-6 0h-2v4m0-11v3m0 0h.01M12 12h4.01M16 20h4M4 12h4m12 0h.01M5 8h2a1 1 0 001-1V5a1 1 0 00-1-1H5a1 1 0 00-1 1v2a1 1 0 001 1zm12 0h2a1 1 0 001-1V5a1 1 0 00-1-1h-2a1 1 0 00-1 1v2a1 1 0 001 1zM5 20h2a1 1 0 001-1v-2a1 1 0 00-1-1H5a1 1 0 00-1 1v2a1 1 0 001 1z"/>
</svg>
</div>
<h3 class="text-lg font-semibold text-slate-900 dark:text-white">
{{ _('Scansiona Barcode') }}
</h3>
</div>
<button @click="stopScan(); clear()"
type="button"
class="text-slate-400 hover:text-slate-600 dark:hover:text-slate-200 transition-colors p-1 rounded-lg hover:bg-slate-100 dark:hover:bg-slate-700">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/>
</svg>
</button>
</div>
<!-- Content -->
<div class="p-6">
<!-- Camera preview container -->
<div x-show="scanning && !result"
class="relative rounded-xl overflow-hidden bg-slate-900 aspect-square mb-4">
<!-- Scanner element -->
<div id="barcode-reader" class="w-full h-full"></div>
<!-- Scanning indicator -->
<div class="absolute inset-0 pointer-events-none">
<div class="absolute inset-0 flex items-center justify-center">
<div class="w-64 h-64 border-2 border-white/50 rounded-lg"></div>
</div>
</div>
<!-- Instruction overlay -->
<div class="absolute bottom-0 inset-x-0 bg-gradient-to-t from-black/80 to-transparent p-4">
<p class="text-white text-sm text-center font-medium">
{{ _('Inquadra il codice a barre nel riquadro') }}
</p>
</div>
</div>
<!-- Success result -->
<div x-show="result"
x-transition
class="text-center py-8">
<!-- Success icon -->
<div class="w-16 h-16 bg-green-100 dark:bg-green-900/30 rounded-full flex items-center justify-center mx-auto mb-4">
<svg class="w-8 h-8 text-green-600 dark:text-green-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"/>
</svg>
</div>
<p class="text-sm text-slate-600 dark:text-slate-400 mb-3">
{{ _('Codice rilevato') }}
</p>
<div class="bg-slate-50 dark:bg-slate-900/50 rounded-lg p-4 mb-4">
<p class="font-mono text-xl font-bold text-primary break-all" x-text="result"></p>
</div>
</div>
<!-- Error message -->
<div x-show="error"
x-transition
class="mb-4 p-4 bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg">
<div class="flex items-center gap-2">
<svg class="w-5 h-5 text-red-600 dark:text-red-400 flex-shrink-0" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z" clip-rule="evenodd"/>
</svg>
<p class="text-sm text-red-800 dark:text-red-200" x-text="error"></p>
</div>
</div>
<!-- Action buttons -->
<div class="flex gap-3">
<button x-show="result"
@click="$dispatch('barcode-use', { code: result }); stopScan(); clear()"
type="button"
class="flex-1 px-4 py-2.5 bg-primary hover:bg-primary-dark text-white font-medium rounded-lg transition-colors duration-200 shadow-sm hover:shadow-md">
{{ _('Usa Codice') }}
</button>
<button x-show="scanning && !result"
@click="stopScan()"
type="button"
class="flex-1 px-4 py-2.5 bg-slate-100 dark:bg-slate-700 hover:bg-slate-200 dark:hover:bg-slate-600 text-slate-700 dark:text-slate-300 font-medium rounded-lg transition-colors duration-200">
{{ _('Ferma') }}
</button>
<button x-show="!scanning || result"
@click="stopScan(); clear()"
type="button"
class="flex-1 px-4 py-2.5 bg-slate-100 dark:bg-slate-700 hover:bg-slate-200 dark:hover:bg-slate-600 text-slate-700 dark:text-slate-300 font-medium rounded-lg transition-colors duration-200">
{{ _('Chiudi') }}
</button>
</div>
</div>
</div>
</div>
</div>
@@ -0,0 +1,77 @@
<!--
Caliper Status Indicator
Compact component for navbar or header
Shows connection status and last reading
-->
<div x-data="caliperConnection()"
x-init="$watch('connected', value => console.log('Caliper connected:', value))"
class="inline-block">
<!-- Compact indicator button -->
<button @click="connected ? disconnect() : connect()"
:disabled="!isSupported"
class="flex items-center gap-2 px-3 py-1.5 rounded-lg border transition-all duration-200 hover:shadow-md disabled:opacity-50 disabled:cursor-not-allowed"
:class="{
'bg-white dark:bg-slate-800 border-slate-200 dark:border-slate-700': status === 'disconnected',
'bg-green-50 dark:bg-green-900/20 border-green-200 dark:border-green-800': status === 'connected',
'bg-amber-50 dark:bg-amber-900/20 border-amber-200 dark:border-amber-800': status === 'connecting',
'bg-red-50 dark:bg-red-900/20 border-red-200 dark:border-red-800': status === 'error'
}">
<!-- Status dot with animation -->
<span class="relative flex h-2.5 w-2.5">
<!-- Ping animation for active states -->
<span x-show="status === 'connected' || status === 'connecting'"
class="absolute inline-flex h-full w-full rounded-full opacity-75 animate-ping"
:class="{
'bg-green-400': status === 'connected',
'bg-amber-400': status === 'connecting'
}"></span>
<!-- Static dot -->
<span class="relative inline-flex rounded-full h-2.5 w-2.5"
:class="{
'bg-green-500': status === 'connected',
'bg-slate-400': status === 'disconnected',
'bg-amber-400': status === 'connecting',
'bg-red-500': status === 'error'
}"></span>
</span>
<!-- Caliper icon -->
<svg class="w-4 h-4 text-slate-600 dark:text-slate-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 7h6m0 10v-3m-3 3h.01M9 17h.01M9 14h.01M12 14h.01M15 11h.01M12 11h.01M9 11h.01M7 21h10a2 2 0 002-2V5a2 2 0 00-2-2H7a2 2 0 00-2 2v14a2 2 0 002 2z"/>
</svg>
<!-- Status label -->
<span class="text-xs font-medium text-slate-700 dark:text-slate-300"
x-text="status === 'connected' ? '{{ _('Calibro') }}' :
status === 'connecting' ? '{{ _('Connessione...') }}' :
status === 'error' ? '{{ _('Errore calibro') }}' :
'{{ _('Calibro') }}'"></span>
<!-- Last reading (when connected) -->
<span x-show="connected && lastReading !== null"
x-transition
class="font-mono text-xs font-semibold text-primary px-1.5 py-0.5 bg-blue-50 dark:bg-blue-900/30 rounded"
x-text="lastReading?.toFixed(3) + ' mm'"></span>
<!-- Not supported warning icon -->
<template x-if="!isSupported">
<div class="flex items-center gap-1.5" x-data="{ showTooltip: false }">
<svg @mouseenter="showTooltip = true"
@mouseleave="showTooltip = false"
class="w-4 h-4 text-amber-500" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z" clip-rule="evenodd"/>
</svg>
<!-- Tooltip -->
<div x-show="showTooltip"
x-transition
class="absolute z-50 px-2 py-1 text-xs text-white bg-slate-900 rounded shadow-lg whitespace-nowrap -translate-y-8">
{{ _('Browser non supporta Web Serial API') }}
</div>
</div>
</template>
</button>
</div>
@@ -0,0 +1,76 @@
{# Measurement Feedback Component
Mostra feedback visivo in tempo reale della misurazione corrente.
Variabili Alpine.js richieste nel parent component:
- currentValue: float | null - valore corrente misurato
- nominal: float - valore nominale
- utl: float - Upper Tolerance Limit
- uwl: float - Upper Warning Limit
- lwl: float - Lower Warning Limit
- ltl: float - Lower Tolerance Limit
- unit: string - unità di misura (es. "mm")
- passFailStatus: computed - 'pass' | 'warning' | 'fail'
- deviation: computed - scostamento dal nominale
- progressWidth: computed - larghezza barra percentuale
#}
<div class="measurement-feedback" x-show="currentValue !== null">
<!-- Status bar colorata -->
<div class="h-3 rounded-full overflow-hidden bg-slate-200 dark:bg-slate-600">
<div class="h-full rounded-full transition-all duration-300"
:class="{
'bg-measure-pass': passFailStatus === 'pass',
'bg-measure-warning': passFailStatus === 'warning',
'bg-measure-fail': passFailStatus === 'fail'
}"
:style="'width: ' + progressWidth + '%'">
</div>
</div>
<!-- Status text + deviation -->
<div class="flex justify-between items-center mt-2">
<div class="flex items-center gap-2">
<!-- Icona stato - CONFORME -->
<template x-if="passFailStatus === 'pass'">
<span class="flex items-center gap-1 text-measure-pass font-semibold text-sm">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2.5" d="M5 13l4 4L19 7"/>
</svg>
{{ _('CONFORME') }}
</span>
</template>
<!-- Icona stato - ATTENZIONE -->
<template x-if="passFailStatus === 'warning'">
<span class="flex items-center gap-1 text-measure-warning font-semibold text-sm">
<svg class="w-5 h-5" fill="currentColor" viewBox="0 0 24 24">
<path fill-rule="evenodd" d="M9.401 3.003c1.155-2 4.043-2 5.197 0l7.355 12.748c1.154 2-.29 4.5-2.599 4.5H4.645c-2.309 0-3.752-2.5-2.598-4.5L9.4 3.003zM12 8.25a.75.75 0 01.75.75v3.75a.75.75 0 01-1.5 0V9a.75.75 0 01.75-.75zm0 8.25a.75.75 0 100-1.5.75.75 0 000 1.5z" clip-rule="evenodd"/>
</svg>
{{ _('ATTENZIONE') }}
</span>
</template>
<!-- Icona stato - NON CONFORME -->
<template x-if="passFailStatus === 'fail'">
<span class="flex items-center gap-1 text-measure-fail font-semibold text-sm">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2.5" d="M6 18L18 6M6 6l12 12"/>
</svg>
{{ _('NON CONFORME') }}
</span>
</template>
</div>
<!-- Deviation con segno -->
<span class="font-mono text-sm font-medium"
:class="{
'text-measure-pass': passFailStatus === 'pass',
'text-measure-warning': passFailStatus === 'warning',
'text-measure-fail': passFailStatus === 'fail'
}"
x-text="deviation !== null ?
(deviation >= 0 ? '+' : '') + deviation.toFixed(3) + ' ' + unit : ''">
</span>
</div>
</div>
@@ -0,0 +1,32 @@
{# Next Measurement Component
Indicatore della prossima misura da effettuare.
Variabili Alpine.js richieste nel parent component:
- nextSubtask: object | null - prossimo subtask da misurare
- marker_number: int
- description: string
- nominal: float
- unit: string
#}
<div x-show="nextSubtask" class="mt-4 p-3 rounded-lg bg-slate-50 dark:bg-slate-800 border border-slate-200 dark:border-slate-700">
<div class="flex items-center gap-2 text-sm text-steel dark:text-steel-light">
<!-- Arrow right icon -->
<svg class="w-4 h-4 text-primary flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 7l5 5m0 0l-5 5m5-5H6"/>
</svg>
<span class="font-medium">{{ _('Prossima misura') }}:</span>
<span class="font-semibold text-[var(--text-primary)]">
#<span x-text="nextSubtask?.marker_number"></span>
<span x-text="nextSubtask?.description" class="ml-1"></span>
</span>
</div>
<!-- Nominal preview -->
<div class="mt-1 ml-6 text-xs text-steel dark:text-steel-light">
<span>Nom: </span>
<span class="font-mono font-medium" x-text="nextSubtask?.nominal?.toFixed(3)"></span>
<span class="ml-1" x-text="nextSubtask?.unit || 'mm'"></span>
</div>
</div>
+121
View File
@@ -0,0 +1,121 @@
<!-- Numpad Component - Touch-friendly numeric keypad for measurement input -->
<div
x-data="numpad()"
@keydown.window="handleKeydown($event)"
class="numpad-container w-full max-w-sm mx-auto"
>
<!-- Value Display -->
<div class="value-display mb-3 p-4 bg-white dark:bg-slate-700 rounded-lg border-2 border-slate-200 dark:border-slate-600 shadow-sm">
<div class="flex items-baseline justify-center">
<span
x-text="displayValue || '0'"
class="font-mono text-3xl font-bold text-slate-900 dark:text-white"
></span>
<span
x-text="unit"
class="ml-2 text-sm font-medium text-steel dark:text-steel-light"
></span>
</div>
</div>
<!-- Keypad Grid 4x4 -->
<div class="grid grid-cols-4 gap-2">
<!-- Row 1: 7 8 9 ⌫ -->
<button
@click="addDigit('7')"
type="button"
class="min-h-[56px] min-w-[56px] font-mono text-xl font-semibold rounded-xl border border-slate-200 dark:border-slate-600 bg-white dark:bg-slate-700 text-slate-900 dark:text-white hover:bg-slate-50 dark:hover:bg-slate-600 active:scale-95 active:bg-slate-100 dark:active:bg-slate-500 transition-all duration-150 select-none cursor-pointer flex items-center justify-center touch-manipulation focus:outline-none focus:ring-2 focus:ring-primary/50"
>7</button>
<button
@click="addDigit('8')"
type="button"
class="min-h-[56px] min-w-[56px] font-mono text-xl font-semibold rounded-xl border border-slate-200 dark:border-slate-600 bg-white dark:bg-slate-700 text-slate-900 dark:text-white hover:bg-slate-50 dark:hover:bg-slate-600 active:scale-95 active:bg-slate-100 dark:active:bg-slate-500 transition-all duration-150 select-none cursor-pointer flex items-center justify-center touch-manipulation focus:outline-none focus:ring-2 focus:ring-primary/50"
>8</button>
<button
@click="addDigit('9')"
type="button"
class="min-h-[56px] min-w-[56px] font-mono text-xl font-semibold rounded-xl border border-slate-200 dark:border-slate-600 bg-white dark:bg-slate-700 text-slate-900 dark:text-white hover:bg-slate-50 dark:hover:bg-slate-600 active:scale-95 active:bg-slate-100 dark:active:bg-slate-500 transition-all duration-150 select-none cursor-pointer flex items-center justify-center touch-manipulation focus:outline-none focus:ring-2 focus:ring-primary/50"
>9</button>
<button
@click="backspace()"
type="button"
class="min-h-[56px] min-w-[56px] font-mono text-xl font-semibold rounded-xl border border-slate-200 dark:border-slate-600 bg-slate-100 dark:bg-slate-600 text-steel dark:text-steel-light hover:bg-slate-50 dark:hover:bg-slate-600 active:scale-95 active:bg-slate-100 dark:active:bg-slate-500 transition-all duration-150 select-none cursor-pointer flex items-center justify-center touch-manipulation focus:outline-none focus:ring-2 focus:ring-primary/50"
></button>
<!-- Row 2: 4 5 6 C -->
<button
@click="addDigit('4')"
type="button"
class="min-h-[56px] min-w-[56px] font-mono text-xl font-semibold rounded-xl border border-slate-200 dark:border-slate-600 bg-white dark:bg-slate-700 text-slate-900 dark:text-white hover:bg-slate-50 dark:hover:bg-slate-600 active:scale-95 active:bg-slate-100 dark:active:bg-slate-500 transition-all duration-150 select-none cursor-pointer flex items-center justify-center touch-manipulation focus:outline-none focus:ring-2 focus:ring-primary/50"
>4</button>
<button
@click="addDigit('5')"
type="button"
class="min-h-[56px] min-w-[56px] font-mono text-xl font-semibold rounded-xl border border-slate-200 dark:border-slate-600 bg-white dark:bg-slate-700 text-slate-900 dark:text-white hover:bg-slate-50 dark:hover:bg-slate-600 active:scale-95 active:bg-slate-100 dark:active:bg-slate-500 transition-all duration-150 select-none cursor-pointer flex items-center justify-center touch-manipulation focus:outline-none focus:ring-2 focus:ring-primary/50"
>5</button>
<button
@click="addDigit('6')"
type="button"
class="min-h-[56px] min-w-[56px] font-mono text-xl font-semibold rounded-xl border border-slate-200 dark:border-slate-600 bg-white dark:bg-slate-700 text-slate-900 dark:text-white hover:bg-slate-50 dark:hover:bg-slate-600 active:scale-95 active:bg-slate-100 dark:active:bg-slate-500 transition-all duration-150 select-none cursor-pointer flex items-center justify-center touch-manipulation focus:outline-none focus:ring-2 focus:ring-primary/50"
>6</button>
<button
@click="clearAll()"
type="button"
class="min-h-[56px] min-w-[56px] font-mono text-xl font-semibold rounded-xl border border-slate-200 dark:border-slate-600 bg-slate-100 dark:bg-slate-600 text-measure-fail hover:bg-slate-50 dark:hover:bg-slate-600 active:scale-95 active:bg-slate-100 dark:active:bg-slate-500 transition-all duration-150 select-none cursor-pointer flex items-center justify-center touch-manipulation focus:outline-none focus:ring-2 focus:ring-primary/50"
>C</button>
<!-- Row 3: 1 2 3 +/ -->
<button
@click="addDigit('1')"
type="button"
class="min-h-[56px] min-w-[56px] font-mono text-xl font-semibold rounded-xl border border-slate-200 dark:border-slate-600 bg-white dark:bg-slate-700 text-slate-900 dark:text-white hover:bg-slate-50 dark:hover:bg-slate-600 active:scale-95 active:bg-slate-100 dark:active:bg-slate-500 transition-all duration-150 select-none cursor-pointer flex items-center justify-center touch-manipulation focus:outline-none focus:ring-2 focus:ring-primary/50"
>1</button>
<button
@click="addDigit('2')"
type="button"
class="min-h-[56px] min-w-[56px] font-mono text-xl font-semibold rounded-xl border border-slate-200 dark:border-slate-600 bg-white dark:bg-slate-700 text-slate-900 dark:text-white hover:bg-slate-50 dark:hover:bg-slate-600 active:scale-95 active:bg-slate-100 dark:active:bg-slate-500 transition-all duration-150 select-none cursor-pointer flex items-center justify-center touch-manipulation focus:outline-none focus:ring-2 focus:ring-primary/50"
>2</button>
<button
@click="addDigit('3')"
type="button"
class="min-h-[56px] min-w-[56px] font-mono text-xl font-semibold rounded-xl border border-slate-200 dark:border-slate-600 bg-white dark:bg-slate-700 text-slate-900 dark:text-white hover:bg-slate-50 dark:hover:bg-slate-600 active:scale-95 active:bg-slate-100 dark:active:bg-slate-500 transition-all duration-150 select-none cursor-pointer flex items-center justify-center touch-manipulation focus:outline-none focus:ring-2 focus:ring-primary/50"
>3</button>
<button
@click="toggleSign()"
type="button"
class="min-h-[56px] min-w-[56px] font-mono text-xl font-semibold rounded-xl border border-slate-200 dark:border-slate-600 bg-slate-100 dark:bg-slate-600 text-steel dark:text-steel-light hover:bg-slate-50 dark:hover:bg-slate-600 active:scale-95 active:bg-slate-100 dark:active:bg-slate-500 transition-all duration-150 select-none cursor-pointer flex items-center justify-center touch-manipulation focus:outline-none focus:ring-2 focus:ring-primary/50"
>+/</button>
<!-- Row 4: 0 . (spacer) ✓ -->
<button
@click="addDigit('0')"
type="button"
class="min-h-[56px] min-w-[56px] font-mono text-xl font-semibold rounded-xl border border-slate-200 dark:border-slate-600 bg-white dark:bg-slate-700 text-slate-900 dark:text-white hover:bg-slate-50 dark:hover:bg-slate-600 active:scale-95 active:bg-slate-100 dark:active:bg-slate-500 transition-all duration-150 select-none cursor-pointer flex items-center justify-center touch-manipulation focus:outline-none focus:ring-2 focus:ring-primary/50"
>0</button>
<button
@click="addDecimal()"
type="button"
class="min-h-[56px] min-w-[56px] font-mono text-xl font-semibold rounded-xl border border-slate-200 dark:border-slate-600 bg-slate-100 dark:bg-slate-600 text-steel dark:text-steel-light hover:bg-slate-50 dark:hover:bg-slate-600 active:scale-95 active:bg-slate-100 dark:active:bg-slate-500 transition-all duration-150 select-none cursor-pointer flex items-center justify-center touch-manipulation focus:outline-none focus:ring-2 focus:ring-primary/50"
>.</button>
<!-- Spacer -->
<div></div>
<button
@click="confirm()"
type="button"
:disabled="!hasValue"
class="min-h-[56px] min-w-[56px] font-mono text-xl font-semibold rounded-xl border border-primary bg-primary text-white hover:bg-primary-dark active:scale-95 transition-all duration-150 select-none cursor-pointer flex items-center justify-center touch-manipulation focus:outline-none focus:ring-2 focus:ring-primary/50 disabled:opacity-40 disabled:cursor-not-allowed disabled:active:scale-100 disabled:hover:bg-primary"
></button>
</div>
</div>