API
Every attribute and every export. Each row says what it trades, not just what
it sets, because almost nothing here is free and the costs are measured.
The value
import 'nocap-js'; // registers <nocap-secret>
el.secret = await fetchAccountNumber(); // write-only. never enters the DOM
| member | type | what it does, and what it costs |
| .secret | set only | The value. No getter, deliberately: one would put the plaintext back within reach of anything holding the element. The element keeps it internally so a restyle does not need it again, which is why scramble exists. |
| .planes | Pixels[] | The frames a capture can land on. Plane k is exactly what one screenshot gets. |
| .measureLeak() | number | Worst plane scored against the mean. ~0.2 at the defaults on text, 1.0 is fully readable. |
| .fitted | object | What the palette was moved to, or null if it already masked. See fit. |
| width / height | CSS px | Attributes, not styles. Do not size with CSS: letting the browser rescale the canvas resamples the noise toward its mean, which is the one transformation that makes a captured frame readable. |
Strength
| value | what it trades |
| weak | The block sits deliberately under the saturation point, so a blur has a radius worth trying. Calmest to look at. |
| medium | Exactly saturated. The default, and the balanced point. |
| strong | Headroom. The most visually active. |
These are tested points on the
masking curve, not the comfort curve.
All three read comfortably at the default size on a 60Hz panel, judged by one
person on one display. Large text is unmeasured. Use a preset rather than
tuning numbers; custom palettes and large-type deployments are a craft of
their own, and the
promo reel shows what a tuned
deployment looks like.
The split
| attribute | default | what it does, and what it costs |
| amplitude | 110 | How far each pixel is displaced, as a fraction of the headroom its colour allows. The biggest lever on raw leak. Capped by the palette, not by this number. |
| noise-scale | 2 × stroke, max 16 | Block size in device px. Its only job is blur resistance: raw leak barely moves with it. Derived rather than set, because the right block follows the stroke, then capped by noise-scale-max. Warns when it lands under twice the stroke. |
| hardness | 1 | 1 puts every pixel at full amplitude. Lower spreads the magnitudes and measures worse both raw and blurred, so 1 is right. |
| chroma | 0 | 0 shares one draw across R/G/B, putting the whole budget into luminance, which is what the eye and OCR key on. 1 is per channel: rainbow static, and measurably weaker. |
| noise-profile | white | blue high-passes the lattice, which may read calmer. Security unchanged. Not true blue noise, which needs void-and-cluster. |
| frames | 2, or 6 for aperture | Planes per cycle. Chosen by the mode. More frames means a slower cycle, and cycle rate is refresh ÷ frames. |
| gamma | 2.4 | Display EOTF. Decides where plane centres are solved, so a wrong value shifts the perceived colour. |
| contrast | 1 | Pre-emphasis. Not needed under linear light, which does not compress. |
| ink-bias | 0 | Leans amplitude toward the content. The trap: where the noise is, is where the text is, and the measured leak climbs steadily across its range. Past 0.3, measure on your own content. |
| pattern | none | dots, hatch, grid or grain drawn into the element background, so a page texture continues across the boundary. Not free, unlike a page texture: the split carries it as content. |
| pattern-strength | 16 | In code levels. The measured leak cost rises with the strength. Judge it live: it survives into the mean at full strength, but a single frame spans far more levels and buries it, so a screenshot shows nothing at any setting. |
| noise-scale-max | 16 | Ceiling on the derived block. Chosen for looks, not security: measured, the leak does not care where the ceiling sits, while large blocks read as tiles rather than noise. |
| edge-fade | 0 | Tapers noise to nothing within N px of the element edge, so a lone block dissolves. Free, unlike ink-bias: it follows the canvas rectangle, which an attacker already sees. Not for adjacent elements -- two taperings meet at a seam and read as a border. |
Colour
| attribute | default | what it does, and what it costs |
| color | #9ea6b4 | Text colour. |
| background | #6b7280 | Background. Usually the one to move: the page has to meet the secret, because a near-black ground has no room to carry noise. |
| fit | on | Moves an unmaskable palette into one that masks, keeping hue and light-or-dark character. off keeps your exact hex and loses the protection. White on black goes from masking nothing to masking. |
| adaptive | off | Exact colours, amplitude capped per pixel to their own headroom. |
Masking and contrast are the same axis pointing opposite ways. Ratio is
min(swing) / separation, so high contrast
is a large
separation, and past a point a single frame stays readable however the rest
is set. The
contrast page measures the trade live
on any pair.
Saturation destroys masking, not hue: a saturated colour
pins a channel at an extreme, and a pinned channel cannot be displaced.
Type
| attribute | default | what it does, and what it costs |
| font-family | ui-monospace | The stroke follows the font and the block follows the stroke, so this changes protection as well as looks. |
| font-weight | 600 | The block derivation assumes roughly this weight. A 300 stem is much thinner and wants a finer block. |
| font-size | height × 0.46 | In device px. On a 2× display a CSS-px number renders half-size -- use font-scale instead. |
| font-scale | 0.46 | Fraction of the element height. Density-independent, so prefer this. |
| letter-spacing | 0 | Needs a CSS length; a bare number gets px added. Inert under scramble. |
| text-align | center | left / center / right. Inert under scramble. |
| padding-x / padding-y | 0 | Inert under scramble. |
Any value that is not usable falls back to the documented default and warns
once, because an invalid ctx.font or a non-finite
fillText coordinate is a silent no-op in canvas. Text wider
than the element also warns rather than being quietly clipped. Stack elements
with --nocap-radius: 0 or the 4px rounding notches every seam.
Modes
| mode | leak | what it does, and what it costs |
| amplitude | masks | Every pixel present, every value displaced. Needs colour headroom. The default. |
| aperture | masks | A band sweeps down; each frame carries one slice and genuinely lacks the rest. Needs no colour headroom, so it works at pure white. Costs a 6-frame cycle, which is 10Hz on a 60Hz panel -- near the peak of temporal sensitivity. |
| interleave | readable | Kept as a counterexample. Splits where pixels are, and subsampled text reads fine. |
| fake | off | auto / number / text / random. Each cycle carries a different plausible wrong value: a capture freezes one at full contrast, the viewer resolves none. Needs a maskable palette (ratio 1.0+). Draws the value centred -- alignment and spacing attributes are inert while on. |
| scramble | -- | Stores glyphs shuffled, so a heap search never finds the value in order. Obfuscation, not encryption. |
| chroma-decoy | -- | A decoy in chrominance at zero luminance contrast. Spatial, so it survives frame averaging -- the only thing here that does. Block 2-4px: 1px is annihilated by 4:2:0. |
| watermark | -- | An identifier baked into chrominance at zero luminance contrast, so it survives the averaging that recovers the value and names the capture that leaked. Attribution, not protection: a single greyscale conversion removes it. Casual leaks, not determined ones. |
| watermark-swing | 60 | Chroma excursion for the watermark. The direction is picked toward the side with headroom, so a blue-heavy ground moves toward yellow rather than quietly getting less swing than asked. |
| watermark-repeat | 3 | How many times the mark tiles, 1-8. More survives cropping; more is also easier to spot. |
| chroma-block | 2 | Block size in px for the chroma decoy and watermark. Keep it ≥ 2: 1px chroma is annihilated by the 4:2:0 subsampling every screenshot pipeline applies. |
| fake-share | 0.8 | Share of each ink pixel's excursion budget the decoy takes, 0-0.9. The re-solved centre keeps the perceived value exact at every setting, so the cost of raising it is noise where the decoy's ink falls, not ghosting. A quiet decoy reads under the truth and convinces nobody. |
| fake-size | 1 | Decoy glyph size as a ratio of the real type. Full size is the default because it is the measured requirement: a smaller decoy scored below the real value in a captured frame at every share. |
Scratch
| attribute | default | what it does, and what it costs |
| scratch | off | Unmask only a trail under the pointer. Needs a pointer, so keyboard and screen-reader users need another route. |
| scratch-linger | 30 | Seconds for a stroke to fade to 1%. A long trail sits near full duty and gives up most of the capture benefit. 1-2s if capture is the threat. |
| scratch-radius | 34, or 52 coarse | Brush radius in CSS px. Wider on touch, because a fingertip covers what it reveals. |
| scratch-hint | Scratch to reveal | The affordance. Without it the element is a blank rectangle. It is real DOM text, so it is the one string the element contributes to innerText. |
| scratch-exclusive | on | Revealing one clears the others. Does not slow extraction -- capture is 0.3s. It stops one frame containing two revealed values. |
Texture
Draws the page's own pattern through the element, so the block reads as part
of the surface instead of a patch on it. The pairing
page is the live version of this table.
| attribute | default | what it does, and what it costs |
| pattern | none | dots, hatch or grid -- the three the page CSS can mirror exactly (16px dot lattice, 3px/13px 45° hatch, 46px grid). Grain does not survive over noise and is deliberately absent. |
| pattern-strength | 16 | Levels the texture moves its ground, same meaning as the page's CSS alpha solved per ground. A level count is not a visibility: over noise spanning the full range the eye normalises it away, so matching the page's look takes a good deal more than the page's own number, and the mismatch in numbers is what makes them look equal. |
| pattern-layer | back | back draws it into the canvas: the split carries it, so it is capped by the ground's headroom and vanishes in a still capture. front composites it over the canvas: free (added identically to every frame, the planes still average to target plus a constant), full-strength on any ground, and it survives a screenshot -- at the cost of competing with the glyphs. It adds no protection either way; an attacker who knows the pattern subtracts it. |
| pattern-offset-x / pattern-offset-y | 0 | Phase, in CSS px: where the element sits relative to the pattern's origin, so the lattice continues through the block instead of restarting at its edge. The front layer also reads the live custom properties --nocap-pattern-ox/oy, which land without a repaint. |
| pattern-enter | -- | left / right / up / down / center: the direction the front texture wipes in from, via clip-path keyframes. Without it the texture is simply shown -- the failure mode is no animation, never no texture. |
| pattern-playing | -- | Presence runs the wipe; remove and re-add to replay it. Honoured only alongside pattern-enter. |
Playback
| attribute | default | what it does, and what it costs |
| max-dpr | uncapped | Ceiling on the devicePixelRatio the canvas renders at. Split cost, bitmap memory and every per-frame draw scale with dpr squared, and the noise is deliberately chunky, so a dpr-3 phone does 2.25× the work of dpr-2 for a look that is indistinguishable at reel-sized type. The promo sets 2. Leave it uncapped for body-sized text, where glyph edges still buy something. |
| paused | -- | Stops the frame cycle. A paused element freezes on ONE plane, which is a full-amplitude noise frame -- exactly what a capture contains, which makes it the honest "what a screenshot gets" demo. Pause anything off screen: thirty-nine running canvases pulled a page to ~41Hz, putting the cycle at 21Hz, squarely in the discomfort band. Pause only after a fade-out, or the frozen plane is visible mid-fade. |
Exports
| export | kind | what it is for |
| splitFrames | core | One image to N planes. Pure, DOM-free, runs in Node. |
| averageFrames | core | Arithmetic mean -- what a re-encoded recording produces. |
| perceivedMean | core | Mean in light -- what a viewer resolves. Differs from the above by ~19 levels. |
| leakScore | metric | |Pearson r| between a plane and the truth. Blind spot: a mode inserting a large content-independent pattern scores better for it. |
| boxBlur / gaussianBlur / medianFilter | attack | Shipped so a claim can be run. Box wins against block noise; median loses because at a radius that keeps strokes it sits inside one block. |
| denoisedLeak / bestAttack | attack | Worst result across radii, or across all three denoisers. |
| checkPalette | palette | Masking ratio, grade and warnings for a pair. |
| fitToBand | palette | Move an unmaskable pair into one that masks. |
| isoluminantPair | palette | Two colours of equal luminance whose mean is exactly the background. |
| contrastRatio / codeSwing | palette | WCAG contrast; how far a colour can travel before clipping. |
| auditPage | check | Search every readable surface for a value. Takes plaintext, so development and tests only. |
| fakeLike / detectFormat / passesLuhn | generate | Plausible values of a matching shape. Luhn-valid cards, real dates. |
| resolveOptions / resolveText | pure | Attribute resolution, exported so it can be tested without a DOM. |
| luma / toRgb / toHex | palette | Perceptual luma of a colour; hex↔[r,g,b]. Arrays, not objects -- a harness passing {r,g,b} once measured NaN for a session and shipped a wrong constant on it. |
| toLight / toCode | palette | sRGB code value ↔ linear light. Averaging happens in light -- a display emits (v/255)γ -- which is why perceivedMean and averageFrames differ by ~19 levels. |
| planeRange / expandRange / maxAmplitudeFor | palette | Range arithmetic: what a plane can span, and the largest amplitude a pair carries without clipping -- clipping silently breaks the zero-sum property. |
| suggestConfig / placeInBand / isoluminantPartner | palette | Pick settings for a palette; move a colour into the maskable band; the equal-luminance partner a chroma effect needs. |
| Flicker | runtime | The presenter, if you want the split without the element. |
How it actually works
Content is split into frames that alternate at your display's refresh rate.
Each frame is noise, their mean is the content, and your visual system does the
averaging. There is no noise layer. Each plane pixel is the target
pixel displaced, so "covering" is the wrong picture: the noise moves values, it
does not sit on top of them.
Two consequences follow, and both surprise people. A pixel can only be
displaced as far as its colour allows, which is why white -- already at the
ceiling -- cannot be displaced at all and why fit exists. And every
pixel inside one block gets the same offset, so local edges survive
intact and the block shifts brightness rather than destroying structure. That
is why it can look faintly transparent while still masking.
The split is solved in light, not in code values. A display emits
(v/255)^gamma and the eye integrates light, so averaging in sRGB
reads far too bright: #ff0000 arrives as #be8c8c.
Solving the centre so the pair averages in light is what makes
color and background mean what they say.