Imported from itkujo/sift (
AGENTS.md). Install upstream withnpx skills add itkujo/sift. Copyright stays with the author.
AGENTS.md — Sift
Private, browser-based CSV viewer. Single-page tool. Zero backend. Everything runs client-side — files never leave the user's machine.
Quick Orientation
- What it is: Upload a CSV, explore it three ways: Data Table, Column Detail, Raw source. Nothing is uploaded anywhere.
- Stack: Vite 5 + React 18 + Tailwind CSS v4 (via
@tailwindcss/vite) + Motion (motion/react) + PapaParse + Lucide React. - Repo:
git@github.com:itkujo/sift.git, branchmain. - Local path:
/Users/phoenix/code/test-website(flat layout — all source at the repo root, nosift/subdirectory). - Production URL:
https://sift.relentnet.dev(deployed via Coolify).
Commands
npm install # First time only
npm run dev # Vite dev server, hot reload
npm run build # Produces dist/ — rewritten index.html + hashed assets
npm run preview # Serve the built dist/ locally to sanity check
There are no tests, no linter, and no typechecker configured. The only
verification loop is npm run build followed by npm run preview.
File Map
/
├── index.html # Vite source template (fonts, meta, no-flash theme script)
├── package.json # Scripts + deps
├── vite.config.js # @vitejs/plugin-react + @tailwindcss/vite
├── README.md # User-facing description
├── .gitignore # node_modules, dist, logs, editor files, .env*
└── src/
├── main.jsx # React 18 root + StrictMode
├── App.jsx # Orchestrator: theme state, view switching, AnimatePresence
├── index.css # Tailwind v4 entry, tokens, @theme inline bridge
├── components/
│ ├── Header.jsx # Wordmark + ThemeToggle
│ ├── ThemeToggle.jsx # 3-way Light/System/Dark segmented control
│ ├── Footer.jsx # Minimal hairline footer
│ ├── EmptyState.jsx # Hero landing with staggered entrance
│ ├── Dropzone.jsx # Drag/drop + click-to-browse
│ ├── FileBar.jsx # Post-upload metadata bar + Clear
│ ├── ViewSwitcher.jsx # Tablist: Table / Columns / Raw
│ ├── DataTable.jsx # Virtualized grid (NOT an HTML table)
│ ├── ColumnDetail.jsx # Per-column stats + SVG histograms
│ └── RawView.jsx # Monospace source with line numbers + copy
├── hooks/
│ └── useCSV.js # FileReader + PapaParse, returns {status, data, error, parseFile, reset}
└── lib/
└── stats.js # Type inference + column statistics (pure functions)
Design System
Name & voice: Sift — tagline See your data clearly. Clinical
minimalism. No emojis anywhere. No placeholder/lorem content.
Accent: #dc2626 light / #f87171 dark. Used for the wordmark dot,
selection, focus ring, active drag border, and chart highlights.
Typography: Fraunces (display serif, variable opsz), Geist (sans),
Geist Mono (mono, tabular-nums). Loaded from Google Fonts with preconnect.
Tokens: Defined as CSS custom properties on :root (light values) with
dark overrides in both @media (prefers-color-scheme: dark) :root:not([data-theme="light"]) and :root[data-theme="dark"] for manual
override precedence. Bridged into Tailwind utilities via @theme inline in
src/index.css — gives you bg-surface, bg-surface-raised,
bg-surface-sunken, text-text, text-text-secondary, text-text-muted,
border-border, border-border-strong, bg-accent, text-accent,
bg-accent-subtle, border-accent-border, font-display, font-sans,
font-mono.
Theme toggle: Three-way (Light / System / Dark). Persisted to
localStorage.sift-theme. A no-flash inline script in index.html sets
data-theme on <html> before React mounts. App.jsx owns the state and
applies changes via applyTheme().
Motion: ease cubic-bezier(0.25, 0.1, 0.25, 1), durations fast 150ms /
base 250ms / slow 400ms. Full prefers-reduced-motion kill switch at the end
of index.css.
Radius: rounded-md for cards and buttons, no rounding on data surfaces
(table cells, raw view gutters).
Data Flow
Dropzonereceives a file, callsuseCSV.parseFile(file).useCSV:- Validates extension (
.csv,.tsv,.txt). - Reads the file as text via
FileReaderso the raw text is preserved forRawView. - Hands the text to
Papa.parsewithheader: true,skipEmptyLines: 'greedy',dynamicTyping: false. - Runs
inferAllTypes(headers, rows)fromlib/stats.js.
- Validates extension (
useCSVreturns{ status, data, error, parseFile, reset }where status isidle | parsing | ready | erroranddatais{ name, size, raw, headers, rows, types, meta }.App.jsxcrossfades betweenEmptyStateand the ready layout viaAnimatePresence mode="wait", then crossfades between the three views (keyed byviewstate). Keyboard shortcuts123switch views, skipped when typing in form fields.
Keyboard Shortcuts
| Key | Action |
|---|---|
1 |
Data Table view |
2 |
Column Detail view |
3 |
Raw view |
Shortcuts only fire when a file is loaded and focus is not inside an
INPUT, TEXTAREA, or SELECT, and no modifier keys are held.
Non-Obvious Implementation Notes
Things I burned time on — read these before touching the matching file.
DataTableis not an HTML table. It's a CSS Grid with absolutely positioned rows inside awidth: max-contentcontainer. This is required for virtualization plus horizontal scroll. Rows use inline{ position: 'absolute', left: 0, width: '100%' }— do NOT change toleft-0 right-0, that pins rows to the scroll viewport and they stay put when you scroll the header horizontally.- Constants:
ROW_HEIGHT = 36,OVERSCAN = 10,ROW_NUM_WIDTH = 56,COL_MIN_WIDTH = 160. - Sort direction cycles
asc → desc → noneon repeated header clicks.
- Constants:
index.cssdoes not have a* { border-color: ... }reset. It was removed during QA because it overrides Tailwind utility border colors likeborder-accent. Don't add it back.RawViewhas a 2000-line cap (MAX_LINES = 2000) with a sticky truncation notice at the bottom. The Copy button always copies the full source, not the truncated view.stats.jstype inference samples up to 200 rows per column with a 70% confidence threshold for majority vote. Types areNUMBER,DATE,BOOLEAN,TEXT,EMPTY.NUMERIC_REhandles comma-grouped numbers; dates match ISO or slash formats; booleans accepttrue/false/yes/no/0/1/t/f/y/n.useCSVreads the file as text first (not usingPapa.parse(file)directly) so the raw text survives forRawView. Don't "simplify" this into passing the File straight to Papa.- Motion stagger in
EmptyState: title 0ms, tagline 150ms, dropzone 250ms, privacy footnote 550ms. These delays are intentional — they pace the first read.
Deployment (Coolify)
Deployed to https://sift.relentnet.dev via Coolify. This is a pure static
site — Coolify must build and serve dist/, not run the source tree.
Coolify settings that must be set:
- Build Pack: Static (or Nixpacks with a static publish directory)
- Install Command:
npm ci - Build Command:
npm run build - Publish Directory:
dist - SPA fallback: any unknown route serves
dist/index.html
Symptom of a misconfigured deploy: browser console shows
Loading module from ".../src/main.jsx" was blocked because of a disallowed MIME type ("application/octet-stream"). That means Coolify is serving the
source tree instead of running vite build and serving dist/. Fix is
always the three settings above. (This happened on the first deploy and was
resolved in the Coolify UI — no repo changes needed.)
There is no Dockerfile, no nixpacks.toml, no nginx.conf in the repo
on purpose. If you ever need portable deployment config, add a two-stage
Dockerfile (node build → nginx:alpine serve) with SPA fallback in the nginx
conf.
Content Strings (authoritative)
These are the final strings. Do not change them without a reason.
- Hero title:
Siftwith a red.accent - Tagline:
See your data clearly. - Dropzone primary:
Drop a CSV file here - Dropzone secondary:
or click to browse - Dropzone dragging:
Release to open - Dropzone parsing:
Parsing your file… - Privacy lines:
Runs entirely in your browser/Your file never leaves this page. - Footer:
Sift · 2026 · No tracking, no uploads, no accounts - Empty filter result:
No rows match your search. - Empty data:
This file has headers but no rows. - Parse error:
This file does not look like a CSV. Try a .csv file.
Rules For Future Work
- No backend. Ever. The privacy guarantee ("your file never leaves this page") is the product. Never add a fetch/upload path.
- No placeholder content. No lorem ipsum, no example.com, no
TODOstrings shipped to users. If a new feature needs copy, write the real copy. - No emojis. Not in the UI, not in commits, not in code comments.
- Every component needs loading, error, and empty states. The existing components all do — match that bar.
- Semantic HTML first, ARIA only as supplement.
<button>for actions,role="tablist"only becauseViewSwitcheris a true tablist, etc. - Match existing patterns. Tailwind utilities with the
@themebridge, Motion viamotion/react, Lucide for icons. Don't introduce a second icon library or a second animation library. prefers-reduced-motionmust keep working. The kill switch at the bottom ofindex.cssoverrides every transition/animation. If you add a new animation, verify it respects the override.- Before claiming done:
npm run buildthennpm run preview. The build must pass and the preview must render correctly in both light and dark mode.
Current State
- Single commit on
main:11b4043("Initial commit: Sift — private browser-based CSV viewer"). - Deployed and working at
https://sift.relentnet.dev. - No open issues, no branches, no TODOs in the code.