- Phase 01 — foundation (scaffold, shadcn init, dark/emerald theme, header/footer, theme toggle)
- Phase 02 — text diff engine (biggest phase — the core product)
- Phase 03 — landing page
- Phase 04 — contact & privacy
- Phase 05 — structured diff (JSON/Excel)
- Phase 06 — image diff
- Phase 07 — document diff
- Phase 08 — sharing & persistence
- Phase 09 — SEO & analytics
- Phase 10 — deploy
When you finish a phase: check its box above, and if you made a decision that deviates from or extends the plan, note it in one line here so the next session doesn't rediscover it.
-
Phase 02 notes:
@codemirror/merge'sDiffConfighas no ignore-whitespace/case/regex options, solib/diff/normalize.tsbuilds a length-mapped "masked" comparison string (regex/whitespace runs → a single placeholder char, same-length so offsets stay valid) andlib/diff/engine.tsrunsjsdiffon it, translating positions back to real doc offsets viaoverride. Precision/ignore-* toggles and the split↔unified switch fully rebuild theMergeView/unifiedMergeView(reconfigure() doesn't re-runChunk.buildfor a changeddiffConfig); collapse/gutter/line-wrap/language changes take the same full-rebuild path for simplicity — cursor/undo history resets on toggle, an acceptable v1 trade-off. Nocodemirror/basicSetupmeta-package —lib/cm/setup.tshand-assembles the minimal extension set from the granular packages listed in 00-overview.md. -
Phase 03 notes: used
motion(theframer-motionsuccessor, imported frommotion/react) instead offramer-motionper current upstream guidance;MotionConfig reducedMotion="user"is set once inapp/layout.tsxrather than scatteringuseReducedMotion()checks into everyinitialprop — SSR bakesinitialvalues into inline styles, so a component-levelreduce ? false : {...}branch renders differently server vs. client whenever the visitor's OS actually has reduced-motion on, which is a real hydration mismatch (confirmed via a PlaywrightreducedMotion: "reduce"context during this session).MotionConfigkeepsinitialdeterministic and collapses the transition to instant client-side instead. The hero'sLiveDiffDemorenders its diff lines as plain static markup (no entrance animation) to protect LCP/avoid the same SSR-opacity-0 trap on above-the-fold content; only the blinking cursor is client-only, gated behind auseSyncExternalStore-baseduseIsClient()(notuseState+useEffect, which trips thereact-hooks/set-state-in-effectlint rule). No dedicated/compare/json,/compare/excel,/compare/image,/compare/documentroutes exist yet (phases 5-7), so the landing page's file-type grid is informational only (no links) with an honest "next" caption rather than linking to 404s. -
Phase 04 notes: built via the
nextjs-contact-formskill's four templates, restyled onto the project's own shadcn components (Input/Label/Textarea/Button) instead of the templates' raw Tailwind baseline; addedcomponents/ui/textarea.tsxsince base-nova/Base UI has no textarea primitive to wrap — it's a plain native<textarea>with the same class treatment asui/input.tsx.lib/rate-limit.tsandapp/api/contact/route.tsare copied in verbatim (they're already framework-neutral); onlysource: "difflab"was filled in. Privacy page's "Save & share" section describes Phase 8's not-yet-built persistence honestly as the intended design (hash for small diffs, DB row + 1d/7d/30d/never expiry for large ones) rather than claiming it's live./contactand/privacyare linked fromSiteFooteronly, not the header's mode nav — verified end-to-end against the real n8n webhook and Upstash instance in.env.local: a real submission returned{success:true}, a honeypot-filled payload was silently swallowed, and the 6th rapid submission returned 429. -
Phase 05 notes: JSON tool (
json-compare.tsx) hands off to the exact Phase 2 engine — it pretty-prints each side (lib/parse/json.ts, with optional recursive key-sort) into the sameMergeView/unifiedMergeViewused by the text tool, so precision/ignore-regex/collapse all carry over for free. Parse errors surface two ways: an inline@codemirror/lintgutter marker (added as a direct dependency — it was only a transitive one before) that re-parses on every doc change, and a red badge in the pane header; the "empty pane" case is special-cased out of both so an untouched side isn't flagged as an error. The semantic key-path summary (lib/diff/json-diff.ts) walks the parsed values, independent of formatting/key order, so it only lights up once both sides are valid JSON. Excel/CSV (excel-compare.tsx) has no test-framework precedent in this repo, so it was smoke-tested with a throwaway Playwright script against the running dev server (chromium-cli wasn't available in this environment) rather than skipped — caught a real bug where CSV uploads were named after the file (left.csv/right.csv), which meant the two sides could never align as "the same sheet"; fixed by always naming a bare CSV/TSV parseSheet1. There's noreact-window/react-virtualdependency —virtual-grid.tsxis a ~100-line hand-rolled windowed grid (absolute-positioned rows + aResizeObserver), reused for both the Original/Changed panes with scroll manually synced via refs. Row alignment is index-based or by a chosen key column (lib/diff/sheet-diff.ts); shadcn has no Tabs primitive in this project, so sheet tabs reuseToggleGrouplike the split/unified switch. One shadcn quirk worth remembering: Base UI's<Select.Value>renders the raw selected value, not the matching<Select.Item>'s label, unless you pass it achildrenrender-prop function — used for the indent and key-column selects since their values (2,4,0,1...) aren't self-describing the waysmart/line/wordalready were in the Phase 2 toolbar. Also updatedfile-type-grid.tsxon the landing page (Phase 3 left it link-free on purpose, see its note above) so the Text/JSON/Spreadsheets cards now link to their live tools; Images/Documents stay plain divs until phases 6-7. -
Phase 06 notes: all four overlay modes (slider/fade/onion/diff) plus side-by-side share one
ImageViewport(components/compare/image-viewport.tsx) driven by a hand-rolleduseZoomPanhook (hooks/use-zoom-pan.ts— new alias dir, wheel-to-zoom + pointer-drag-to-pan, clamped 0.1–8×, with a fit-to-container effect that re-fits whenever a new image pair's padded canvas size changes). Images load viacreateImageBitmapfor pixel access plus a parallelURL.createObjectURLfor cheap<img>display (lib/diff/image-diff.ts); mismatched dimensions are handled by sizing every mode's canvas box tomax(leftW,rightW) x max(leftH,rightH)and drawing each image at its own natural size top-left, so the extra area shows through as a CSS checkerboard rather than stretching either image. Pixel diff usespixelmatchdirectly on two same-sizeImageDatabuffers from offscreen canvases (padded area vs. any real pixel reliably counts as a diff, which is the desired behavior for size mismatches); a 40-megapixel padded-canvas guard skips the computation with an inline message instead of hanging the main thread — no worker, consistent with Phases 2/5 not using theworkers/dir either.pixelmatchships its own.d.ts(v7.2.0), so@types/pixelmatchwas installed then removed as redundant. Onion skin is implemented as an instant A/B toggle (plus an optional auto-flicker interval) rather than a continuous blend, to stay meaningfully distinct from the fade mode's opacity slider.InputPaneHeader's clipboard-paste button only reads clipboard text, so the image tool passeshidePaste— no clipboard-image paste in v1, upload/drag-drop only. Added theslidershadcn/Base UI component (none existed) for the fade-opacity and diff-sensitivity controls; its genericonValueChangetyped asValue extends number ? number : Valueresolves to a union when the wrapper component doesn't forward a generic, so callers narrow with a smallfirstValue()helper inimage-toolbar.tsxrather than casting. Verified end-to-end with a throwaway Playwright script (nochromium-cliin this environment, same as Phase 5) against two differently-sized generated PNGs: padding/checkerboard, slider drag, fade blend, onion A/B toggle, pixel-diff highlighting- stats, zoom, and pan all confirmed visually with zero console errors.
-
Phase 07 notes: both parsers (
lib/parse/pdf.ts,lib/parse/docx.ts) return the same shape —{ text, markers: {line, label}[] }— sodocument-compare.tsxhandstextstraight to the exact Phase 2MergeView/unifiedMergeViewsetup (no language extension) and rendersmarkersthrough a new hand-rolled gutter (lib/cm/marker-gutter.ts, a plaingutter({ lineMarker })closing over a static array — document text only changes on file load, never per keystroke, so there's no need for aStateField/effect like CodeMirror's own dynamic gutters use). PDF:pdfjs-dist's worker is a build artifact, not a static asset, soscripts/copy-pdf-worker.jscopiesnode_modules/pdfjs-dist/build/pdf.worker.min.mjs→public/pdf.worker.min.mjson everynpm install(wired viapostinstall);GlobalWorkerOptions.workerSrcpoints at that public path. Pages are split into lines usingTextItem.hasEOL(pdf.js's own line-break flag) rather than any y-position heuristic, with a "Page N" marker recorded at each page's first line. DOCX:mammothships no types (no@types/mammothexists either), sotypes/mammoth.d.tsdeclares the minimalconvertToHtmlsurface actually used. Rather than trust blank-line grouping inextractRawText, DOCX goes throughconvertToHtml+DOMParserand flattens each block element (p/h1-6/li/ table cell) to exactly one line, so every line has an unambiguous 1:1 paragraph marker (¶ N) — list items insideul/olare recursed into rather than squashed into their parent's text. PPTX (and legacy.doc) are rejected with an inline error badge via the sameInputPaneHeadererror prop Phase 5 uses for bad JSON/CSV, not silently mis-parsed. No language selector or examples dropdown indocument-toolbar.tsx(documents have neither concept) — otherwise identical options to the text tool. Verified end-to-end with a throwaway Playwright script (same pattern as Phases 5/6): two real multi-page PDFs from Chromium's ownpage.pdf()and two real DOCX files frompandoc(both already present in this environment) confirmed page/paragraph gutters, word-level highlighting, stats, and the PPTX rejection message, with zero console errors. -
Phase 08 notes: the three pre-Phase-8 risks were resolved for real, not just documented: SSH'd into the VPS and found
postgres-pgvector(the container behindpostgresdb) hadssl = offandufwinactive, confirming both Risk 1 and Risk 3. Generated a self-signed cert withopensslinside the container (postgresruns as uid 999), setssl = oninpostgresql.conf, and restarted the container — a brief blip for any other app sharing that Postgres instance, done with the user's explicit go-ahead since it's shared infra.DATABASE_URLgained?sslmode=require&connection_limit=1 &pool_timeout=20; the self-signed cert means no CA to verify against, which is fine forsslmode=require(encrypt, don't verify) — except recentpg/pg-connection-stringnow treatsrequireas an alias forverify-fulland rejects the self-signed cert, so&uselibpqcompat=trueis also required to restore the old encrypt-only behavior (see the warning inpg's own connection code if this ever needs revisiting). Prisma 7 gotcha: theprisma-database-setupskill's driver-adapter pattern (@prisma/adapter-pg+PrismaPg) moves the connection URL out ofschema.prismaintoprisma.config.ts, and critically, the generated client fully qualifies every query's schema name at codegen time rather than trusting the connection'ssearch_path— so?schema=difflabin the URL (which is all classic Prisma needed, and still allprisma migrateneeds) silently does nothing for actual query execution and every query 404's with "tablepublic.diffsdoes not exist" even though the table is right there indifflab. The fix is the multi-schema feature:schemas = ["difflab"]on thedatasourceblock plus@@schema("difflab")on theDiffmodel (nopreviewFeaturesflag needed — it's stable in 7.10).prisma.config.tsloads.env.localexplicitly viadotenvsince the Prisma CLI only reads.envby default and this repo keeps every secret in.env.local. Sharing itself:lib/share/url.ts(client-safe:lz-stringhash encode/decode, a 6000-char budget past which the share dialog switches to the saved-link flow) is imported directly by each tool component so#d=...restores on the live/compare/*URL, while/d/[id]is a Server Component that fetches vialib/share/persist.ts(server-only Prisma calls) and feeds the row'spayloadintoToolShell's newinitialprop — both paths converge on the samePartial<...ShareData>restore effect inside each tool. Wired the Share button (newdialog/sonnershadcn components) into Text, JSON, and Excel only — their state is plain JSON-serializable data (text/options, orParsedWorkbook). Image and Document are binary-shaped (pixel canvases, PDF/DOCX bytes) and don't fit the same payload; sharing for those two is deliberately out of scope for v1, same precedent as Phase 3 leaving unbuilt tool cards unlinked rather than half-wiring something misleading.lib/rate-limit.tsnow takes akindparam ("contact"|"diffs") so/api/diffsgets its own tighter Upstash limiter (10/10min) instead of sharing the contact form's budget. Verified end-to-end with a throwaway Playwright script (same pattern as Phases 5-7): small diff round-tripped through a#d=hash in a fresh tab, a 20k-line diff correctly fell back to the oversized-saved-link UI, the created/d/[id]rendered its content in a fresh browser context, aSELECTagainstdifflab.diffsshowed real rows withviewsincrementing, and a rapid-fire loop against/api/diffsreturned 429 — zero console errors throughout. -
Phase 09 notes:
lib/seo.tscentralizesSITE_URL/SITE_NAMEand apageMetadata({title, description, path})helper used by every route'smetadataexport — necessary because Next's metadata merging is shallow: a page that definesopenGraph/twitterfully replaces (doesn't deep-merge with) the root layout's, so each page must restate its own title/description inside those nested objects rather than relying on inheritance.app/opengraph-image.tsxis a single root-levelnext/ogImageResponse(dark/emerald branded, badge + wordmark + tagline + a "Try it free — no signup →" CTA pill) that applies to every route via file-convention lookup; no per-route OG images since the tools have no per-page visual to differentiate on.app/sitemap.tslists the 8 public routes (/, five/compare/*,/contact,/privacy);/d/[id]is intentionally excluded and stays noindexed via the per-pagerobotsmeta added in Phase 8, reinforced byapp/robots.tsdisallowing/d/and/api/outright. JSON-LD (SoftwareApplication+FAQPage, built from aFAQSarray now exported out ofcomponents/landing/faq.tsxso the structured data can't drift from the visible FAQ copy) lives inapp/page.tsxas a single<script type="application/ ld+json">with both objects in a top-level array. Running theseo-auditskill against the built site surfaced one real defect worth recording: all five/compare/*tool pages had zero<h1>—ToolShellis a client-only dynamic import with no heading of its own, sopage.tsx(a Server Component) needed its own compact<h1>added directly, sized small (text-base font-semibold) to keep the "quiet and utilitarian" tool chrome per 00-overview.md's design direction without disturbing thecalc(100vh-230px)viewport-height budget each tool's editor pane depends on (confirmed via a rebuilt+restarted prod server, not the stale one still bound to the port from the previous check —next startbinds to a build snapshot, so a killed/re-launched server is required to see fixes, acurlagainst a stale process silently serves the old HTML with no error). Also fixed from the audit: the image tool's meta description was 161 chars (over the 160 truncation limit) and the contact page's was only 51 (under the 70-char "wasted opportunity" floor) — both rewritten to fit.@vercel/analytics's<Analytics/>(from the/nextentry, App-Router-aware) is mounted directly inapp/layout.tsx's<body>as a sibling after the provider tree, not inside it — it injects its tracking script client-side viadocument.createElementon mount, so it produces no server-rendered markup and is a verified no-op until the app is actually deployed on Vercel with Web Analytics enabled in the project dashboard. -
Phase 10 notes: deployed via the Vercel CLI (
npx vercel) rather than the dashboard import flow —vercel link --project difflabboth created the project and auto-connected the existingmzeeshanaltaf/DiffLabGitHub repo (device-flow login auto-approved against an already-authenticated browser session, no manual click needed). All 5 env vars from.env.localwere pushed viavercel env add <NAME> <target>(one call per target — passing multiple targets as separate args is misparsed as<environment> <gitBranch>and fails withbranch_not_found);DATABASE_URLwent to Production + Preview, the other 4 to Production + Preview as planned. First deploy:vercel deploy --prod. Deviation from this file's own plan:vercel domains add difflab.zeeshanai.cloud difflabthenvercel domains inspectreturned Vercel's actual required record for this domain as an A recorddifflab → 76.76.21.21, not theCNAME → cname.vercel-dns.comthe plan guessed — created that A record on Hostinger DNS via thehostinger-dnsMCP (DNS_updateDNSRecordsV1,overwrite:falseso it only appended, verified first againstDNS_getDNSRecordsV1that nodifflabrecord already existed and that it wouldn't collide with the Coolify box's other 20+ subdomains). DNS resolved and Vercel's auto-provisioned SSL was live within under a minute (no manual "verify" step needed). Smoke-tested every route plus the two DB-backed API routes for real:POST /api/diffs→GET /d/[id]round-tripped successfully (confirms the Phase 8 Prisma multi-schema/uselibpqcompatsetup works from Vercel's network, not just locally), andPOST /api/contactwith the honeypot field filled returned{success:true}without hitting the real n8n webhook — verified the anti-spam path without sending a real notification. Live at https://difflab.zeeshanai.cloud.