# Build spec — Lid Creak 2.2.0 (native macOS app)

## The ask, in the user's words

> "can you stop the creaking sound" → then, after the launchd helper was explained:
> "is it an app?" → "make it an app" → "the app doesnt open" → "the sensitivity didnt matter
> i did low and i angle changed 1 degree still creaked"

Four separate failures behind one feature, each one only visible after the previous was fixed:

1. FreeSlapMac 1.1.0 has a **Lid Creaking Sound** toggle with no sensor code behind it.
2. The first fix was a launchd + Python helper — it worked, but it was not an app, and
   "make it an app" meant a thing with a window.
3. The app that replaced it was a menu-bar accessory (`LSUIElement` + `.accessory`), so a
   double-click on an already-running copy did nothing visible: "the app doesnt open".
4. With a window finally visible, the sensitivity setting was exposed as fake — every level
   fired on a one-degree movement.

## What had to be true at the end

- A real `.app` in `/Applications` that opens a window on double-click, with a Dock icon, a
  menu, ⌘Q, and `applicationShouldHandleReopen` so a second double-click reopens it.
- It **plays** a creak (the complaint was never "make it silent" — the silent fork is a
  separate, earlier build).
- It uses only public APIs: no kernel extension, no admin password, no private framework.
- Low / Normal / High must **measurably** differ: a 1° movement must not creak at the least
  sensitive setting, and the difference must be provable without moving a real lid.
- The sound is FreeSlapMac's and is not redistributed.
- Ship it: a downloadable zip, a build log page, and a card on the portfolio.

## Hardware facts established first

| fact | value |
|---|---|
| device | HID usage page `0x20` (sensors) / usage `0x8A` (lid angle) |
| read | feature report 1, `uint16` little-endian, degrees |
| resolution | **1° — whole degrees** |
| persistence | the device disappears when the lid closes; re-enumerate |

The third row is the whole sensitivity bug. A sensor that only reports integers cannot
express speed inside one sample; it can only report that the number changed.

## The arithmetic that had to be fixed

```
v2.1.0:  speed = |angle[n] − angle[n−1]| / 0.05 s
         1° / 0.05 s = 20 °/s
         thresholds: Low 15 · Normal 8 · High 5 °/s      → every level fires
```

## The detector that replaced it

Three gates, none of which trusts a single sample:

| gate | rule | constant |
|---|---|---|
| travel | ≥ 3° across a 0.35 s window | `kMinTravel`, `kWindow`, `kMinWindow = 0.18` |
| speed | measured across a ≥ 0.15 s span | `kSpeedWindow`, `kSpeedMin = 0.10` |
| sustain | 2 consecutive qualifying ticks | `kConfirmTicks` |

Retrigger cooldown `kRetrigger = 1.4 s`. Discard a `ΔT > 0.5 s` (sleep/stall) and any
single-step jump over 40° (lid closing / machine moving) instead of dividing by it.

Rescaled thresholds: **Low 35 · Normal 22 · High 14 °/s.**

## Testability, which is the actual fix

The decision logic had to be a pure object:

```swift
final class Detector {
    func feed(time: TimeInterval, angle: Double) -> Decision   // .creak / .quiet
}
```

`LidSensor` only feeds it. That buys three modes:

- `--simulate` — eight scripted lid timelines (the regression suite for the bug).
- `--trigger-test` — ten boundary assertions, run by `build.sh` before it will finish.
- `--record N` — log a real sensor trace so a real creak can be replayed offline.

## Audio path

1. Find `creaking.mp3` in `FreeSlapMac.app` / `FreeSlapMac Silent.app` / `SlapMac.app` /
   `slapmac.app`.
2. Render the first 1.8 s to `~/Library/Application Support/LidCreak/creak.caf`, normalised
   to −1 dBFS, gain measured from the clip's own peak (≈ ×8; source peaks ≈ 0.11 / −19 dBFS).
   Idempotent: re-running `--selftest` does not re-render.
3. Playback volume rides speed: `0.55 + speed/120`, clamped to 1.0.
4. `--selftest` exits 2 and says so when the output device is muted or at volume 0 — the
   failure that made an earlier build look broken.

## Build

```sh
arch -arm64 swiftc -O -swift-version 5 -target arm64-apple-macos13.0 LidCreak.swift \
  -o "Lid Creak.app/Contents/MacOS/LidCreak" \
  -framework AppKit -framework AVFoundation -framework IOKit -framework QuartzCore
codesign --force --deep --sign - "Lid Creak.app"
```

`arch -arm64` is required because the agent shell runs under Rosetta
(`sysctl -n sysctl.proc_translated` is non-zero); without it the compiler resolves an
x86_64 `swift-driver` and dies on `llbuild.framework`.

## Packaging

- `ditto` for the bundle (preserves the signature), `ditto -c -k --sequesterRsrc --keepParent`
  for the zip.
- `SHA256SUMS.txt` covering the binary and every source file.
- Verified by extracting the zip back out: `codesign --verify --deep --strict` valid,
  `lipo -archs` = arm64, `shasum -c` 8/8 OK, `--simulate` PASS from the extracted copy.

## Acceptance

| check | result |
|---|---|
| `--simulate` 8 timelines | 8/8 PASS (1° blip = 6.7 °/s, 0 fires) |
| `--trigger-test` | 10/10 PASS |
| zip extracted and run | signature valid, 8/8 checksums, PASS |
| window opens on launch | yes — `.regular` app, 480×309 window, Dock icon |
| audio | plays FreeSlapMac's clip, volume rides speed |
| admin | none |
