Skip to content

Global Input System: Implementation Spec

Prism v1.3 | Date: 2026-04-02


Table of Contents

  1. Architecture Overview
  2. GlobalInputProvider
  3. Virtual On-Screen Keyboard
  4. Voice-to-Text
  5. Barcode Scanner
  6. Floating Keyboard Toggle Button
  7. Scroll-into-View
  8. Mobile Exclusion
  9. Physical Keyboard Auto-Dismiss
  10. Settings
  11. File Manifest
  12. Open Questions / Deferred

1. Architecture Overview

src/app/layout.tsx (Server Component)
  └── <Providers> (client boundary: ThemeProvider, FamilyProvider, AuthProvider)
        └── <GlobalInputProvider>          ← NEW: wraps all pages once
              ├── <VirtualKeyboard />      ← NEW: portals to document.body
              ├── <KeyboardToggleButton /> ← NEW: portals to document.body
              └── {children}              ← all pages via AppShell

GlobalInputProvider (Context + Logic)
  ├── activeInputRef            → currently focused input element
  ├── lastPointerType           → "touch" | "mouse" | "keyboard"
  ├── keyboardVisible           → boolean
  ├── isListening               → boolean (voice)
  ├── isSuppressedForScan       → boolean (barcode in flight)
  ├── isMobile                  → boolean (never show keyboard)
  │
  ├── event listeners (document-level)
  │     ├── pointerdown         → set lastPointerType
  │     ├── focusin             → detect new active input
  │     ├── focusout            → clear active input, hide toggle btn
  │     └── keydown             → auto-dismiss keyboard; feed barcode buffer
  │
  ├── injectText(text)          → shared React-safe value injection
  ├── dispatchScan(barcode)     → barcode POST + result handling
  └── useGlobalInput() hook     → exposes context to consumers

VirtualKeyboard (Component)
  └── uses simple-keyboard npm package
  └── calls injectText() on key press

KeyboardToggleButton (Component)
  └── reads keyboardVisible + activeInput from context
  └── calls setKeyboardVisible(true) on tap

useSpeechRecognition (Hook)
  └── wraps webkitSpeechRecognition
  └── appends final results via injectText()

Why GlobalInputProvider mounts inside Providers (not inside AppShell): AppShell can be used with hideNav for the login page, and login PIN inputs must NOT trigger the virtual keyboard. Mounting inside Providers keeps the provider tree clean and avoids per-page suppression props.

Event delegation pattern: All listeners attached once to document using focusin/focusout which bubble. Consistent with how useAutoHideUI and useIdleDetection work in the existing codebase.


2. GlobalInputProvider

File: src/lib/hooks/useGlobalInput.tsx

Context Shape

interface GlobalInputContextValue {
  keyboardVisible: boolean;
  isListening: boolean;
  lastPointerType: 'touch' | 'mouse' | 'keyboard';
  isMobile: boolean;
  activeInputRef: React.RefObject<HTMLInputElement | HTMLTextAreaElement | null>;
  setKeyboardVisible: (visible: boolean) => void;
  setIsListening: (listening: boolean) => void;
  injectText: (text: string) => void;
}

Input Selector

Only these inputs trigger the virtual keyboard:

input[type="text"], input[type="search"], input[type="email"], textarea, [contenteditable]

contentEditable (rich-text) elements are also supported: the provider tracks an activeContentEditableRef, and both keyboard and voice inject into them via document.execCommand('insertText', …) rather than the native value setter.

Password inputs: keyboard appears but mic button is hidden (see §3).

Excluded (never trigger): input[type="time"], input[type="date"], input[type="number"], select

function shouldShowKeyboard(el: Element): boolean {
  if (!(el instanceof HTMLInputElement) && !(el instanceof HTMLTextAreaElement)) return false;
  if (el instanceof HTMLInputElement) {
    return ['text', 'search', 'email', 'password'].includes(el.type.toLowerCase());
  }
  return true; // textarea
}

function isPasswordInput(el: Element): boolean {
  return el instanceof HTMLInputElement && el.type.toLowerCase() === 'password';
}

Event Listeners

All registered with { passive: true } unless noted.

pointerdown: sets lastPointerType = e.pointerType ("touch" | "mouse" | "pen")

focusin:

const target = e.target as Element;
if (!shouldShowKeyboard(target)) { activeInputRef.current = null; setKeyboardVisible(false); return; }
activeInputRef.current = target as HTMLInputElement | HTMLTextAreaElement;
if (lastPointerType === 'touch' && !isMobile && !suppressedForScan && virtualKeyboardEnabled) {
  suppressOsKeyboard(target);   // inputmode="none", see "OS keyboard suppression"
  setKeyboardVisible(true);     // the keyboardVisible effect lifts the field (§7)
} else {
  releaseOsKeyboard();          // mouse, phone width, off for this device: OS keyboard as normal
}

OS keyboard suppression (#498). On a touch tablet or a 2-in-1 in tablet mode, Chrome raises its own soft keyboard on a tapped field, so without this both keyboards appear stacked. While Prism's keyboard serves a field, the field carries inputmode="none", the standard signal that the page supplies its own keyboard. It is set on the touch pointerdown, before the field takes focus, so the OS keyboard never starts to open; again in focusin; and when the toggle button opens the keyboard. The field's previous inputmode is recorded and put back exactly (or the attribute removed, if it had none) when the field loses focus or focus moves elsewhere. Mouse focus, phone widths and a disabled keyboard never get the attribute, so the OS keyboard works as before there.

Since the OS keyboard stays down, re-tapping the field that already has focus after Enter or a physical key closed Prism's keyboard reopens Prism's keyboard from pointerdown (no focusin fires for an already-focused field).

focusout:

const next = e.relatedTarget as Element | null;
if (next && isInsideKeyboard(next)) return; // focus moved to a focusable keyboard control
// Keyboard keys are non-focusable <div>s, so tapping one blurs the input with a
// NULL relatedTarget. Detect that via the pointerdown target we tracked and
// restore focus instead of tearing the keyboard down.
if (pointerOnKeyboardRef.current) {
  const el = activeContentEditableRef.current ?? activeInputRef.current;
  if (el) { el.focus({ preventScroll: true }); return; }
}
releaseOsKeyboard();              // put the field's own inputmode back
activeInputRef.current = null;
setKeyboardVisible(false);
releaseView(!textInjectedWhileOpen); // drop added padding; scroll back if nothing was typed

isInsideKeyboard(el) checks el.closest('[data-virtual-keyboard]').

⚠️ Recurring bug — "first key tap dismisses the keyboard" (#125 → #135 → #234 → and again). Tapping a key blurs the focused input (keys are non-focusable divs → null relatedTarget), so focusout hid the keyboard and the character was lost. Earlier fixes tried to prevent the blur — onPointerDown/onMouseDown/ onPointerDownCapture preventDefault on the keyboard container — but simple-keyboard swallows the pointerdown and touch displays emit no usable mousedown, so each patch fixed only one input path (mouse, or one key group). The durable fix does not rely on preventDefault or relatedTarget: track whether the last pointerdown landed inside [data-virtual-keyboard] (pointerOnKeyboardRef), and in focusout re-focus the active field when it did. setKeyboardVisible(false) clears that flag so the ↓/Enter keys still close the keyboard. If this regresses again, verify pointerOnKeyboardRef is being set on pointerdown and read in focusout — don't add another preventDefault.

keydown: physical keyboard auto-dismiss (§9) + barcode buffer (§5).

injectText(text: string)

Shared injection for keyboard and voice. Handles React controlled inputs via native value setter:

function injectText(text: string): void {
  const input = activeInputRef.current;
  if (!input) return;
  const proto = input instanceof HTMLTextAreaElement
    ? window.HTMLTextAreaElement.prototype
    : window.HTMLInputElement.prototype;
  const setter = Object.getOwnPropertyDescriptor(proto, 'value')?.set;
  if (!setter) return;
  setter.call(input, text);
  input.dispatchEvent(new Event('input', { bubbles: true }));
  input.dispatchEvent(new Event('change', { bubbles: true }));
}
  • Keyboard: passes the full simple-keyboard state string (its onChange provides the complete value)
  • Voice: passes existingValue + ' ' + recognizedText

Barcode Scan Suppression

When a scan fires, set suppressedForScan = true for 500ms. During this window, focusin does not open the keyboard. Prevents the keyboard from popping after a scan into a search field.


3. Virtual On-Screen Keyboard

File: src/components/input/VirtualKeyboard.tsx

Package

npm install simple-keyboard

Pin to a specific minor version (e.g., "simple-keyboard": "3.7.x"). The library has breaking changes between minor versions.

Layout

const layout = {
  default: [
    '` 1 2 3 4 5 6 7 8 9 0 - = {bksp}',
    '{tab} q w e r t y u i o p [ ] \\',
    '{lock} a s d f g h j k l ; \' {enter}',
    '{shift} z x c v b n m , . / {shift}',
    '{space} {mic} {dismiss}',
  ],
  shift: [
    '~ ! @ # $ % ^ & * ( ) _ + {bksp}',
    '{tab} Q W E R T Y U I O P { } |',
    '{lock} A S D F G H J K L : " {enter}',
    '{shift} Z X C V B N M < > ? {shift}',
    '{space} {mic} {dismiss}',
  ],
};

Custom button labels: {bksp} → "⌫", {enter} → "↵", {shift} → "⇧", {lock} → "⇪", {tab} → "⇥", {mic} → mic icon, {dismiss} → ↓ icon.

{mic} is hidden (opacity 0, pointer-events none) when isPasswordInput(activeInputRef.current) is true.

Positioning

Fixed overlay, portalled to document.body:

position: fixed
bottom: 0
left: 0
right: 0
z-index: 9000
height: 38vh
min-height: 320px
max-height: 480px

These numbers live in one place, src/lib/input/keyboardLayout.ts (KEYBOARD_HEIGHT_VH, KEYBOARD_MIN_HEIGHT_PX, KEYBOARD_MAX_HEIGHT_PX, keyboardHeightPx()). The container style, the scroll math (§7) and the --keyboard-height CSS var all read them, so the drawn height and the space the field is lifted clear of cannot drift apart again.

Key height ≥ 52px, key font size 18px. Sized for comfortable use on 24" 1080p display.

Theming

Override simple-keyboard's CSS variables in globals.css scoped to [data-virtual-keyboard]. Reads Prism's CSS custom properties for automatic dark/light mode support. Import simple-keyboard/build/css/index.css only inside the component file (not globally).

simple-keyboard Integration

const keyboardRef = useRef<Keyboard | null>(null);

// On mount:
keyboardRef.current = new Keyboard(containerRef.current, {
  onChange: (input) => injectText(input),
  onKeyPress: (button) => {
    if (button === '{shift}' || button === '{lock}') handleShift();
    if (button === '{dismiss}') setKeyboardVisible(false);
    if (button === '{mic}') startListening();
  },
  layout,
  physicalKeyboardHighlight: false,
  syncInstanceInputs: false,
});

// When active input changes, sync keyboard state to existing value:
keyboardRef.current?.setInput(activeInputRef.current?.value ?? '');

Animation

Slide in from bottom on open, slide out on close. translateY(100%) → translateY(0) over 200ms ease-out. Use a controlled isExiting state to trigger exit animation before unmounting.


4. Voice-to-Text

File: src/lib/hooks/useSpeechRecognition.ts

Extensibility Interface

export interface SpeechRecognizer {
  isListening: boolean;
  isSupported: boolean;
  start: () => void;
  stop: () => void;
  error: string | null;
}

v1 implements with webkitSpeechRecognition. To swap to cloud (Azure/Whisper/Deepgram) in v2: implement the same interface in a new file, update the import in useGlobalInput.tsx. No callers change.

Configuration

rec.continuous = false;    // single utterance per tap
rec.interimResults = true; // v1: ignored; v2: display as interim chip above input
rec.lang = 'en-US';        // v1 hardcoded; v2: read from speech.language setting

Append Behavior

const handleSpeechResult = (transcript: string) => {
  const input = activeInputRef.current;
  if (!input) return;
  const current = input.value;
  const separator = current.length > 0 && !current.endsWith(' ') ? ' ' : '';
  injectText(current + separator + transcript);
};

Error Toasts

Error Toast message
no-speech "No speech detected. Tap the mic to try again."
not-allowed "Microphone permission denied. Check browser settings."
network "Speech recognition needs an internet connection."
audio-capture "No microphone found."
aborted Silent

Mic Button Visual State

Apply a class to the keyboard container (is-listening) when isListening is true. CSS targets .is-listening .key-mic { animation: pulse 1s infinite; }.


5. Barcode Scanner

5.1 Detection Algorithm

Lives inside GlobalInputProvider's keydown handler:

const barcodeBuffer = useRef<{ char: string; time: number }[]>([]);

// In keydown handler:
if (e.key === 'Enter') {
  const buf = barcodeBuffer.current;
  if (buf.length >= 10) {
    const elapsed = buf[buf.length - 1].time - buf[0].time;
    if (elapsed < 100) {
      const barcode = buf.map(b => b.char).join('');
      barcodeBuffer.current = [];
      e.preventDefault();
      dispatchScan(barcode);
      return;
    }
  }
  barcodeBuffer.current = [];
  return;
}
if (e.key.length === 1) {
  barcodeBuffer.current.push({ char: e.key, time: Date.now() });
  // Trim: keep only chars in last 200ms
  const cutoff = Date.now() - 200;
  barcodeBuffer.current = barcodeBuffer.current.filter(b => b.time >= cutoff);
} else {
  barcodeBuffer.current = [];
}

Threshold: 10+ characters in <100ms ending with Enter. Well above human typing speed, well below scanner speed.

5.2 Server API

File: src/app/api/shopping/scan/route.ts

POST /api/shopping/scan
Body: { barcode: string, dryRun?: boolean, listId?: string, category?: ShoppingCategory }

200 found:    { found: true, item: { name, brand?, category?, imageUrl? }, action: "added"|"updated_existing", listId, itemId }
200 missing:  { found: false, barcode }
200 dryRun:   { found: true, product: { name, brand?, suggestedCategory }, existingInLists: [...] }  // nothing is written
400:          { error: "barcode is required" }
401:          { error: "Unauthorized" }       // no display session
403:          { error: ... }                  // scanner.enabled is false

Requires a display session: getDisplayAuth() returns 401 if absent. Returns 403 when scanner.enabled is false. Optional body fields: dryRun (look up and report without adding), listId (override target list), category (override resolved category). Otherwise reads the scanner.defaultListId setting to determine the target list.

5.3 Product Lookup Cascade

File: src/lib/integrations/product-lookup.ts

export interface ProductLookupResult {
  name: string;
  brand?: string;
  category: ShoppingCategory;
  imageUrl?: string;
  source: 'open-food-facts' | 'upcitemdb' | 'cache';
}

export async function lookupBarcode(barcode: string): Promise<ProductLookupResult | null>

Provider order: 1. Redis cache: key barcode:{barcode}, TTL 7 days, check first 2. Open Food Facts: free, no key, best grocery coverage 3. UPCitemdb: free tier (100/day), good US coverage, handles non-food pantry items

3-second timeout per provider via AbortController. (Nutritionix/Edamam were considered but are not implemented: no integrations.nutritionix.* / integrations.edamam.* settings exist.)

5.4 Route Logic

1. Validate barcode (non-empty, max 20 chars, alphanumeric + hyphens)
2. Check Redis cache
3. Call lookupBarcode()
4. If not found: return { found: false }
5. Resolve target list: scanner.defaultListId → "Groceries" list → first list
6. Duplicate check: SELECT WHERE listId=? AND name ILIKE ? AND checked=false
7. Duplicate: update source='scan', return action:"updated_existing"
8. New: INSERT with source='scan', trigger MS To-Do sync, return action:"added"

5.5 Client-Side dispatchScan

async function dispatchScan(barcode: string) {
  suppressedForScan.current = true;
  setTimeout(() => { suppressedForScan.current = false; }, 500);
  playBeep();

  const res = await fetch('/api/shopping/scan', { method: 'POST', ... });
  const data = await res.json();

  if (!data.found) {
    // Unknown barcode: show a toast and add NOTHING (no placeholder item is created).
    toast({ title: 'Unknown barcode', description: `No product found for ${barcode}` });
    return;
  }

  const isOnShopping = window.location.pathname.startsWith('/shopping');
  if (isOnShopping) {
    window.dispatchEvent(new CustomEvent('prism:scan-result', { detail: data }));
  }

  toast({
    title: data.action === 'updated_existing' ? `${data.item.name} already on list` : `${data.item.name} added`,
    action: !isOnShopping ? { label: 'View', onClick: () => router.push('/shopping') } : undefined,
  });
}

5.6 Database

shopping_items.source column already exists with default('internal'). Add 'scan' as a new valid value. No migration needed. Document in a schema comment.

5.7 Audio Feedback

Beeps are synthesized with the Web Audio API. No MP3 assets are bundled. playBeep() builds an AudioContext oscillator and plays a short tone: 1800 Hz for the "scan" (Scanner chirp) style, 1200 Hz for the "beep" (Short beep) style.

function playBeep() {
  if (!settings['scanner.soundEnabled']) return;
  const ctx = new AudioContext();
  const osc = ctx.createOscillator();
  osc.frequency.value = settings['scanner.soundStyle'] === 'scan' ? 1800 : 1200;
  // …gain envelope, then osc.start()/osc.stop() for a brief chirp
}

5.8 Scan Icon on Shopping Items

{item.source === 'scan' && (
  <ScanBarcode className="h-3 w-3 text-muted-foreground/60" aria-label="Added by scanner" />
)}

Rendered inline before quantity badge in ShoppingItemRow.

5.9 Scroll-to and Highlight on /shopping

ShoppingView listens for prism:scan-result:

const el = document.getElementById(`shopping-item-${data.itemId}`);
el?.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
el?.classList.add('scan-highlight');
setTimeout(() => el?.classList.remove('scan-highlight'), 1500);

Each item row gets id={shopping-item-${item.id}}. Add scan-highlight keyframe in globals.css (300ms yellow background flash).

5.10 Camera Scanner (shipped)

Besides USB/Bluetooth keyboard-wedge (HID) scanners, the Shopping page ships a camera-based scanner for phones/tablets with no dedicated hardware.

Files: src/components/input/CameraScannerOverlay.tsx, src/lib/hooks/useCameraScanner.ts.

  • Trigger: a camera icon in the Shopping page header. It's opened by dispatching the prism:open-barcode-scanner event; ShoppingView lazy-loads the overlay (dynamic import) and toggles showCameraScanner.
  • Full-screen overlay: the camera viewfinder fills the screen and self-dismisses on a successful read.
  • Two decode paths:
  • BarcodeDetector (native, Android/Chrome): continuous live scanning of the video stream.
  • @zxing/browser photo-capture fallback (iOS/Safari, where BarcodeDetector is unavailable): captures a still frame and decodes it.
  • Feedback: haptic buzz + the same synthesized beep (§5.7) on a hit.
  • A decoded barcode is handed to the same dispatchScan flow (§5.5), so lookup/dedup/add behavior is identical to the HID path.

6. Floating Keyboard Toggle Button

File: src/components/input/KeyboardToggleButton.tsx

Visibility

Show when ALL: activeInputRef.current !== null AND keyboardVisible === false AND isMobile === false AND lastPointerType === 'touch'.

Positioning

position: fixed
bottom: 1.5rem
right: 1.5rem
z-index: 8500
size: 48×48px

shadcn Button variant secondary, rounded-xl, Keyboard icon from lucide-react, aria-label="Open keyboard".

Tapping calls setKeyboardVisible(true). Fade in/out 150ms.


7. Scroll-into-View

File: src/lib/input/keyboardLayout.ts

The keyboard is a fixed overlay, so the browser neither shrinks the viewport nor scrolls the focused field into view for it. When the keyboard opens, or focus moves to another field while it is open, the provider:

  1. Sets --keyboard-height (in px, from keyboardHeightPx()) and the data-virtual-keyboard-open attribute on <html>. A rule in globals.css, active only under that attribute, centres dialogs ([data-keyboard-aware-dialog], set by DialogContent) in the space above the keyboard and caps their height to it. With the keyboard closed, dialogs keep their own top and max-height.
  2. Calls revealAboveKeyboard(field, keyboardTop). It walks the scrollable ancestors from the nearest outward and scrolls each by what is still needed to put the field's bottom 16px above the keyboard (or above that scroller's own visible bottom, when that is higher). It stops at the first position: fixed ancestor, since a fixed dialog does not move when the page behind it scrolls. Otherwise it scrolls the page last.
  3. When a scroller's content ends too soon to scroll that far (the last field of a full-height page, e.g. shopping's inline "Add item" row), it extends that scroller's padding-bottom for as long as the keyboard is open.

On close, restoreReveal() removes the added padding and, if nothing was typed while the keyboard was open, scrolls each scroller back to where it was.

Phones (§8) use the OS keyboard only and are left to the browser.


8. Mobile Exclusion

Detection: useIsMobile() (existing hook, window.innerWidth < 768). No user-agent sniffing.

Provider behavior: Provider mounts on all devices (owns barcode scanner). When isMobile is true: focusin never sets keyboardVisible = true.

Component behavior: VirtualKeyboard and KeyboardToggleButton both return null when isMobile is true from context (safety net).

Laptop touchscreens: Handled correctly by pointer type detection. Tap → keyboard appears. Physical keypress → keyboard auto-dismisses. Trackpad click → keyboard never appears.


9. Physical Keyboard Auto-Dismiss

function isRealKeyboardEvent(e: KeyboardEvent): boolean {
  if (!e.isTrusted) return false; // programmatic events (simple-keyboard does not dispatch these)
  if (['Shift','Control','Alt','Meta','CapsLock','Tab'].includes(e.key)) return false;
  return true;
}

// In keydown handler:
if (isRealKeyboardEvent(e) && keyboardVisible) {
  setKeyboardVisible(false);
}

Auto-dismiss is synchronous and runs before the barcode buffer check, so USB barcode scanners (which appear as keyboard input) still trigger barcode detection correctly.


10. Settings

New Settings Keys

Key Type Default Description
scanner.enabled boolean true Master switch for barcode scanning
scanner.defaultListId string|null null UUID of default list; null = auto-select "Groceries"
scanner.soundEnabled boolean true Audio feedback on scan
scanner.soundStyle "beep"\|"scan" "beep" Which tone to play
input.virtualKeyboardEnabled boolean true Household switch for the virtual keyboard; a device can override it (below)

Per-device keyboard choice (#525)

Each browser can override the household switch: Use household setting (default) / Always / Never, stored in localStorage as prism:keyboard-on-this-device (src/lib/input/keyboardDevicePref.ts). A kiosk display with no system keyboard can keep Prism's while a tablet uses its own. The provider's virtualKeyboardEnabled is the effective value:

  • Always: on, even with the household switch off.
  • Never: off. No auto-open, no inputmode="none", the toggle button is hidden and setKeyboardVisible(true) is refused, so the system keyboard is untouched.
  • Use household setting: the household switch.

The touch-only and phone-width rules still apply under Always.

Both values load after mount. Until the stored choice has been read and, when it defers to the household, the setting fetch has settled, the effective value is false, so a device set to Never never briefly behaves as Always. Changing the choice in Settings applies at once (a window event, plus storage for other tabs) and closes the keyboard if it was open.

Settings UI

New section: { id: 'input', label: 'Input', icon: Keyboard }. Add to sections array in SettingsView.tsx after 'display'.

File: src/app/settings/sections/InputSection.tsx

Card 1: Barcode Scanner - Enable scanner (Switch) - Default list (Select from shopping lists) - Scanner sound (Switch) - Sound style (Select: Short beep / Scanner chirp, disabled when sound off)

Card 2: Virtual Keyboard - Enable on-screen keyboard (Switch, household) - On-screen keyboard on this device (Select: Use household setting / Always / Never, stored in the browser) - Description: "Shows a touch keyboard when tapping text fields. Disable if using a physical keyboard."


11. File Manifest

New Files

File Responsibility
src/lib/hooks/useGlobalInput.tsx GlobalInputProvider + useGlobalInput(). All state, all document listeners, injectText, dispatchScan, playBeep, scroll helpers, barcode buffer. Instantiates useSpeechRecognition.
src/lib/hooks/useSpeechRecognition.ts webkitSpeechRecognition wrapper. Returns SpeechRecognizer interface. Swap this file's implementation for cloud STT in v2 without changing any callers.
src/components/input/VirtualKeyboard.tsx Keyboard UI. simple-keyboard. Portalled to document.body. Reads context via useGlobalInput().
src/components/input/KeyboardToggleButton.tsx Fixed bottom-right button. Returns null when isMobile or keyboardVisible.
src/components/input/index.ts Re-exports above two components.
src/lib/integrations/product-lookup.ts lookupBarcode(). Cascade + Redis cache + category mapping + per-provider timeouts.
src/app/api/shopping/scan/route.ts POST /api/shopping/scan. Validate → lookup → deduplicate → insert → sync.
src/app/settings/sections/InputSection.tsx Scanner + keyboard settings UI.
src/components/input/CameraScannerOverlay.tsx Full-screen camera scanner UI (BarcodeDetector + @zxing/browser fallback).
src/lib/hooks/useCameraScanner.ts Camera-scan decode loop / stream management.

Modified Files

File Changes
src/components/providers/Providers.tsx Add <GlobalInputProvider> wrapping {children}.
src/components/layout/AppShell.tsx Add --keyboard-height CSS custom property on :root based on keyboardVisible.
src/app/shopping/ShoppingView.tsx Add prism:scan-result event listener. Scroll + highlight on scan.
src/app/shopping/ShoppingItemRow.tsx Render ScanBarcode icon when item.source === 'scan'. Add id prop.
src/types/models.ts Add source?: string to ShoppingItem.
src/app/settings/SettingsView.tsx Add Input section to nav + render tree.
src/styles/globals.css simple-keyboard CSS overrides. scan-highlight animation. Mic pulse animation. --keyboard-height consumer patterns.
tailwind.config.js Add animate-slide-up / animate-slide-down keyframes.
package.json Add simple-keyboard (pinned minor version).

12. Open Questions / Deferred

# Item
1 simple-keyboard CSS isolation: import scoped to [data-virtual-keyboard] via PostCSS to prevent class bleed
2 Voice language: hardcoded en-US in v1; add speech.language setting in v2
3 Interim speech results: suppressed in v1; v2 can show as floating chip above active input (interimText state already in context shape)
4 Caps lock behavior: double-tap {shift} activates caps lock; requires careful state tracking in handleShift()
5 MS To-Do sync on scan: verify microsoft-todo.ts sync function is callable server-side from route handler; if not, queue the sync
6 Camera-based scanning on mobile: ✅ SHIPPED (see §5.10): BarcodeDetector with a @zxing/browser + getUserMedia photo-capture fallback, triggered from the Shopping page header
7 PIN entry on login page: verify PIN pad uses buttons (not input[type=text]) and is excluded from keyboard trigger
8 Accessibility: add role="application" and aria-label="Virtual keyboard" to keyboard container; aria-hidden if AT should skip
9 Hardware mic availability: ViewSonic TD2465 has audio I/O ports but no built-in mic; USB mic needed; test before voice goes live
10 AppShell bottom padding: pages with fixed-height layouts (Shopping, Tasks) need inner scroll container to consume --keyboard-height var directly rather than relying on padding on <main>