Methodology & Accuracy

A keepsake is a bad place for an unreliable QR code. This page documents how the studio's codes are produced, which conventions the composition follows, what deliberately stays on your device, and how the 30-day retention promise is actually enforced — so you can judge exactly how far to trust the object you print.

This page is the reference; each section below is expanded in a guide written to the same standard. Start with how the studio composes a keepsake, then why error correction is chosen for print, what stays in your browser, how the 30-day retention works, how Live Keepsakes reveal, how decode verification reaches a verdict, and what the studio does not model. The guides index lists them all. Every engine figure on this page — versions, module counts, ratios, verdicts, particle counts — is computed when the site is built by the same functions that run in the editor, on fictional inputs named as such; none is typed in by hand.

Standards-compliant encoding

Every code is a standard QR symbol as specified by ISO/IEC 18004, produced by a tested encoder — the same encoding discipline used across the Sumvia network. There is no proprietary format and no wrapper: any camera app that reads QR codes reads these. The destination you encode is placed in the symbol exactly as entered, with no shortening, tracking parameters, or redirection added.

A worked example: what the encoder chose

Take a fictional destination, https://example.com/album/ella-and-sam-2027 (43 characters), encoded at error-correction level H as every framed keepsake is. The encoder chose version 5 and reports a 37-module side. The engine documents that field as the symbol's width and height in modules, 17 plus 4 times the version. The two figures in that sentence are not typed here: they are derived from the encoder's own output on two fictional symbols of different versions, checked against the engine's documentation, and the build asserts that the derived rule reproduces the side of every example on this page — a disagreement fails the build instead of printing two different numbers. Around the symbol the renderer always draws the 4-module quiet zone the standard requires on all four sides, so the drawn code is 45 modules across — the quiet zone cannot be switched off. At 8 pixels per module the bare code is 360 px square. The destination inside it is the string above, byte for byte: the encoder was not handed a shortened or rewritten form of it.

Ella & Sam — June 2027
Floral frame, classic modules — a fictional destination, composed at build time
Heart modules on the bare code — a fictional destination, composed at build time

How much a destination costs in symbol size depends on its length and the correction level. The table encodes three fictional links at level H and at level M. One detail is worth knowing when you read the right-hand columns: the encoder never lowers the level you ask for, but it may raise it when the higher level fits in the same version at no cost — so the level actually encoded is shown beside the one requested.

Fictional destination Characters Version at H Side at H Version at M Side at M Level encoded at M
Short link 21 3 29 modules 2 25 modules M
Framed example link 43 5 37 modules 4 33 modules Q
Long link 95 9 53 modules 6 41 modules M

Two inputs the encoder refuses outright, with the exact messages it returns. An empty field: “Enter the content to encode.” Content beyond the capacity of the largest symbol the standard defines: “This content is too long for a QR code. Shorten it or lower the error correction level.”

Error correction, chosen for print

QR codes carry redundancy so a partially obscured or imperfectly printed symbol still scans. Keepsake codes default to a higher error-correction level than screen-only codes need, because keepsakes are printed, framed behind glass, photographed at an angle, and read in candlelight. The honest trade-off: more redundancy means a denser symbol, so very long destinations plus high correction can produce codes that need to be printed larger. Where a composition choice affects scannability, the studio surfaces it rather than silently accepting it.

The rule, and what decoration spends

The artistic engine's first production rule is that error correction is H for every artistic style, without exception — frames, heart modules and photo-woven codes alike. Decoration spends decode margin, and only the ~30% recovery of EC-H leaves enough. The rule is enforced rather than advised: hand the heart-module renderer a code encoded at level M and it returns a refusal, not a weaker code — “Artistic styles require error correction level H — re-encode with ecLevel "H".” The shared QR core underneath can encode at L, M, Q or H and defaults to M when nothing is asked of it, but every artistic style — frames, heart modules and photo-woven codes — passes through the artistic engine's guard first.

The second rule says where decoration may not go: the finder patterns in three corners and the alignment patterns stay classic squares, because restyling alignment patterns breaks decoding outright. Timing modules may be styled. The third rule sizes the hearts: each heart covers 97% of its cell so a scanner's sampling grid still reads the cell as decisively dark. On the hearts example above — https://example.com/r/ask, version 4, 33 modules a side — the engine classifies 554 dark modules; 116 of them sit in finder or alignment patterns and are drawn as squares, and the other 438 are drawn as hearts. Its alignment-pattern centres fall at module coordinates 6 and 26, read from the same table the encoder used to place them, so the classifier cannot disagree with the symbol that was actually drawn.

Print-size readouts

The shared QR core carries a print-size readout for a square PNG, defined for its 3 size presets. The readout's arithmetic is fixed: inches are pixels divided by 300, millimetres are inches times 25.4. The table applies that readout to each of the core's presets, then shows the width of one module at that size for the framed example's code (45 modules across, quiet zone included) and for the long link above (61 modules across). That is the whole of the trade-off the paragraph above describes: the same square spreads over more modules for a longer destination, so each module is smaller. No scanning threshold is implied by these figures — the test the studio recommends is the proof print listed under “what the studio does not model”.

Size preset Print-size readout One module, framed example One module, long link
512 px 1.71 in × 1.71 in (43.3 mm × 43.3 mm) at 300 DPI 0.96 mm 0.71 mm
1024 px 3.41 in × 3.41 in (86.7 mm × 86.7 mm) at 300 DPI 1.93 mm 1.42 mm
2048 px 6.83 in × 6.83 in (173.4 mm × 173.4 mm) at 300 DPI 3.85 mm 2.84 mm

Contrast is computed, not guessed

The shared core measures a foreground/background pair with the WCAG 2.x relative-luminance contrast ratio and warns when the ratio falls below 4.5:1. The artistic engine's fifth rule turns that warning into a refusal, and it also refuses inverted codes — light modules on a dark background. Palette colours are applied to the frame art, which sits below the code panel and is never subject to this rule; the table shows what the engine measures for each template's primary colour against white, which is what the rule would see if that colour were used for the modules themselves. The heart-module renderer's own default foreground measures 9.57:1 on white.

Template Primary Secondary Accent Primary on white Meets the rule
heart #be123c #fda4af #fecdd3 6.29:1 yes
floral #15803d #86efac #f472b6 5.02:1 yes
ring #a16207 #eab308 #fde68a 4.92:1 yes
classic #1f2937 #9ca3af #be123c 14.68:1 yes
confetti #be123c #2563eb #f59e0b 6.29:1 yes

A refusal, verbatim, when the heart template's secondary colour #fda4af is offered as a module colour on white: “Artistic styles require at least 4.5:1 contrast (got 1.89:1) — decoration already spends decode margin.” And the warning the shared core attaches to a white-on-black pair: “Light-on-dark (inverted) codes are not reliably read by all scanners. Prefer dark modules on a light background.”

Composition happens in your browser

Framing, captions, styling, and any photos you work with during composition are processed by JavaScript on your own device. Nothing you type or place on the canvas is transmitted in order to render a preview or produce a download. When you save a project to your account, the saved record contains the project's settings and the composed design — the working photos themselves are not uploaded. This is a design constraint, not a configuration: the site is static and has no server that could receive them.

What a project is made of

A studio project is a single serializable editor state, currently version 1. It holds a title; an occasion from Wedding, Memorial, Anniversary, Graduation, New arrival, Celebration; a payload type of link or text, with the raw value exactly as typed; a frame choice from heart, floral, ring, classic, confetti, none, where none composes the bare code without frame art; a palette of three hex colours; a caption; a module style of classic or hearts; and a photo-woven flag. The photo itself is deliberately not part of the state. Photos are processed on-device only and never serialized, which is the mechanism behind the sentence above: “the saved record contains the project's settings and the composed design — the working photos themselves are not uploaded.” A reopened photo-woven project shows its saved artwork and asks for the photo again before re-editing.

Parsing is total. Corrupt JSON, foreign shapes and unknown values all read as null, and the editor then starts fresh instead of wedging. Fed the string {not json, the parser returns null; a state whose occasion is “Birthday”, which is not in the list, validates to null; a state stamped with a future version number parses to null; and a fresh state serialized and parsed again survives the round trip (it does). A fresh project opens with the Wedding occasion, the floral frame, a link payload and classic modules. Each occasion suggests a frame template when you have not chosen one — a suggestion only; every template stays available for every occasion:

  • Wedding — floral
  • Memorial — classic
  • Anniversary — heart
  • Graduation — confetti
  • New arrival — heart
  • Celebration — confetti

How the frame cannot touch the code

The sixth rule is structural: a framed composite never modifies the code. The rendered QR SVG is nested verbatim on an opaque panel drawn above the frame art, so decoration cannot touch a module by construction — there is no step at which a sprig or a heart could be drawn over the symbol. The framed example composes onto a 760 × 880 unit canvas; its code panel is 460 units square and sits at (150, 240), and recolouring any of the three palette inputs changes only the frame art, the panel's border stroke and the caption around the code — never a module. Captions are bounded by the engine, in its own words when a deliberately oversized, 200-character test caption is offered: “Captions are limited to 80 characters.”

Photo-woven codes

The fourth rule governs the photo-woven style. Each data cell keeps a pure, authoritative centre square in the module colour at 50% of the cell side; the ring around it shows the photo quantized to three tones at luminance thresholds 85 and 175, on the same luminance scale the engine samples the photo at. Finders, alignment, timing and the quiet zone stay fully pure. Payload complexity is capped at version 4, because denser symbols shrink the authoritative centres below what a decoder can sample; in characters that is about 34 of plain ASCII, fewer with emoji or accents. A 34-character ASCII payload encodes at version 4 and the engine accepts it; the long link above needs version 9 and is refused: “Photo-woven codes need short content — use a short link or pick another style. This content needs a version-9 code; photo weaving is decode-proven only up to version 4 (about 34 characters, fewer with emoji or accents).” The shared core also caps any logo knockout at 16% of the symbol area, but the photo-woven style will not take a logo at all: “A halftone code cannot carry a logo — the photo already spends the decode margin.”

How the 30-day retention really works

Saved projects carry an expiry timestamp set 30 days ahead at save time, and the database's row-level security policy makes expired rows invisible to every client — the policy that grants you access to your own projects also requires them to be unexpired. Expiry is therefore not a scheduled cleanup job that might lag or a client-side filter that could be bypassed: at the moment a project expires it stops being readable, and a background purge permanently removes expired rows. Download anything you want to keep; the downloaded file is independent of all of this.

Reading an expiry

The date a project is kept until is a property of the row, not of the screen showing it: it is the expiry timestamp written when the project was saved, and a client that labels a project's remaining time is reading that timestamp. Because the access policy itself requires a row to be unexpired, a client cannot show an expired project by ignoring the date — it never receives the row. The only figure in this mechanism is the one stated above: 30 days, counted from the save. A live keepsake — hosted by design, unlike a downloaded file — carries its own expiry instant on its record; after that instant the reveal link goes dark, and a recipient who arrives at it is handled by the recipient state machine described below as a missing reveal. Nothing about expiry reaches a downloaded file: the PNG or SVG on your device encodes its destination directly and is unaffected by any row disappearing.

What the studio does not model

  • Destination longevity. The studio verifies the format of what you encode, not the future of the service it points to. A photo-album link that its host deletes in five years stops working regardless of how well the code scans — for the longest-lived keepsakes, point codes at destinations you control.
  • Print-shop color fidelity. Contrast and sizing guidance follows scanning practice, but printers, papers, and inks vary. Test a proof print before a large run.
  • Scan counting or analytics. Static codes cannot report scans, and the studio adds no tracking to your destinations. If you need scan counts, that is a different product — we would rather say so than quietly add a middleman to your keepsake.

Common misreadings

  • “Verified scannable” is not a forecast. It is a verdict about a render decoded in your browser from pixels your browser produced. It says nothing about ink on paper, which is why the proof print stays on the list above.
  • The contrast rule measures two hex colours. It does not know how a printer will reproduce them. A pair that meets the rule on screen is a pair the engine will compose; whether a print reproduces it is the proof print's question.
  • A live keepsake's open tally is not scan analytics. It counts open events recorded for the hosted reveal page — a page that exists only because live keepsakes are hosted by design. A downloaded keepsake reports nothing, and the studio adds nothing to a destination that could.
  • Expiry is not a display rule. The retention window is a condition in the access policy, so an expired row is not returned to any client — no client setting or ignored date can fetch it.

Decode verification, in the studio

Every artistic render is rasterized in your browser and decoded with a real QR decoder at 3 raster sizes: 800, 1200, 1600 px. A single fixed raster can produce a false negative through sampling resonance — a render that happens to fail at exactly 1200 px can decode cleanly at 800 and 1600 px — so the “Verified scannable” badge is earned only when at least 2 of the 3 rasters decode to an exact byte match against the content you entered. The comparison prefers the decoder's raw byte stream over its displayed text, so the check is on what was encoded, not on how it reads. Anything short of the rule shows a cannot-verify warning and blocks downloading the render as a verified keepsake.

The engine combines the per-raster outcomes in a fixed order. If enough rasters match, the verdict is verified and names the rasters that passed. Otherwise a mismatch anywhere is reported first, because a render that decodes to different content is never safe; then an undecodable render; then a rasterization error. The table runs the combining rule on scripted outcomes — not a live render — so you can see the exact wording each verdict produces. The failing outcome used here is a real one: a blank white raster handed to the decoder, which returns “Cannot verify: no QR code was detected in this render. Do not print it — adjust the style (larger size, stronger contrast, or a calmer photo) until verification passes.”

Scripted scenario Rasters that matched Verdict Message shown
Every raster decodes 800, 1200, 1600 verified Verified scannable at 800, 1200, 1600 px (3 of 3 rasters) — decoded back to your exact content.
Only the 1200 px raster fails 800, 1600 verified Verified scannable at 800, 1600 px (2 of 3 rasters) — decoded back to your exact content.
Only the 800 px raster decodes 800 undecodable Cannot verify: this render decoded at only 1 of 3 raster sizes (at least 2 must match). Do not print it — adjust the style (larger size, stronger contrast, shorter content, or a calmer photo) until verification passes.

How to read a verdict: the raster list is the evidence, the status is the decision, and the message is the engine's instruction. A verified verdict names which of the 3 rasters matched. A cannot-verify verdict tells you the render decoded at too few sizes and asks you to change the style until it passes; it does not ask you to print and hope. What verification cannot promise is anything about the physical object — that remains the proof print.

How Live Keepsakes reveal

A live keepsake runs on two screens, and each screen is a small state machine whose every transition is a pure function. Neither machine can move backwards, and unknown events never change state. Reduced motion is an input to both: with it, the dissolve and reveal stages are skipped entirely, content appears at once, and every burst collapses to zero particles. The tables below are produced by calling the machines with each stage and event in turn.

The sender's card. It starts on the photo (or a fallback card), a tap begins the dissolve, and the dissolve's end lands on the code. A tap mid-dissolve, a tap on the code, or a stray dissolve callback changes nothing.

From Event Reduced motion To
photo tap off dissolving
dissolving dissolveDone off qr
qr tap off qr
photo dissolveDone off photo
dissolving tap off dissolving
photo tap on qr

The recipient's screen. It starts loading. A successful load begins the reveal animation, which ends in the open state; a reveal that does not exist or has expired lands on gone; a network or parse failure lands on error, which unlike gone is worth retrying. Gone, error and open are terminal — a finished reveal can never regress to loading, and a missing one can never start playing.

From Event Reduced motion To
loading loaded off revealing
revealing revealDone off open
loading missing off gone
loading failed off error
open loaded off open
gone loaded off gone
loading loaded on open

Bursts. Particle counts per burst are constants with a deliberate hierarchy: the recipient's reveal moment is the largest, and on the sender's screen a loveback bursts larger than an open. Reduced motion means zero particles for every kind, and the painter never starts its animation loop for a burst of zero.

Burst Particles Particles with reduced motion
reveal 120 0
open 45 0
loveback 90 0

The particle system is pure where it can be: spawning takes an injected random source, and stepping is a function of the particles and the elapsed time. Spawned here with a scripted source that always returns the midpoint, a hearts reveal burst yields 120 particles, each with a lifetime of 2500 ms — the midpoint of the hearts range; the same call with the reduced-motion count yields 0. Stepped forward by one second, 120 of the 120 are still alive; stepped past the longest hearts lifetime, 0 remain. Each occasion fixes a particle shape and a default accent. The engine describes the shapes' motion in its own header — “balloons and hearts are buoyant and drift upward, confetti tumbles down under gravity, sparkles and stars radiate and fade in place” — and the table prints, unchanged, the two physics figures it stores behind that description: gravity, which the engine defines as “downward acceleration in px/s² (negative = buoyant)”, and the “initial vertical velocity range [min, max] in px/s”:

Occasion Composer label Particle Gravity (px/s²) Initial vertical velocity (px/s) Default accent
love A love note hearts -35 -130 to -50 #be123c
birthday A birthday surprise balloons -60 -170 to -80 #2563eb
congrats Congratulations confetti 260 -320 to -140 #f59e0b
proposal A proposal sparkles 0 -90 to 90 #a16207
friendship For a friend stars 40 -200 to -60 #15803d

What a draft must satisfy. Before a reveal is created, the draft is validated against the database's own constraints, and every error is returned at once so a form can mark all offending fields in one pass. A fictional draft — label “Kitchen — Saturday”, occasion birthday, message “The surprise is in the kitchen.” — returns 0 errors. A draft with an occasion of wedding, a message of only spaces and a label one character over the limit returns 3:

  • occasion — Choose one of the occasions.
  • message — Write the message your keepsake reveals.
  • title — The private label can be at most 120 characters.

A message one character over its limit returns 1: The message can be at most 500 characters.

What the recipient's read can return. The anonymous read path returns only the occasion, the message and the style, and the parser on the recipient side is written as an allowlist: it copies exactly those three fields, so it structurally cannot leak the sender's user id, the private label, or anything a future column might add. Handed a fictional row with the keys occasion, message, style, user_id, title, created_at, it returns an object with the keys occasion, message, style. The style parse is total as well: a malformed accent such as red falls back to the occasion's own accent, so a birthday reveal with a corrupt style renders in #2563eb — the birthday default of #2563eb.

What the code encodes. The sender's QR encodes a single static route, /r/, with the reveal id carried as a query parameter, because a static site cannot prerender a path for an id created at runtime. For a fictional id the encoded URL is https://keepsakeqrcodes.com/r/?k=0f3a9c2e-5b7d-4e81-9a6c-2d4f8b1e7c3a. The recipient page accepts only canonical uuid shapes: ?k=not-a-uuid reads as null and shows the not-found state rather than querying with garbage, while the same fictional id in upper case reads back as 0f3a9c2e-5b7d-4e81-9a6c-2d4f8b1e7c3a.

The sender's status line. Open and loveback events are records carrying only their kind and a timestamp. With no events the line reads “Not opened yet”. With two fictional opens an hour apart and one loveback a minute later, rendered in UTC, it reads “Opened 2 times · last 9:14 PM · 1 love back”. The time shown is wall-clock in the sender's own zone.

Constants the rules refer to

Every rule above names a constant, and every constant below is read from the engine at build time — change one in the code and this table changes with it.

Constant Value Where it bites
Quiet zone 4 modules Always drawn on all four sides; cannot be disabled
Contrast threshold 4.5:1 Warning in the shared core; refusal in every artistic style
PNG size presets 512, 1024, 2048 px Square sizes the shared core's print-size readout is defined for
Print resolution 300 DPI Divisor in the print-size readout
Logo knockout cap 16% of symbol area Shared core limit; the photo-woven style refuses any logo
Heart cell coverage 97% of the cell Keeps each heart cell decisively dark to a sampling grid
Halftone centre 50% of the cell side The authoritative pure square in every photo-woven data cell
Halftone tone thresholds 85 / 175 Luminance cut-offs for the three-tone ring
Halftone version cap version 4 (about 34 ASCII characters) Denser photo-woven symbols are refused
Frame templates heart, floral, ring, classic, confetti Plus none, which composes the bare code
Verification rasters 800, 1200, 1600 px Every render is decoded at each size
Verification passes required 2 of 3 Exact byte matches needed for “Verified scannable”
Editor state version 1 Any other version parses to null
Studio occasions Wedding, Memorial, Anniversary, Graduation, New arrival, Celebration Stored verbatim; anything else fails validation
Reveal occasions love, birthday, congrats, proposal, friendship Must match the database check constraint exactly
Reveal message limit 500 characters Database constraint, mirrored in draft validation
Reveal private-label limit 120 characters Sender-only label; never returned to a recipient
Reveal path /r/ The single static route the sender's code points at
Burst particles reveal 120, open 45, loveback 90 Zero for every kind under reduced motion

Tested, and worked examples can't drift

The studio's logic — encoding, validation, expiry labeling, project handling — is implemented as small, typed functions covered by automated tests, and any worked example shown in page copy is produced at build time by the same code that powers the tools, so documentation cannot silently disagree with behavior. If you believe a code is malformed or a claim on this page is wrong, that is exactly the report we want: see the contact page. Confirmed issues are fixed in the engine and locked in with a new test.