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.
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.One SQLite table, submissions, one row per device, upserted on device_id.
| column | notes |
|---|---|
device_id | random id kept in the visitor's browser; resubmitting revises this row |
fp | fingerprint hash of device/screen/cores/timezone — catches cleared storage |
ip_hash | salted 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_json | which 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.
Two layers, because neither is enough alone:
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.
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.
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.