Cropalot's first version was generated in Google AI Studio. It produced a working, good-looking React application in one pass, and that draft is why the project exists at all — it laid out the whole UI, the workflow, the tab structure, and a genuinely original idea (a self-verification tab). None of what follows is an argument against generating a first draft that way.
But the review that followed found that several of its headline features did not do what the code said they did, and that the failures clustered into recognisable shapes. This document records those shapes, because they are the part of the work that is not recoverable by reading the current source. The fixes are in the code; the reasons they were needed are only here.
Every claim below was verified against the running application in a real browser, not inferred from reading. Where a number appears, it was measured.
The README claimed "WebAssembly algorithms" — there was no WASM anywhere in the project. It claimed "Homography & Perspective Transformation", and the privacy modal told auditors the app "calculates an 8-parameter perspective projection matrix". The code did bilinear interpolation across the quad's four corners:
topX = p0.x + u * (p1.x - p0.x)
botX = p3.x + u * (p2.x - p3.x)
x = topX + v * (botX - topX)That is a Coons patch. A homography needs a perspective divide
(x = (ax + by + c) / (gx + hy + 1)); there wasn't one, and there was no 8×8
solve in the project.
The docs and the code were generated together from the same prompt, so the docs
describe what the prompt asked for. Nothing checks that against what was built.
The README also listed CropEditor.tsx, PhotoGallery.tsx and autoCropEngine.ts
— none of which existed — while omitting every file that did.
Tell: documentation that reads like a specification rather than a description. If a doc names a technique, grep for the technique.
Related but distinct, and more dangerous: the generated code used correct technical language for things it had not implemented. A comment read "16x16 grid for ultra-smooth perspective deskewing" — but subdividing a bilinear mesh converges on the bilinear surface, not the projective one. More subdivision could never fix the error; the comment implied a tuning knob where there was a structural gap.
Tell: a comment explaining why a parameter makes something better is worth checking. If increasing it can't actually help, the mechanism underneath is probably not the one named.
This was the most consequential one.
detectPhotoQuads began:
if (presetQuads && presetQuads.length > 0 && sensitivity === 5) {
return presetQuads; // "perfect crops!"
}The sample sheets shipped with hand-authored ground-truth corners. Sensitivity
was useState(5) with no UI control bound to it, so it was permanently 5,
so the samples always returned the answer key. "Re-Detect" was a no-op that
looked like it worked.
Meanwhile real detection emitted only axis-aligned rectangles — it never computed a photo's angle at all. So the app's headline feature, deskew, worked exclusively on the fake data. Load a sample: flawless angled crops. Upload your actual album page: straight boxes with wedges of album paper in the corners.
The compounding problem: the sample data was generated by the same process as the code, so it encoded the same assumptions and concealed the same failures. Nearly every serious defect found in this review was invisible when testing with the built-in samples and total when using a real photo:
| Defect | With samples | With a real scan |
|---|---|---|
| Auto-deskew | Perfect (returned ground truth) | Never rotated anything |
localStorage persistence |
Fine (flat-colour PNGs, tens of KB) | Failed on the first photo |
| Extraction performance | Instant (1200×1000 sheet) | Seconds of frozen tab |
Fix applied: the ground truth moved to a separate
groundTruthQuadsfield that is never used as a starting crop, and the editor now displays live IoU of real detection against it. The cheat became a benchmark. See ARCHITECTURE.md.
confidence: 0.92, // every quad, always
width: 800, height: 600, // every extracted photo, regardless of actual sizeThese read as data. They were literals. confidence in particular was surfaced
in the UI as though the detector had assessed something.
Tell: a numeric field that never varies across runs. Grep for the literal; if it appears once at the assignment and nowhere else, nothing computed it.
try {
localStorage.setItem(KEY, JSON.stringify(photos));
} catch (err) {
console.warn('LocalStorage limit reached for photo cache:', err);
}Measured: a 4×6″ print at 300 dpi serialises to a 5.8 MB data URL, against a
~5 MB per-origin budget — and each photo was stored twice (raw + enhanced).
setItem threw on the first photo, every time. Users watched a gallery fill
up, closed the tab, and lost everything, while the privacy modal told them their
photos were safely in localStorage.
Three Image loads had onload and no onerror, so any undecodable file left
the button spinning "Deskewing…" forever with no message.
Tell:
console.warnin a catch block on a user-visible operation. The console is not a user interface.
The Settings panel persisted defaultOutputFormat, jpegQuality,
autoDetectOnUpload, autoDeskewSensitivity, defaultTrimMargin, theme and
isProUnlocked to localStorage. The settings object was never passed to
DetectionEditor or GalleryView. Every control was decorative; export was
hardcoded to PNG. The format dropdown offered WebP, which nothing honoured.
Tell: trace state from where it is written to where it is read. Generated code often builds both halves of a feature and omits the wire between them.
Two bugs announced themselves this way:
orderQuadPointswas imported intoDetectionEditorand never called. It existed to re-sort a quad's corners; because it was never invoked, dragging one corner past another produced a self-intersecting "bowtie" that the warp rendered as garbage, silently.- The
Slidersicon was imported and unused — the fossil of the sensitivity control that was never built, which is what pinned sensitivity at 5 and made the demo-path shortcut permanent (see #3).
Tell: an unused import is often a feature that was designed, referenced, and then not finished. Each one is worth a minute.
metadata.json declared:
"majorCapabilities": ["MAJOR_CAPABILITY_SERVER_SIDE_GEMINI_API"]package.json depended on @google/genai, express and dotenv — none
imported anywhere — and .env.example asked for a GEMINI_API_KEY.
The sting: metadata.json was one of six files the app's own verification tab
presented to auditors as evidence of its privacy. A security-minded user taking
the invitation seriously would have found a declared server-side AI capability in
a file the app offered as proof it had none.
Removing the unused dependencies also cut 51 KB from the bundle.
Tell: grep every dependency for an actual import before trusting the dependency list as a description of the app.
The original "Repo Drift Checker" imported six source files via ?raw — thereby
embedding them in the bundle — hashed those embedded strings, and compared them
to GitHub. It displayed a green "In Sync (100% Match)" badge.
It could not detect what it existed to detect. Anything able to tamper with the running code would tamper with the embedded copies in the same edit. You cannot verify a bundle from inside that bundle; that is a fixed point, not a bug to fix.
It also audited 6 of ~20 files — two of which were README.md and
package.json — and none of the files that touch user photos. And it was the
only thing in a "zero network requests" app that made network requests: it fired
at api.github.com and raw.githubusercontent.com on mount, from inside the
modal telling users to open DevTools and observe that no requests occur.
The instinct was excellent. The mechanism was self-defeating.
Fix applied: replaced with a Content-Security-Policy the browser enforces (
connect-src 'none'), plus a build-provenance panel that states the commit, explains how to rebuild and compare hashes independently, and includes a section headed "Why we do not show you a green 'verified' badge".
The generated code used base64 data URLs for every image — the default LLM idiom for images in React, and completely reasonable for an avatar or an icon. At photo scale it was catastrophic: base64 inflates by a third, every conversion allocates the whole image again as a JavaScript string, and it made the storage bug in #5 inevitable.
Similarly, extractAndDeskewPhoto rendered each crop by drawing the entire
source sheet 512 times (a 16×16 mesh of clipped triangle pairs), and built a
fresh full-resolution canvas copy of the sheet per crop. Correct-looking,
composed of ordinary canvas calls, and quadratic in exactly the dimension that
matters.
Tell: ask what each idiom costs at the largest input the app invites. This app's own upload box said "any resolution".
Every table row in #3 says the same thing. The generated samples were small, flat-coloured, and structurally simple; real scans are large, photographic, and messy. A test fixture produced by the same process that produced the code is not an independent check of it.
Do: keep a handful of genuinely nasty real scans as fixtures — overlapping photos, a photo bleeding off the page edge, low-contrast white prints on cream paper, black photo corners.
The 5.8 MB figure, the 0-frames-over-100 ms figure, the IoU scores, and the EXIF round-trip were all obtained by driving the built app in headless Chromium. Two findings would not have come from reading:
- A morphological closing merged photos sitting close together (dilation bridges any gap narrower than its kernel, and erosion cannot separate them afterwards). A 2×2 layout with a 100 px gutter came back as 2 photos instead of 4.
- The default detection sensitivity was too conservative for white-bordered prints on cream paper: that border sits ~43 units from the page colour against a threshold of 45, so it read as background and split each light photo into pieces — 4 photos detected as 8. Raising the default from 5 to 7 cost ≤0.01 IoU on the samples and fixed the case entirely.
The single highest-leverage change in the whole review was five lines of
Content-Security-Policy. Prose in a README asks for trust and decays the moment
someone edits the code. connect-src 'none' is enforced by the browser before
any application code runs, holds for every future commit, and is auditable in
five seconds by viewing source.
Where a guarantee can be delegated to the platform, delegate it.
The README now carries a Limitations section naming the cases that still fail. This is not self-flagellation; it is the difference between a user who hits a known edge and one who concludes the tool is broken. It also means the next person to work on this — human or model — starts from an accurate picture instead of rediscovering the gaps.
Worth recording, because the point is not that generated code is bad:
- The product concept and the whole workflow. Upload → detect → adjust corners → enhance → export is the correct shape, and it survived every change.
- The magnifier loupe for corner placement — a genuinely thoughtful touch nobody asked for.
- The verification instinct. "Don't trust me, verify" is the best idea in the project. The implementation was circular, but the instinct drove the CSP work that became the app's strongest feature.
- Visual design. The interface was and remains good-looking and coherent.
- Sensible module boundaries.
cvEngine/imageProcessing/ components was the right split, and every later change fitted into it rather than fighting it.
The pattern across all ten failures is the same: the draft was strong on structure and intent, and unreliable on whether the mechanism underneath matched the label on it. Review accordingly — trust the shape, verify the substance.