Phase 1 foundation: backend, web profile, and mobile app
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:
@@ -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";
|
||||
}
|
||||
Reference in New Issue
Block a user