Skip to content

Latest commit

 

History

History
261 lines (180 loc) · 20.4 KB

SPEC.md

File metadata and controls

261 lines (180 loc) · 20.4 KB

haptic/0.1-draft

An open, portable haptic format and registry. This document is the normative specification for the .hap.md file and the fenced haptic JSON block it contains.

Repository: github.com/Bimbi-Digital/haptics.md · Site: https://haptics.md · License: Apache-2.0 (code), CC0 (pattern snippets).


Status

haptic/0.1-draft — unstable. This is a working draft. Field names, defaults, and the JSON grammar may change without a compatibility shim before 1.0. Do not treat 0.1 as frozen.

RFC-2119 keywords (MUST, MUST NOT, SHOULD, MAY) are used in exactly two places in this document: the Intent vocabulary and the Degradation ladder. Those two contracts are what a conforming runtime is judged against. Everything else in this draft is provisional and descriptive — read it as guidance, not obligation.


Overview

A haptic pattern is authored once, as a single .hap.md file, and rendered on whatever the target device can actually do — from a full Core Haptics waveform on an iPhone down to a crude on/off buzz on Android Chrome, down to nothing at all on a device with no actuator. A device with no actuator is a valid target that produces nothing; this is a success, not a failure.

The format is deliberately layered so that the same file carries progressively cruder representations of the same feeling:

  1. an optional intent — a semantic token (impact.rigid, notification.success, …) that maps to a native platform primitive and produces the best feel;
  2. a track — a synthesized timeline of transient and continuous events with intensity, sharpness, and curves;
  3. fallbacks.web — a duration-only on/off millisecond pattern for navigator.vibrate;
  4. silence.

A runtime walks that ladder top-down and renders the highest rung the device supports.

Honesty is a design constraint, not a footnote. Web haptics are the weakest surface anywhere: navigator.vibrate is duration-only, gesture-gated, throttled, and exists only in the Android Chrome family — it is absent on iOS Safari and on desktop. This spec never lets intensity or sharpness silently "survive" to a surface that cannot express them.


The .hap.md file

A .hap.md file has three parts, in order:

1. YAML frontmatter

A single YAML block delimited by ---. Recognized keys:

Key Type Notes
id string (slug) ^[a-z0-9]+(-[a-z0-9]+)*$. Matches the id inside the haptic block.
title string Human title.
spec string Grammar version, e.g. "0.1". Matches the block's haptic field.
version string (semver) Content version of this pattern, independent of the spec grammar version.
category string Free-form grouping, e.g. gesture, feedback, ui.
intent intent token Mirrors the block's intent, if any.
requiredCapability capability token | null Mirrors the block's requiredCapability.
platforms string[] Honest target labels, e.g. ios, android, web:best-effort.
tags string[] Search/registry tags.
feel string One-line description of the intended sensation.

Unknown frontmatter keys are ignored, never fatal.

2. Human prose

Free markdown between the frontmatter and the haptic block. This is for people: what the pattern is for, when to fire it, what it should feel like. It is not compiled.

3. Exactly one fenced haptic block

A single fenced code block tagged haptic containing a JSON object. This block is the compilable source of truth. A .hap.md file MUST contain exactly one such block (this is the one structural rule outside the two RFC-2119 contracts, and it is enforced by conformance, not by RFC-2119 language here). Any prose fences (```json, ```swift) are documentation and are ignored by the compiler.


The haptic JSON object

{
  "haptic": "0.1",
  "id": "pattern-slug",
  "intent": "impact.rigid",
  "track": [ /* events */ ],
  "requiredCapability": "composition",
  "fallbacks": { "web": [20, 40, 30], "reduced": "selection" }
}
Field Type Required Range / values Meaning
haptic string yes ^\d+\.\d+$ (e.g. "0.1") Grammar version of this block.
id string yes ^[a-z0-9]+(-[a-z0-9]+)*$ Stable slug; matches frontmatter id.
intent string no an Intent token (see below) Semantic primitive. Resolved natively first.
track array no ≥ 1 event Synthesized timeline. Used when intent is absent or the device cannot render the intent.
requiredCapability string no a Capability token Minimum device capability the full track needs. If absent, the runtime steps down the ladder.
fallbacks object no { web, reduced } Crude cross-surface fallbacks.

Track events

track is an array of events, each:

Field Type Required Range / values Meaning
at integer yes ≥ 0 (milliseconds) Start offset from the track's origin. Events should be ordered by non-decreasing at.
type string yes "transient" | "continuous" Transient = a discrete tap. Continuous = a sustained vibration.
intensity number no 0..1 Normalized strength.
sharpness number no 0..1 Normalized "crispness" (Core Haptics term). Higher = sharper/tighter.
duration integer no ≥ 0 (milliseconds) Length of a continuous event. Required when type is "continuous". Ignored for transients.
curve string no linear | easeIn | easeOut | hold Envelope of a continuous event's intensity over its duration.
primitive string no platform hint string Optional native-primitive hint (e.g. an Android composition primitive name) a compiler MAY honor.

fallbacks

Field Type Range / values Meaning
web integer[] each ≥ 0 (ms) On/off pattern for navigator.vibrate([...]). Odd indices are pauses. Duration-only — carries no intensity or sharpness.
reduced string an Intent token A gentler semantic substitute for the reduced-feedback path.

Unknown fields anywhere in the object are ignored, never fatal. Future grammar versions add fields; a 0.1 runtime encountering a 0.2 field skips it and renders what it understands.


Intent vocabulary

The Tier-0 intent tokens are frozen for 0.1. A conforming runtime MUST resolve each of these tokens to the platform's native semantic primitive when one exists, and MUST NOT invent a token outside this set under the 0.1 grammar. Tokens outside this set MAY appear in future tiers and MUST be treated as unknown (ignored, stepped down the ladder) by a 0.1 runtime.

Token Intended feeling
impact.light A light tap against a surface.
impact.medium A medium tap.
impact.heavy A heavy, weighty tap.
impact.soft A dull, cushioned tap.
impact.rigid A stiff, crisp tap.
notification.success The system "it worked" signature.
notification.warning The system "careful" signature.
notification.error The system "it failed" signature.
selection A tick as a discrete value changes (picker, segmented control).

A runtime MUST NOT map a token to a native primitive that contradicts its meaning. Where no native primitive exists (see the mapping table), the runtime MUST step down the ladder rather than fake it.


Capabilities

requiredCapability and the runtime's device probe use these tokens:

Token Device can… Probed via
amplitude vary per-event intensity iOS: Core Haptics always. Android: Vibrator.hasAmplitudeControl() == true.
sharpness vary sharpness independently iOS Core Haptics only. No Android/web/RN/Flutter equivalent.
curve render a parameter envelope over a continuous event iOS Core Haptics CHHapticParameterCurve.
composition play Android primitive compositions Android: VibrationEffect.Composition.areAllPrimitivesSupported(...) == true (API 30+). Many mid-range ERM devices report false.

If a pattern declares requiredCapability and the device lacks it, the runtime skips the track rung and continues down the ladder.


The normative mapping table

This table is the heart of the spec. Each row is a format construct; each column is a target. A cell is either the exact native API parameter used, or unsupported -> falls back to X where X is the next rung. All API names are real and current as of this draft.

Column meanings, honestly labeled:

  • iOS — first-class. UIFeedbackGenerator / iOS 17 .sensoryFeedback for intents; Core Haptics + AHAP for tracks. iPhone 8+.
  • Android — first-class. VibrationEffect + VibrationEffect.Composition. VibratorManager is API 31+; Vibrator below that. Amplitude control is API 26+ and many devices report hasAmplitudeControl() == false. Requires the VIBRATE manifest permission.
  • web — web (Android Chrome), best-effort, duration-only. navigator.vibrate only. No intensity, no sharpness. Gesture-gated, throttled, Android-Chrome-family only. Does not exist on iOS Safari or on desktop — there it is a silent no-op.
  • React Native — intent-tier only. expo-haptics exposes semantic constants and nothing else. There is no public custom-waveform API; a native module for tracks is not yet shipped.
  • Flutter — intent-tier only. HapticFeedback exposes a small set of semantic constants and nothing else.
Construct iOS Android web React Native Flutter
intensity 0..1 CHHapticEventParameter(.hapticIntensity) amplitude 1..255 via createWaveform/createOneShot when hasAmplitudeControl()==true; else DEFAULT_AMPLITUDE (on/off) → intensity dropped, duration-only unsupported -> dropped (vibrate is duration-only) unsupported -> falls back to intent (no amplitude param) unsupported -> falls back to intent
sharpness 0..1 CHHapticEventParameter(.hapticSharpness) no API → approximated by primitive choice on composition path (e.g. PRIMITIVE_TICK sharp vs PRIMITIVE_THUD dull); unsupported -> dropped on waveform path unsupported -> dropped unsupported -> falls back to intent unsupported -> falls back to intent
transient event CHHapticEvent(.hapticTransient) createOneShot(ms, amp) or Composition primitive (PRIMITIVE_CLICK/PRIMITIVE_TICK) one pulse from fallbacks.web → navigator.vibrate(ms) unsupported -> falls back to intent (nearest impact constant) unsupported -> falls back to intent
continuous event (+duration) CHHapticEvent(.hapticContinuous, duration) + intensity/sharpness params sustained createWaveform/createOneShot(duration); amplitude only if hasAmplitudeControl() single sustained navigator.vibrate(duration), no amplitude unsupported -> falls back to intent unsupported -> falls back to intent
curve CHHapticParameterCurve (control points over time) approximated by multi-step createWaveform amplitudes; no true curve; unsupported -> dropped on composition path unsupported -> dropped unsupported -> falls back to intent unsupported -> falls back to intent
impact.light UIImpactFeedbackGenerator(.light) / .sensoryFeedback(.impact(weight:.light)) Composition PRIMITIVE_LOW_TICK; else createPredefined(EFFECT_TICK); else duration-only fallbacks.web → navigator.vibrate([...]) Haptics.impactAsync(ImpactFeedbackStyle.Light) HapticFeedback.lightImpact()
impact.medium UIImpactFeedbackGenerator(.medium) Composition PRIMITIVE_CLICK; else createPredefined(EFFECT_CLICK); else duration-only fallbacks.web → vibrate Haptics.impactAsync(Medium) HapticFeedback.mediumImpact()
impact.heavy UIImpactFeedbackGenerator(.heavy) Composition PRIMITIVE_THUD (API 31+) or PRIMITIVE_CLICK@1.0; else createPredefined(EFFECT_HEAVY_CLICK); else duration-only fallbacks.web → vibrate Haptics.impactAsync(Heavy) HapticFeedback.heavyImpact()
impact.soft UIImpactFeedbackGenerator(.soft) (iOS 13+) Composition PRIMITIVE_LOW_TICK; else createPredefined(EFFECT_TICK); else duration-only fallbacks.web → vibrate Haptics.impactAsync(Soft) no native equivalent → unsupported -> falls back to impact.light (HapticFeedback.lightImpact())
impact.rigid UIImpactFeedbackGenerator(.rigid) (iOS 13+) Composition PRIMITIVE_CLICK (sharp) / PRIMITIVE_TICK; else createPredefined(EFFECT_CLICK); else duration-only fallbacks.web → vibrate Haptics.impactAsync(Rigid) no native equivalent → unsupported -> falls back to impact.heavy (HapticFeedback.heavyImpact())
notification.success UINotificationFeedbackGenerator().notificationOccurred(.success) no native semantic → synthesized from track/Composition (e.g. ascending PRIMITIVE_CLICK pair); else duration-only fallbacks.web → vibrate Haptics.notificationAsync(NotificationFeedbackType.Success) no native notification API → unsupported -> falls back to track, else impact.medium
notification.warning .notificationOccurred(.warning) synthesized from track/Composition; else duration-only fallbacks.web → vibrate Haptics.notificationAsync(Warning) unsupported -> falls back to track, else impact.medium
notification.error .notificationOccurred(.error) synthesized from track/Composition; else duration-only fallbacks.web → vibrate Haptics.notificationAsync(Error) unsupported -> falls back to track, else impact.heavy
selection UISelectionFeedbackGenerator().selectionChanged() Composition PRIMITIVE_TICK / createPredefined(EFFECT_TICK); else HapticFeedbackConstants.CLOCK_TICK; else duration-only fallbacks.web → vibrate Haptics.selectionAsync() HapticFeedback.selectionClick()

Notes:

  • Android amplitude is the recurring cliff. A pattern that assumes graded intensity gets a flat on/off buzz on any device where hasAmplitudeControl() is false. Emitted Android code must guard with hasAmplitudeControl() / areAllPrimitivesSupported(...) and carry a real fallback — never a silent lie that amplitude worked.
  • web never carries amplitude or sharpness. Those parameters are dropped at the boundary. This is by design and stated plainly wherever web is a target.
  • RN and Flutter are intent-only surfaces. A track on those platforms resolves through the pattern's intent (if present) to the nearest constant, or falls silent. Custom waveforms require a native module that is not part of this draft.

The degradation ladder

This is a normative contract. A conforming runtime MUST implement it top-down and render the highest rung the device supports.

  1. intent — if the object has an intent and the platform has a native semantic primitive for it (per the mapping table), the runtime MUST render that primitive. This is the best feel.
  2. track — else, if the object has a track and the device satisfies requiredCapability (or none is declared), the runtime SHOULD render the synthesized timeline using the platform's per-event APIs.
  3. fallbacks.web — on the web target, or where no richer rung is available, the runtime MUST use fallbacks.web with navigator.vibrate where it exists, and MUST treat its absence (iOS Safari, desktop) as a silent no-op rather than an error.
  4. silent no-op — if nothing above can render, the runtime MUST do nothing and report success.

Hard invariants:

  • A device with no actuator is a valid target that produces nothing.
  • Application logic MUST NEVER gate on haptic success. Firing a pattern is fire-and-forget; the surrounding UI action MUST proceed identically whether or not anything buzzed.
  • A runtime MUST NOT lie that a parameter took effect. If amplitude/sharpness/curve was dropped, any capability report the runtime exposes MUST reflect that it was dropped.
  • The reduced fallback and the OS system-haptics setting (see Accessibility) MUST be honored before the full intent/track rungs.

Accessibility

Haptics are a feedback and accessibility channel — including for deaf and hard-of-hearing users — and are governed by consent, not by motion sensitivity. A runtime MUST respect the OS system-haptics setting (on iOS: Settings → Accessibility → Touch → Vibration), not prefers-reduced-motion. When the OS setting is off, the runtime treats every pattern as a silent no-op. Frame haptics as feedback-with-consent.


Conformance

A conforming pattern and a conforming runtime are judged on two axes.

Structural conformance (machine-checkable)

A .hap.md file is structurally conformant when:

  1. it contains exactly one fenced haptic block;
  2. that block validates against the published JSON Schema for its haptic version;
  3. id matches the frontmatter id and the slug pattern;
  4. every intent and fallbacks.reduced value is a Tier-0 token;
  5. track events are ordered by non-decreasing at, and every continuous event carries a duration;
  6. unknown fields are present-but-ignored, never causing a hard failure.

A conforming runtime implements the degradation ladder and its hard invariants above, and never gates app logic on haptic success.

Human device-verification (not machine-checkable)

A pattern is only "verified" when a person has felt it on real hardware. Structural validity is necessary but not sufficient. Verification requires:

  • felt on a physical Android device (with and without amplitude control, ideally) and a physical iPhone;
  • demo media led on Android for the web/navigator.vibrate path, because that is the only place web haptics fire;
  • an explicit "haptics unavailable on iOS Safari" state in any web playground — never a fake or hacky buzz to paper over the gap.

Emulators and desktop browsers cannot verify feel.


Versioning

Two independent version lines:

  • Grammar version — the block's haptic field and frontmatter spec ("0.1"). Governs the JSON shape and this document. Pre-1.0 it is unstable; breaking changes MAY land without migration.
  • Content version — the frontmatter version (semver) of an individual pattern. A pattern author bumps this when the feeling changes, independent of the grammar.

A runtime built for 0.1 MUST ignore unknown fields from later grammar versions rather than reject the block, so that newer patterns degrade gracefully on older runtimes.


In-scope / out-of-scope platform matrix

Platform Tier In scope now Out of scope / honest limit
iOS (UIFeedbackGenerator, iOS 17 .sensoryFeedback, Core Haptics + AHAP) First-class Full intent + track + curve + sharpness. iPhone 8+. Pre-iOS-13 soft/rigid; devices without the Taptic Engine (produce nothing — valid).
Android (VibrationEffect + Composition) First-class Intent + track; amplitude when hasAmplitudeControl(); primitives when areAllPrimitivesSupported(). Sharpness has no true API. Mid-range ERM devices: no amplitude, no composition → duration-only. Requires VIBRATE permission.
web (navigator.vibrate) First-class, best-effort, duration-only On/off fallbacks.web on the Android Chrome family, gesture-gated. No intensity, no sharpness, no continuous amplitude. Absent on iOS Safari and desktop → silent no-op.
React Native (expo-haptics) Intent-tier only The nine Tier-0 tokens via semantic constants. Custom track / sharpness / curve — needs a native module not yet shipped.
Flutter (HapticFeedback) Intent-tier only A subset of the tokens via semantic constants. impact.soft/impact.rigid/notification.* have no native constant → documented fallbacks. Custom waveforms out of scope.
Game controllers (Gamepad rumble) Rumble-only escape hatch Map intensity onto weak/strong motors. No waveform, no sharpness, no transients. A blunt approximation, not a target for authored tracks.

On patents and licensing (honesty)

The code is Apache-2.0; pattern snippets are CC0. Apache-2.0's patent grant covers only patents held by contributors on their own contributions. It does not provide freedom-to-operate against third-party haptic patents (for example, patents held by Immersion). Patterns in the registry are built only from public, documented platform APIs, but that is not a clearance guarantee — no such guarantee is offered or implied. Contributions require a DCO sign-off.