Phase 1 foundation: backend, web profile, and mobile app
CI / policy (push) Successful in 0s
CI / mobile (push) Successful in 47s
CI / profile (push) Successful in 49s
CI / backend (push) Failing after 49s

Greenfield implementation of CardClaws Phase 1 across three surfaces.

Backend (Rust/Axum workspace, 67 tests):
- cardclaws-types/config/db/auth/api/wallet crates
- Auth: register, login + lockout, magic link, refresh rotation, Apple verify
- Cards: CRUD, tier-limited publish, duplicate, public handle lookup
- Assets: R2 presigned uploads; vCard export
- Apple Wallet .pkpass pipeline (PKCS#7 signer behind apple-signing feature)
- Analytics ingest + summary with daily-salted IP hashing
- Migrations 0001 (incl. cardclaws_sessions) + 0002 analytics

Web profile (Astro SSR): cardclaws.com/[handle] hero + flip, contact actions,
client-built vCard, visit attribution. Verified end-to-end.

Mobile (Expo SDK 51): auth, card list/create, builder v1 (bg/text/logo,
palette, undo/redo), Skia/Reanimated viewer (flip + ambient). 22 logic tests.

CI: policy/backend/profile/mobile jobs; LOC + no-placeholder lint.

Review fixes baked in: `back` (not `cardclaws`) key; strip image is a bundled
manifest file (not a URL); sessions table added; NFC reframed; test doubles
allowed for external services.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
This commit is contained in:
Omar Sobh
2026-06-04 10:17:26 -05:00
co-authored by Claude Opus 4.8
commit c30d3afeec
128 changed files with 36279 additions and 0 deletions
@@ -0,0 +1,89 @@
// On-device palette extraction via k-means (k=8) over an image's pixels
// (PRD §6.1.3). Pure function over an RGBA byte array so it is unit-testable
// without any native image decoding.
import { RGB, rgbToHex } from "../../utils/colorUtils";
export interface QuantizeOptions {
k?: number;
maxIterations?: number;
/** Skip every Nth pixel for speed on large images. */
sampleStride?: number;
}
/**
* Extract up to `k` dominant colors from an RGBA pixel buffer (4 bytes/pixel).
* Returns hex strings ordered by cluster population (most dominant first).
* Deterministic: centroids are seeded by evenly spaced samples, not randomness.
*/
export function quantize(rgba: Uint8Array | number[], opts: QuantizeOptions = {}): string[] {
const k = opts.k ?? 8;
const maxIterations = opts.maxIterations ?? 10;
const stride = Math.max(1, opts.sampleStride ?? 1);
const pixels: RGB[] = [];
for (let i = 0; i + 3 < rgba.length; i += 4 * stride) {
const a = rgba[i + 3];
if (a < 16) continue; // skip near-transparent
pixels.push({ r: rgba[i], g: rgba[i + 1], b: rgba[i + 2] });
}
if (pixels.length === 0) return [];
const realK = Math.min(k, pixels.length);
// Deterministic seeding: evenly spaced samples across the pixel list.
let centroids: RGB[] = Array.from({ length: realK }, (_, c) => {
const idx = Math.floor((c * pixels.length) / realK);
return { ...pixels[idx] };
});
let assignment = new Array<number>(pixels.length).fill(0);
for (let iter = 0; iter < maxIterations; iter++) {
let moved = false;
// Assign each pixel to nearest centroid.
for (let p = 0; p < pixels.length; p++) {
let best = 0;
let bestDist = Infinity;
for (let c = 0; c < realK; c++) {
const d = dist2(pixels[p], centroids[c]);
if (d < bestDist) {
bestDist = d;
best = c;
}
}
if (assignment[p] !== best) {
assignment[p] = best;
moved = true;
}
}
// Recompute centroids.
const sums = Array.from({ length: realK }, () => ({ r: 0, g: 0, b: 0, n: 0 }));
for (let p = 0; p < pixels.length; p++) {
const s = sums[assignment[p]];
s.r += pixels[p].r;
s.g += pixels[p].g;
s.b += pixels[p].b;
s.n += 1;
}
centroids = sums.map((s, c) =>
s.n === 0 ? centroids[c] : { r: s.r / s.n, g: s.g / s.n, b: s.b / s.n },
);
if (!moved && iter > 0) break;
}
// Order clusters by population.
const counts = new Array<number>(realK).fill(0);
for (const a of assignment) counts[a] += 1;
return centroids
.map((c, i) => ({ hex: rgbToHex(c), n: counts[i] }))
.filter((x) => x.n > 0)
.sort((a, b) => b.n - a.n)
.map((x) => x.hex);
}
function dist2(a: RGB, b: RGB): number {
const dr = a.r - b.r;
const dg = a.g - b.g;
const db = a.b - b.b;
return dr * dr + dg * dg + db * db;
}
@@ -0,0 +1,28 @@
// On-device QR generation (PRD §15.2 `qrGenerator.test.ts`). Wraps the `qrcode`
// library, exposing the raw module matrix (for Skia rendering on the card back)
// and a data URL (for quick previews / share overlays).
import QRCode from "qrcode";
/**
* Produce the QR module matrix for `text`. `matrix[row][col] === true` means a
* dark module. The matrix is always square.
*/
export function qrMatrix(text: string): boolean[][] {
const qr = QRCode.create(text, { errorCorrectionLevel: "M" });
const size = qr.modules.size;
const data = qr.modules.data;
const matrix: boolean[][] = [];
for (let row = 0; row < size; row++) {
const cols: boolean[] = [];
for (let col = 0; col < size; col++) {
cols.push(Boolean(data[row * size + col]));
}
matrix.push(cols);
}
return matrix;
}
export function qrDataUrl(text: string): Promise<string> {
return QRCode.toDataURL(text, { errorCorrectionLevel: "M", margin: 1, width: 512 });
}
@@ -0,0 +1,31 @@
// vCard 3.0 (RFC 6350) serializer — mirrors the backend `vcard` module so the
// app produces identical .vcf output (PRD §15.2 `vcfExporter.test.ts`, §17.3).
import { ContactFields } from "../../types/card";
function escape(value: string): string {
return value
.replace(/\\/g, "\\\\")
.replace(/;/g, "\\;")
.replace(/,/g, "\\,")
.replace(/\n/g, "\\n");
}
export function buildVcard(displayName: string, c: ContactFields): string {
const [first, ...rest] = displayName.split(" ");
const last = rest.join(" ");
const lines = [
"BEGIN:VCARD",
"VERSION:3.0",
`FN:${escape(displayName)}`,
`N:${escape(last)};${escape(first ?? "")};;;`,
];
if (c.company) lines.push(`ORG:${escape(c.company)}`);
if (c.title) lines.push(`TITLE:${escape(c.title)}`);
if (c.phone) lines.push(`TEL;TYPE=CELL:${escape(c.phone)}`);
if (c.email) lines.push(`EMAIL:${escape(c.email)}`);
if (c.website) lines.push(`URL:${escape(c.website)}`);
if (c.linkedin) lines.push(`X-SOCIALPROFILE;TYPE=linkedin:${escape(c.linkedin)}`);
lines.push("END:VCARD");
return lines.join("\r\n") + "\r\n";
}