Files
orca/config/scripts/mobile-web-app-haptics-seam.mjs
Jinwoo Hong c77a82f783 feat(mobile): fire the page's haptics over the bridge notify (OTA phase C, C7.10 E) (#21864)
* feat(mobile): give the page a haptics notify and the grant that gates it

`native.haptics.trigger` joins the envelope's notify union with a `kind` of exactly
the five `src/platform/haptics.ts` has, and the single token `haptics` joins
`BRIDGE_NOTIFY_GRANTS` and `MOBILE_WEB_SHELL_GRANTS`. A notify rather than a verb
because nothing is owed back: a reply would spend a slot in the same 64-deep
in-flight window a forwarded request does, and there are 90 call sites in this app,
some of them one per row of a scrolling list (rulings-ota-c7.md ruling 30).

The arm's fields live in their own module because `bridge-envelope.ts` is at its
line cap, as `bridge-event-envelope-bytes.ts` already is; the version literal stays
in the envelope, so the fields are spread in beside it rather than reading it back
through an import cycle.

The shell's half rides `onHaptic` on `BridgeHostOptions`, as every other
device-local notify does: the host is the protocol's side of the bridge and a static
import of the app's haptics would put `react-native` and `expo-haptics` in its
graph, which breaks every test that loads it. `page-haptics.ts` is the one mapping —
`haptics.ts`'s own functions, its `Platform.OS` split and its Android
`HapticFeedbackConstants` untouched.

The dispatch branch rides along with the union rather than waiting for the page
side: `Record<BridgeNotifyName, …>` and the `notify` fall-through are total over
that union, so the shell does not compile without it. That is the totality working,
and `bridge-notify-grants.test.ts` shows it as the TS2741 a missing row is.

Red first: the envelope cases per kind, the ungranted refusal, the grant-list pin
and the missing-row type error all failed against the tree before this. Control on
the dispatch: neutering `options.onHaptic` reds 2 of the 29 cases.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* feat(mobile): post the page's haptics over the notify instead of doing nothing

`haptics.web.ts` stops being five no-ops. Each of the five posts its own kind
through the notify seam the entry publishes — the same shape
`publishExternalLinkOpener` has, and for the same reason: every caller is a plain
function inside a row's press handler that no provider wraps. `notifyHaptics` joins
the page client beside the other gated notifies and answers whether the frame left,
which nothing reads: a tap that did not buzz is what the page did before this, and a
warning per refusal would be one per row of a scrolling list.

Measured off the frame the client posted rather than a written copy of its shape,
which is what drifts: 77 / 74 / 72 / 70 / 73 bytes for mediumImpact / selection /
success / error / edgeBump, the widest under 0.012% of `BRIDGE_MAX_MESSAGE_BYTES`,
and a twelve-row scroll 888 bytes across twelve frames.

The `web-overrides.json` reason now says what the file does instead of what it
declines to do.

Red first: the nine web-seam cases failed on `publishHapticsNotifier is not a
function`, and the six client cases on `notifyHaptics is not a function`.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* feat(mobile): grant haptics on every page route, with a census that derives the list

All five declared page routes carry the `haptics` grant, and the list is a
measurement rather than a hand choice: `WorktreeListRow` is in every page closure and
calls the seam, so a route without the grant is a page whose taps stop buzzing with
nothing on screen to say why. Grants are resolved once from the route the shell
opened and held for the session, so the declaration is the only place to fix it.

`mobile-web-app-haptics-seam.mjs` is the shared walk, beside the external-link one:
it reads the kinds off the tuple that declares them, finds every exported `trigger…`
function in a haptics module, and reports the kind each one posts. The posting call is
found through the binding `publishHapticsNotifier` assigns rather than a local spelled
`post`, because a rename would otherwise turn every posting site into a non-posting
one and leave this green on a page with no haptics at all.

The census proper holds each route's closure to the `.web.ts` sibling, asserts at
least one importer so the grant is not idle, and derives the granted-route list from
the closures. The control is the design's: the same walk over the native sibling
finds the same five functions and no posting site, so "all five post" is a number
rather than an empty scan.

Controls run: dropping `haptics` from one route reds 1 of 23; neutering one web post
reds 1 of 23.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* test(mobile): record what the haptics notify costs a page closure

One module. Every page closure grew by exactly `bridge-haptics-notify.ts`, and it
arrives through `page-route-policy.ts` reading the grant token rather than through the
seam, whose import of the kind type is erased; its only dependency is `zod`, which the
envelope already put in every closure, so the module total moved by the same one.
Local counts per route went 294 → 295, 379 → 380, 435 → 436, 309 → 310, 335 → 336.

Pinned structurally rather than as a total, because an absolute closure count is
main's to move and a number that drifts for unrelated reasons is one nobody reads.

The call sites this replaces, measured over product modules: `triggerError` 43,
`triggerSuccess` 24, `triggerSelection` 12, `triggerMediumImpact` 10,
`triggerEdgeBump` 1 — 90 across 35 importing modules, which is the design's count plus
`page-haptics.ts` itself.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* test(mobile): carry the haptics grant into the shell's two grant pins

`bridge-host-init.test.ts` names the grants `init` issues, so the token belongs in
that list. `MobileWebShellScreen.test.tsx` now mocks `expo-haptics` for the reason it
already mocks the clipboard and both pickers: the screen hands `playPageHaptic` over
and reaching the real module pulls in an Expo runtime this test does not have, which
failed the whole suite at import. Which expo member each kind reaches stays in
`page-haptics.test.ts`.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* refactor(mobile): map each haptic kind to a named import, not a namespace index

The changed-code gate refuses a computed reference into an imported namespace, in
both the mapping and its test, and it is right to: `haptics[NAME_BY_KIND[kind]]()`
is a call nothing can follow. Each function is a named import instead, which also
keeps the second compile-time direction — a row naming something `haptics.ts` does
not export is now an import error rather than a `keyof` mismatch.

The third direction moves with it, from a namespace read in the test to the census
that already reads both files' text: `hapticsImportedNames` names what the shell's
mapping takes from the app's haptics, and the census holds that to the five the
native file exports. So a haptic added there with no kind of its own still fails,
and now it fails where the other two siblings' names are already compared.

The test's two `as` assertions become one annotated hoisted type, the shape
`MobileWebShellScreen.test.tsx` uses.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* docs(mobile): state the true reason the haptics grant is one token

`GRANT_NAME_PATTERN` accepts `native.haptics.trigger` — it admits `native.<a>.<b>`
with lowercase segments, which is why it rejected `native.media.readChunk` and
rejects `navigate-back`, not a dotted name as such. So four comments claiming a route
declaring the notify's own name would have its bundle refused were false, and they
are gone: the grant is a token because the notify table's grants are tokens, a notify
not being a verb, and the dotted names in `MOBILE_WEB_SHELL_GRANTS` are spread from
the verb table alone.

Also folded, with the false claim: `implementedPageRoutes` filters on
`grants.every(implementsGrant)`, so a token every page route declares couples the
whole set to a shell that carries it — against one without it, no page route is
served at all and the phone renders five native screens. Stated in the function's
docstring and beside the census's derived list, and pinned: the same declaration
under a grant this build does not implement comes back empty, with the token-free
route as the control. Removing `BRIDGE_HAPTICS_GRANT` from `MOBILE_WEB_SHELL_GRANTS`
reds that case.

`%#` consumes no argument, so the web seam's five cases were titled with the whole
function body; the kind is the first element now and `%s` names it. One 110-char
comment line in `bridge-client-notifications.ts` wrapped to the file's 100; the two
still over it there are main's.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
2026-09-21 00:29:52 -04:00

214 lines
8.2 KiB
JavaScript

/**
* The haptics seam, and how a census tells a page function that asks the shell from one that does
* nothing.
*
* `haptics.web.ts` used to be five empty bodies, which is a shape no scan can distinguish from a
* file it failed to read: an empty result and a green census meant the same thing. Now each of the
* five posts one `native.haptics.trigger` notify carrying its own kind, so the census measures the
* kinds it found — and runs the same walk over the native sibling, where the same five functions
* exist and none of them posts, as the control that says the walk can tell the two apart.
*
* Shared rather than restated in each route's census, for the reason
* `mobile-web-app-external-link-seam.mjs` is: two spellings of one rule drift.
*/
import { readFileSync } from 'node:fs'
import { join } from 'node:path'
import ts from 'typescript-api'
/** The seam as the web build resolves it: `.web.ts` wins under the builder's `resolveExtensions`. */
export const HAPTICS_SEAM = 'src/platform/haptics.web.ts'
/** Its native sibling, which the page must never resolve to — it imports `expo-haptics`. */
export const HAPTICS_NATIVE = 'src/platform/haptics.ts'
/** The module that declares the kinds, so a census reads them instead of listing them again. */
export const HAPTICS_KINDS_MODULE = 'src/mobile-web-shell/bridge/bridge-haptics-notify.ts'
const KINDS_CONST = 'BRIDGE_HAPTICS_KINDS'
const PUBLISH_FUNCTION = 'publishHapticsNotifier'
const parse = (source, fileName) =>
ts.createSourceFile(fileName, source, ts.ScriptTarget.Latest, true)
/**
* The kinds the notify admits, read off the tuple that declares them.
*
* Parsed rather than matched, so a mention of the name in a comment or a docstring is not a
* declaration, and so the quote style is settled for free.
*/
export function bridgeHapticsKinds(source, fileName = 'bridge-haptics-notify.ts') {
const parsed = parse(source, fileName)
for (const statement of parsed.statements) {
if (!ts.isVariableStatement(statement)) {
continue
}
for (const declaration of statement.declarationList.declarations) {
if (!ts.isIdentifier(declaration.name) || declaration.name.text !== KINDS_CONST) {
continue
}
// `as const` wraps the literal in an assertion expression; the tuple is inside it.
const initializer =
declaration.initializer !== undefined && ts.isAsExpression(declaration.initializer)
? declaration.initializer.expression
: declaration.initializer
if (initializer === undefined || !ts.isArrayLiteralExpression(initializer)) {
continue
}
return initializer.elements
.filter((element) => ts.isStringLiteral(element))
.map((element) => element.text)
}
}
return []
}
/**
* The name of the module-level binding `publishHapticsNotifier` assigns, or null in a module that
* publishes nothing.
*
* Derived rather than assumed: the census must not be keyed on a local called `post`, because
* renaming it would silently turn every posting site into a non-posting one and leave the census
* green on a page with no haptics at all.
*/
function notifierBinding(parsed) {
let binding = null
const visit = (node) => {
if (
ts.isFunctionDeclaration(node) &&
node.name !== undefined &&
node.name.text === PUBLISH_FUNCTION
) {
const assign = (inner) => {
if (
ts.isBinaryExpression(inner) &&
inner.operatorToken.kind === ts.SyntaxKind.EqualsToken &&
ts.isIdentifier(inner.left)
) {
binding = inner.left.text
}
ts.forEachChild(inner, assign)
}
ts.forEachChild(node, assign)
return
}
ts.forEachChild(node, visit)
}
ts.forEachChild(parsed, visit)
return binding
}
/** Every string literal this call is handed, so a site that posts a computed kind reports none. */
function literalArguments(call) {
return call.arguments.filter((argument) => ts.isStringLiteral(argument)).map((a) => a.text)
}
/**
* Every exported `trigger…` function in a haptics module, and the kind it posts.
*
* `kind` is null for a function that posts nothing, which is what the native sibling's five are and
* what the web sibling's five used to be. Reported as sites rather than as a boolean because a
* census whose red names `path:line` is read once and one that names a file is grepped for.
*/
export function hapticsTriggerSites(source, fileName = 'haptics.ts') {
const parsed = parse(source, fileName)
const binding = notifierBinding(parsed)
const lineOf = (node) => parsed.getLineAndCharacterOfPosition(node.getStart(parsed)).line + 1
const sites = []
for (const statement of parsed.statements) {
if (
!ts.isFunctionDeclaration(statement) ||
statement.name === undefined ||
!statement.name.text.startsWith('trigger') ||
statement.modifiers?.some((modifier) => modifier.kind === ts.SyntaxKind.ExportKeyword) !==
true
) {
continue
}
const posted = []
if (binding !== null && statement.body !== undefined) {
const visit = (node) => {
if (ts.isCallExpression(node) && ts.isIdentifier(node.expression)) {
if (node.expression.text === binding) {
posted.push(...literalArguments(node))
}
}
ts.forEachChild(node, visit)
}
ts.forEachChild(statement.body, visit)
}
sites.push({
name: statement.name.text,
line: lineOf(statement),
// One kind per call and one call per function: two would be two taps for one gesture.
kind: posted.length === 1 ? posted[0] : null
})
}
return sites
}
/** The kinds a module posts, in the order its functions are declared. */
export function hapticsPostedKinds(source, fileName = 'haptics.ts') {
return hapticsTriggerSites(source, fileName)
.map((site) => site.kind)
.filter((kind) => kind !== null)
}
/**
* The names a module imports from the app's haptics, which is how the shell's mapping is held to it.
*
* `page-haptics.ts` names each function as a named import rather than reaching a namespace, so a
* row naming something `haptics.ts` does not export is already a compile error. This is the other
* direction, which no type states: a haptic that file grows with no kind of its own would be one
* the page can never ask for, and comparing this list against the file's own exports is the only
* thing that sees it.
*/
export function hapticsImportedNames(source, fileName = 'module.ts') {
const parsed = parse(source, fileName)
const names = []
for (const statement of parsed.statements) {
if (
!ts.isImportDeclaration(statement) ||
!ts.isStringLiteral(statement.moduleSpecifier) ||
!/(?:\.\.?\/)+platform\/haptics$/.test(statement.moduleSpecifier.text)
) {
continue
}
const bindings = statement.importClause?.namedBindings
if (bindings !== undefined && ts.isNamedImports(bindings)) {
// The imported name, not the local one: a renamed import is the same export.
names.push(...bindings.elements.map((element) => (element.propertyName ?? element.name).text))
}
}
return [...new Set(names)].sort()
}
/**
* Every module in a closure that imports the haptics seam, as the path the closure reports.
*
* The specifier is read extensionless, because that is how a consumer writes it and how the builder
* resolves it: a module importing `../platform/haptics` gets the `.web.ts` on the page and the
* native file on a phone, and the census's job is to say which one the closure ended up with.
*/
export function hapticsSeamImporters(mobileDir, closure) {
const specifier = /(?:^|['"])(?:\.\.?\/)+platform\/haptics(?:\.web)?['"]$/
return closure.local
.filter((file) => file !== HAPTICS_SEAM && file !== HAPTICS_NATIVE)
.filter((file) => {
let source
try {
source = readFileSync(join(mobileDir, file), 'utf8')
} catch {
return false
}
const parsed = parse(source, file)
return parsed.statements.some(
(statement) =>
(ts.isImportDeclaration(statement) || ts.isExportDeclaration(statement)) &&
statement.moduleSpecifier !== undefined &&
ts.isStringLiteral(statement.moduleSpecifier) &&
specifier.test(`'${statement.moduleSpecifier.text}'`)
)
})
.sort()
}