A rule profile is a named set of rule values an owner assigns to a tenant. Every validate and
publish of a face lens or a screen effect also checks it against the profiles that apply to it,
and the editor shows the same issues live. Profiles are data: the platform defines the rules
below; which values a tenant uses is configuration, never code.
Implementation: packages/profiles (@ingevora-mirage/profiles). Designs:
docs/superpowers/specs/2026-09-29-mirage-editor-rule-profiles-design.md (face lenses) and
2026-10-01-mirage-screen-effect-rules-design.md (screen effects), and
2026-10-11-mirage-rule-checks-design.md (pixel flashes, reference screens). Numbers in brackets refer to
rendering-v1.md, E-numbers to effect-rendering-v1.md.
Face lenses and screen effects are separate products with their own manifests. Each rule applies
to one product: face lenses are checked against keepOutZones (§2, §3), screen effects against
the flashing limit, the screen-coverage cap (§5) and the pixel flash limits (§6), on the profile's
reference screens. A face lens ignores the screen-effect rules, and a screen effect ignores
keepOutZones.
#1. The rules object
A strict JSON object (ruleProfileSchema); unknown keys are rejected. Every rule is optional,
and an absent rule is not checked. Values are INTEGER enums.
| Rule |
Value |
Applies to |
Check |
keepOutZones |
non-empty array of unique INTEGER zone ids: 1 mouth, 2 eyes |
face lenses |
No face-anchored drawable — type 2 sticker; type 4 with placement.mode 1; type 5 particle spawn point — may overlap a listed zone on the reference face (§2). |
maxFlashesPerSecond |
integer 0–30 |
screen effects |
No one-second window of the effect holds more flash starts than this; 0 allows no flash at all (§5; photosensitivity, WCAG 2.3.1 allows at most 3). |
screenCoverage |
{ maxShare, maxMs }: maxShare a number strictly between 0 and 1, maxMs an integer 0–10 000 (both required, no other key) |
screen effects |
The share of the reference screen the effect paints may exceed maxShare for at most maxMs in a row (§5): a short full-screen burst passes, a full-screen image held for seconds does not. |
maxGeneralFlashesPerSecond |
integer 0–30 |
screen effects |
WCAG 2.3.1 general flashes, measured in the drawn pixels — images, sprite frames, particles, the flash fill, opacity clips (§6): no one-second window may hold more than this over more than the flash area, on a dark or a light backdrop. WCAG allows 3. |
maxRedFlashesPerSecond |
integer 0–30 |
screen effects |
The same for WCAG 2.3.1 red flashes — transitions to or from a saturated red (§6). WCAG allows 3. |
referenceScreens |
1–3 unique { width, height }, integers 240–1366 points (both required, no other key) |
screen effects |
The screens the coverage cap and the pixel flash limits play on (§5); absent: the 390 × 845 reference screen. Alone it checks nothing. |
json
{ "keepOutZones": [1, 2], "maxFlashesPerSecond": 3, "screenCoverage": { "maxShare": 0.6, "maxMs": 500 },
"maxGeneralFlashesPerSecond": 3, "maxRedFlashesPerSecond": 3,
"referenceScreens": [{ "width": 390, "height": 845 }, { "width": 834, "height": 1194 }] }
The last three rules were added to v1 additively (2026-10-11): every earlier profile is still valid
and is checked exactly as before. maxFlashesPerSecond keeps counting flash-motion cycles only.
Frame overlays (type 3), colour looks (type 1) and full-frame sprites (type 4, placement.mode 2)
are exempt from keep-out: they are full-frame by design, and v1 checks geometry, not pixels (a
transparent-centre frame is legitimate).
#2. Reference face and zones
Keep-out is checked statically on one reference face (referenceFace()), in a
1000 × 1000 frame (REFERENCE_FRAME):
| Property |
Value |
| Face frame [1.5, 1.6] |
upright (roll 0), M = (500, 500), W = 400 exactly |
| Eye centres |
±200 / 2.2 = ±90.909… from M along x; the subject's left eye on the image right [1.4] |
| Other landmarks |
the render package tests' neutral upright face (packages/render/test/helpers.ts, W = 44) scaled by 400 / 44 about M: brows 0.10 W above the eyes, lid gap 0.30 × eye width, nose tip +109.09, menton +409.09, closed lips (both inner-lip centres) at +227.27, mouth corners 0.36 W = 144 apart — every signal [6.1] is 0 |
Zones follow the face's own landmarks (keepOutZones(face, frame), ZONE_GEOMETRY), so the same
definition draws correctly on a live face:
| Zone |
Id |
Shape |
On the reference face (pixels) |
| mouth |
1 |
ellipse centred on the midpoint of the inner-lip centres; semi-axis along u = 0.6 × mouth-corner distance; semi-axis along v = 0.10 W; rotated with the face roll |
centre (500, 727.27), semi-axes (86.4, 40) |
| eyes |
2 |
two circles centred on the eye centres, radius 0.12 W |
centres (590.91, 500) and (409.09, 500), radius 48 |
keepOutZones returns image-pixel shapes — { id, kind: 'ellipse', center, radii, rotationDeg }
or { id, kind: 'circle', center, radius, rotationDeg }, rotationDeg = the face roll — in the
order mouth, the subject's left eye, the subject's right eye.
Overlap.
- A sticker or face-anchored sprite overlaps a zone when its drawn quad intersects the zone
shape. The quad is the render package's own [1.8] placement: one step of a lens instance on the
reference face at instance time 0, with every layer forced visible and no triggers or clips — so the
layer's manifest transform (anchor, offset, scale,
rotationDeg, followRoll) and its image
aspect (sprites: the sheet cell) decide it. Intersection is exact: the zone centre inside the
quad, or a quad edge within the ellipse (touching counts).
- A face particle layer overlaps a zone when its spawn point (anchor +
offset in the face
frame [2.6]) lies inside it (boundary included). Particle motion is not simulated.
- Keyframe clips (rendering-v1 §11) are followed: each clip on a sticker, face-anchored sprite
or face particle layer is sampled at every key time and every 10 ms of clip time
(
CLIP_SAMPLE_MS), from 0 to its durationMs — a looping clip for one period. At each sample
the layer takes the clip's values (rendering-v1 §11.1; the properties the clip does not animate
keep their resting values; scale clamped as drawn) and is placed exactly as above: the quad from
placeOnFace(faceTransformAt(…)), or the spawn point from the animated offset. The first sample
that overlaps a listed zone is reported. Opacity does not matter, and neither does which clips a
trigger would start or when: every clip is checked on its own. Engine-2 clips (format-v1.1.0): an anchor
track glides the anchor point (rendering-v1 11.9) and a roll-follow track turns the placement
(11.10) at each sample; a signal-driven clip is sampled over its whole durationMs (every signal
value). Lanes: clips on different lanes of one layer play together (rendering-v1 11.11), so every
combination of one clip from each of two or more lanes is also sampled, over the product of its
clips' times (key times first, thinned evenly so a layer's combinations share at most
LANE_COMBINATION_SAMPLES = 100 000 poses), each pose layered as drawn (higher lane wins per
property); a combination whose clips already hit alone is not re-reported. A hit is reported on
the highest-lane clip's path, naming each clip, its lane and its time.
- Triggers, visibility and head motion are not simulated — v1 checks the authored placement and
every clip. A hidden layer is checked like a visible one.
- A sticker whose image size is not known draws nothing [5.5] and is not checked; a face-anchored
sprite is placed by its sheet cell, which the manifest gives, so it is always checked.
#3. Issues
Profile issues join the format's { path, code, message } issue list (ManifestIssue), after the
format's own. Paths are JSON Pointers into the manifest. Messages are English and name the
profile.
| Code |
Path |
Raised when |
profile.keep_out |
/layers/<i> |
face lens: layer i overlaps one or more listed zones at rest — one issue per layer, naming every zone it hits |
profile.keep_out |
/animations/<i> |
face lens: clip i moves its layer into one or more listed zones — one issue per clip, naming the zones and the first sampled time (ms of clip time) |
profile.flashing |
/layers/<i> |
screen effect: a one-second window holds more flash starts than maxFlashesPerSecond (§5); i is the first flash motion, in layer order, flashing in the worst window |
profile.screen_coverage |
/layers/<i> |
screen effect: the painted share stays above maxShare for longer than maxMs (§5) — the first such run; i is the layer painting the most where the run passes maxMs |
profile.general_flash |
/layers/<i> |
screen effect: a one-second window holds more general flashes than maxGeneralFlashesPerSecond over more than the flash area (§6) — one issue per screen; i is the layer drawing at the most flashing points |
profile.red_flash |
/layers/<i> |
screen effect: the same for red flashes and maxRedFlashesPerSecond (§6) |
profile.asset_unreadable |
/layers/<i>/asset |
screen effect: an image the coverage cap or the pixel flash limits need could not be decoded, so those checks are not run — one issue per image, naming the checks; i is the first layer using it |
Example messages: Rule profile "Studio One": layer "mask" overlaps the mouth and eyes keep-out zones on the reference face. · Rule profile "Studio One": clip "drop" moves layer "badge" into the mouth keep-out zone at 240 ms on the reference face. · for particles, … clip "rise" moves the particle spawn point of layer "sparks" into the eyes keep-out zone at 610 ms ….
Screen effects (times in seconds to two decimals, measured shares to a tenth of a percent):
Rule profile "Studio One": 6 flashes within one second from 0.40 s (flash motions "strobe", "boom"); at most 3 are allowed. · Rule profile "Studio One": the effect paints 82.4 % of the screen for 1.20 s from 0.30 s (layers "sky", "badge"); at most 60 % for 500 ms is allowed. — a
run that reaches the end reads … for 1.20 s from 0.30 s, to the end … · Rule profile "Studio One": the image "a_00000000.png" could not be decoded, so screen coverage was not checked. (with a
pixel flash limit set too: … so screen coverage and pixel flashes were not checked.; with only a
pixel flash limit: … so pixel flashes were not checked.) · Rule profile "Studio One": 5 general flashes within one second from 0.40 s over 12.3 % of the screen on a dark backdrop (layers "blink", "sky"); at most 3 are allowed. — red flashes for the red limit, light backdrop for white. One
flash, motion or layer reads in the singular (1 flash, flash motion "strobe", layer "sky");
a limit of 0 reads no flash is allowed (no general flash is allowed, no red flash is allowed).
When the profile lists referenceScreens, every coverage and pixel flash message names its screen:
Rule profile "Studio One" (834 × 1194 screen): …. A screen effect's flashing issue comes first,
then its unreadable images, then per screen in order its general flash, red flash and coverage
issues.
#4. Which profiles apply
The same for both products:
- A lens or screen effect with
tenantId = T → the profile assigned to T, if any.
- A platform-wide lens or screen effect (
tenantId null) → every profile assigned to any
tenant (it is visible to all of them). Issues from several profiles are all listed, each naming
its profile.
checkProfile(manifest, assets, profile) checks one profile against a face lens, and
checkEffectProfile(manifest, assets, profile) against a screen effect (§5); the caller (API,
editor) runs it for every profile that applies. Both expect a schema-valid manifest; assets maps
each asset name to its pixel size (sprite sheets: the whole sheet) — for a screen effect, with its
decoded alpha when the coverage cap is checked (§5) and its decoded RGBA when a pixel flash limit is
(§6; needsPixels, needsColour).
#5. Screen-effect checks
The flashing limit and the screen-coverage cap are worked out by playing the effect through the
render package's own effect instance (effect-rendering-v1), so they see exactly what the SDKs
draw: triggers, sprite frames, clips, particles, shake and pulse.
The play-through.
- The reference screen is 390 × 845 points (
REFERENCE_SCREEN), the editor's phone screen
(S = 390, E1.3). A profile that lists referenceScreens is played once per listed screen
instead, in order, with that screen as the overlay size (so S = its shorter side): the coverage
cap and the pixel flash limits are checked on each, on grids covering ⌊width / pt⌋ × ⌊height / pt⌋ points; shares are of that screen. On a screen larger in area than 390 × 845 the grid pitch
pt grows by √(area ÷ (390 × 845)) (gridPitch), so no grid holds more points than the
reference screen's and each screen costs at most one phone screen's play-through. The flashing limit does not depend on the screen and is
taken from the first play-through.
- The effect is stepped every 1 ms (
EFFECT_STEP_MS) from 0 to the last millisecond before
durationMs. The first frame fires effect.started (E5.4 step 2). Every host event a trigger
listens to arrives once, on the second frame (1 ms) — the earliest a host event can apply, since
the first frame drops host events (E5.4 step 3). Content only a host event starts is therefore
checked as if every host event fired at the start, alongside what plays by itself: the worst
case. A host re-firing an event to restart content is not simulated.
Flashes (maxFlashesPerSecond).
- The renderer draws one white fill per running flash motion (type 3, motion 2; E8.2). A flash
starts on the frame the fill first appears (its alpha is 0 there) and on every later frame where
its alpha stops falling and starts rising —
alpha(t − 1) ≥ alpha(t) < alpha(t + 1): the cycle
boundaries every 1 / frequencyHz s, and any restart (run motion, E6.5). Starts are exact to
the millisecond; a 3 Hz flash motion visible from 250 ms starts flashes at 250, 583 and 917 ms.
- Every cycle counts — one cut short by the motion's or the effect's end too — whatever the
intensity (a faint flash counts). All flash motions count together. Keyframed opacity is not a
flash (the platform does not know image colours), and shake and pulse draw no fill.
- A 1000 ms window
[s, s + 1000) is placed at every start s; the window holding the most
starts is the worst (the earliest of equals). If it holds more than maxFlashesPerSecond, that
is the issue: its start s, its count and the flash motions starting flashes in it.
Coverage (screenCoverage).
- Every 10 ms (
COVERAGE_SAMPLE_MS; every 10th frame, from 0) the frame's quads are sampled on a
grid of points every 5 points (COVERAGE_GRID_PT): 78 × 169 points, each at the centre of its
5 × 5 cell.
- At a point inside a quad (corners top-left, top-right, bottom-right, bottom-left), the point's
position along the quad's edges
(u, v) ∈ [0, 1)² picks the source pixel
(src.x + ⌊u · src.width⌋, src.y + ⌊v · src.height⌋) of its asset — a sprite's sheet cell — and
the quad contributes a = opacity × alpha / 255 there. Contributions composite as
1 − Π(1 − a); the point is painted at PAINTED_ALPHA (0.5) or more. Images, sprites,
full-screen sprites and particles count as drawn, shake and pulse included; the flash fill is
left to the flashing limit. A mostly transparent full-screen sprite therefore passes, an opaque
one does not.
- A frame's share is painted points ÷ all points. Consecutive samples with a share above
maxShare form a run; a run of n samples lasts n × 10 ms and fails when that exceeds
maxMs. With { "maxShare": 0.6, "maxMs": 500 }, an opaque full-screen image shown for 500 ms
passes (50 samples) and one shown for 510 ms fails.
- The first failing run is the issue: its first sample's time, its length (to the end when it
reaches the last sample), its peak share, and the layers painting at the sample where it passes
maxMs — ranked by the painted points each contributes to (a > 0), most first, equals in
layer order; the first is the issue's path.
Pixels. The coverage cap needs each asset's alpha. decodePngAlpha decodes a PNG to one byte
of alpha per pixel, row-major, expanded as E4.4 says: a palette entry takes its tRNS alpha
(entries past the chunk's are opaque); grey and RGB pixels are opaque except those equal to the
tRNS key colour; 16-bit samples keep their high byte. An interlaced PNG below 8 bits per sample
cannot be decoded — export it without interlacing. The cap is checked only once every asset a
layer uses has its alpha; an image that could not be decoded is reported
(profile.asset_unreadable) and the cap is not checked further. The flashing limit needs no
pixels.
Cost. At most 10 000 renderer steps and 1 000 sampled frames; a sample drawing the same quads
as the one before it (static content) is not composited again. Deterministic: the API and the
editor run the same code on the same bytes, so they report the same issues. Each listed reference
screen is one more play-through.
#6. Pixel flashes
maxGeneralFlashesPerSecond and maxRedFlashesPerSecond apply WCAG 2.3.1's general and red flash
thresholds to what the effect actually draws, so a flash made by sprite frames, by an image's
opacity clip, by several layers together or by the flash motion's fill (at its intensity) is
measured the same way. They run in the same play-through as the coverage cap (§5), on each
reference screen.
Compositing. At every coverage sample (every 10 ms), the frame's draw list is composited on a
grid of points every 10 points (FLASH_GRID_PT, grown on larger screens as §5 says; 39 × 84 on
390 × 845), each at the centre of its cell; a point stands for pt² square points of area. A quad contributes its source pixel (the same lookup as coverage) at
a = opacity × alpha / 255, a fill its colour at its alpha everywhere; in command order,
source-over, on sRGB-encoded values (8-bit samples scaled to [0, 1], composited unquantised): premultiplied colour P ← c · a + P · (1 − a),
coverage A ← a + A · (1 − a). The host app behind the effect is unknown, so every point is
measured over two backdrops: dark (black: P) and light (white: P + (1 − A)).
Values. From the linearised R, G, B of the point (sRGB decoding through a 4096-entry table):
relative luminance Y = 0.2126 R + 0.7152 G + 0.0722 B; red Q = max(0, R − G − B) × 320;
saturated red when R + G + B > 0 and R / (R + G + B) ≥ 0.8.
Transitions. Per point and backdrop, one tracker per kind holds the extreme value since its last
transition and the direction of that transition; before the first, the lowest and highest values
so far. A value beyond the extreme in the current direction becomes the extreme; a value the other
way that qualifies is a transition at that sample and becomes the new extreme, the direction
flipping. Qualifying, from WCAG 2.2's definitions:
| Kind |
A change between two values qualifies when |
| general |
` |
| red |
` |
Windows and area. A flash is a pair of opposing transitions; n transitions in a window count
as ⌈n / 2⌉ flashes. A 1000 ms window [s, s + 1000) starts at every sample time; a point fails a
window when its flashes there exceed the limit (n ≥ 2 · limit + 1) — so with a limit of 0 a
single transition over more than the flash area fails (one bright image appearing once is a
started flash). For each kind, the window and
backdrop with the most failing points (the earliest window of equals, dark before light) is the
worst; it is an issue when its failing points cover more than FLASH_AREA_PT2, 25 % of a 10°
visual field — a square of FLASH_FIELD_PT = 337 points (10° at 30 cm on a 163-points-per-inch
screen): 28 392.25 pt², more than 283 grid points on the 10-point grid. The area is summed over the whole screen (a phone
screen is not much wider than the field).
The issue reports the worst window's start, the most flashes any failing point makes in it,
the failing points' share of the screen, the backdrop, and the layers drawing at failing points
(contributing a > 0 at a sample where the point made a transition in the window), most points
first, equals in layer order; the first is the path.
Pixels. The limits need every used asset's RGBA (decodePngPixels: E4.4's expansion as for
alpha, palette colours, greys repeated into R, G and B and scaled to 8 bits; its alpha byte for
byte decodePngAlpha's). An asset whose RGBA has not loaded skips the limits (the editor while it
loads); one that could not be decoded is reported (profile.asset_unreadable) and the limits are
not checked. A sample drawing exactly the commands of the one before it is not composited again.
This is an automated screen of the effect against WCAG 2.3.1's thresholds, not a certified
photosensitive-epilepsy test.