feat: rebrand MONO->0pt (hero, header, layout) + add project docs/contract

Hero is now a data-native 0pt artifact (no photos, keeps grain/reveal/float); header is the 0pt wordmark with Swedish nav; layout is sv + loads JetBrains Mono + 0pt metadata; globals wires the loaded fonts. Adds CLAUDE.md + docs/01-09 as the build contract. next build green (5 routes).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-24 17:38:07 +02:00
co-authored by Claude Opus 4.8
parent 24035dec8b
commit 22e09d984d
14 changed files with 1052 additions and 322 deletions
+57
View File
@@ -0,0 +1,57 @@
# CLAUDE.md — 0pt (SIAX Technology AB)
> Read this first. It is the contract for how this project is built. Then read `docs/` in order.
## What 0pt is
**0pt — your data.** A Nordic/European data-services platform (`0pt.app`) where customers:
1. **Compose** a data job (markets, entity type, fields, enrichment, frequency, output) — or browse ready-made datasets.
2. Pay a **50 % deposit** to start; the remaining 50 % is held in **escrow** and released only on approval.
3. **Watch production live** (sourcing → extraction → structuring → enrichment → QA).
4. **Validate** the result with full freedom (table / map / schema / quality views) and deliver to any format/destination.
Owned by SIAX Technology AB. Feeds internal products too (Protosell/FiveCRM, VehIQ).
## Non-negotiable constraints (do not violate)
1. **Legal sourcing only.** The data engine uses, in strict priority: official APIs/bulk → authorized scraping of clearly public pages (respect `robots.txt` + rate limits) → licensed partner data → otherwise the field is delivered as **"not available / licensed only"**. We never invent values.
- **Do NOT build, keep, or extend**: WAF/bot-detection bypass (incl. FlareSolverr, "stealth" browsers, undetected-* used to defeat detection), fingerprint/proxy rotation to evade bans, scraping behind login walls, or automated fake-account creation (Apollo etc.).
- If existing code (e.g. `src/workers/browser_worker.py`) contains a FlareSolverr/bypass path, **remove or disable it** and replace with compliant fetching. This is a product-positioning + compliance requirement, not a preference. "Honest accuracy" is the whole moat.
2. **Postgres is the backbone.** One Postgres is the source of truth. Catalog, portal, NocoDB views, Metabase dashboards, workers, exports all read/write the *same* DB. A tool may only enter the stack if it (a) reads that Postgres and (b) sits behind the same auth.
3. **Provenance per field.** Every fact carries source, freshness, confidence (`bridge_provenance`). This drives the UI signature too.
4. **One tool per job.** Do not add overlapping tools. The approved stack is in `docs/02-architecture.md` and `docs/07-dependencies.md`. Anything not listed needs a documented reason.
5. **GDPR-native.** DPA, lawful basis, audit log, deletion-after-job. Customer DB access is *pull by the customer* via a read-only, time-boxed role they create — never us asking for admin, never raw creds stored in plaintext.
6. **Design is fixed.** Mono/editorial system (white `#fff`, text `#0a0a0a`, hairline `#e5e5e5`, radius 0, Inter + JetBrains Mono, single red `#e40014`). Exact tokens + motion in `docs/05-design-system.md`. No photography — imagery is rendered data (`DataArtifact`).
## Build on existing code
A working repo already exists (~145 files): FastAPI orchestrator (`src/orchestrator/main.py`, 25+ endpoints), workers (`src/workers/`: adaptive_router, browser_worker, ocr_worker, db), and country data for 20 markets. **Reconcile with it, don't blindly replace.** Where this documentation defines a target contract, migrate the existing code toward it. Flag conflicts instead of guessing.
## How to work
- Work in **dependency order** per `docs/09-build-plan.md`. Finish a phase to its acceptance criteria before the next.
- Small, reviewable commits. Conventional Commits (`feat:`, `fix:`, `docs:`, `chore:`).
- Frontend = Next.js App Router + TS + Tailwind + shadcn/ui on Vercel. Backend = FastAPI + workers, **not** on Vercel (long jobs).
- Tech docs + code identifiers in **English**; all visible **UI copy in Swedish**.
- Never hardcode secrets — use env (`docs/08-infrastructure.md`).
- When unsure about a source's current access/licence terms, **verify before relying on it** and record it in the sourcing map; do not assume.
## Documentation index
| Doc | Purpose |
|---|---|
| `docs/01-product.md` | Vision, the three flows, order lifecycle, business model |
| `docs/02-architecture.md` | System architecture, stack decisions (in/out), hosting |
| `docs/03-data-model.md` | Postgres schema, provenance model, migrations |
| `docs/04-api.md` | FastAPI contract (canonical endpoints) |
| `docs/05-design-system.md` | Mono tokens, motion, visual system, signature |
| `docs/06-data-engine.md` | Workers, legal sourcing chain, pipeline, QA, fusion, sources.yaml |
| `docs/07-dependencies.md` | Repos/libraries to import (+ explicit exclusions) |
| `docs/08-infrastructure.md` | Hosting, docker-compose, env, security, CI/CD |
| `docs/09-build-plan.md` | Phased milestones with acceptance criteria |
| `docs/design/0pt-v0-build-brief.md` | Per-component v0 prompts (design reference) |
| `docs/design/0pt-design-coverage-and-visuals.md` | Full page/component inventory + visual system |
## Definition of done (every screen, before real data)
Mono chrome · correct scroll-reveal motion · representative placeholder content · **loading + empty + error** states · mobile layout · provenance signature where facts appear · red used ≤ once · visible focus ring · `prefers-reduced-motion` respected.
+3 -3
View File
@@ -75,9 +75,9 @@
}
@theme inline {
--font-sans: 'Inter', 'Inter Fallback', system-ui, sans-serif;
--font-mono: 'JetBrains Mono', monospace;
--font-display: 'PP Editorial New', 'Times New Roman', serif;
--font-sans: var(--font-inter), 'Inter', system-ui, sans-serif;
--font-mono: var(--font-jetbrains-mono), 'JetBrains Mono', ui-monospace, monospace;
--font-display: var(--font-inter), 'Inter', system-ui, sans-serif;
--color-background: var(--background);
--color-foreground: var(--foreground);
--color-card: var(--card);
+16 -35
View File
@@ -1,45 +1,26 @@
import React from "react"
import type { Metadata } from 'next'
import { Inter } from 'next/font/google'
import { Analytics } from '@vercel/analytics/next'
import './globals.css'
import React from "react";
import type { Metadata } from "next";
import { Inter, JetBrains_Mono } from "next/font/google";
import { Analytics } from "@vercel/analytics/next";
import "./globals.css";
const inter = Inter({ subsets: ["latin"], variable: '--font-inter' });
const inter = Inter({ subsets: ["latin"], variable: "--font-inter" });
const jetbrainsMono = JetBrains_Mono({ subsets: ["latin"], variable: "--font-jetbrains-mono" });
export const metadata: Metadata = {
title: 'EVASION',
description: 'High-performance outdoor gear engineered for the modern explorer. Lightweight, durable, adventure-ready.',
generator: 'v0.app',
icons: {
icon: [
{
url: '/icon-light-32x32.png',
media: '(prefers-color-scheme: light)',
},
{
url: '/icon-dark-32x32.png',
media: '(prefers-color-scheme: dark)',
},
{
url: '/icon.svg',
type: 'image/svg+xml',
},
],
apple: '/apple-icon.png',
},
}
title: "0pt — your data",
description:
"Datatjänster på beställning för Norden & Europa. Härkomst per fält, GDPR-native. Sätt ihop ditt datajobb, betala 50 % för att starta, följ produktionen live och validera resultatet med full insyn.",
generator: "0pt.app",
};
export default function RootLayout({
children,
}: Readonly<{
children: React.ReactNode
}>) {
export default function RootLayout({ children }: Readonly<{ children: React.ReactNode }>) {
return (
<html lang="en">
<body className={`${inter.variable} font-sans antialiased`}>
<html lang="sv">
<body className={`${inter.variable} ${jetbrainsMono.variable} font-sans antialiased`}>
{children}
<Analytics />
</body>
</html>
)
);
}
+63 -95
View File
@@ -1,126 +1,94 @@
"use client";
import { useState, useEffect } from "react";
import Link from "next/link";
import { Menu, X } from "lucide-react";
import { useEffect, useState } from "react";
const NAV = [
{ label: "Katalog", href: "/#katalog" },
{ label: "Så funkar det", href: "/#motorn" },
{ label: "Källor", href: "/#tackning" },
{ label: "Priser", href: "/#priser" },
];
function Wordmark() {
return (
<Link href="/" className="flex items-center gap-2">
<span className="text-xl font-semibold tracking-[-0.02em]">0pt</span>
<span className="inline-block h-1 w-1" style={{ backgroundColor: "var(--accent-red)" }} />
<span className="font-mono text-[10px] uppercase tracking-[0.18em] text-muted-foreground">your data</span>
</Link>
);
}
export function Header() {
const [isMenuOpen, setIsMenuOpen] = useState(false);
const [isScrolled, setIsScrolled] = useState(false);
const [scrolled, setScrolled] = useState(false);
const [open, setOpen] = useState(false);
useEffect(() => {
const handleScroll = () => {
setIsScrolled(window.scrollY > 50);
};
window.addEventListener("scroll", handleScroll, { passive: true });
return () => window.removeEventListener("scroll", handleScroll);
const onScroll = () => setScrolled(window.scrollY > 40);
window.addEventListener("scroll", onScroll, { passive: true });
onScroll();
return () => window.removeEventListener("scroll", onScroll);
}, []);
return (
<header
className={`fixed top-4 left-1/2 -translate-x-1/2 z-50 w-[90%] max-w-3xl transition-all duration-300 ${isScrolled ? "bg-background/80 backdrop-blur-md rounded-full" : "bg-transparent"}`}
<header
className="sticky top-0 z-50 border-b border-border transition-colors"
style={{
boxShadow: isScrolled ? "rgba(14, 63, 126, 0.04) 0px 0px 0px 1px, rgba(42, 51, 69, 0.04) 0px 1px 1px -0.5px, rgba(42, 51, 70, 0.04) 0px 3px 3px -1.5px, rgba(42, 51, 70, 0.04) 0px 6px 6px -3px, rgba(14, 63, 126, 0.04) 0px 12px 12px -6px, rgba(14, 63, 126, 0.04) 0px 24px 24px -12px" : "none"
backgroundColor: scrolled ? "rgba(255,255,255,0.82)" : "var(--background)",
backdropFilter: scrolled ? "blur(8px)" : "none",
}}
>
<div className="flex items-center justify-between transition-all duration-300 px-2 pl-5 py-2">
{/* Logo */}
<Link href="#hero" className="text-lg font-medium tracking-tight transition-colors duration-300 text-foreground">
MONO
</Link>
<div className="mx-auto flex h-16 max-w-[80rem] items-center justify-between px-6">
<Wordmark />
{/* Desktop Navigation */}
<nav className="hidden items-center gap-10 md:flex">
<Link
href="#technology"
className="text-sm transition-colors text-muted-foreground hover:text-foreground"
>
Design
</Link>
<Link
href="#gallery"
className="text-sm transition-colors text-muted-foreground hover:text-foreground"
>
Gallery
</Link>
<Link
href="#accessories"
className="text-sm transition-colors text-muted-foreground hover:text-foreground"
>
Models
</Link>
<Link
href="#about"
className="text-sm transition-colors text-muted-foreground hover:text-foreground"
>
About
</Link>
<nav className="hidden items-center gap-7 md:flex">
{NAV.map((n) => (
<Link
key={n.label}
href={n.href}
className="font-mono text-[12px] uppercase tracking-[0.1em] text-muted-foreground transition-colors hover:text-foreground"
>
{n.label}
</Link>
))}
</nav>
{/* CTA */}
<div className="hidden items-center gap-6 md:flex">
<div className="hidden items-center gap-5 md:flex">
<Link href="/bestall" className="font-mono text-[12px] uppercase tracking-[0.1em] text-muted-foreground transition-colors hover:text-foreground">
Logga in
</Link>
<Link
href="#reserve"
className="px-4 py-2 text-sm font-medium transition-all rounded-full bg-foreground text-background hover:opacity-80"
href="/bestall"
className="bg-foreground px-5 py-2.5 font-mono text-[12px] uppercase tracking-[0.1em] text-background transition-colors hover:bg-[var(--accent-red)]"
>
Contact
Starta beställning
</Link>
</div>
{/* Mobile Menu Button */}
<button
type="button"
onClick={() => setIsMenuOpen(!isMenuOpen)}
className="transition-colors md:hidden text-foreground"
aria-label="Toggle menu"
aria-label="Meny"
onClick={() => setOpen((v) => !v)}
className="flex h-9 w-9 items-center justify-center border border-border md:hidden"
>
{isMenuOpen ? <X size={24} /> : <Menu size={24} />}
<span className="font-mono text-sm">{open ? "✕" : "≡"}</span>
</button>
</div>
{/* Mobile Menu */}
{isMenuOpen && (
<div className="border-t border-border bg-background px-6 py-8 md:hidden rounded-b-2xl">
<nav className="flex flex-col gap-6">
<Link
href="#technology"
className="text-lg text-foreground"
onClick={() => setIsMenuOpen(false)}
>
Design
{open ? (
<div className="border-t border-border bg-background md:hidden">
<div className="flex flex-col gap-1 px-6 py-4">
{NAV.map((n) => (
<Link key={n.label} href={n.href} onClick={() => setOpen(false)} className="py-2 font-mono text-[12px] uppercase tracking-[0.1em] text-muted-foreground">
{n.label}
</Link>
))}
<Link href="/bestall" onClick={() => setOpen(false)} className="mt-2 bg-foreground px-5 py-3 text-center font-mono text-[12px] uppercase tracking-[0.1em] text-background">
Starta beställning
</Link>
<Link
href="#gallery"
className="text-lg text-foreground"
onClick={() => setIsMenuOpen(false)}
>
Gallery
</Link>
<Link
href="#accessories"
className="text-lg text-foreground"
onClick={() => setIsMenuOpen(false)}
>
Models
</Link>
<Link
href="#about"
className="text-lg text-foreground"
onClick={() => setIsMenuOpen(false)}
>
About
</Link>
<Link
href="#reserve"
className="mt-4 bg-foreground px-5 py-3 text-center text-sm font-medium text-background rounded-full"
onClick={() => setIsMenuOpen(false)}
>
Contact
</Link>
</nav>
</div>
</div>
)}
) : null}
</header>
);
}
+81 -189
View File
@@ -1,208 +1,100 @@
"use client";
import Image from "next/image";
import { useEffect, useRef, useState } from "react";
import Link from "next/link";
import { useReveal } from "@/hooks/use-reveal";
const word = "MONO";
const sideImages = [
{
src: "/images/hero-side-1.png",
alt: "Modern architecture with corten steel",
position: "left",
span: 1,
},
{
src: "/images/hero-side-2.png",
alt: "Aerial view of modern home",
position: "left",
span: 1,
},
{
src: "/images/hero-side-3.png",
alt: "Interior view with landscape",
position: "right",
span: 1,
},
{
src: "/images/hero-side-4.png",
alt: "Modern architecture at night",
position: "right",
span: 1,
},
const STATS = [
["20", "marknader täckta"],
["Per fält", "härkomst & konfidens"],
["50/50", "start & godkänt"],
["GDPR", "native, med DPA"],
];
// Data-native hero artifact: a miniature company table that "is" the product.
const ROWS: [string, string, string, 0 | 1 | 2 | 3][] = [
["556016-0680", "Volvo Group", "GLEIF", 3],
["915933149", "Equinor ASA", "Brreg", 3],
["25052943", "Maersk A/S", "CVR", 2],
["0112038-9", "Nokia Oyj", "PRH", 2],
["—", "E-post (FI)", "licens", 0],
];
const PROV = ["var(--prov-0)", "var(--prov-1)", "var(--prov-2)", "var(--prov-3)"];
export function HeroSection() {
const sectionRef = useRef<HTMLElement>(null);
const [scrollProgress, setScrollProgress] = useState(0);
useEffect(() => {
const handleScroll = () => {
if (!sectionRef.current) return;
const rect = sectionRef.current.getBoundingClientRect();
const scrollableHeight = window.innerHeight * 2;
const scrolled = -rect.top;
const progress = Math.max(0, Math.min(1, scrolled / scrollableHeight));
setScrollProgress(progress);
};
window.addEventListener("scroll", handleScroll, { passive: true });
handleScroll();
return () => {
window.removeEventListener("scroll", handleScroll);
};
}, []);
// Text fades out first (0 to 0.2)
const textOpacity = Math.max(0, 1 - (scrollProgress / 0.2));
// Image transforms start after text fades (0.2 to 1)
const imageProgress = Math.max(0, Math.min(1, (scrollProgress - 0.2) / 0.8));
// Smooth interpolations - More balanced distribution
const centerWidth = 100 - (imageProgress * 80); // 100% to 20% (same as each side image)
const centerHeight = 100; // Always 100% height
const sideWidth = imageProgress * 40; // 0% to 40% (20% per image, 2 images = 40%)
const sideOpacity = imageProgress;
const sideTranslateLeft = -100 + (imageProgress * 100); // -100% to 0%
const sideTranslateRight = 100 - (imageProgress * 100); // 100% to 0%
const borderRadius = 0; // No border radius
const gap = imageProgress * 8; // 0px to 8px
// Vertical offset for side columns to move them up on mobile
const sideTranslateY = -(imageProgress * 15); // Move up by 15% when fully expanded
const ref = useReveal<HTMLElement>();
return (
<section ref={sectionRef} className="relative bg-background">
{/* Sticky container for scroll animation */}
<div className="sticky top-0 h-screen overflow-hidden">
<div className="flex h-full w-full items-center justify-center">
{/* Bento Grid Container */}
<div
className="relative flex h-full w-full items-stretch justify-center"
style={{ gap: `${gap}px` }}
>
{/* Left Column */}
<div
className="flex h-full flex-row will-change-transform"
style={{
width: `${sideWidth}%`,
gap: `${gap}px`,
transform: `translateX(${sideTranslateLeft}%) translateY(${sideTranslateY}%)`,
opacity: sideOpacity,
}}
>
{sideImages.filter(img => img.position === "left").map((img, idx) => (
<div
key={idx}
className="relative h-full overflow-hidden will-change-transform"
style={{
flex: img.span,
borderRadius: `${borderRadius}px`,
}}
>
<Image
src={img.src || "/placeholder.svg"}
alt={img.alt}
fill
className="object-cover"
/>
</div>
))}
</div>
<section ref={ref} className="grain-overlay relative overflow-hidden border-b border-border bg-background">
{/* faint oversized wordmark */}
<div aria-hidden className="pointer-events-none absolute -right-10 top-1/2 -translate-y-1/2 select-none">
<span className="text-[34vw] font-semibold leading-none tracking-tighter text-border/60 md:text-[22vw]">0pt</span>
</div>
<span aria-hidden className="animate-float pointer-events-none absolute right-[8%] top-[22%] inline-block h-3 w-3" style={{ backgroundColor: "var(--accent-red)" }} />
{/* Main Hero Image - Center */}
<div
className="relative overflow-hidden will-change-transform"
style={{
width: `${centerWidth}%`,
height: `${centerHeight}%`,
flex: "0 0 auto",
borderRadius: `${borderRadius}px`,
}}
<div className="relative mx-auto grid max-w-[80rem] grid-cols-1 gap-12 px-6 py-24 lg:grid-cols-[1.1fr_0.9fr] lg:py-32">
<div className="flex flex-col justify-center">
<p className="reveal delay-1 font-mono text-[11px] uppercase tracking-[0.14em] text-muted-foreground">
00 — Datatjänster på beställning · Norden &amp; EU
</p>
<h1 className="reveal delay-2 mt-5 text-[clamp(2.6rem,7vw,6rem)] font-medium leading-[0.95] tracking-[-0.025em]">
Beställ exakt den data du behöver.
<br />
<span style={{ color: "var(--accent-red)" }}>Validera varje fält.</span>
</h1>
<p className="reveal delay-3 mt-6 max-w-[54ch] text-lg text-muted-foreground">
Sätt ihop ditt eget datajobb. Betala 50 % för att starta. Följ produktionen i realtid. Godkänn
resultatet med full insyn i var varje uppgift kommer ifrån — och hur säker den är.
</p>
<div className="reveal delay-4 mt-8 flex flex-wrap gap-3">
<Link
href="/bestall"
className="bg-foreground px-6 py-3.5 font-mono text-[12px] uppercase tracking-[0.1em] text-background transition-colors hover:bg-[var(--accent-red)]"
>
{/* Text Behind - Fades out first */}
<div
className="absolute inset-0 z-0 flex items-center justify-center"
style={{ opacity: textOpacity, transform: 'translateY(-200px)' }}
>
<h1 className="whitespace-nowrap text-[35vw] font-bold leading-[0.8] tracking-tighter text-black">
{word.split("").map((letter, index) => (
<span
key={index}
className="inline-block animate-[slideUp_0.8s_ease-out_forwards] opacity-0"
style={{
animationDelay: `${index * 0.08}s`,
transition: 'all 1.5s',
transitionTimingFunction: 'cubic-bezier(0.86, 0, 0.07, 1)',
}}
>
{letter}
</span>
))}
</h1>
Bygg en beställning
</Link>
<Link
href="/order/SX-4471/validera"
className="border border-foreground px-6 py-3.5 font-mono text-[12px] uppercase tracking-[0.1em] transition-colors hover:bg-foreground hover:text-background"
>
Se ett levererat dataset
</Link>
</div>
<div className="reveal delay-5 mt-12 grid grid-cols-2 gap-px border border-border bg-border sm:grid-cols-4">
{STATS.map(([n, l]) => (
<div key={l} className="bg-background p-4">
<p className="text-2xl font-semibold tracking-[-0.02em]">{n}</p>
<p className="mt-1 font-mono text-[10px] uppercase tracking-[0.08em] text-muted-foreground">{l}</p>
</div>
<Image
src="/images/hero-mono.png"
alt="Modern architectural structure with reflection"
fill
className="absolute inset-0 z-10 object-cover"
priority
/>
</div>
))}
</div>
</div>
{/* Right Column */}
<div
className="flex h-full flex-row will-change-transform"
style={{
width: `${sideWidth}%`,
gap: `${gap}px`,
transform: `translateX(${sideTranslateRight}%) translateY(${sideTranslateY}%)`,
opacity: sideOpacity,
}}
>
{sideImages.filter(img => img.position === "right").map((img, idx) => (
<div
key={idx}
className="relative h-full overflow-hidden will-change-transform"
style={{
flex: img.span,
borderRadius: `${borderRadius}px`,
}}
>
<Image
src={img.src || "/placeholder.svg"}
alt={img.alt}
fill
className="object-cover"
/>
{/* the artifact */}
<div className="reveal-fade flex items-center">
<div className="w-full border border-border bg-background">
<div className="flex items-center justify-between border-b border-border px-4 py-2">
<span className="font-mono text-[10px] uppercase tracking-[0.12em] text-muted-foreground">dim_company · exempel</span>
<span className="font-mono text-[10px] uppercase tracking-[0.12em] text-muted-foreground">härkomst</span>
</div>
{ROWS.map((r, i) => (
<div key={i} className={`reveal delay-${i + 1} flex items-center justify-between border-b border-border px-4 py-3 last:border-b-0`}>
<div className="min-w-0">
<p className="truncate text-sm">{r[1]}</p>
<p className="font-mono text-[10.5px] text-muted-foreground">{r[0]}</p>
</div>
))}
</div>
<span className="ml-3 inline-flex items-center gap-1.5 border border-border px-1.5 py-0.5 font-mono text-[10px] uppercase text-muted-foreground">
<span
className="inline-block h-[7px] w-[7px]"
style={{ backgroundColor: r[3] === 0 ? "transparent" : PROV[r[3]], border: r[3] === 0 ? `1px solid ${PROV[0]}` : "none" }}
/>
{r[2]}
</span>
</div>
))}
</div>
</div>
</div>
{/* Tagline Section - Fixed at bottom */}
<div
className="pointer-events-none fixed bottom-0 left-0 right-0 z-10 px-6 pb-12 md:px-12 md:pb-16 lg:px-20 lg:pb-20"
style={{ opacity: textOpacity }}
>
<p className="mx-auto max-w-2xl text-center text-2xl leading-relaxed text-white md:text-3xl lg:text-[2.5rem] lg:leading-snug">
Lightweight, durable
<br />
and adventure-ready.
</p>
</div>
{/* Scroll space to enable animation */}
<div className="h-[200vh]" />
</section>
);
}
+49
View File
@@ -0,0 +1,49 @@
# 01 · Product
## Vision
0pt is the most flexible, honest data-services platform for the Nordic + European B2B market. Customers order any kind of data work — from ready-made datasets to bespoke pipelines — and get full visibility into where every value came from and how confident it is. The differentiator is **honest accuracy**: provenance per field, freshness tracking, source transparency. Incumbents (Apollo, Cognism, ZoomInfo) overclaim; 0pt shows its work.
## The three flows (everything reduces to these)
Every service maps to one of three intake/delivery flows. The nine "categories" (scraping, structuring, datasets, ICP/leads, BI, ML, documents, subscriptions, custom) are **tags/filters within these flows**, not separate machinery.
1. **Catalog product** — ready-made, browse → sample → buy → deliver. Exists in Postgres or produced fast from the pipeline. Margin + scale.
2. **Custom job** — does not exist yet; must be scoped. `configure (AI-assist) → estimate → manual scope/quote confirm → produce → QA → validate → deliver`. The hero flow. Consulting in a portal.
3. **Subscription / feed** — recurring product or job: lead feeds, freshness monitoring, ongoing enrichment. `configure → recurring payment → recurring delivery/webhook`.
## Order lifecycle (state machine)
```
DRAFT → QUOTED → DEPOSIT_PENDING → IN_PRODUCTION → REVIEW
→ (REVISION → IN_PRODUCTION)* → ACCEPTED → DELIVERED → [SUBSCRIPTION_ACTIVE]
```
- **DRAFT**: configurator in progress.
- **QUOTED**: estimate produced (catalog = instant; custom = after scope confirm).
- **DEPOSIT_PENDING**: awaiting 50 % deposit.
- **IN_PRODUCTION**: deposit captured → workers run. Customer watches live.
- **REVIEW**: QA passed threshold → customer validates (table/map/schema/quality).
- **REVISION**: customer requested changes → escrow untouched → back to production.
- **ACCEPTED**: customer approves → remaining 50 % released from escrow.
- **DELIVERED**: exports + destinations unlocked; provenance log + quality report attached.
- **SUBSCRIPTION_ACTIVE**: if converted to a feed.
## Business / escrow model
- **50 / 50.** Deposit (50 %) captured to start production. Balance (50 %) **held** and only captured/released on **ACCEPTED**. Revision never moves the held amount.
- Pricing models: pay-per-job, volume, subscription (monthly), custom quote.
- Estimate is a function of: selected field weights × markets × volume × entity multiplier × confidence-target multiplier + enrichment. Custom jobs get a manual scope/quote gate before DEPOSIT_PENDING.
- Stripe: deposit = capture now; balance = either a held authorization released on accept, or a second invoice on accept (implement as a second Checkout/invoice keyed to the order; see `docs/04-api.md`).
## Customer DB access (designed, not a checkbox)
Default: the customer **pushes** data (upload/export) — we rarely need to read their DB. If we must: a **read-only, table-scoped, time-boxed** role the customer creates, IP-allowlisted, ideally against a replica. Creds encrypted, deleted after the job. Everything logged. This is a selling point to compliance-heavy buyers, and it requires a DPA.
## Personas (brief — for copy + prioritization)
SaaS-Martin (RevOps, wants clean ICP/leads), Bilhandlar-Bengt (vehicle + company data via VehIQ), Rekryterar-Rebecka (people/firmographics), Investor-Ivar (diligence, sanctions/UBO, scrutinizes compliance), Ekonomi-Erik (financials, scrutinizes compliance), Data-David (raw datasets + API). Ivar and Erik are *why* honest sourcing + DPA must be airtight.
## First-launch scope (de-risked)
Do **not** ship "20 markets × 9 categories." Ship the thinnest sellable slice: **Nordic company data** (the open stuff — GLEIF + Brreg + verified sources) + **vehicle data** (VehIQ synergy) as a couple of catalog datasets, **plus the custom-job intake**, Stripe, and delivery to download + into FiveCRM/Data Loader. First customers are Protosell internally + existing dealer relationships — services you already deliver, productized. Then expand markets as the sourcing map fills in.
+70
View File
@@ -0,0 +1,70 @@
# 02 · Architecture
## Principle: one database, many lenses
Postgres is the source of truth. Everything else is a **lens** on it. A tool enters the stack only if it (a) reads that Postgres and (b) sits behind the same auth. This is the difference between an app and a pile of separate logins.
```
┌──────────────────────────────────────────────┐
COLLECTION │ POSTGRES │ LENSES (customer + admin)
(legal engine) │ dim_company · dim_contact · dim_vehicle │
─────────────── │ fact_financials · dim_ubo │ ─────────────────────────────
Official APIs │ dim_source · bridge_provenance │ Next.js portal (Vercel)
/bulk ───────▶│ orders · order_items · jobs · deliveries │◀── Catalog + order lifecycle
Authorized web │ subscriptions · users · orgs · files │ NocoDB (embedded "Min data")
Licensed data │ db_grants · audit_log · pgvector embeddings │ Metabase (embedded dashboards)
Customer upload│ │ Export: CSV/Parquet/JSON/API/FiveCRM
└───────────────────▲──────────────────────────┘
│
FastAPI orchestrator + workers
(estimates, orders, jobs, AI-assist, webhooks, pipeline)
```
## Components & responsibilities
- **Next.js (Vercel)** — all customer/admin UI: marketing site, catalog, configurator, checkout, production tracker, validation, portal, admin. Calls FastAPI over an internal token. Embeds NocoDB/Metabase via signed URLs (no separate login shown).
- **FastAPI orchestrator** (`src/orchestrator`) — REST contract in `docs/04-api.md`: estimates, orders, AI-assist, uploads, deliveries, webhooks, catalog, sources, job status/SSE. **Not on Vercel** (serverless timeouts kill long jobs).
- **Workers** (`src/workers`) — adaptive router + pipeline stages (sourcing → extraction/OCR → structuring → enrichment/fusion → QA). Write to Postgres with provenance. See `docs/06-data-engine.md`.
- **Postgres** — backbone (`docs/03-data-model.md`) incl. `pgvector` for matching/embeddings.
- **NocoDB** — Airtable-style views over existing tables (no data duplication). Embedded into portal as "Min data".
- **Metabase** — customer-facing dashboards. Embedded.
- **Cloudflare R2** — file storage (uploads + deliverables) via presigned URLs.
- **Stripe** — deposit capture + escrow balance release.
- **Resend** — transactional email (order confirmation, "din data är klar", feedback).
- **Auth.js** (NextAuth) with a Postgres adapter — single auth for the whole app; NocoDB/Metabase hidden behind it.
## Stack decisions — IN
| Concern | Choice | Why |
|---|---|---|
| Frontend | Next.js App Router + TS + Tailwind + shadcn/ui | Vercel-native, matches existing skills |
| Backend/jobs | FastAPI + workers (existing) | already built; long-running jobs |
| DB | Postgres (+ pgvector) | backbone; Neon free tier or on-VPS |
| Airtable layer | **NocoDB** | sits on existing tables, no duplication |
| BI | **Metabase** | customer-facing embedding, simple |
| Files | Cloudflare R2 + presigned URLs | cheapest S3-compatible |
| Payments | Stripe Checkout + webhook → FastAPI | ~1 day, no SaaS boilerplate needed |
| Auth | Auth.js + Postgres adapter | one system |
| Email | Resend | best Next.js integration, free tier |
| Quality | Great Expectations / checks **inside workers** | scores shipped with each order |
## Stack decisions — OUT (do not add)
| Rejected | Reason |
|---|---|
| Superset | Metabase already covers BI — one tool, not two |
| DataHub / OpenMetadata | enterprise-heavy; catalog = a Postgres table in Next.js + NocoDB. Add lineage only if an enterprise customer demands it |
| Baserow / Teable | NocoDB chosen — not three Airtable clones |
| Meilisearch (v1) | Postgres full-text suffices to a few thousand rows; add later |
| Delta Sharing (v1) | unnecessary; customer pull via scoped role + exports |
| Trigger.dev | the existing queue already schedules jobs; Resend covers mail |
| FlareSolverr / stealth browsers / undetected-* / proxy-fingerprint rotation / fake-account creation | **prohibited** — see CLAUDE.md §1. Replace with compliant fetching |
## Hosting (cheapest coherent)
- **Vercel** (Hobby/free) — Next.js + custom domain `0pt.app`.
- **One small VPS** (e.g. Hetzner ~€5/mo) running `docker-compose`: FastAPI, workers, NocoDB, Metabase, (Postgres or use Neon), optional Meilisearch later. Per-service managed platforms (Railway) get expensive once you have 4–5 always-on services; a single VPS + Vercel + R2 lands ≈ <€10/mo to start.
- **Cloudflare R2** — object storage.
- **Postgres** — on the VPS, or **Neon** free tier if you want it managed.
Full config, env, security, CI/CD: `docs/08-infrastructure.md`.
+201
View File
@@ -0,0 +1,201 @@
# 03 · Data model
Postgres is the backbone. Two halves: **data entities** (what we sell) and **platform tables** (orders, users, jobs). Every fact links to `bridge_provenance`. Migrations via Alembic (Python) or `sql/` migrations checked into the repo — pick one and be consistent; align with existing `src/workers/db.py`.
## Conventions
- `snake_case`, UTC timestamptz, soft-delete via `deleted_at` where useful.
- Natural keys for registries (`org_nr`), surrogate `uuid` PKs for platform tables.
- `created_at` / `updated_at` on every table.
- All monetary amounts in **öre** (integer) to avoid float errors; currency `SEK` default.
## Data entities
```sql
-- A company (the core registry entity)
CREATE TABLE dim_company (
org_nr text PRIMARY KEY, -- national reg. number (per market format)
market text NOT NULL, -- ISO country code: SE/NO/DK/FI/...
lei text, -- GLEIF cross-border key
name text NOT NULL,
status text, -- active/dissolved/...
nace text, -- NACE/SNI code
registered_at date,
address jsonb,
website text,
updated_at timestamptz NOT NULL DEFAULT now(),
created_at timestamptz NOT NULL DEFAULT now()
);
CREATE TABLE fact_financials (
org_nr text REFERENCES dim_company(org_nr),
fiscal_year int NOT NULL,
revenue bigint, -- öre
result bigint,
employees int,
source_id text REFERENCES dim_source(source_id),
PRIMARY KEY (org_nr, fiscal_year)
);
CREATE TABLE dim_contact (
contact_id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
org_nr text REFERENCES dim_company(org_nr),
name text,
role text,
email text,
phone text,
linkedin text,
-- PII: subject to GDPR; lawful basis recorded per delivery
updated_at timestamptz NOT NULL DEFAULT now()
);
CREATE TABLE dim_ubo ( -- ultimate beneficial owner
ubo_id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
org_nr text REFERENCES dim_company(org_nr),
owner text,
share numeric(5,2)
);
CREATE TABLE dim_vehicle ( -- VehIQ synergy
vin text PRIMARY KEY,
market text NOT NULL,
reg_plate text,
make text, model text, year int,
owner_org text REFERENCES dim_company(org_nr),
specs jsonb,
updated_at timestamptz NOT NULL DEFAULT now()
);
```
## Provenance model (the signature — every fact links here)
```sql
CREATE TABLE dim_source (
source_id text PRIMARY KEY, -- 'gleif','brreg','companies_house',...
name text NOT NULL,
market text, -- or 'global'
access text, -- 'api'|'bulk'|'authorized_web'|'licensed'
licence text, -- short licence note
cadence text, -- 'daily'|'weekly'|'annual'|...
url text
);
-- One row per (entity field) we deliver, linking value → where it came from + confidence
CREATE TABLE bridge_provenance (
field_id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
entity_type text NOT NULL, -- 'company'|'contact'|'vehicle'|'financials'
entity_key text NOT NULL, -- org_nr / vin / contact_id
field_name text NOT NULL, -- 'revenue','email',...
source_id text REFERENCES dim_source(source_id),
freshness date, -- when this value was confirmed
confidence numeric(4,3) NOT NULL, -- 0.000–1.000
level smallint NOT NULL, -- 0 unavailable/licensed, 1 single, 2 partial, 3 corroborated
agreed_sources int DEFAULT 1, -- # independent sources that agree
UNIQUE (entity_type, entity_key, field_name)
);
CREATE INDEX ON bridge_provenance (entity_type, entity_key);
```
> Rule: **no value ships without a `bridge_provenance` row.** Level 0 = delivered as "not available / licensed", never a guessed value.
## Platform tables
```sql
CREATE TABLE orgs (
org_id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
name text NOT NULL, created_at timestamptz DEFAULT now()
);
CREATE TABLE users (
user_id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
org_id uuid REFERENCES orgs(org_id),
email text UNIQUE NOT NULL,
role text NOT NULL DEFAULT 'member', -- 'owner'|'admin'|'member'|'siax_admin'
created_at timestamptz DEFAULT now()
);
CREATE TABLE orders (
order_id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
org_id uuid REFERENCES orgs(org_id),
flow text NOT NULL, -- 'catalog'|'custom'|'subscription'
status text NOT NULL DEFAULT 'draft', -- see lifecycle in docs/01
config jsonb NOT NULL, -- configurator state (markets, fields, ...)
total_ore bigint, deposit_ore bigint, balance_ore bigint,
est_days int, est_confidence numeric(4,3),
created_at timestamptz DEFAULT now(), updated_at timestamptz DEFAULT now()
);
CREATE TABLE order_items ( -- resolved line items / dataset refs
item_id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
order_id uuid REFERENCES orders(order_id),
kind text, ref text, qty int, meta jsonb
);
CREATE TABLE jobs ( -- one production run per order
job_id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
order_id uuid REFERENCES orders(order_id),
stage text, -- sourcing|extraction|structuring|enrichment|qa
pct numeric(5,2) DEFAULT 0,
state text DEFAULT 'queued', -- queued|running|done|failed
metrics jsonb, -- rows, confidence, per-market progress
started_at timestamptz, finished_at timestamptz
);
CREATE TABLE deliveries (
delivery_id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
order_id uuid REFERENCES orders(order_id),
formats text[], destinations text[],
record_count int, avg_confidence numeric(4,3),
quality_report jsonb, provenance_export_url text,
files jsonb, -- [{format,url,bytes}]
created_at timestamptz DEFAULT now()
);
CREATE TABLE subscriptions (
sub_id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
order_id uuid REFERENCES orders(order_id),
cadence text, -- weekly|daily
webhook_url text, status text DEFAULT 'active',
next_run_at timestamptz
);
CREATE TABLE files ( -- customer uploads
file_id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
org_id uuid REFERENCES orgs(org_id),
order_id uuid REFERENCES orders(order_id),
name text, mime text, bytes bigint, r2_key text,
created_at timestamptz DEFAULT now()
);
CREATE TABLE db_grants ( -- customer read-only DB access (encrypted)
grant_id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
org_id uuid REFERENCES orgs(org_id),
order_id uuid REFERENCES orders(order_id),
conn_encrypted bytea NOT NULL, -- encrypted connection string
scope text, expires_at timestamptz, revoked_at timestamptz,
created_at timestamptz DEFAULT now()
);
CREATE TABLE audit_log (
id bigserial PRIMARY KEY,
actor text, action text, target text, meta jsonb,
at timestamptz DEFAULT now()
);
CREATE TABLE payments (
payment_id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
order_id uuid REFERENCES orders(order_id),
kind text, -- 'deposit'|'balance'
amount_ore bigint, stripe_ref text,
status text, -- 'pending'|'captured'|'released'|'failed'
created_at timestamptz DEFAULT now()
);
```
## pgvector
`CREATE EXTENSION vector;` Use for entity matching/dedup and for the AI-assist (semantic match of NL request → catalog/config). Store embeddings in a `embeddings` table keyed by `(entity_type, entity_key)` or alongside catalog rows.
## Row-level security & catalog
- Catalog products: a `catalog` table (or view) over curated datasets with sample rows, counts, coverage, price.
- Per-customer data isolation enforced by `org_id` filtering in the API and (where exposed via NocoDB/Metabase) by scoped views/connection params, plus Postgres RLS where practical.
+97
View File
@@ -0,0 +1,97 @@
# 04 · API contract (FastAPI)
Canonical REST contract. Reconcile the existing 25+ endpoints in `src/orchestrator/main.py` toward this. JSON in/out, `snake_case`. All amounts in **öre**. Auth: Next.js → FastAPI over a shared internal token (`X-Internal-Token`); customer identity passed as `org_id`/`user_id` resolved from the Auth.js session server-side.
## Conventions
- Errors: `{ "error": { "code", "message" } }`, proper HTTP status. Interface-voice messages (Swedish) are produced in the frontend, not here.
- Idempotency: accept `Idempotency-Key` on POSTs that create orders/payments.
- Pagination: `?limit=&cursor=`.
## Catalog
```
GET /catalog -> [{id,title,markets,entity_type,count,updated,price_ore,confidence}]
GET /catalog/{id} -> full dataset (overview, coverage, schema, quality summary)
GET /catalog/{id}/sample?n=10 -> sample rows WITH per-field provenance
```
## Estimate (configurator → price/time/confidence)
```
POST /estimates
body: { flow, markets[], entity_type, fields[], enrichment[], volume, frequency, confidence_target, formats[] }
-> { total_ore, deposit_ore, balance_ore, est_days, est_confidence, breakdown[] }
```
## AI-assist (NL → config)
```
POST /ai/parse
body: { text } -- "5000 svenska SaaS-bolag med e-post och omsättning"
-> { config: <estimate body>, rationale } -- prefilled configurator; uses pgvector match to catalog/fields
```
## Orders (lifecycle)
```
POST /orders body: { flow, config } -> { order_id, status:'draft'|'quoted' }
GET /orders -> [orders for org]
GET /orders/{id} -> order + current job + delivery (if any)
POST /orders/{id}/scope (custom flow) body:{ notes } -> status:'quoted' (manual quote step)
POST /orders/{id}/accept customer approves delivery -> triggers balance release; status:'accepted'
POST /orders/{id}/revise body:{ notes } -> status:'revision'; escrow untouched
```
## Payments (50/50 escrow)
```
POST /payments/{order_id}/deposit -> { checkout_url } (Stripe Checkout for 50%; on success → IN_PRODUCTION)
POST /payments/{order_id}/release -> captures/charges the held balance (called on accept)
POST /webhooks/stripe (Stripe → here) verify signature; update payments + order status
```
> Implement balance as either a manual capture on a held PaymentIntent, or a second Checkout/invoice keyed to `order_id`. Never charge balance before `accept`.
## Jobs / live status
```
GET /orders/{id}/status -> { stage, pct, per_market[], confidence, eta, rows }
GET /orders/{id}/events -> Server-Sent Events stream (preferred for the production tracker)
```
## Uploads (R2 presigned)
```
POST /uploads body:{ order_id, name, mime, bytes } -> { file_id, put_url } (client PUTs to R2)
GET /orders/{id}/files -> [files]
```
## Customer DB access (read-only grant)
```
POST /connections/db-grant
body: { order_id, conn_string, scope, expires_at } -- stored ENCRYPTED; validated read-only
-> { grant_id, status:'pending'|'verified' }
DELETE /connections/db-grant/{id} -> revoke
```
## Deliveries
```
GET /orders/{id}/delivery -> { formats, destinations, record_count, avg_confidence, quality_report, files[], provenance_export_url }
POST /orders/{id}/delivery/destinations body:{ destinations[] } -> updates targets (CSV/Parquet/JSON/API/NocoDB/Metabase/FiveCRM/Sheets/webhook)
```
## Sources (the transparency moat)
```
GET /sources -> [{source_id,name,market,access,licence,cadence,confidence}]
GET /sources/{market} -> sources + freshness for a market
```
## Subscriptions / feeds
```
POST /subscriptions body:{ order_id, cadence, webhook_url } -> { sub_id }
GET /subscriptions -> [subs]
POST /subscriptions/{id}/pause | /resume
```
## Webhooks out (to customers)
- On delivery / feed update: POST to `subscriptions.webhook_url` with `{ order_id, delta_url, summary }`.
## Health
```
GET /health -> { ok, db, workers, queue }
```
## Existing-code reconciliation
Map current endpoints (orders, estimates, ai-assistent, catalog, webhooks, health) onto the names above; rename for consistency, keep behavior. Add: `/orders/{id}/events` (SSE), `/payments/*` escrow split, `/connections/db-grant`, `/sources`, `/subscriptions`. Remove any endpoint that drives a bypass/stealth fetch path.
+87
View File
@@ -0,0 +1,87 @@
# 05 · Design system (mono)
Self-contained spec. Component-level v0 prompts live in `docs/design/0pt-v0-build-brief.md`; the full page/component inventory + visual rules in `docs/design/0pt-design-coverage-and-visuals.md`. This file is the source of truth for tokens + motion; build them once and reuse.
Derived from the **mono / EVASION** template (itself v0-generated). Light, editorial, monochrome, sharp, with a single red accent and animated grain + scroll-reveal motion. **No photography** — imagery is rendered data.
## Tokens
```
THEME (light, default):
--background #ffffff --foreground #0a0a0a --muted-foreground #737373
--border #e5e5e5 --card #ffffff --input #f5f5f5
THEME (dark sections — footer, one editorial block):
--background #0a0a0a --foreground #fafafa --muted-foreground #a3a3a3 --border #262626
ACCENT (single, sparing): red --accent #e40014 (one CTA / one number / one marker per screen)
PROVENANCE squares (ONLY non-mono color besides the red CTA):
level 0 #737373 (unavailable/licensed) · 1 #143aff (single source) · 2 #fcbb00 (partial) · 3 #00d294 (corroborated)
RADIUS: 0 everywhere (max 2px on tiny chips). No drop shadows; structure via 1px hairlines.
TYPE: Inter (everything; headlines weight 500–600, letter-spacing -0.02em, large) · JetBrains Mono (eyebrows, labels, numbers, codes, table headers — UPPERCASE, letter-spacing 0.12em, ~11px, #737373)
GRID: 8px base; graph-paper hairlines; generous whitespace; left-aligned headlines.
SECTION SPINE: real ordered indices 00–NN as mono eyebrows.
```
## globals.css (add verbatim — guarantees identical motion + grain)
```css
@keyframes reveal-up{0%{opacity:0;transform:translateY(60px)}to{opacity:1;transform:translateY(0)}}
@keyframes reveal-left{0%{opacity:0;transform:translateX(-60px)}to{opacity:1;transform:translateX(0)}}
@keyframes reveal-right{0%{opacity:0;transform:translateX(60px)}to{opacity:1;transform:translateX(0)}}
@keyframes scale-in{0%{opacity:0;transform:scale(.9)}to{opacity:1;transform:scale(1)}}
@keyframes fadeIn{0%{opacity:0;transform:scale(1.02)}to{opacity:1;transform:scale(1)}}
@keyframes slideUp{0%{opacity:0;transform:translateY(100%)}to{opacity:1;transform:translateY(0)}}
@keyframes float{0%,100%{transform:translateY(0)}50%{transform:translateY(-20px)}}
@keyframes grain{0%,100%{transform:translate(0,0)}10%{transform:translate(-5%,-10%)}20%{transform:translate(-15%,5%)}30%{transform:translate(7%,-25%)}40%{transform:translate(-5%,25%)}50%{transform:translate(-15%,10%)}60%{transform:translate(15%,0)}70%{transform:translateY(15%)}80%{transform:translate(3%,35%)}90%{transform:translate(-10%,10%)}}
.reveal{opacity:0}
.reveal.in-view{animation:.8s ease-out forwards reveal-up}
.reveal-left.in-view{animation:.8s ease-out forwards reveal-left}
.reveal-right.in-view{animation:.8s ease-out forwards reveal-right}
.reveal-scale.in-view{animation:.6s ease-out forwards scale-in}
.reveal-fade.in-view{animation:.7s ease-out forwards fadeIn}
.delay-1{animation-delay:.1s}.delay-2{animation-delay:.2s}.delay-3{animation-delay:.3s}.delay-4{animation-delay:.4s}.delay-5{animation-delay:.5s}.delay-6{animation-delay:.6s}
.animate-float{animation:6s ease-in-out infinite float}
.grain-overlay::before{content:"";position:absolute;inset:-100%;opacity:.03;pointer-events:none;background-image:url("data:image/svg+xml,%3Csvg viewBox='0 0 200 200' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.9' numOctaves='4' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)'/%3E%3C/svg%3E");animation:8s steps(10,end) infinite grain}
@media(prefers-reduced-motion:reduce){.reveal,.reveal-left,.reveal-right,.reveal-scale,.reveal-fade{opacity:1;animation:none}.animate-float,.grain-overlay::before{animation:none}}
```
## useReveal hook
```tsx
"use client";
import { useEffect, useRef } from "react";
export function useReveal<T extends HTMLElement = HTMLElement>() {
const ref = useRef<T>(null);
useEffect(() => {
const el = ref.current; if (!el) return;
const io = new IntersectionObserver((es) => es.forEach((e) => {
if (e.isIntersecting) {
el.querySelectorAll(".reveal,.reveal-left,.reveal-right,.reveal-scale,.reveal-fade")
.forEach((n) => n.classList.add("in-view"));
io.unobserve(el);
}
}), { threshold: 0.15, rootMargin: "0px 0px -10% 0px" });
io.observe(el); return () => io.disconnect();
}, []);
return ref;
}
```
Every section wraps in a tag using `useReveal()`; children get `reveal[/-left/-right/-scale/-fade]` + `delay-N`.
## Buttons & labels
- Primary: solid `#0a0a0a` bg, white text, square, mono uppercase 12px / spacing 0.1em, padding 14px 26px; hover → bg `#e40014`.
- Secondary: transparent, 1px `#0a0a0a` border; hover → bg `#0a0a0a`, white text.
- Eyebrow: mono uppercase 11px, spacing 0.14em, `#737373`, with a 2-digit index ("01 — KATALOG").
- Headlines: sentence case, never uppercase.
## Visual system — no photos (`DataArtifact`)
Build one generative primitive; every "image" surface uses it, animating with the effect it replaces:
- `table` (rows cascade in, provenance square per fact cell) — hero/cards.
- `map` (country-code cells, fill bars animate width on view) — coverage.
- `schema` (ER mini-diagram, key boxes red border) — schema previews.
- `chart` (black bars/lines, exactly ONE red series, grow on view) — dashboards.
- `grid` (hairline graph-paper + faint oversized numeral + one floating red square) — pure texture.
Mapping of template image elements → replacement + effect kept is in the coverage doc. Rules: code-only (CSS/SVG/canvas), color discipline inside visuals (mono + provenance squares + one red series), grain over any "image" surface, `prefers-reduced-motion` settles to static. Optional: add **Lenis** smooth scroll on top — preserves all effects.
## The signature (carry everywhere)
Provenance + confidence is the identity: the provenance square on every fact, one red, mono numerals as texture, section spine, identical motion. A screen that lacks these is off-brand.
+70
View File
@@ -0,0 +1,70 @@
# 06 · Data engine
The engine turns sources into delivered, provenance-tagged data. It is the legal motor — the whole product positioning depends on it. Build on existing `src/workers/` (`adaptive_router`, `browser_worker`, `ocr_worker`, `db`) but enforce the sourcing boundary below.
## Sourcing chain (strict priority — this is the law)
For each field × market, acquire in this order; stop at the first that works:
1. **Official API / bulk** — registries with programmatic/bulk access (highest confidence).
2. **Authorized public web** — clearly public pages, **respecting `robots.txt` and rate limits**, no auth walls.
3. **Licensed partner data** — contractually obtained.
4. **Otherwise → "not available / licensed only"** — delivered honestly as level 0. **Never guessed.**
### Prohibited (do not build, keep, or extend)
- WAF/bot-detection bypass — **including FlareSolverr**, "stealth"/patched browsers, undetected-* used to defeat detection.
- Proxy/fingerprint rotation to evade bans; scraping behind login walls; automated fake-account creation (Apollo etc.).
- **Reconciliation:** if `browser_worker.py` has an `HTTP → FlareSolverr → Playwright` chain, **drop the FlareSolverr stage** and keep only: polite HTTP fetch (identifies itself, obeys robots + rate limits) → optional Playwright for genuinely public JS-rendered pages (same politeness). The `adaptive_router` then chooses among **compliant** methods only (API > bulk > authorized fetch), not bypass tiers.
## Pipeline stages (per job)
```
sourcing → extraction/OCR → structuring → enrichment/fusion → QA
```
1. **Sourcing** — `adaptive_router` picks the compliant method per source; "source-racing" = query several *authorized* sources and take the fastest/freshest. Writes raw payloads + which source answered.
2. **Extraction / OCR** — API JSON parsed directly; documents (PDF/HTML/images) via **Unstructured** + OCR cascade (PaddleOCR → Tesseract → LLM fallback) in `ocr_worker`. Output: candidate fields.
3. **Structuring** — NER + entity resolution (spaCy / transformers, sv/no/da/fi/de/fr), normalize to schema, link entities across markets via **LEI** (GLEIF golden key). Dedup with **RapidFuzz**.
4. **Enrichment / fusion** — combine fields from multiple sources; compute **multi-source agreement** → confidence + level; append optional enrichment (NACE/SNI, sanctions/PEP flag from official lists, website/tech).
5. **QA** — quality checks (**Great Expectations** or equivalent, run inside the worker): validity (format/range), completeness, freshness, uniqueness (post-dedup), corroboration. Drop rows below the order's `confidence_target` (deliver fewer, never fabricate). Emit the quality report stored on `deliveries`.
Each stage updates `jobs` (`stage`, `pct`, `metrics` incl. per-market progress, running counts, climbing confidence) so the production tracker / SSE can render it live.
## Confidence & provenance scoring
- Per field, write a `bridge_provenance` row: `source_id`, `freshness`, `confidence` (0–1), `level` (0–3), `agreed_sources`.
- Level: 0 = unavailable/licensed; 1 = single source; 2 = partial corroboration; 3 = ≥2 independent sources agree.
- `est_confidence` on the order = aggregate target; delivered `avg_confidence` = actual.
## `sources.yaml` (machine-readable registry)
Single registry that drives the router, the `/sources` API, and the public sourcing-map page. Schema per entry:
```yaml
- source_id: gleif
name: GLEIF (LEI)
market: global
access: api # api | bulk | authorized_web | licensed
cadence: daily
licence: "open (CC0) — VERIFY current terms"
entities: [company]
fields: [lei, legal_name, address]
base_url: https://api.gleif.org
robots_ok: true # for authorized_web only; must be honored
rate_limit: "per provider terms"
notes: "cross-border key"
```
### Starter set (VERIFY current access/licence per source before relying — terms change)
- **GLEIF / LEI** — typically open; cross-border key. *(verify)*
- **Brønnøysund (Brreg, NO)** — typically open API. *(verify)*
- **Companies House (UK)** — free API (key required). *(verify)*
- **CVR / Virk (DK)** — API access with terms. *(verify)*
- **PRH (FI)** — varies. *(verify)*
- **Bolagsverket (SE)** — more restricted for bulk; plan licensed/partner route. *(verify)*
- Vehicle: **RDW (NL)** open-ish, **Traficom (FI)**, **Transportstyrelsen (SE)**, **DVLA (UK)** — *(verify each; vehicle/PII rules differ)*
> Do not hardcode access terms as fact. When building each connector, confirm the current API/licence/robots status and record it in `sources.yaml`. If unclear or disallowed for bulk, route to licensed/partner or mark fields level 0.
## Libraries used here
Crawlee or Scrapy (orchestration of **authorized** fetches) · Playwright (public JS pages, politely) · Unstructured (document parsing) · PaddleOCR/Tesseract (OCR) · spaCy / HF transformers (NER, multilingual) · Polars (fast tabular) · RapidFuzz (dedup/matching) · Great Expectations (quality) · dbt (optional transform layer). Versions/install: `docs/07-dependencies.md`.
+65
View File
@@ -0,0 +1,65 @@
# 07 · Dependencies & repos to import
Curated. Each entry: what it is, where it slots in, how to install. **Do not import on reputation/star-count** — several items in earlier brainstorming were unverified or hallucinated (see Exclusions). Verify each repo exists and is maintained before adopting.
## Frontend (Next.js on Vercel)
| Package | Purpose | Install |
|---|---|---|
| next, react, typescript | App Router base | `npx create-next-app@latest --ts` |
| tailwindcss | styling (mono tokens) | per Next setup |
| shadcn/ui | base components | `npx shadcn@latest init` |
| @tanstack/react-table **or** ag-grid-react | data tables / catalog grid | `npm i @tanstack/react-table` |
| @uppy/core @uppy/react @uppy/dashboard @uppy/aws-s3 | file upload → R2 (presigned) | `npm i @uppy/core @uppy/react @uppy/dashboard @uppy/aws-s3` |
| next-auth (Auth.js) + @auth/pg-adapter | single auth, Postgres-backed | `npm i next-auth @auth/pg-adapter` |
| stripe / @stripe/stripe-js | checkout + portal | `npm i stripe @stripe/stripe-js` |
| resend / react-email | transactional email | `npm i resend @react-email/components` |
| lenis *(optional)* | smooth scroll (keeps all effects) | `npm i lenis` |
| recharts *(only if needed)* | charts inside DataArtifact/dashboards | `npm i recharts` |
> Catalog/search in v1 = Postgres full-text. Add Meilisearch later only past a few thousand rows.
## Backend / workers (Python, FastAPI — existing)
| Package | Purpose | Install |
|---|---|---|
| fastapi, uvicorn, pydantic | API (existing) | `pip install fastapi uvicorn pydantic` |
| sqlalchemy / asyncpg | Postgres access (align `db.py`) | `pip install sqlalchemy asyncpg` |
| alembic | migrations | `pip install alembic` |
| celery + redis **or** existing adaptive_router/queue | job queue/concurrency | `pip install celery redis` |
| httpx | polite HTTP fetch (identifies itself, obeys rate limits) | `pip install httpx` |
| scrapy **or** crawlee | orchestrate **authorized** fetches | `pip install scrapy` |
| playwright | public JS-rendered pages, politely | `pip install playwright && playwright install chromium` |
| unstructured | parse PDF/HTML/DOCX/images → elements | `pip install "unstructured[all-docs]"` |
| paddleocr / pytesseract | OCR cascade | `pip install paddleocr pytesseract` (+ tesseract bin) |
| spacy (+ models) / transformers | NER, entity resolution (multilingual) | `pip install spacy transformers` |
| polars | fast tabular processing | `pip install polars` |
| rapidfuzz | dedup / fuzzy matching | `pip install rapidfuzz` |
| great-expectations | data quality checks in pipeline | `pip install great-expectations` |
| dbt-postgres *(optional)* | transform layer | `pip install dbt-postgres` |
| pgvector (Postgres ext) | embeddings/matching | `CREATE EXTENSION vector;` |
| boto3 *(or aioboto3)* | R2 (S3-compatible) | `pip install boto3` |
| stripe (python) | webhook handling | `pip install stripe` |
## Self-hosted services (docker-compose on the VPS)
| Service | Purpose | Image |
|---|---|---|
| **NocoDB** | Airtable-style views over existing Postgres tables; embedded "Min data" | `nocodb/nocodb` |
| **Metabase** | customer-facing dashboards; embedded | `metabase/metabase` |
| **Postgres** *(or Neon managed)* | backbone (+ pgvector) | `pgvector/pgvector:pg16` |
| **Redis** *(if Celery)* | queue | `redis` |
| **Meilisearch** *(later)* | catalog search at scale | `getmeili/meilisearch` |
## External managed (free/cheap tiers)
- **Vercel** — Next.js hosting.
- **Cloudflare R2** — object storage (presigned URLs).
- **Stripe** — payments (test mode first).
- **Resend** — email.
- **Neon** *(optional)* — managed Postgres.
## EXCLUSIONS — do not import
- **Bypass/stealth/evasion**: FlareSolverr, "CloakBrowser", undetected-chromedriver/undetected-playwright (as bypass), proxy/fingerprint-rotation engines, any "WAF bypass" repo. Prohibited by CLAUDE.md §1 — they destroy the honest-accuracy positioning and create legal exposure.
- **Fake-account automation** on Apollo/Cognism/etc.
- **Unverified / likely-hallucinated** from earlier brainstorming: "CloakBrowser/CloakHQ", "Rota proxy engine", "OpenClaw". Do not adopt unless you confirm a real, maintained project — and even then, not if it falls in the bypass category.
- **Overlapping tools** already decided against (Superset, DataHub/OpenMetadata, Baserow/Teable, Trigger.dev, Delta Sharing in v1) — see `docs/02-architecture.md`.
> Note on real-but-overhyped tools (Firecrawl, browser-use, etc.): they exist, but ignore inflated star-counts and only adopt if they fit a *compliant* role and don't duplicate the stack.
+118
View File
@@ -0,0 +1,118 @@
# 08 · Infrastructure
## Repo structure (monorepo)
```
0pt/
├─ CLAUDE.md
├─ docs/ # this documentation set
├─ apps/
│ └─ web/ # Next.js (Vercel): marketing, catalog, portal, admin
├─ services/
│ └─ api/ # FastAPI orchestrator + workers (existing src/* migrates here)
│ ├─ orchestrator/ # main.py + routers (docs/04-api.md)
│ ├─ workers/ # adaptive_router, fetch_worker, ocr_worker, pipeline, db
│ └─ sources/ # sources.yaml + connectors (docs/06)
├─ packages/
│ └─ shared/ # shared TS types (mirror API contract), design tokens
├─ sql/ (or services/api/alembic/) # migrations (docs/03)
├─ infra/
│ ├─ docker-compose.yml # VPS always-on services
│ └─ Caddyfile # reverse proxy + TLS
└─ .github/workflows/ # CI/CD
```
## Hosting topology
- **Vercel** — `apps/web` → `0pt.app`. Free Hobby until paying customers.
- **One VPS** (Hetzner ~€5/mo) — `docker-compose`: FastAPI, workers, NocoDB, Metabase, Postgres (or Neon), Redis (if Celery), Caddy. Caddy terminates TLS and proxies `api.0pt.app`, `data.0pt.app` (NocoDB), `dash.0pt.app` (Metabase).
- **Cloudflare R2** — files.
- **Postgres** — on VPS (`pgvector/pgvector:pg16`) or **Neon** managed.
## docker-compose (infra/docker-compose.yml — sketch)
```yaml
services:
postgres:
image: pgvector/pgvector:pg16
environment: { POSTGRES_USER: opt, POSTGRES_PASSWORD: ${PG_PASSWORD}, POSTGRES_DB: opt }
volumes: ["pgdata:/var/lib/postgresql/data"]
redis:
image: redis:7
api:
build: ../services/api
command: uvicorn orchestrator.main:app --host 0.0.0.0 --port 8000
environment: [DATABASE_URL, R2_*, STRIPE_*, RESEND_API_KEY, INTERNAL_TOKEN]
depends_on: [postgres, redis]
worker:
build: ../services/api
command: python -m workers.run # or celery -A workers worker
environment: [DATABASE_URL, R2_*]
depends_on: [postgres, redis]
nocodb:
image: nocodb/nocodb
environment: { NC_DB: "pg://postgres:5432?u=opt&p=${PG_PASSWORD}&d=opt", NC_PUBLIC_URL: "https://data.0pt.app" }
depends_on: [postgres]
metabase:
image: metabase/metabase
environment: { MB_DB_TYPE: postgres, MB_DB_HOST: postgres, MB_DB_USER: opt, MB_DB_PASS: ${PG_PASSWORD}, MB_DB_DBNAME: opt }
depends_on: [postgres]
caddy:
image: caddy:2
ports: ["80:80","443:443"]
volumes: ["./Caddyfile:/etc/caddy/Caddyfile", "caddydata:/data"]
volumes: { pgdata: {}, caddydata: {} }
```
## Environment variables (full inventory)
```
# Postgres
DATABASE_URL=postgres://opt:***@host:5432/opt
PG_PASSWORD=***
# Internal auth (Next ↔ FastAPI)
INTERNAL_TOKEN=*** # sent as X-Internal-Token
# Auth.js
AUTH_SECRET=***
AUTH_URL=https://0pt.app
# Stripe
STRIPE_SECRET_KEY=sk_***
STRIPE_WEBHOOK_SECRET=whsec_***
NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=pk_***
# Cloudflare R2 (S3-compatible)
R2_ACCOUNT_ID=*** R2_ACCESS_KEY_ID=*** R2_SECRET_ACCESS_KEY=*** R2_BUCKET=opt-files R2_ENDPOINT=https://<acct>.r2.cloudflarestorage.com
# Email
RESEND_API_KEY=re_***
# Embeds (signed URLs)
METABASE_SITE_URL=https://dash.0pt.app METABASE_SECRET_KEY=***
NOCODB_BASE_URL=https://data.0pt.app NOCODB_API_TOKEN=***
# Encryption for db_grants
GRANT_ENC_KEY=*** # 32-byte key, AES-GCM
# App
NEXT_PUBLIC_API_URL=https://api.0pt.app
```
Never hardcode. Vercel env for `apps/web`; VPS `.env` (git-ignored) for compose. Provide `.env.example` with empty values.
## Security & compliance (build in, not bolt on)
- **Customer DB grants**: customer creates a read-only, table-scoped, time-boxed DB role; we store the connection string **AES-GCM encrypted** (`GRANT_ENC_KEY`), validate it's read-only, and **delete after the job**. IP-allowlist; prefer a replica. Log access in `audit_log`.
- **File delivery**: short-lived **presigned R2 URLs**; never public buckets.
- **Isolation**: API filters by `org_id`; Postgres **RLS** where practical; NocoDB/Metabase exposed only via **signed embed URLs** scoped per customer/order (no separate login surfaced).
- **PII (contacts/UBO)**: lawful basis recorded per delivery; honor deletion; minimize retention.
- **DPA**: ship a Data Processing Agreement; provenance log + audit log are part of the offering.
- **Secrets**: rotate; least privilege; no secrets in client bundles (only `NEXT_PUBLIC_*`).
## Embedding NocoDB / Metabase (so it feels like one app)
- Generate a **signed embed URL** server-side (Metabase signed embedding with `METABASE_SECRET_KEY`; NocoDB shared/locked view + token) scoped to the customer's data, and render it in an iframe inside the mono chrome. The customer never sees a NocoDB/Metabase login.
## CI/CD (.github/workflows)
- **web**: lint + typecheck + build → deploy to Vercel on push to `main` (Vercel Git integration).
- **api**: lint (ruff) + tests (pytest) → build Docker image → deploy to VPS (SSH `docker compose pull && up -d`, or a small runner).
- Run DB migrations as a deploy step.
## Monitoring (cheap)
- Vercel Analytics + Speed Insights (web).
- **Sentry** free tier (web + api).
- Uptime ping on `/health`.
- Worker/job health visible in admin (reads `jobs`).
+75
View File
@@ -0,0 +1,75 @@
# 09 · Build plan
Phased, in dependency order. **Finish a phase to its acceptance criteria before the next.** Each phase = small, reviewable commits. Reconcile with existing code rather than rewriting wholesale.
## Phase 0 — Foundation & repo
- Monorepo structure (`docs/08`). Migrate existing `src/orchestrator` + `src/workers` into `services/api`.
- Postgres schema + migrations (`docs/03`); `pgvector` enabled. `.env.example`.
- `docker-compose` up (postgres, redis, api, worker, nocodb, metabase, caddy). `/health` green.
- CI skeleton (lint/typecheck/build).
- **Accept:** `docker compose up` runs the whole stack locally; migrations apply; `/health` returns ok for db + queue.
## Phase 1 — Design system in code
- Tailwind + shadcn; `globals.css` (grain + reveal keyframes) and `useReveal` verbatim (`docs/05`).
- Build **DataArtifact** primitive (table/map/schema/chart/grid) and **ProvenanceBadge** (`docs/design` prompts 5.1, 2.11).
- **Accept:** a sample page renders mono tokens, a DataArtifact animates on scroll, reduced-motion respected, focus rings visible.
## Phase 2 — Marketing + catalog (placeholder data)
- Landing components top-to-bottom (build-brief 2.1–2.10): Header, Hero, Philosophy, Catalog teaser, Engine, Visualisation gallery, Coverage, Editorial, Proof, Footer.
- Full **Catalog** page + **Dataset detail** + **Sourcing-map** page (coverage doc 5.4). Postgres full-text search.
- All with loading/empty/error states + mobile.
- **Accept:** marketing site + catalog browse work end-to-end on placeholder catalog rows; one continuous scroll reads well; DoD met per screen.
## Phase 3 — Auth & portal shell
- Auth.js + Postgres adapter; sign in/up/verify/forgot. `orgs`/`users`.
- Portal shell + Dashboard ("Mina beställningar") with empty state.
- **Accept:** a user can sign up, land in an empty dashboard, sign out; routes protected.
## Phase 4 — Order lifecycle end-to-end (test mode)
- **Configurator** (2.12) wired to `POST /estimates` (real estimate math in FastAPI).
- **Deposit checkout** (2.13) → Stripe **test** Checkout → webhook → order `IN_PRODUCTION`.
- **Production tracker** (2.14) reading `GET /orders/{id}/events` (SSE) — pipeline may be **simulated** at this phase.
- **Validation & delivery** (2.15): tabs render from a sample delivery; **accept** → `POST /payments/release` (test) → `DELIVERED`; **revise** leaves escrow untouched.
- AI-assist (`POST /ai/parse`) prefilling the configurator.
- **Accept:** a full custom order can be configured, deposit paid (test), watched, validated, accepted, and "delivered" — state machine in `docs/01` traversed; escrow split correct.
## Phase 5 — Real data engine (Nordic slice)
- Compliant connectors first: **GLEIF + Brreg + Companies House** (verify access terms; record in `sources.yaml`). Remove any FlareSolverr/bypass path (`docs/06`).
- Pipeline stages produce real `dim_company` + `fact_financials` with `bridge_provenance`; QA threshold drops low-confidence rows.
- Production tracker now reflects **real** job metrics.
- **Accept:** a Nordic company dataset is produced for a real order, every field has provenance, quality report generated, level-0 fields shown honestly (not fabricated).
## Phase 6 — Delivery surfaces & integrations
- Exports: CSV / Parquet / JSON via R2 presigned URLs.
- Embedded **NocoDB** ("Min data") and **Metabase** dashboards via signed URLs in mono chrome.
- Integrations: **FiveCRM push**, webhooks, Google Sheets.
- File **uploads** (Uppy → R2) for matching/enrichment.
- **Accept:** a delivered dataset is downloadable, viewable in embedded NocoDB + Metabase (no separate login), and pushable to FiveCRM; uploads work.
## Phase 7 — Subscriptions / feeds
- `subscriptions`; recurring jobs (weekly/daily) delivering **deltas**; webhooks on change.
- Freshness monitoring; Resend emails (confirmation, "din data är klar", feedback).
- **Accept:** an order converts to a feed; a scheduled run produces a delta + fires a webhook + email.
## Phase 8 — Admin, hardening, compliance
- Admin: order queue + scope/quote + escrow release; **source registry & freshness** monitoring; worker/job monitoring; customer + catalog management.
- Customer **DB-access grant** flow (encrypted, read-only, time-boxed) + revoke.
- Security pass: RLS, presigned-only files, signed embeds, secret rotation, `audit_log` everywhere; **DPA** published; legal pages.
- Finalize all loading/empty/error states + mobile across the app.
- **Accept:** admin can run the business; DB-grant flow works and self-revokes; security checklist passes; DoD met app-wide.
## Phase 9 — Launch
- Domain `0pt.app` (Vercel) + subdomains (api/data/dash via Caddy). Sentry + uptime live.
- Soft launch: Protosell internally + a handful of existing dealer relationships.
- **Accept:** a real external customer completes an order start→delivery in production; feedback loop running.
---
## Working conventions (for Claude Code)
- Tech docs + identifiers **English**; UI copy **Swedish**.
- Conventional Commits; one concern per PR; keep diffs reviewable.
- No secrets in code; update `.env.example` when adding env.
- Add/extend tests for engine logic (estimate math, fusion/confidence, dedup) and API contract.
- When a source's access/licence is unclear, **verify and record in `sources.yaml`** before relying; if disallowed for bulk, route to licensed/partner or mark level 0 — never bypass, never fabricate.
- Touch the design tokens/motion only via `docs/05`; keep red ≤ once per screen; no photography.
- If anything here conflicts with existing code, **flag it** in the PR description and propose the reconciliation rather than guessing.