# Skinning Operational Limit

A skin changes everything the cockpit paints with, how the readout draws its
digits, and can add status screens of its own. The layout never moves. This
folder is the kit: this guide, `AGENTS.md` for an AI assistant, `editor.html`
for drawing screens in a browser, `check.js` for testing without a phone,
and `examples/`.

A skin is one file, `name.oplskin`, which is JSON. Get it onto the phone by
AirDrop, by saving it to Files and tapping it, or with **Import a skin** in
Setup. Skins are part of the Full Version.

## Quick start: a palette

1. Copy `examples/example.oplskin`, or start from the complete reference
   below.
2. Change `id`, `name`, `nameJP` and the colours you care about. Any key you
   leave out keeps Amber's value, so a skin that only sets `focus` is a
   complete skin.
3. Check it: `node check.js my.oplskin`.
4. AirDrop it to the phone. OpLimit opens, says what it installed, and
   switches to it. Setup › Palette lists it after the built-ins; **Share this
   skin** exports it again, **Remove this skin** deletes it.

## The skin file

The complete reference, which is Amber:

```json
{
  "format": 1,
  "id": "amber", "name": "Amber", "nameJP": "琥珀",
  "colors": {
    "background": "#000000",  "well": "#050300",    "ink": "#000000",
    "focus": "#FF8F1F",       "warning": "#FF381F", "rest": "#6BFF85",
    "accent": "#5EF0FF",      "hazard": "#FF381F",
    "dim": "#593D1A",         "dimRule": "#4A3317", "dimHazard": "#4A1A14",
    "modeText": "#FFD13B",    "modeLit": "#FFEB70", "modeRacing": "#FFFFFF",
    "modeOff": "#292929",     "modeRule": "#6B591A",
    "bar":     ["#7A2BD6", "#2E40D9", "#1C70DB"],
    "service": ["#38FF6B", "#D9FF3B", "#FF993B", "#FF3BA6", "#8A3BFF"],
    "supply":  ["#C21C1C", "#E8521C", "#FF8F1F"],
    "digitRim": "#FFFFFF73"
  },
  "digits": {
    "style": "segment",
    "skew": 0.22, "thickness": 24, "rimWidth": 2.5,
    "font": { "design": "mono", "family": null, "weight": "bold" }
  },
  "screens": []
}
```

Rules: `id` is letters, digits, dot, dash and underscore, up to 64, and not
one of the five built-in ids. Colours are `#RRGGBB` or `#RRGGBBAA`. A
gradient has two or more stops, evenly spaced. One bad value rejects the
whole file, and the phone stays on the skin it had; unknown keys are ignored.

### What each colour paints

| token | where you see it |
|---|---|
| `background` | the screen behind everything |
| `well` | the readout's well, a shade off the background |
| `ink` | text on a filled area: the caption tags, the filled START button, the supply band |
| `focus` | the tint through a focus block |
| `warning` | the tint in the last two minutes and in overrun |
| `rest` | the tint through a break |
| `accent` | the tick strips along the panel's edges |
| `hazard` | the hazard stripes, the live mode cell's underline |
| `dim` `dimRule` `dimHazard` | an unlit power box: text, frame, wedge |
| `modeText` `modeLit` `modeRacing` `modeOff` `modeRule` | the STOP SLOW NORMAL RACING cells |
| `bar` | the gradient under the mode cells |
| `service` | the band down the leading edge |
| `supply` | the MAIN ENERGY SUPPLY band |
| `digitRim` | the lighter rim on a lit segment |

"The tint" is what everything that follows the timer is drawn in: the
digits, the frames, the gauge, the status panel.

### Digits

`style` is `segment` (seven-segment cells) or `text`. For segments, `skew`
is the lean (0 upright, clamped to ±0.6), `thickness` the segment width in a
digit 100 wide and 180 tall (8...40), `rimWidth` the lit rim (0...6, 0 for
none). For text, `font.design` is `default`, `mono`, `rounded` or `serif`;
`font.family` names a face installed on iOS ("Courier New", "Menlo",
"American Typewriter", "Futura", "Didot") and falls back to the design if
the phone lacks it; `weight` is regular, medium, semibold, bold, heavy or
black. The pilot can override your choice in Setup › Digits.

## Screens

The status panel, the box above the readout (in landscape: the well, when
tapped), cycles through five built-in screens. A skin adds its own, or
stands one in for a built-in. A screen is a JavaScript file with two things
at top level, no `export`:

```js
const meta = { nameJP: "原子炉", caption: "REACTOR" };

function draw(ctx, s) {
  const w = s.width, h = s.height, cx = w / 2, cy = (h + 18) / 2;
  ctx.strokeStyle = s.color.tint;
  ctx.globalAlpha = 0.18;
  ctx.beginPath();
  for (let x = 12; x < w; x += 26) ctx.line(x, 28, x, h - 10);
  ctx.stroke();
  ctx.globalAlpha = 1;
  const level = s.overrun ? 1 : s.isBreak ? s.progress : 1 - s.progress;
  for (let i = 0; i < 5; i++) {
    const r = 14 + i * 11 + 3 * Math.sin(s.t * 2 + i);
    ctx.globalAlpha = i / 5 < level ? 0.9 : 0.2;
    ctx.lineWidth = 1.5;
    ctx.beginPath(); ctx.ellipse(cx, cy, r * 1.6, r * 0.6); ctx.stroke();
  }
  ctx.fillStyle = s.color.tint;
  ctx.font = "9px mono"; ctx.textAlign = "left"; ctx.globalAlpha = 1;
  ctx.fillText("CORE", 14, h - 14);
  return { value: s.overrun ? "CRITICAL" : Math.round(level * 100) + "%" };
}
```

`draw` runs 30 times a second while the screen is showing. It gets a
recording context and the timer's state, and may return `{ caption, value }`
for the header. The header occupies the top 28 points; draw below it.

### `s`, the state

| field | |
|---|---|
| `t` | wall-clock seconds; use it modulo for motion |
| `width` `height` | the panel in points (about 360 × 150 portrait; wider and taller in the landscape well) |
| `progress` | 0...1 through the current phase |
| `remaining` | seconds left in the phase; 0 in overrun |
| `overrunElapsed` | seconds past the limit while overrunning, else 0 |
| `phase` | `"focus"`, `"shortBreak"`, `"longBreak"` |
| `isBreak` `idle` `running` `paused` `warning` `overrun` `flow` | booleans; `warning` is the last two minutes |
| `sortie` `sorties` | the sortie under way (1-based) and the cycle length |
| `today` | sorties flown today |
| `color` | the skin's colours as `#RRGGBB`: `tint` (follows phase and warning), `focus`, `warning`, `rest`, `accent`, `hazard`, `dim`, `ink`, `background`, `well` |

Fields are added over time, never renamed.

### `ctx`, the canvas

A subset of the HTML Canvas 2D API, recorded and replayed by the app.

- Properties: `strokeStyle`, `fillStyle` (a hex string, a token name such as
  `"tint"`, or `[r, g, b, a]` in 0...1), `globalAlpha`, `lineWidth`, `font`
  (`"9px mono"`, `"12px jp"`, `"10px cond"`: a size and one of the cockpit's
  three faces), `textAlign` (`left`, `center`, `right`).
- Paths: `beginPath`, `closePath`, `moveTo`, `lineTo`, `rect`,
  `arc(cx, cy, r, a0, a1, counterclockwise)`, `ellipse(cx, cy, rx, ry)`,
  `stroke`, `fill`.
- Direct: `strokeRect`, `fillRect`, `fillText(text, x, y)` (vertically
  centred on `y`), and `line(x0, y0, x1, y1)` as a convenience that adds one
  segment to the current path.
- Not available: save/restore, transforms, gradients, images, clipping,
  shadows, `strokeText`, `measureText`, dashes, line caps.

Draw with `s.color.*` rather than literal colours, and your screen looks
right on every skin it is paired with.

### Limits, and what happens when they break

- Script 64 KB at most; a frame 60,000 numbers at most.
- Budget 8 ms a frame on the phone. Thirty slow frames in a row, an
  exception, or a malformed frame **faults** the screen for good: the panel
  shows 表示装置故障 VIZ FAULT with the reason, the timer keeps running.
- The script sees nothing but the context and the state: no network, files,
  timers, storage or DOM. `Math.random` works.
- Importing a skin runs each screen's first frame against a one-second
  deadline. A screen that hangs is refused and nothing is installed.

### Putting screens in the skin

```json
"screens": [
  { "id": "reactor", "nameJP": "原子炉", "caption": "REACTOR", "source": "...the script..." },
  { "id": "sync2", "replaces": "sync", "source": "..." }
]
```

Up to eight. `replaces` names a built-in (`sync`, `atField`, `breakers`,
`stream`, `data`) this screen stands in for; without it the screen is added
after the five. `nameJP` and `caption` here override the script's `meta`.
The script goes inline as `source`; the editor's **export .oplskin** button
and `../scripts/skin_pack.py` both do the inlining for you.

## Tools

- **`editor.html`**: open it in a browser (from this folder, so it finds the
  shim). Write the script, press run or ⌘-Enter, move the state sliders,
  load a skin JSON for colours. The FRAME box shows numbers per frame and
  milliseconds against the budget. **save .js** downloads the script,
  **export .oplskin** wraps it into the loaded skin.
- **`check.js`**: `node check.js screen.js` or `node check.js skin.oplskin`.
  Runs the real shim over ten timer states, validates every frame the way
  the app does, times it, checks the manifest. Exit code 1 on failure.
- **`../scripts/skin_pack.py`**: `skin_pack.py base.json out.oplskin --id x
  --screen id=reactor file=reactor.js nameJP=原子炉 caption=REACTOR`.
- **Setup › Share this skin** exports what is on the phone, so you can round
  trip edits.

## Examples

- `examples/sync.js`: the built-in sync graph, ported line for line. The
  reference for matching the native screens' feel.
- `examples/radar.js`: an original screen, 探知 SEARCH RADAR: a sweep with a
  fading trail, range rings, contacts that light as the beam passes, a
  readout column and a signal meter. Uses arcs, filled cells and
  right-aligned text, and answers every timer state differently; read it
  for the pattern. `radar.png` is a frame of it mid-sortie, rendered through
  the editor.
- `examples/example.oplskin`: Amber's colours plus both screens appended, so
  on the phone you can flip between the native sync graph, its scripted
  twin, and the radar.

## Sharing

The gallery is at https://oplimit.squarewavellc.com/ (the app's Setup ›
Skin gallery reads the same catalogue); `SUBMISSION.md` is what the form
asks for. Skins also travel as files: AirDrop, Mail, any file host.
