Skip to content

Release: Rebuild csvjson.com as a minimal modern converter - #155

Merged
dboskovic merged 24 commits into
masterfrom
release/rebuild-csvjson-spa
Sep 1, 2026
Merged

Release: Rebuild csvjson.com as a minimal modern converter#155
dboskovic merged 24 commits into
masterfrom
release/rebuild-csvjson-spa

Conversation

@obvious-autobuild-staging

@obvious-autobuild-staging obvious-autobuild-staging Bot commented Aug 31, 2026

Copy link
Copy Markdown
Contributor

Release: Rebuild csvjson.com as a minimal modern converter

Rebuild csvjson.com (FlatFilers/csvjson-app, PHP/CodeIgniter) as a single-purpose, minimal, modern converter: static Vite + React + TS + Tailwind + shadcn/ui SPA, all conversion client-side, thin PHP shim (static serving + 301 map), read-only hydration of legacy S3 permalinks, full legacy teardown (controllers, views, promos, ads, telemetry), and a prerendered single route for SEO. Sharing and the chat promo are cut for v1 (promo removed post-verification per product decision; parked for a future release).

Branch: release/rebuild-csvjson-spa
Spec: approved Blueprint (art_K61RhTbt) · Research: art_XBXAvnl3 · Mockup: art_m9v3z0Rv
Progress: 9/9 PRs merged — ready for review
Verification: docs/verification-report.md (all 12 spec criteria with evidence) · Playwright walkthrough 18/18 · full report in the description below

Completed Work

Subtask Expected outcome Status PR
SPA scaffold Vite+React+TS+Tailwind+shadcn app in app/, CI from zero (lint/typecheck/vitest/build), npm csvjson packages + .d.ts shim, typed conversion service + full fixture suite (criteria 1, 2, 11) merged #156
Converter shell Split pane with flush draggable 1px seam + floating ⇄ switch, topbar, persisted dark/light theme, direction-conditional bottom options bar merged #158
Converter panes Dense virtualized CSV table, CodeMirror 6 JSON editor, FileReader upload, dropzone/drag-over/error/throttle states, copy/download (UTF-8 BOM) merged #160
Chat promo "Chat with this data in…" top-bar control: provider constant (Obvious/ChatGPT/Claude), static template, ≤8k inline vs clipboard handoff, deep links + fallback merged, then removed for v1 #159 — cut per product decision (2026-08-31), surface fully removed, TopBar extension slot retained for a future release
PHP shim + teardown Static-serving front controller, 301 redirect map, sitemap/robots, delete all legacy controllers/views/telemetry/vendored stacks/S3 write path merged #157
Legacy permalink hydration /:tool/:id route fetches S3 data/ directly, hydrates converter read-only, inline not-found/error states merged #161
SEO Prerendered single route: title/H1/meta/OG/JSON-LD, FAQ accordion + info-icon hints in initial DOM, CI crawlability check merged #162
Heroku deploy config Procfile (web: heroku-php-apache2), composer.json/lock (PHP ~8.3) for buildpack detection, docs/deploy.md transition runbook, docroot deny hardening merged #164
Release assembly Integrated verification pass (curl 301 map, crawlability grep, telemetry grep, state walkthrough, live S3 hydration E2E), dist/ committed, handoff notes complete — (this PR)

Merged PRs

PR Title Summary
#156 SPA scaffold: Vite+React+TS+Tailwind+shadcn, CI from zero, conversion service + fixture suite Vite/React/TS-strict/Tailwind/shadcn scaffold in app/, CI from zero (lint+typecheck+vitest+build), typed conversion service over csvjson-csv2json@5.0.6 / csvjson-json2csv@1.0.3 with .d.ts shims, 23 green fixtures covering criteria 1/2/11
#157 PHP shim: static serving, 301 map, legacy app teardown PHP front controller serving app/dist (immutable cache on hashed assets, traversal refusal), 301 map for all 8 retired tools + /dataclean with permalink pass-through and 410s, sitemap/robots regenerated, 326 legacy files (~102k lines) deleted, CI redirect-table curl checks + criterion-8 remnant grep
#158 Converter shell: split pane, seam switch, topbar, theme, options bar TopBar with wordmark + persisted dark/light toggle (no dark-mode flash) + extension slot; SplitPane flush 1px seam, drag 20–80% + dblclick reset + full keyboard support, stacking below 768px; DividerSwitch ⇄ with flip rule (last valid output becomes input); direction-conditional OptionsRow with debounced live conversion; 35/35 component tests, 3 clean review rounds, validated against mockup rev 6 in light/dark/flipped/mobile
#159 Chat promo: provider deep links with clipboard fallback "Chat with this data in…" top-bar control: CHAT_PROMO_PROVIDERS constant (Obvious/ChatGPT/Claude), static prompt templates with ≤8000-char inline vs clipboard handoff, Claude + ChatGPT deep links, prompt-always-copied fallback with toasts, chips disabled while input is empty, synchronous window.open inside the click gesture (Safari popup loss fix); 55/55 tests, CI 3/3
#160 Converter panes: dense CSV table, CodeMirror JSON, upload, pane states Dense virtualized CSV table (TanStack Virtual, sticky header, monospace numerics), raw-text toggle, CodeMirror 6 JSON editor + read-only view, empty-state dropzone, drag-over with non-text rejection, FileReader upload with filename/counts + TSV detection, inline errors retaining last valid output, >2MB throttle hint, copy/clear/download with UTF-8 BOM; 79/79 tests, 2 bot review passes addressed
#161 Legacy permalink hydration: /:tool/:id fetches S3 read-only Legacy /:tool/:id route with tool-gated hydration (csv2json/json2csv/csvjson2json hydrate, others fall through), read-only S3 fetch via VITE_S3_DATA_URL with typed not-found/fetch errors, single element-id→state mapping table, loading/not-found/retry states (converter never blank), zero write-API usage enforced in CI; 105/105 tests, real-object E2E committed as scripts/e2e-legacy-permalink.ts
#162 SEO: prerender the route, on-page targets, JSON-LD, FAQ + option hints Build-time prerender of the single route (full app markup in dist/index.html), title/H1/meta/OG/canonical naming both directions, SoftwareApplication + FAQPage JSON-LD, collapsed FAQ accordion + option hints rendered into the initial DOM, CI check grepping the built HTML for H1/FAQ/hints/JSON-LD; 110/110 tests, verify-seo.sh passing
#164 Heroku deploy config: Procfile, composer stub, transition runbook, docroot hardening Root Procfile (web: heroku-php-apache2), composer.json (~8.3.0) + platform-only composer.lock for buildpack detection, docs/deploy.md transition runbook (buildpack detection, cutover checklist, immutable-release rollback, post-cutover SEO monitoring with blocking Search Console baseline + week-2/4 checkpoints), .htaccess/index.php docroot deny list (docs/, screenshots, dotfiles, composer files no longer publicly served), verify-shim.sh extended

Integrated Verification (this PR)

All 12 spec acceptance criteria verified with evidence on the integrated branch (docs/verification-report.md):

  • Tests/build: 111/111 vitest green; lint/typecheck/build clean (re-run on the integrated branch)
  • Criterion 7 (redirect map): every 301 curl-verified against the PHP shim — 8 tool roots, sub-routes, trailing slashes, case variants; 410 for /csv2json/upload and /instrument; legacy permalinks stay 200; 404 elsewhere
  • Criterion 10 (crawlability): built index.html ships title, H1, og tags, canonical, SoftwareApplication + FAQPage JSON-LD
  • Criterion 8 (telemetry): grep over dist/src/shim/sitemap/robots/.htaccess — zero hits
  • Criterion 6 (permalinks): legacy permalink hydration E2E against a real S3 object — HTTP 200, read-only, correct direction+options, URL never rewritten
  • Criterion 12 (states): scripted Playwright walkthrough, 18/18 checks, screenshots in verification-screenshots/

Two browser-only bugs found during verification and fixed on this PR (both invisible to jsdom): (1) the seam's setPointerCapture swallowed ⇄ switch clicks on real mouse input; (2) csvjson-json2csv@1.0.3's implicit global keyValues crashed JSON→CSV in the strict-mode ESM bundle — fixed via a one-token postinstall patch (app/scripts/patch-json2csv.mjs), no conversion logic changed.

Cutover (after this PR merges)

The deploy path is documented in docs/deploy.md (PR #164): the existing Heroku app picks the release branch up via Procfile (web: heroku-php-apache2) + composer buildpack detection — no infra changes; Cloudflare DNS/SSL untouched. Cutover checklist, immutable-release rollback, and post-cutover SEO monitoring (week-2/week-4 Search Console checkpoints) are in the runbook.

Pre-launch items for David

  • Search Console baseline (blocking): export the current query/position baseline before the cutover lands — it is the only before/after evidence for the migration, and the docs/deploy.md week-2/week-4 checkpoints measure against it.
  • Review & merge this PR: on merge, the next deploy to the Heroku app ships the new app (301 map + SPA take over).

🔗 Obvious Project · 🧵 Obvious Thread

ObviousApp and others added 13 commits August 31, 2026 04:31
…ersion service (#156)

* chore: scaffold vite react spa

Vite + React 18 + TypeScript (strict) + Tailwind v4 + shadcn/ui foundation (components.json, cn util, theme variables) with vitest and eslint flat config. Sits in app/ alongside the PHP app until cutover.

* chore: add ci workflow

GitHub Actions from zero: lint + typecheck + vitest + production build on every PR (and pushes to the release branch). Node 20, npm ci with lockfile cache.

* feat: conversion service with typed options

Typed csvToJson/jsonToJsonCsv wrappers over csvjson-csv2json@5.0.6 and csvjson-json2csv@1.0.3 with .d.ts shims (packages are untyped CommonJS). Wrapper strips UTF-8 BOM, normalizes the packages' thrown strings into real Errors, and maps non-tabular JSON to the spec's 'I need an array of objects' message. Minify is exposed as toJsonString(), matching the legacy tool's serialization behavior (js/src/csv2json.js) — csv2json has no minify parse option. Conversion logic is imported, not reimplemented.

* test: round-trip + options + edge-case fixtures

23 vitest fixtures over csvToJson/jsonToJsonCsv covering spec criteria 1, 2, 11: round-trip fidelity (quoted fields, embedded commas/newlines/quotes, TSV, semicolons, unicode), per-option behavior, and edge cases (ragged rows, duplicate headers, empty cells stay empty strings, BOM, CRLF/mixed endings, non-tabular JSON error).

* fix: normalize csvToJson errors and scope CI token permissions

Addresses obvious/code-review findings: csvToJson now rethrows the packages' plain-string throws as real Errors (same contract as jsonToJsonCsv), the error-contract test asserts Error instances explicitly, ci.yml gains a least-privilege permissions block (contents: read), and the build step runs vite build alone since the typecheck step already runs tsc -b.

---------

Co-authored-by: Obvious <obvious@obvious.ai>
* feat: static-serving front controller

Replace the CodeIgniter bootstrap with a minimal PHP shim that serves
the built SPA from app/dist: / and built assets (with fingerprint-aware
cache headers), plus /img/favicon.ico. Everything else is rewritten to
the front controller by .htaccess. Sitemap and robots regenerate to
list / only.

* feat: 301 redirect map

Permanent 301s home for every retired tool URL — /csv2json, /json2csv,
/json_validator, /json_beautifier, /sql2json, /csvjson2json, /datajanitor
and all sub-routes, plus the /dataclean alias. Legacy
/<tool>/<32-hex-id> permalinks are not redirected: they pass through to
the SPA shell so the router can hydrate them read-only from S3.
/csv2json/instrument and /<tool>/upload return 410 Gone — uploads are
client-side FileReader now and the telemetry write is deleted.

CI gains a shim job: php -l plus curl checks covering every row of the
redirect table, permalink passthrough, 410s, 404s, and the root serving
the SPA shell (spec criterion 7).

* chore: delete legacy controllers, views, and telemetry

Remove the entire legacy stack the static shim replaces: all tool
controllers and views (including /build, the banner and CTA chrome,
the ad views, and Typekit), the CodeIgniter framework, the vendored
jQuery/Underscore/Backbone/Backgrid/Bootstrap/CodeMirror-5 stacks and
legacy stylesheets, the Csv2json::instrument() MySQL write,
MY_Controller::save(), and the S3 write library wiring. The S3 bucket
and its legacy objects are untouched — the browser reads them directly.

CI also gains a grep gate asserting zero hits for promotional or
telemetry terms across the shipped frontend and PHP shim (spec
criterion 8), and stale ignore rules for deleted folders are dropped.

* fix: address review — delete unreferenced promo images, test dist-asset serving

Two review findings from the automated review:

- The remnant gate never scanned image assets, and img/ still shipped
  the Flatfile/Obvious ad artwork (logomarks, sponsor logo, banner
  background, ad PNGs) orphaned by the view teardown. All unreferenced
  images are deleted — favicon.ico is the only survivor, the one file
  the shim serves — and the CI gate now also fails on surviving img/
  filenames matching flatfile|obvious|_ad|sponsor.

- serve_dist_file had zero test coverage despite carrying the
  traversal refusal and cache-control logic. verify-shim.sh now
  fixtures a hashed asset and asserts 200 + immutable Cache-Control on
  it, 404 on a ..%2f traversal attempt, and 404 on a missing dist path.

---------

Co-authored-by: Obvious <obvious@obvious.ai>
…158)

* feat: converter shell with split pane and seam switch

Full-viewport SplitPane with a flush 1px seam (no gutter), drag resize
clamped 20-80%, double-click reset to 50/50, and vertical stacking below
768px where the seam becomes a horizontal drag strip. Floating circular
DividerSwitch centered on the seam flips CSV<->JSON and swaps pane roles:
the last valid output becomes the new input; an error output leaves the
input untouched. Conversion stays in src/lib — components are
presentational and prop-driven.

Includes the jsdom testing setup and component tests for seam clamping,
reset, flip rules, and output rendering.

* feat: topbar with theme toggle

csvjson wordmark, empty chat-promo slot reserved for the promo task,
and a dark/light toggle persisted to localStorage behind try/catch with
OS-preference fallback.

* feat: options bar

Direction-conditional bottom bar: CSV->JSON shows separator (auto/
forced), parse numbers, parse JSON, transpose, hash; JSON->CSV shows
separator and flatten; minify appears when applicable. Prerendered
info-icon hints keep the SEO initial-DOM rule.

* fix: sync npm lockfile with testing dependencies

package.json gained the jsdom/testing-library devDependencies but the
lockfile was regenerated with a different package manager, leaving
@testing-library/user-event absent from package-lock.json — npm ci
failed on CI. Regenerated with npm install; verified with a clean
rm -rf node_modules && npm ci, then lint/typecheck/tests/build.

* fix: address review findings — flip race, seam input, theme flash

High: handleFlip used the debounced memoized result, which lags input by
the debounce window (1s for >2MB pastes) — flipping inside that window
could wipe a freshly typed input. The handler now converts the current
input synchronously; regression test flips without waiting for the
debounce.

Medium:
- OutputView memoizes tokens, wraps in React.memo, and skips highlighting
  above 256KB (multi-MB outputs render plain but stay usable).
- SplitPane seam drag now uses Pointer Events (touch devices can resize;
  touch-action: none) and the separator is keyboard-operable — arrows
  ±1% (shift ±5), Home/End to the clamps, Enter/space resets, tabIndex
  + aria-label.
- index.html applies the saved/OS theme before first paint — no
  dark-mode white flash. Theme persists only on explicit toggle, not
  when an OS-derived theme is applied on mount.

Also: hint buttons get aria-expanded + aria-describedby linkage, and the
large-file notice copy now matches the actual mechanism.

Tests updated to pointer events; added flip-within-debounce and
keyboard-resize regression tests. 35/35 green.

* fix: end seam drag on pointercancel (palm rejection, gesture takeover)

---------

Co-authored-by: Obvious <obvious@obvious.ai>
* feat: chat promo with provider constant and deep links

"Chat with this data in…" in the top bar — the single surviving
promotion (spec art_K61RhTbt, "Chat with this data in…" section).

- CHAT_PROMO_PROVIDERS: one configurable constant (Obvious, ChatGPT,
  Claude — Codex omitted, no documented web prefill mechanism; re-add
  is a one-line append).
- Static prompt templates: data inlined at ≤ 8000 chars; larger data
  goes to the clipboard with the paste-it-next prompt and a toast.
- Deep links: Claude claude.ai/new?q= (documented prefill), ChatGPT
  chatgpt.com/?q= (best-effort, may auto-submit — clipboard fallback
  covers it); prompt always copied as the prefill fallback.
- Chips disabled while the input is empty; enabled the moment any
  valid input exists.
- Clipboard has an execCommand fallback; every successful copy toasts.

Verified: 54/54 tests (template purity, ~8k branch at both edges,
disabled-when-empty, per-provider URL construction, fallback copy),
lint + tsc + vite build green.

* fix: open provider synchronously within the click gesture

Bot review (obvious-autobuild-staging[bot]) on #159:
- High: window.open after await loses the Safari user gesture — popups
  silently blocked. Open synchronously in the gesture; toast only after
  the copy promise settles; new toast when copy and open both fail.
- Suggestion: title tooltip moved to a wrapper span with
  pointer-events-none on the disabled button, so the empty-state hint
  is actually reachable.

55/55 tests, lint + tsc + build green.

---------

Co-authored-by: Obvious <obvious@obvious.ai>
… states (#160)

* feat: csv dense table with virtualization

Dense full-bleed CSV table renderer with sticky header, compact rows,
monospace numerics, and TanStack Virtual so 10k+ rows render without
frame freeze. Delimiter sniffing (comma/semicolon/tab) and row/col
utilities live in lib/csvTable.

* feat: json codemirror editor

CodeMirror 6 JSON editor with lint for the input side and a read-only
highlighted view for the output side. Replaces the scaffold's
OutputView/highlight implementation.

* feat: file upload and pane states

Dropzone empty state ('Drag & drop, paste, or browse' + Try an example)
only while the input is empty, whole-pane drag-over highlight with
inline non-text rejection, FileReader upload (file picker + drop, zero
network) with filename and row/col counts in pane headers, TSV
auto-detection alongside comma/semicolon, inline errors with position
that keep the last valid conversion visible, >2MB debounce stretch with
the bottom-bar hint, raw-text toggle on the CSV input side, and
copy/clear/download actions — CSV downloads carry a UTF-8 BOM.

* fix: address bot review — dropzone activation, paste-on-load, gate hardening

- Dropzone: click + Enter/Space open the browse dialog (was a dead
  target); holds focus while empty so a fresh-load Ctrl+V reaches the
  pane's paste handler instead of document.body.
- clipboard.ts: restore the try/catch around the execCommand fallback
  (a throw became an unhandled rejection).
- files.ts: accept known text extensions regardless of MIME — older
  Excel saves put application/vnd.ms-excel on .csv files.
- numericColumns: per-column sawCell flag; an all-empty column is no
  longer monospace just because another column has data.
- JsonCodeMirror: skip JSON.parse lint above 512KB — cost exceeds the
  diagnostics on multi-MB docs.

Tests: dropzone click/Enter/Space open the picker, paste lands on a
fresh load, and .csv with a non-text MIME is accepted.

* fix: container keydown no longer swallows nested-button activation

The dropzone's onKeyDown matched event.key only, so Enter/Space bubbling
from 'Try an example' hit the container guard, and its preventDefault()
cancelled the child button's native activation — keyboard users tabbing
to the button got the file picker instead of the sample. The handler now
returns early when event.target !== event.currentTarget; a test pins the
nested-button path.

---------

Co-authored-by: Obvious <obvious@obvious.ai>
)

* feat: legacy permalink route and hydration service

Route /:tool/:id in the SPA: legacy /<tool>/<32-hex-id> URLs passed
through by the PHP front controller now hydrate in the client. A
read-only fetchLegacyPermalink(id) GETs data/<id> straight from the
production S3 bucket (build-time VITE_S3_DATA_URL, cors mode) and a
single element-id → converter-state mapping table populates the
converter in the matching direction. The URL is never rewritten and
nothing is ever written back; CI gains a zero-tolerance no-write-API
check (spec: Old share links keep resolving — read-only).

* test: hydrate and failure states

Component tests for hydrate/not-found/failure-with-retry states and
the read-only contract (URL never rewritten, plain GET with cors
mode, no calls without a permalink id), unit tests for path parsing,
shape matching, and fetch failure classes, plus a manual E2E script
that hydrates a real 2019 bucket object through the unmocked code
paths.

* fix: gate hydration by tool, honest unsupported notice, runnable E2E

Address the automated review (1 blocker, 1 high, 1 medium):

- parsePermalinkPath now returns {tool, id} and hydration is gated to
  converter-shaped tools (csv2json, json2csv, csvjson2json). A sql2json
  save's `json: true` format radio can no longer masquerade as input:
  only a non-empty string counts as saved input, so a boolean radio
  can never hydrate an empty json2csv converter. Fixtures built from
  the real legacy view ids (sql2json, json_beautifier, json_validator)
  pin the routing.
- Objects that exist but no longer map (Data Janitor sessions, any
  unmappable shape) get a distinct "can't be shown in the new
  converter" notice — "doesn't exist (or was deleted)" is reserved
  for genuine 403/404.
- The env read goes through an optional chain with trailing-slash
  normalization, so permalink.ts loads in non-Vite runtimes and the
  committed E2E script runs as-is under tsx (inert globalThis shim
  deleted). Unit fixtures pin the sql2json/json_beautifier/
  json_validator view shapes.

---------

Co-authored-by: Obvious <obvious@obvious.ai>
…red data (#162)

* feat: prerender single route at build

The SPA route is rendered to static HTML at build time (scripts/
prerender.mjs boots Vite in middleware mode, renderToStrings the App,
and writes it into dist/index.html) — a build step, not SSR. The
deployed index.html now carries the title, an sr-only H1 naming both
directions, meta description, OG/Twitter tags, and canonical to /,
so crawlers index the page without executing JavaScript.

CI runs npm run build (which ends with the prerender) and then curls
the served build, grepping for each on-page target; any miss fails
the run (spec criterion 10).

* feat: faq accordion and option hints

Adds the collapsed-by-default "How it works & FAQ" accordion below
the converter (how it works, what the options do, TSV vs CSV, and
privacy — conversion never leaves the browser), plus info-icon hints
on the non-obvious options (parse numbers, parse JSON, transpose,
hash output, minify, flatten).

Every answer and hint is rendered into the DOM at load; clicks only
toggle visibility via the hidden attribute, so the prerendered shell
carries the full copy for crawlers and nothing is mounted on click
(spec: SEO disclosure pattern). FAQ copy lives in one module the
accordion renders from.

Tests pin the initial-DOM rule for both the FAQ and the option hints.

* chore: structured data

SoftwareApplication JSON-LD in the document head names the app and
both conversion directions; the FAQ block carries FAQPage markup
derived from the same items the accordion renders, so the
machine-readable questions can never drift from the visible copy.
Both are rendered unconditionally, so the prerendered HTML ships
them regardless of UI state. CI greps for both blocks in the built
index.html.

* fix: neutralize the pre-mount input-loss window; FAQ aria linkage

The prerendered shell is real markup a user can see before the bundle
loads, and React's fresh mount discards #root's contents — anything
typed during that window was silently wiped. An inline script now sets
inert on #root before first paint and main.tsx lifts it right after
createRoot(...).render(...), so the shell is never interactive while
its state is disposable (review: Medium reliability).

Also links the FAQ toggle to its region (useId + aria-controls),
matching the OptionHint convention (review suggestion).

* fix: guard window read so the prerender survives #161's permalink hydration

parsePermalinkPath(window.location.pathname) ran during render and blew
up the build-time prerender ("window is not defined") after the rebase
onto #161. Guard with the codebase's standard typeof-window check —
the shell renders the default converter state server-side; the hook
already accepts null and only fetches in a client effect.

---------

Co-authored-by: obvious-autobuild-staging[bot] <262703071+obvious-autobuild-staging[bot]@users.noreply.github.com>
…utton

The seam's onPointerDown called setPointerCapture() unconditionally —
including when the gesture started on the floating ⇄ switch, retargeting
the click to the seam so the direction switch never fired on real mouse
input (jsdom's synthetic click cannot reproduce pointer capture, so the
component tests missed it; the Playwright walkthrough caught it).

The seam now ignores pointerdowns that originate on the switch, and a
regression test pins the behavior.
…bundle

csvjson-json2csv@1.0.3 assigns `keyValues = []` without a declaration —
an implicit global that Node tolerates in sloppy mode but that throws
`ReferenceError: keyValues is not defined` in a strict-mode ESM bundle,
i.e. in the shipped browser build (the legacy site loaded the same code
as a classic script, so it never hit this; caught by the Playwright
walkthrough, invisible to jsdom).

Fix is a one-token mechanical patch applied by app/scripts/patch-json2csv.mjs
as a postinstall step so CI's fresh `npm ci` gets it too. No conversion
logic changes.
Scripted trusted-input walkthrough of every state in the spec's states
table (empty, drag-over, ready, raw editing, direction flip, invalid
input, dark mode, narrow viewport, permalink hydration, FAQ) against the
PHP shim serving the production build — 18/18 checks green. Both
browser-only bugs above were caught by this walkthrough and are
invisible to jsdom.

verification-screenshots/ holds the 12 evidence captures referenced by
docs/verification-report.md.
Spec default pending David's host-access call: the production build is
committed (app/dist), so the host needs only a git pull — Apache's PHP
front controller serves it unchanged. The built bundle also carries the
keyValues fix, so the deployed artifact is current.
@obvious-autobuild-staging
obvious-autobuild-staging Bot marked this pull request as ready for review August 31, 2026 10:45

@obvious-autobuild-staging obvious-autobuild-staging Bot left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Obvious Code Review

Verdict: COMMENT (2 Medium findings, 2 Suggestions — no Blocker/High)

Release promotion of the reviewed-clean stack #156#162 plus a 5-commit delta. Verified: release head app code is identical to #162's clean-pass head plus exactly the two fix commits; the committed app/dist bundle contains both post-stack fixes (keyValues patch and split-pane pointer guard); asset paths are absolute so permalink URLs load correctly; the teardown ↔ shim seam (favicon, sitemap, robots, 301 map) is coherent; no credentials ship with the SPA.

Medium

  • .github/workflows/ci.yml:40 — CI never verifies the committed app/dist (the deploy transport) matches a fresh build; a future source edit with a forgotten rebuild ships stale silently.
  • app/scripts/states-walkthrough.mjs:10 — output path hardcoded to a machine-specific absolute path; the script is non-portable off this sandbox.

Suggestions

  • app/src/components/SplitPane.tsx:131 — production gesture logic keyed on a test-only data-testid attribute; use a semantic attribute the component owns.
  • .github/workflows/ci.yml:6 — push CI triggers only on the release branch; direct pushes to master after merge run no CI.

View full review in Obvious

Comment thread .github/workflows/ci.yml
run: npm test

- name: Production build
run: npm run build # typecheck step already runs tsc -b; build ends with the prerender step

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Medium · reliability — No CI gate that the committed dist matches a fresh build. This PR commits app/dist as the default deploy transport (98d9012), but this step builds into the working tree and verify-seo.sh validates that fresh build — the committed app/dist is never compared. Failure mode: a future PR edits app/src, CI goes green (lint/tsc/tests/build all validate the fresh build, not the committed artifact), the author forgets to re-commit dist/, and the deploy ships the stale bundle silently. Add a step after the build: git diff --exit-code app/dist (or fail on git status --porcelain app/dist output). Vite's content-hashed filenames make drift mechanically detectable. Run it in this ci job before verify-shim.sh writes its stand-in app/dist/index.html.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 5a149c6. The ci job now hashes app/dist with sha256sum before the build regenerates it and diffs the two manifests after — drift in file set, name (Vite content hashes), or bytes fails the job with an ::error pointing at npm run build + commit. Snapshot runs before Production build, well before verify-shim.sh can write anything. The guard proved itself immediately: it flagged the committed dist as stale after the SplitPane selector change, so the rebuilt bundle is committed (756f4f0).

Comment thread app/scripts/states-walkthrough.mjs Outdated
import { mkdirSync } from "node:fs";

const BASE = process.env.BASE_URL ?? "http://127.0.0.1:8899";
const OUT = "/home/user/work/csvjson-app/verification-screenshots";

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Medium · reliability — Walkthrough output path is a hardcoded machine-specific absolute path. OUT = "/home/user/work/csvjson-app/verification-screenshots" is the repo sandbox's path, committed to the repo. On any other checkout location (CI, another machine) the script either crashes in mkdirSync (permission denied outside $HOME) or litters an unrelated absolute directory. BASE is correctly env-overridable; OUT is not. Derive it from the script location, matching the pattern already used in patch-json2csv.mjs: const OUT = fileURLToPath(new URL("../../verification-screenshots", import.meta.url));

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in d04d82b — OUT is now derived from the script's own location (path.resolve(dirname(fileURLToPath(import.meta.url)), "../..") + verification-screenshots), matching the pattern in patch-json2csv.mjs. Verified locally from the sandbox checkout.

The seam's pointer-capture guard keyed on the switch's test-only
data-testid attribute; production gesture logic should not depend on a
test-only attribute. The component now skips pointer capture for any
pointerdown originating on a button element — a semantic guarantee the
component owns, and the existing regression test still pins it.
The output path was a machine-specific absolute path; derive
verification-screenshots/ from the script's own location so the
walkthrough runs from any checkout.
Two guards for the committed-artifact deploy transport:
- hash app/dist before the build regenerates it and diff after, so a
  forgotten rebuild cannot ship stale frontend code;
- run CI on pushes to master too, so post-merge pushes stay covered.

Triggered locally: the guard correctly flagged the committed dist as
stale after the SplitPane selector change; fresh dist is committed in
the next commit.
Carries the SplitPane semantic-guard change; verified byte-identical to
a fresh build and walkthrough 18/18.
@obvious-autobuild-staging

Copy link
Copy Markdown
Contributor Author

Replying to this comment

All four findings addressed on release/rebuild-csvjson-spa and pushed (c02d448, d04d82b, 5a149c6, 756f4f0):

  1. dist-staleness (Medium) — new CI steps hash the committed app/dist before the build and diff against the fresh build after; a forgotten rebuild now fails CI with an explicit error. Validated locally: it correctly caught the stale dist from the SplitPane change before the push.
  2. Walkthrough output path — resolved repo-relative from the script location (verification-screenshots/ at repo root).
  3. SplitPane test-id keying — the seam guard now skips pointer capture for pointerdowns on any button element (semantic, component-owned); regression test still passes.
  4. master push trigger — added to the push trigger alongside the release branch.

The staleness guard caught the refreshed-bundle drift on this very push cycle, so the regenerated app/dist is committed too (756f4f0). Walkthrough re-run: 18/18. Promotion merge stays with David.

@obvious-autobuild-staging obvious-autobuild-staging Bot left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Obvious Code Review — Pass 2

Verdict: COMMENT (1 new Medium finding — no Blocker/High)

All four Pass 1 findings verified resolved:

  • dist freshness gate — snapshot-before-build / diff-after-build sha256 comparison, correctly ordered and isolated from verify-shim.sh's stand-in overwrite; ran on this push and passed (committed dist is byte-identical to a fresh build).
  • walkthrough output path — now resolved repo-root-relative from import.meta.url; correct.
  • seam gesture guardclosest("button") instead of the test-only testid; the seam subtree contains only the switch button, so behavior is unchanged.
  • push CI on master — branch added to the trigger.

Also verified: old hashed assets fully removed from the tree, dist/index.html references only the new hashes, and the new bundle carries the guard.

New finding

  • app/src/index.css:1 (with app/dist/assets/index-ChiiLHFC.css) — the rebuild grew the stylesheet 75% (18,460 → 32,314 bytes) with utility classes the app never uses; root cause is Tailwind auto source detection scanning the whole repo, so non-app text (CI workflow, script comments) now leaks into production CSS.

View full review in Obvious

Comment thread app/src/index.css Outdated
@@ -0,0 +1,62 @@
@import "tailwindcss";

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Medium · performance — Rebuilt CSS ships ~75% unused utility bloat; Tailwind source scan is unconstrained. The dist rebuild (756f4f0) changed the stylesheet from 18,460 → 32,314 bytes even though no CSS-affecting source changed (the only TSX delta was a 1-line guard swap). Spot-check of 18 added utility families: 17 have zero occurrences in app/src or the prerendered markup (.transform, .filter, .blur, .grayscale, .isolate, .not-sr-only, .table-caption, .table-row-group, .stacked-fractions, .oldstyle-nums, .touch-pinch-zoom, .inline-grid, .flow-root, .list-item, .rounded-tl, .divide-y, .backdrop-blur). Root cause: bare @import "tailwindcss" here means Tailwind v4 auto source detection scans the whole repository — CI workflow text, script comments, tsconfig (isolatedModules) — and emits utilities for candidate tokens found in non-app files. Editing .github/workflows/ci.yml or app/scripts/*.mjs changed the shipped stylesheet; any text edit anywhere in the repo can silently change production CSS, and the new dist-freshness gate will lock that bloat in as "correct". Fix: constrain the scan explicitly — @source "../src/**/*.{ts,tsx}"; @source "../index.html"; — which shrinks the stylesheet back and makes the freshness gate detect real drift instead of repo-text noise.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 8a435c4. The import is now @import "tailwindcss" source(none); with explicit @source "./**/*.{ts,tsx}" (relative to src, so it covers the app components and their tests) plus the entry HTML — stylesheet is back to exactly 18,460 bytes (same content hash as the pre-bloat copy), 111/111 tests green, walkthrough re-run 18/18 against the rebuilt bundle. Editing CI workflows or scripts can no longer change shipped CSS, so the freshness gate now detects real drift only.

Bare auto source detection scanned the whole repository, so text in CI
workflows, script comments, and tsconfig leaked unused utilities into
production CSS (18,460 -> 32,314 bytes on the last rebuild). Constrain
it explicitly with source(none) + @source for src and the entry HTML:
the stylesheet is back to 18,460 bytes and editing non-app files can no
longer change shipped CSS, so the dist-freshness gate detects real
drift only.
@obvious-autobuild-staging

Copy link
Copy Markdown
Contributor Author

Replying to this comment

New finding addressed in 8a435c4: the Tailwind import is now @import "tailwindcss" source(none); with explicit @source "./**/*.{ts,tsx}" and the entry HTML. The stylesheet is back to exactly 18,460 bytes (same content hash as the pre-bloat copy), 111/111 tests green, and the walkthrough re-run 18/18 against the rebuilt bundle. Shipped CSS is now invariant to non-app file edits, so the dist-freshness gate detects real drift only. All findings from both passes are resolved on release/rebuild-csvjson-spa; promotion merge remains David's call.

@obvious-autobuild-staging obvious-autobuild-staging Bot left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Obvious Code Review — Pass 3

No findings — clean pass.

The Pass 2 finding (unused CSS utility bloat from the unconstrained Tailwind source scan) is resolved and decisively verified:

  • 8a435c4 constrains the scan in app/src/index.css@import "tailwindcss" source(none) plus explicit @source for ./**/*.{ts,tsx} and ../index.html.
  • The rebuilt stylesheet (index-BO5hHe75.css) is byte-identical to the original 18,460-byte output (the original content-hash filename returned) — the constrained scan reproduces exactly the app's real usage, zero bloat.
  • JS blob unchanged (identical git blob hash; the filename rehash is a chunk-graph naming artifact, not a content change). dist/index.html references only the new hashed names; the stale asset is fully removed from the tree.
  • CI on that head — including the new dist-freshness gate — fully green. With the scan constrained, the gate now detects real drift rather than repo-text noise.

The follow-up push (4ae40b0) is a walkthrough screenshot refresh for the rebuilt bundle — PNG binaries only, no code.

All findings raised across this PR's three passes (2 Medium + 2 Suggestions, then 1 Medium) are resolved and verified. Zero open findings.


View full review in Obvious

Product decision (David): the "Chat with this data in…" promo is parked
for a future release. Surgically removes the PR #159 surface: ChatPromo
component + tests, chatPromo lib (providers, prompt templates, 8k
inline/clipboard handoff, deep links, toasts), the App composition, and
the walkthrough's chat-chip check (18 → 17). The TopBar keeps its
wordmark and theme toggle; its slot stays as a neutral, intentionally
empty extension point for the future re-add.

Grep-verified: no chat/ChatGPT/Claude references remain in app/src,
app/index.html, scripts, or the prerendered markup.
17/17 checks against the promo-free production build.
Criterion 13 marked cut per David's direction — a product decision, not
a verification failure. Counts updated: 91/91 tests (11 files), 17/17
walkthrough checks, 15329-char prerender.

@obvious-autobuild-staging obvious-autobuild-staging Bot left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Obvious Code Review — Pass 4

No findings — clean pass.

This push cuts the chat promo for v1 (documented product decision in docs/verification-report.md, criterion 13). The removal is complete and verified:

  • No dead code: ChatPromo.tsx, its test suite, chatPromo.ts, and its tests all deleted; App.tsx usage removed. Zero chat-promo references remain in the tree outside the intentional decision record.
  • TopBar generalized cleanly: chatPromo prop → generic slot prop with a comment documenting the intentionally-empty extension point for the future re-add.
  • Prerender rebuilt correctly: the promo block is gone from dist/index.html; the JSON-LD / SEO surface is untouched (the promo was never in structured data). Stale hashed assets fully removed.
  • CSS shrank 18,460 → 17,645 bytes as promo-exclusive utilities dropped — the Pass-3 @source constraint held; no scan bloat reintroduced.
  • Verification artifacts updated honestly: walkthrough 18/18 → 17/17 with all screenshots regenerated, tests 111/111 (13 files) → 91/91 (11 files), prerender size restated with pre-cut values for traceability.
  • CI fully green on this head — including the dist-freshness gate, which byte-verified the committed promo-free dist against a fresh build.

All findings raised across this PR's four passes (2 Medium + 2 Suggestions, then 1 Medium) are resolved and verified. Zero open findings.


View full review in Obvious

@obvious-autobuild-staging

Copy link
Copy Markdown
Contributor Author

Replying to this comment

Thank you — glad the constrained Tailwind scan checked out byte-identical. Note this thread pre-dates the chat-promo cut: since then the promo has been removed for v1 (a849543) and the stylesheet shrank further to 17,645 bytes as promo-exclusive utilities dropped. See the Pass 4 review for the verification of that change.

@obvious-autobuild-staging

Copy link
Copy Markdown
Contributor Author

Replying to this comment

Thank you — all four deliverables are on release/rebuild-csvjson-spa at 2031376 (a849543 removal, a3178fd dist rebuild, 22a457e refreshed screenshots, 2031376 docs decision record). 91/91 tests, lint/typecheck clean, 17/17 walkthrough checks against the promo-free build, committed dist verified byte-identical to a fresh build by the CI guard. CI is green on both runs. Promotion merge remains David's call.

…oots the rebuilt SPA (#164)

* feat(deploy): Procfile + composer stub for Heroku cutover

Root Procfile boots heroku-php-apache2 at the default docroot (app root);
the committed .htaccess rewrites non-file requests to index.php, which
serves app/dist and applies the legacy 301 map. composer.json pins the
PHP runtime to ~8.3.0 and is required for PHP buildpack detection;
matching platform-only composer.lock generated with composer update
--no-install --ignore-platform-reqs.

docs/deploy.md is the transition runbook (buildpack detection, cutover
checklist after PR #155 merges, immutable-release rollback, DNS/SSL
untouched at Cloudflare). Handoff section in docs/verification-report.md
now points at it. Shim verified: php -l clean on 8.4, full
verify-shim.sh redirect table green locally.

* fix(security): deny docroot internals in the default Heroku docroot

With the default docroot = repo root, Apache serves every committed real
file unless denied. Extend the .htaccess deny list to repo docs,
verification screenshots, dotfiles, build/dep metadata, and editor
project files (403), mirror the same list in the front controller so the
dev server 404s them identically, pin both behaviors in verify-shim.sh,
and state in the runbook that the deny list is the only gate on docroot
files.

* docs(deploy): SEO monitoring checkpoints

Post-cutover SEO monitoring section: Search Console baseline export is a
blocking pre-cutover artifact (step 0, the only before/after evidence);
week-2/week-4 checkpoints against the baseline with the specific check
that legacy tool URLs show as 'Page is redirected' (not errors) in
coverage; sustained >20% drop on core-tool queries at week 4 triggers a
revisit, with dedicated landing pages as the recovery play — undoing the
redirects is explicitly not the mitigation; satellite-tool query traffic
(sql to json, data janitor) is expected to drop per the locked product
decision.

---------

Co-authored-by: Obvious <obvious@obvious.ai>
@dboskovic
dboskovic merged commit 78c3834 into master Sep 1, 2026
4 checks passed

@obvious-autobuild-staging obvious-autobuild-staging Bot left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@/tmp/pass5-body.md

@obvious-autobuild-staging obvious-autobuild-staging Bot left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Obvious Code Review — Pass 5 (final)

Verdict: COMMENT — no findings; clean pass. PR merged as 78c3834; review closed out.

Head: 2ca3a7e — the delta since Pass 4 is exactly the squash-merge of PR #164 (Procfile + composer deploy config, docroot deny list, deploy docs), whose content is byte-identical to #164's reviewed-clean final head (3 passes there, including the resolved-and-verified docroot exposure fix). CI fully green on this head (both ci and shim jobs).

Final ledger across 5 passes: 2 Medium + 2 Suggestions (Pass 1, all resolved and verified in Pass 2) → 1 Medium performance (Pass 2, resolved and byte-verified in Pass 3) → clean (Pass 3) → clean (Pass 4, chat-promo cut) → clean (Pass 5). Zero open findings at merge.

Post-merge recommendations issued in the workspace.

View full review in Obvious

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants