Haptic Tuner

A public poll for the nine semantic haptic names iOS gives you and the web does not. Visitors tune each name as a navigator.vibrate pattern on their own device; the results page pools the answers per device and emits a drop-in vibrationPatterns record for packages/shared/haptics.ts.

Files

  • main.tsx — Hono app. Serves the UI, plus /api/submit, /api/results, /api/export.csv.
  • index.html — the whole UI. No build step, no dependencies, no framework.

Data

One SQLite table, submissions, one row per device, upserted on device_id.

columnnotes
device_idrandom id kept in the visitor's browser; resubmitting revises this row
fpfingerprint hash of device/screen/cores/timezone — catches cleared storage
ip_hashsalted SHA-256 of the client IP. Never stored raw, never sent to clients
patterns_json{name: {pulses: number[], gaps: number[]}} for all nine names
tuned_jsonwhich names the visitor actually moved — the rest are defaults, not votes

Set IP_SALT in the val's environment variables. Without it the hash uses a known constant, which still dedupes but is guessable.

Dedupe

Two layers, because neither is enough alone:

  1. Device — resubmitting from the same browser revises its row.
  2. Network — at most MAX_DEVICES_PER_IP (3) new devices per IP hash. Existing devices can always revise, so the cap never locks anyone out of correcting their own answer. Three, not one, because a phone, a tablet and a laptop behind one router are exactly the spread this poll wants.

Neither stops a determined person with a VPN and a fresh browser profile. The results page surfaces shared fingerprints so stuffing is at least visible.

Vocabulary

selection light soft medium rigid heavy success warning error — mirrors availableHaptics in packages/shared/haptics.ts. Adding a name means editing HAPTICS in both main.tsx (validation) and index.html (UI).

Note that soft and rigid describe attack, not intensity — UIKit varies the sharpness of the tap, and navigator.vibrate can only vary its length. Duration is a crude proxy for those two, which is worth remembering when reading the results.

Removing a bad submission

Public polls accumulate junk, and the val-scoped SQLite database is not reachable from Val Town's REST API — so deletion goes through the val itself:

curl -X DELETE -H "x-admin-key: $ADMIN_KEY" \ https://orta--ba6b72c0ad8811f181e21607ee4eb77e.web.val.run/api/rows/<device_id>

device_id comes from /api/results or the CSV export. The route returns 403 unless ADMIN_KEY is set in the val's environment variables and matches.