From edddeaded36255a85c3d1353fa383de66850e134 Mon Sep 17 00:00:00 2001 From: Cody Date: Wed, 30 Sep 2026 12:06:52 -0700 Subject: [PATCH 01/22] Add opt-in Access validation and preserve account ownership --- migrations/0004_access_identities.sql | 9 +++ package-lock.json | 10 +++ package.json | 1 + src/worker/access.ts | 76 +++++++++++++++++++++++ src/worker/index.ts | 24 +++++++- tests/access-account.test.ts | 87 +++++++++++++++++++++++++++ tests/access-token.test.ts | 57 ++++++++++++++++++ 7 files changed, 263 insertions(+), 1 deletion(-) create mode 100644 migrations/0004_access_identities.sql create mode 100644 src/worker/access.ts create mode 100644 tests/access-account.test.ts create mode 100644 tests/access-token.test.ts diff --git a/migrations/0004_access_identities.sql b/migrations/0004_access_identities.sql new file mode 100644 index 0000000..44ae201 --- /dev/null +++ b/migrations/0004_access_identities.sql @@ -0,0 +1,9 @@ +CREATE TABLE IF NOT EXISTS access_identities ( + issuer TEXT NOT NULL, + subject TEXT NOT NULL, + user_id TEXT NOT NULL, + created_at TEXT NOT NULL, + PRIMARY KEY (issuer, subject), + FOREIGN KEY (user_id) REFERENCES users(id) +); +CREATE INDEX IF NOT EXISTS idx_access_identities_user ON access_identities(user_id); diff --git a/package-lock.json b/package-lock.json index f5ef71d..f2a3b20 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,6 +8,7 @@ "name": "cardcap-app", "version": "0.0.0", "dependencies": { + "jose": "6.2.12", "react": "^19.2.6", "react-dom": "^19.2.6" }, @@ -3510,6 +3511,15 @@ "dev": true, "license": "ISC" }, + "node_modules/jose": { + "version": "6.2.12", + "resolved": "https://registry.npmjs.org/jose/-/jose-6.2.12.tgz", + "integrity": "sha512-9NiFmJEex0sy2Dk58j2UGBSHgUs2ypF9eZSu4L6vjOX3Dp96Sw1F3uL+H+D1sx02jZZdzUT0HgvCy59CuvXcWw==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, "node_modules/js-tokens": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", diff --git a/package.json b/package.json index 3d4a977..0475f87 100644 --- a/package.json +++ b/package.json @@ -15,6 +15,7 @@ "test:browser:server": "tsx e2e/browser/server.ts" }, "dependencies": { + "jose": "6.2.12", "react": "^19.2.6", "react-dom": "^19.2.6" }, diff --git a/src/worker/access.ts b/src/worker/access.ts new file mode 100644 index 0000000..79e7eff --- /dev/null +++ b/src/worker/access.ts @@ -0,0 +1,76 @@ +import { createRemoteJWKSet, jwtVerify } from 'jose' +import { envValue } from './config' +import type { CurrentUser } from './auth' + +export class AccessConfigurationError extends Error {} +export class AccessAccountConflictError extends Error {} + +export function authenticationMode(env: Env): 'legacy' | 'access' { + const mode = envValue(env, 'AUTH_MODE') || 'legacy' + if (mode !== 'legacy' && mode !== 'access') throw new AccessConfigurationError('Invalid authentication mode') + return mode +} + +const keySets = new Map>() + +export async function accessIdentity(request: Request, env: Env): Promise<{ issuer: string; subject: string; email: string } | null> { + const issuer = envValue(env, 'ACCESS_ISSUER') + const audience = envValue(env, 'ACCESS_AUDIENCE') + const hosts = envValue(env, 'ACCESS_ALLOWED_HOSTS').split(',').map(host => host.trim().toLowerCase()).filter(Boolean) + if (!/^https:\/\/[a-z0-9-]+\.cloudflareaccess\.com$/.test(issuer) || !audience || !hosts.length || + hosts.some(host => !/^[a-z0-9.-]+(?::[0-9]+)?$/.test(host))) { + throw new AccessConfigurationError('Access is not configured') + } + const url = new URL(request.url) + if (!hosts.includes(url.host.toLowerCase())) return null + const hostHeader = request.headers.get('host') + if (hostHeader && hostHeader.toLowerCase() !== url.host.toLowerCase()) return null + const token = request.headers.get('Cf-Access-Jwt-Assertion') + if (!token || token.length > 16384) return null + let keySet = keySets.get(issuer) + if (!keySet) { + keySet = createRemoteJWKSet(new URL(`${issuer}/cdn-cgi/access/certs`), { timeoutDuration: 5000 }) + keySets.set(issuer, keySet) + } + try { + const { payload } = await jwtVerify(token, keySet, { + issuer, audience, algorithms: ['RS256'], requiredClaims: ['iss', 'aud', 'exp', 'iat', 'sub'], + }) + const now = Math.floor(Date.now() / 1000) + if (payload.type !== 'app' || typeof payload.iat !== 'number' || payload.iat > now || + typeof payload.sub !== 'string' || !payload.sub.trim() || payload.sub.length > 512 || + typeof payload.email !== 'string' || payload.email.length > 254) return null + const email = payload.email.trim().toLowerCase() + if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) return null + return { issuer, subject: payload.sub, email } + } catch { + return null + } +} + +export async function resolveAccessUser(db: D1Database, identity: { issuer: string; subject: string; email: string }): Promise { + const { issuer, subject, email } = identity + const readMapping = () => db.prepare(`SELECT users.id, users.email FROM access_identities + JOIN users ON users.id = access_identities.user_id WHERE issuer = ? AND subject = ?`).bind(issuer, subject).first() + const existing = await readMapping() + const candidates = await db.prepare('SELECT id FROM users WHERE lower(trim(email)) = ? LIMIT 2').bind(email).all<{ id: string }>() + if (candidates.results.length > 1 || (existing && existing.email.trim().toLowerCase() !== email)) { + throw new AccessAccountConflictError('Account mapping requires review') + } + if (existing) return existing + const digest = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(`${issuer}\0${subject}`)) + const id = `access:${Array.from(new Uint8Array(digest), byte => byte.toString(16).padStart(2, '0')).join('')}` + const now = new Date().toISOString() + // D1 batches are transactional; a concurrent first login cannot replace an owner ID. + await db.batch([ + db.prepare(`INSERT OR IGNORE INTO users (id, email, name, auth_provider, created_at, updated_at) + SELECT ?, ?, ?, 'access', ?, ? WHERE NOT EXISTS (SELECT 1 FROM users WHERE lower(trim(email)) = ?)`) + .bind(id, email, email.split('@')[0], now, now, email), + db.prepare(`INSERT OR IGNORE INTO access_identities (issuer, subject, user_id, created_at) + SELECT ?, ?, id, ? FROM users WHERE lower(trim(email)) = ? + AND (SELECT count(*) FROM users WHERE lower(trim(email)) = ?) = 1`).bind(issuer, subject, now, email, email), + ]) + const mapped = await readMapping() + if (!mapped || mapped.email.trim().toLowerCase() !== email) throw new AccessAccountConflictError('Account mapping requires review') + return mapped +} diff --git a/src/worker/index.ts b/src/worker/index.ts index 2741af1..a3f673b 100644 --- a/src/worker/index.ts +++ b/src/worker/index.ts @@ -22,6 +22,7 @@ import { verifyLogin, } from './auth' import { extractCard } from './extract' +import { accessIdentity, authenticationMode, resolveAccessUser, AccessAccountConflictError, AccessConfigurationError } from './access' import { ContactConflictError, countExtractionJobsSince, @@ -47,6 +48,8 @@ export default { ctx.waitUntil(Promise.resolve()) return response } catch (error) { + if (error instanceof AccessConfigurationError) return json({ error: 'Sign-in is temporarily unavailable.' }, 503) + if (error instanceof AccessAccountConflictError) return json({ error: 'Account access requires review.' }, 403) if (error instanceof ContactConflictError) return json({ error: 'Contact id is not available' }, 409) console.error(JSON.stringify({ level: 'error', errorType: error instanceof Error ? error.name : 'UnknownError', path: url.pathname })) return json({ error: 'Unable to complete this request. Please try again.' }, 500) @@ -55,6 +58,25 @@ export default { } satisfies ExportedHandler async function route(request: Request, env: Env, url: URL): Promise { + const mode = authenticationMode(env) + let accessUser = null + if (mode === 'access') { + const identity = await accessIdentity(request, env) + if (!identity) return json({ error: 'Sign in to continue.', loginUrl: '/cdn-cgi/access/login' }, 401) + accessUser = await resolveAccessUser(env.DB, identity) + if (['/api/auth/request-link', '/api/auth/password-login', '/api/auth/verify', '/api/auth/set-password'].includes(url.pathname)) { + return json({ error: 'Use Access to sign in.' }, 410) + } + if (request.method === 'GET' && url.pathname === '/api/auth/config') { + return json({ mode: 'access', loginUrl: '/cdn-cgi/access/login', logoutUrl: '/cdn-cgi/access/logout' }) + } + if (request.method === 'POST' && url.pathname === '/api/auth/logout') { + const response = logout() + return new Response(JSON.stringify({ logoutUrl: '/cdn-cgi/access/logout' }), { + headers: response.headers, + }) + } + } if (request.method === 'GET' && url.pathname === '/api/health') { return json({ ok: true, extractor: env.AI_EXTRACTOR }) } @@ -79,7 +101,7 @@ async function route(request: Request, env: Env, url: URL): Promise { return logout() } - const user = await currentUser(request, env) + const user = accessUser ?? await currentUser(request, env) if (!user) { return json({ error: 'Sign in to continue.' }, 401) } diff --git a/tests/access-account.test.ts b/tests/access-account.test.ts new file mode 100644 index 0000000..f7893fc --- /dev/null +++ b/tests/access-account.test.ts @@ -0,0 +1,87 @@ +import { DatabaseSync } from 'node:sqlite' +import { readFileSync } from 'node:fs' +import { describe, expect, it } from 'vitest' +import { authenticationMode, resolveAccessUser } from '../src/worker/access' + +function database() { + const sqlite = new DatabaseSync(':memory:') + sqlite.exec('PRAGMA foreign_keys = ON') + for (const name of ['0001_initial.sql', '0002_login_tokens.sql', '0003_password_auth.sql', '0004_access_identities.sql']) { + sqlite.exec(readFileSync(new URL(`../migrations/${name}`, import.meta.url), 'utf8')) + } + const db = { + prepare(sql: string) { + let args: unknown[] = [] + const statement = { + bind(...values: unknown[]) { args = values; return statement }, + async run() { return sqlite.prepare(sql).run(...args as []) }, + async all() { return { results: sqlite.prepare(sql).all(...args as []) } }, + async first() { return sqlite.prepare(sql).get(...args as []) ?? null }, + } + return statement + }, + async batch(statements: Array<{ run(): Promise }>) { + sqlite.exec('BEGIN') + try { + const results = [] + for (const statement of statements) results.push(await statement.run()) + sqlite.exec('COMMIT') + return results + } catch (error) { sqlite.exec('ROLLBACK'); throw error } + }, + } as unknown as D1Database + function user(id: string, email: string) { + sqlite.prepare("INSERT INTO users VALUES (?, ?, 'Owner', 'local', 'before', 'before')").run(id, email) + } + return { db, sqlite, user } +} + +const identity = { issuer: 'https://test.cloudflareaccess.com', subject: 'google-subject', email: 'owner@example.invalid' } + +describe('Access account compatibility using real migrations', () => { + it('preserves noncanonical ownership and credentials across Google and PIN identities', async () => { + const { db, sqlite, user } = database() + user('old-opaque-id', 'Owner@Example.invalid') + sqlite.prepare("INSERT INTO user_passwords VALUES ('old-opaque-id', 'preserved-hash', 'before')").run() + sqlite.prepare("INSERT INTO contacts (id,user_id,name,source_image_key,created_at,updated_at) VALUES ('private-contact','old-opaque-id','Private name','private/image.png','before','before')").run() + expect(await resolveAccessUser(db, identity)).toEqual({ id: 'old-opaque-id', email: 'Owner@Example.invalid' }) + expect((await resolveAccessUser(db, { ...identity, subject: 'pin-subject' })).id).toBe('old-opaque-id') + expect(sqlite.prepare('SELECT user_id,name,source_image_key FROM contacts').get()).toEqual({ user_id: 'old-opaque-id', name: 'Private name', source_image_key: 'private/image.png' }) + expect(sqlite.prepare('SELECT password_hash FROM user_passwords').get()).toEqual({ password_hash: 'preserved-hash' }) + expect(sqlite.prepare('SELECT count(*) AS count FROM users').get()).toEqual({ count: 1 }) + }) + + it('creates one stable account on repeated first login without a password row', async () => { + const { db, sqlite } = database() + const first = await resolveAccessUser(db, identity) + expect(first.email).toBe('owner@example.invalid') + expect(first.id).toMatch(/^access:[a-f0-9]{64}$/) + expect(await resolveAccessUser(db, identity)).toEqual(first) + expect(sqlite.prepare('SELECT count(*) AS count FROM users').get()).toEqual({ count: 1 }) + expect(sqlite.prepare('SELECT count(*) AS count FROM user_passwords').get()).toEqual({ count: 0 }) + }) + + it('rejects ambiguous normalized emails without claiming either account', async () => { + const { db, sqlite, user } = database() + user('one', 'owner@example.invalid'); user('two', ' Owner@Example.invalid ') + await expect(resolveAccessUser(db, identity)).rejects.toThrow('Account mapping requires review') + expect(sqlite.prepare('SELECT count(*) AS count FROM access_identities').get()).toEqual({ count: 0 }) + }) + + it('rejects a subject whose email changes instead of silently remapping data', async () => { + const { db, user } = database() + user('original', identity.email); user('other', 'other@example.invalid') + await resolveAccessUser(db, identity) + await expect(resolveAccessUser(db, { ...identity, email: 'other@example.invalid' })).rejects.toThrow('Account mapping requires review') + }) + + it('does not quietly create a missing migration or fall back to legacy auth', async () => { + const { db, sqlite } = database() + sqlite.exec('DROP TABLE access_identities') + await expect(resolveAccessUser(db, identity)).rejects.toThrow('no such table') + expect(authenticationMode({} as Env)).toBe('legacy') + expect(() => authenticationMode({ AUTH_MODE: 'typo' } as unknown as Env)).toThrow('Invalid authentication mode') + }) +}) + + diff --git a/tests/access-token.test.ts b/tests/access-token.test.ts new file mode 100644 index 0000000..91410f4 --- /dev/null +++ b/tests/access-token.test.ts @@ -0,0 +1,57 @@ +import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest' +import { exportJWK, generateKeyPair, SignJWT } from 'jose' +import { accessIdentity } from '../src/worker/access' + +const issuer = 'https://crypto-test.cloudflareaccess.com' +const audience = 'test-app-audience' +const env = { ACCESS_ISSUER: issuer, ACCESS_AUDIENCE: audience, ACCESS_ALLOWED_HOSTS: 'cardcap.test' } as unknown as Env +let keys: Awaited> +let foreignKeys: Awaited> + +beforeAll(async () => { + keys = await generateKeyPair('RS256', { extractable: true }) + foreignKeys = await generateKeyPair('RS256') + const jwk = { ...await exportJWK(keys.publicKey), kid: 'test-key', alg: 'RS256', use: 'sig' } + vi.stubGlobal('fetch', async (input: string | URL | Request) => { + const url = input instanceof Request ? input.url : String(input) + if (url !== `${issuer}/cdn-cgi/access/certs`) throw new Error('Unexpected key source') + return Response.json({ keys: [jwk] }) + }) +}) +afterAll(() => vi.unstubAllGlobals()) + +async function token(changes: Record = {}, foreign = false) { + const now = Math.floor(Date.now() / 1000) + return new SignJWT({ iss: issuer, aud: audience, sub: 'human-123', email: 'Owner@Example.invalid', type: 'app', iat: now, exp: now + 3600, ...changes }) + .setProtectedHeader({ alg: 'RS256', kid: 'test-key' }).sign(foreign ? foreignKeys.privateKey : keys.privateKey) +} +function request(jwt?: string, url = 'https://cardcap.test/api/me', headers: Record = {}) { + return new Request(url, { headers: { ...(jwt ? { 'Cf-Access-Jwt-Assertion': jwt } : {}), ...headers } }) +} + +describe('signed Access identity verification', () => { + it('accepts a real RSA signature and normalizes the signed email', async () => { + expect(await accessIdentity(request(await token()), env)).toEqual({ issuer, subject: 'human-123', email: 'owner@example.invalid' }) + }) + it.each([ + { iss: 'https://attacker.cloudflareaccess.com' }, { aud: 'other-app' }, + { exp: 1 }, { iat: 9999999999 }, { nbf: 9999999999 }, + { sub: '' }, { email: null }, { email: 'not-an-email' }, { type: 'org' }, + { exp: undefined }, { iat: undefined }, + ])('rejects invalid signed claims %j', async changes => { + expect(await accessIdentity(request(await token(changes)), env)).toBeNull() + }) + it('rejects a signature from an unrelated key', async () => { + expect(await accessIdentity(request(await token({}, true)), env)).toBeNull() + }) + it('rejects an unsigned token and header-only identity', async () => { + const encoded = btoa(JSON.stringify({ email: 'owner@example.invalid', sub: 'human-123' })) + expect(await accessIdentity(request(`eyJhbGciOiJub25lIn0.${encoded}.`), env)).toBeNull() + expect(await accessIdentity(request(undefined, undefined, { 'Cf-Access-Authenticated-User-Email': 'owner@example.invalid', Cookie: 'cardcap_session=forged; CF_Authorization=forged' }), env)).toBeNull() + }) + it('rejects alternate hosts even with a valid signed token and forwarded host', async () => { + const jwt = await token() + expect(await accessIdentity(request(jwt, 'https://alternate.workers.dev/api/me', { 'X-Forwarded-Host': 'cardcap.test' }), env)).toBeNull() + expect(await accessIdentity(request(jwt, undefined, { Host: 'alternate.workers.dev' }), env)).toBeNull() + }) +}) From d0329fe487fd69d6155d902fbeb14d6bd9cf94b2 Mon Sep 17 00:00:00 2001 From: Cody Date: Wed, 30 Sep 2026 11:52:16 -0700 Subject: [PATCH 02/22] test: run e2e suites on a migration-built D1 database Apply migrations/*.sql in the harness instead of relying on the Worker's runtime ensureSchema, and add guards that prove indexes and production foreign keys exist. Co-Authored-By: Claude Sonnet 5.5 --- e2e/harness.ts | 17 ++++++++++++++++- e2e/schema.e2e.test.ts | 30 ++++++++++++++++++++++++++++++ 2 files changed, 46 insertions(+), 1 deletion(-) create mode 100644 e2e/schema.e2e.test.ts diff --git a/e2e/harness.ts b/e2e/harness.ts index 9cd8536..1018f12 100644 --- a/e2e/harness.ts +++ b/e2e/harness.ts @@ -1,4 +1,4 @@ -import { mkdirSync, mkdtempSync, readFileSync, writeFileSync } from 'node:fs' +import { mkdirSync, mkdtempSync, readdirSync, readFileSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { fileURLToPath } from 'node:url' import { dirname, join, resolve } from 'node:path' @@ -99,6 +99,7 @@ export async function startHarness(): Promise { }, }) await mf.ready + await applyMigrations(mf, root) const origin = 'https://cardcap.test' @@ -164,3 +165,17 @@ export function uploadForm(fileName: string, type = 'image/png', bytes: Uint8Arr export function emptyForm(): FormData { return new MfFormData() as unknown as FormData } + +// Build the test database the way production is built: apply migrations/*.sql in order (not the Worker's runtime ensureSchema). +// D1 enforces the foreign keys these migrations declare, so ordering bugs surface here like they would in production. +async function applyMigrations(mf: Miniflare, root: string): Promise { + const dir = join(root, 'migrations') + const db = await mf.getD1Database('DB') + const files = readdirSync(dir).filter((f) => /^\d+.*\.sql$/.test(f)).sort() + if (files.length === 0) throw new Error(`no migrations found in ${dir}`) + for (const file of files) { + const sql = readFileSync(join(dir, file), 'utf8').replace(/--.*$/gm, '') + const statements = sql.split(/;\s*(?:\r?\n|$)/).map((stmt) => stmt.trim()).filter(Boolean) + await db.batch(statements.map((stmt) => db.prepare(stmt))) + } +} diff --git a/e2e/schema.e2e.test.ts b/e2e/schema.e2e.test.ts new file mode 100644 index 0000000..99046ce --- /dev/null +++ b/e2e/schema.e2e.test.ts @@ -0,0 +1,30 @@ +import { afterEach, beforeEach, expect, it } from 'vitest' +import { startHarness, type Harness } from './harness' + +let h: Harness +beforeEach(async () => { + h = await startHarness() +}) +afterEach(async () => { + await h.dispose() +}) + +// Guards the harness itself: the suites must run on a migration-built database, like production, +// not on the tables the Worker would create at runtime. +it('the test database is built from migrations (indexes and foreign keys that only migrations define)', async () => { + const db = await h.mf.getD1Database('DB') + const indexes = (await db.prepare("SELECT name FROM sqlite_master WHERE type = 'index'").all<{ name: string }>()).results.map((r) => r.name) + expect(indexes).toEqual(expect.arrayContaining(['idx_contacts_user_updated', 'idx_contacts_user_name', 'idx_jobs_contact', 'idx_login_tokens_email', 'idx_users_email'])) + const jobFks = (await db.prepare('PRAGMA foreign_key_list(extraction_jobs)').all<{ table: string }>()).results.map((r) => r.table).sort() + expect(jobFks).toEqual(['contacts', 'users']) +}) + +it('an extraction job cannot reference a contact that does not exist (production foreign key)', async () => { + const db = await h.mf.getD1Database('DB') + await db.prepare("INSERT INTO users (id, email, name, created_at, updated_at) VALUES ('u1','u1@example.test','u1','t','t')").run() + await expect( + db + .prepare("INSERT INTO extraction_jobs (id, user_id, contact_id, source_image_key, status, created_at, updated_at) VALUES ('j1','u1','missing','k','extracted','t','t')") + .run(), + ).rejects.toThrow(/FOREIGN KEY/i) +}) From acc386855e434f1d85500c77086404cf10aa7ac1 Mon Sep 17 00:00:00 2001 From: Cody Date: Wed, 30 Sep 2026 11:57:21 -0700 Subject: [PATCH 03/22] feat(ui): apply the CSI Ledger design system to CardCap Use the shared CSI stylesheet, tokens, Atkinson fonts and logo files. Square corners, 1px rules, 44px targets, visible focus ring, dark mode, grouped toolbar, skip link, alert/status roles on messages, and a single-column phone layout. No auth or API changes. Co-Authored-By: Claude Sonnet 5.5 --- index.html | 10 +- public/brand/csi-monogram-dark.svg | 1 + public/brand/csi-monogram-light.svg | 1 + public/brand/csi-wordmark-dark.svg | 1 + public/brand/csi-wordmark-light.svg | 1 + public/favicon.svg | 2 +- src/App.css | 632 +++++++++++++++++++--------- src/App.tsx | 35 +- src/csi.css | 137 ++++++ src/index.css | 13 +- src/main.tsx | 1 + 11 files changed, 615 insertions(+), 219 deletions(-) create mode 100644 public/brand/csi-monogram-dark.svg create mode 100644 public/brand/csi-monogram-light.svg create mode 100644 public/brand/csi-wordmark-dark.svg create mode 100644 public/brand/csi-wordmark-light.svg create mode 100644 src/csi.css diff --git a/index.html b/index.html index 742e507..7c14bb6 100644 --- a/index.html +++ b/index.html @@ -4,7 +4,15 @@ - cardcap-app + + + + + + CardCap | Copper State Intelligence
diff --git a/public/brand/csi-monogram-dark.svg b/public/brand/csi-monogram-dark.svg new file mode 100644 index 0000000..cb60041 --- /dev/null +++ b/public/brand/csi-monogram-dark.svg @@ -0,0 +1 @@ +AAAWgmp1bWIAAAAeanVtZGMycGEAEQAQgAAAqgA4m3EDYzJwYQAAABZcanVtYgAAAEdqdW1kYzJtYQARABCAAACqADibcQN1cm46YzJwYTpjMjkzYWZmMi0xZDk0LTQ5ZDMtYTIxMi0yMjZlNzNkMDI1ZWQAAAADl2p1bWIAAAApanVtZGMyYXMAEQAQgAAAqgA4m3EDYzJwYS5hc3NlcnRpb25zAAAAALxqdW1iAAAARGp1bWRjYm9yABEAEIAAAKoAOJtxE2MycGEuaW5ncmVkaWVudC52MwAAAAAYYzJzaIP9WyX7bAJpQWbl+2h8ocAAAABwY2JvcqNpZGM6Zm9ybWF0bWltYWdlL3N2Zyt4bWxqaW5zdGFuY2VJRHgseG1wOmlpZDo1NjcyN2IyZS1lMzZmLTQ1ODAtOGJhNC1jN2RiNjE0N2MzMDBscmVsYXRpb25zaGlwaHBhcmVudE9mAAAB4mp1bWIAAABBanVtZGNib3IAEQAQgAAAqgA4m3ETYzJwYS5hY3Rpb25zLnYyAAAAABhjMnNoCm8GxqMfk489Fei+7AbfdgAAAZljYm9yomdhY3Rpb25zgqJmYWN0aW9ua2MycGEub3BlbmVkanBhcmFtZXRlcnOha2luZ3JlZGllbnRzgaJjdXJseC1zZWxmI2p1bWJmPWMycGEuYXNzZXJ0aW9ucy9jMnBhLmluZ3JlZGllbnQudjNkaGFzaFggt+dKYNIKgCNsslykJfZl1jLZNVKOvq7PsNIuPKIGwRqkZmFjdGlvbngdY29tLmFudGhyb3BpYy5jbGF1ZGUucHJvdmlkZWRqcGFyYW1ldGVyc6F4H2NvbS5hbnRocm9waWMub3JpZ2luLWNvbmZpZGVuY2VndW5rbm93bmtkZXNjcmlwdGlvbnhmQ2xhdWRlIHByb3ZpZGVkIHRoaXMgZmlsZSBhdCB0aGUgcmVxdWVzdCBvZiBhIHVzZXIgYW5kIG1heSBoYXZlIGNyZWF0ZWQgb3IgbW9kaWZpZWQgdGhlIGZpbGUgY29udGVudHMubXNvZnR3YXJlQWdlbnShZG5hbWVmQ2xhdWRlcmFsbEFjdGlvbnNJbmNsdWRlZPUAAADIanVtYgAAAEBqdW1kY2JvcgARABCAAACqADibcRNjMnBhLmhhc2guZGF0YQAAAAAYYzJzaDOIg7+p+fcOpYlj1LM8/5UAAACAY2JvcqVjYWxnZnNoYTI1NmNwYWRNAAAAAAAAAAAAAAAAAGRoYXNoWCDCcyxGtjWOBT8wk6LMy2NxBY85rjUNZjJkPyut8uRwKWRuYW1lbmp1bWJmIG1hbmlmZXN0amV4Y2x1c2lvbnOBomVzdGFydBiSZmxlbmd0aBkeBAAAAj5qdW1iAAAAJ2p1bWRjMmNsABEAEIAAAKoAOJtxA2MycGEuY2xhaW0udjIAAAACD2Nib3KlY2FsZ2ZzaGEyNTZpc2lnbmF0dXJleE1zZWxmI2p1bWJmPS9jMnBhL3VybjpjMnBhOmMyOTNhZmYyLTFkOTQtNDlkMy1hMjEyLTIyNmU3M2QwMjVlZC9jMnBhLnNpZ25hdHVyZWppbnN0YW5jZUlEeCx4bXA6aWlkOjM5Y2IxNDQ1LTU4MjAtNGFiYS1hNTc5LTFlMTU0ZTIzZTc5YXJjcmVhdGVkX2Fzc2VydGlvbnODomN1cmx4LXNlbGYjanVtYmY9YzJwYS5hc3NlcnRpb25zL2MycGEuaW5ncmVkaWVudC52M2RoYXNoWCC350pg0gqAI2yyXKQl9mXWMtk1Uo6+rs+w0i48ogbBGqJjdXJseCpzZWxmI2p1bWJmPWMycGEuYXNzZXJ0aW9ucy9jMnBhLmFjdGlvbnMudjJkaGFzaFgg7etWS5/SkiyKYd0If3aHQdNk0x2EoMrVyL6JfaY62QCiY3VybHgpc2VsZiNqdW1iZj1jMnBhLmFzc2VydGlvbnMvYzJwYS5oYXNoLmRhdGFkaGFzaFgg6Vw0ifRDj76Vq5LOj0HHSoztrtWHGf8M4wVprb26rKJ0Y2xhaW1fZ2VuZXJhdG9yX2luZm+jZG5hbWVvQW50aHJvcGljIEZpbGVzZ3ZlcnNpb25lMS4wLjBrc3BlY1ZlcnNpb25lMi40LjAAABA4anVtYgAAAChqdW1kYzJjcwARABCAAACqADibcQNjMnBhLnNpZ25hdHVyZQAAABAIY2JvctKEWQISogEmGCFZAgowggIGMIIBjaADAgECAhRA5aAK7sI50L64g/oGQgU9Z1UTADAKBggqhkjOPQQDAzBJMRcwFQYDVQQKEw5BbnRocm9waWMsIFBCQzEuMCwGA1UEAxMlQW50aHJvcGljIENvbnRlbnQgQ3JlZGVudGlhbHMgUm9vdCBDQTAeFw0yNjA4MDcxODQzNTZaFw0yODA4MDYxOTQzNTZaMEQxFzAVBgNVBAoTDkFudGhyb3BpYywgUEJDMSkwJwYDVQQDEyBBbnRocm9waWMgQ2xhdWRlIENvbnRlbnQgU2lnbmluZzBZMBMGByqGSM49AgEGCCqGSM49AwEHA0IABJh6CmvLUBgFFNU0vUKlOVtE6djd17L5SuwX0LemFisBM3dkd/3cyjxFA3Qo5S46fX0/ihY0VZ7mfb9KF703t5OjWDBWMA4GA1UdDwEB/wQEAwIHgDAVBgNVHSUEDjAMBgorBgEEAYPoXgIBMAwGA1UdEwEB/wQCMAAwHwYDVR0jBBgwFoAUzlHiBIFOZFsj+OPEz5o+nMHXXMIwCgYIKoZIzj0EAwMDZwAwZAIwMXMdFJ4BetLLVY7ORuE9noqbbAZOZn/aArXyTwFAZfKrPzxF2vPoJNf1+UCdg1XGAjBwX1zd9WGqYkqmL5SFqw1QySjr1zJfpJM9+1rdDwSPLMOPOjKuiXjoU/pUUeG9RwmhY3BhZFkNngAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAPZYQKFz0OdbEJXz5OcQ8nTwyLG8qEHaR+7sLZ7dDTXBetsF9/zWdZu2u2+TqbnCwb4K5xUsHJPmHocqrHbTk+7u7NY=CSI \ No newline at end of file diff --git a/public/brand/csi-monogram-light.svg b/public/brand/csi-monogram-light.svg new file mode 100644 index 0000000..d83dd6b --- /dev/null +++ b/public/brand/csi-monogram-light.svg @@ -0,0 +1 @@ +AAAWgmp1bWIAAAAeanVtZGMycGEAEQAQgAAAqgA4m3EDYzJwYQAAABZcanVtYgAAAEdqdW1kYzJtYQARABCAAACqADibcQN1cm46YzJwYTo4MzM4YzhlYy0wMmQ4LTQ2NjgtYTMxMS0wNzdjNjNkMTZmODgAAAADl2p1bWIAAAApanVtZGMyYXMAEQAQgAAAqgA4m3EDYzJwYS5hc3NlcnRpb25zAAAAALxqdW1iAAAARGp1bWRjYm9yABEAEIAAAKoAOJtxE2MycGEuaW5ncmVkaWVudC52MwAAAAAYYzJzaF767vFsvmKCTTjDkXDvlW4AAABwY2JvcqNpZGM6Zm9ybWF0bWltYWdlL3N2Zyt4bWxqaW5zdGFuY2VJRHgseG1wOmlpZDo3MjVkN2VmMy04NzQyLTQyZWQtOWNkYi00MmEzNTIxNTkwNjVscmVsYXRpb25zaGlwaHBhcmVudE9mAAAB4mp1bWIAAABBanVtZGNib3IAEQAQgAAAqgA4m3ETYzJwYS5hY3Rpb25zLnYyAAAAABhjMnNoRgUtxe69wjxmCDl5o26akwAAAZljYm9yomdhY3Rpb25zgqJmYWN0aW9ua2MycGEub3BlbmVkanBhcmFtZXRlcnOha2luZ3JlZGllbnRzgaJjdXJseC1zZWxmI2p1bWJmPWMycGEuYXNzZXJ0aW9ucy9jMnBhLmluZ3JlZGllbnQudjNkaGFzaFggue+sdmGV0fEkOAjPcWntzR646ESkFk7yM1IBuZGf3VCkZmFjdGlvbngdY29tLmFudGhyb3BpYy5jbGF1ZGUucHJvdmlkZWRqcGFyYW1ldGVyc6F4H2NvbS5hbnRocm9waWMub3JpZ2luLWNvbmZpZGVuY2VndW5rbm93bmtkZXNjcmlwdGlvbnhmQ2xhdWRlIHByb3ZpZGVkIHRoaXMgZmlsZSBhdCB0aGUgcmVxdWVzdCBvZiBhIHVzZXIgYW5kIG1heSBoYXZlIGNyZWF0ZWQgb3IgbW9kaWZpZWQgdGhlIGZpbGUgY29udGVudHMubXNvZnR3YXJlQWdlbnShZG5hbWVmQ2xhdWRlcmFsbEFjdGlvbnNJbmNsdWRlZPUAAADIanVtYgAAAEBqdW1kY2JvcgARABCAAACqADibcRNjMnBhLmhhc2guZGF0YQAAAAAYYzJzaH7lCZ04pdC4cH4zhvaLy6EAAACAY2JvcqVjYWxnZnNoYTI1NmNwYWRNAAAAAAAAAAAAAAAAAGRoYXNoWCBwT46O7yRGvu1wvNNfz39zHSUDvFTyVa44vlQ3ZmplmWRuYW1lbmp1bWJmIG1hbmlmZXN0amV4Y2x1c2lvbnOBomVzdGFydBiSZmxlbmd0aBkeBAAAAj5qdW1iAAAAJ2p1bWRjMmNsABEAEIAAAKoAOJtxA2MycGEuY2xhaW0udjIAAAACD2Nib3KlY2FsZ2ZzaGEyNTZpc2lnbmF0dXJleE1zZWxmI2p1bWJmPS9jMnBhL3VybjpjMnBhOjgzMzhjOGVjLTAyZDgtNDY2OC1hMzExLTA3N2M2M2QxNmY4OC9jMnBhLnNpZ25hdHVyZWppbnN0YW5jZUlEeCx4bXA6aWlkOjdhZDFhZmQ2LTQ2MTAtNGE0OC1hZGQ5LWE2MjYzY2JlZDQ2OHJjcmVhdGVkX2Fzc2VydGlvbnODomN1cmx4LXNlbGYjanVtYmY9YzJwYS5hc3NlcnRpb25zL2MycGEuaW5ncmVkaWVudC52M2RoYXNoWCC576x2YZXR8SQ4CM9xae3NHrjoRKQWTvIzUgG5kZ/dUKJjdXJseCpzZWxmI2p1bWJmPWMycGEuYXNzZXJ0aW9ucy9jMnBhLmFjdGlvbnMudjJkaGFzaFggHnFn9nVNI+JSMpP6LoyawGSUnjUxyQ6s66LvofsF9aKiY3VybHgpc2VsZiNqdW1iZj1jMnBhLmFzc2VydGlvbnMvYzJwYS5oYXNoLmRhdGFkaGFzaFgg7GLqwj8snwE/zvVK4wCGndkiof+bJRVZI6pbO/ByWsd0Y2xhaW1fZ2VuZXJhdG9yX2luZm+jZG5hbWVvQW50aHJvcGljIEZpbGVzZ3ZlcnNpb25lMS4wLjBrc3BlY1ZlcnNpb25lMi40LjAAABA4anVtYgAAAChqdW1kYzJjcwARABCAAACqADibcQNjMnBhLnNpZ25hdHVyZQAAABAIY2JvctKEWQISogEmGCFZAgowggIGMIIBjaADAgECAhRA5aAK7sI50L64g/oGQgU9Z1UTADAKBggqhkjOPQQDAzBJMRcwFQYDVQQKEw5BbnRocm9waWMsIFBCQzEuMCwGA1UEAxMlQW50aHJvcGljIENvbnRlbnQgQ3JlZGVudGlhbHMgUm9vdCBDQTAeFw0yNjA4MDcxODQzNTZaFw0yODA4MDYxOTQzNTZaMEQxFzAVBgNVBAoTDkFudGhyb3BpYywgUEJDMSkwJwYDVQQDEyBBbnRocm9waWMgQ2xhdWRlIENvbnRlbnQgU2lnbmluZzBZMBMGByqGSM49AgEGCCqGSM49AwEHA0IABJh6CmvLUBgFFNU0vUKlOVtE6djd17L5SuwX0LemFisBM3dkd/3cyjxFA3Qo5S46fX0/ihY0VZ7mfb9KF703t5OjWDBWMA4GA1UdDwEB/wQEAwIHgDAVBgNVHSUEDjAMBgorBgEEAYPoXgIBMAwGA1UdEwEB/wQCMAAwHwYDVR0jBBgwFoAUzlHiBIFOZFsj+OPEz5o+nMHXXMIwCgYIKoZIzj0EAwMDZwAwZAIwMXMdFJ4BetLLVY7ORuE9noqbbAZOZn/aArXyTwFAZfKrPzxF2vPoJNf1+UCdg1XGAjBwX1zd9WGqYkqmL5SFqw1QySjr1zJfpJM9+1rdDwSPLMOPOjKuiXjoU/pUUeG9RwmhY3BhZFkNngAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAPZYQEFhxiupsZkJtrgXRBmFUc9/KBwudyFUJmUL8kpY5+8p/kRPjLgIILC329djLvzJEkZl4sY7UU0Vu4o4dq5JXh0=CSI \ No newline at end of file diff --git a/public/brand/csi-wordmark-dark.svg b/public/brand/csi-wordmark-dark.svg new file mode 100644 index 0000000..0879302 --- /dev/null +++ b/public/brand/csi-wordmark-dark.svg @@ -0,0 +1 @@ +AAAWgmp1bWIAAAAeanVtZGMycGEAEQAQgAAAqgA4m3EDYzJwYQAAABZcanVtYgAAAEdqdW1kYzJtYQARABCAAACqADibcQN1cm46YzJwYTozM2I5MTczZi02NzU3LTQ2OTgtOGYzMC0wZTU3MTkxMmVhNzgAAAADl2p1bWIAAAApanVtZGMyYXMAEQAQgAAAqgA4m3EDYzJwYS5hc3NlcnRpb25zAAAAALxqdW1iAAAARGp1bWRjYm9yABEAEIAAAKoAOJtxE2MycGEuaW5ncmVkaWVudC52MwAAAAAYYzJzaL9iQ7GaID1ZjFDCSja/04gAAABwY2JvcqNpZGM6Zm9ybWF0bWltYWdlL3N2Zyt4bWxqaW5zdGFuY2VJRHgseG1wOmlpZDpjYjIxMDhkMS01ODIzLTQ0YTAtYTZlMS0wYWI2ZTZlN2UyZjRscmVsYXRpb25zaGlwaHBhcmVudE9mAAAB4mp1bWIAAABBanVtZGNib3IAEQAQgAAAqgA4m3ETYzJwYS5hY3Rpb25zLnYyAAAAABhjMnNo2akkLAL2MiRrOQdOLnNj5QAAAZljYm9yomdhY3Rpb25zgqJmYWN0aW9ua2MycGEub3BlbmVkanBhcmFtZXRlcnOha2luZ3JlZGllbnRzgaJjdXJseC1zZWxmI2p1bWJmPWMycGEuYXNzZXJ0aW9ucy9jMnBhLmluZ3JlZGllbnQudjNkaGFzaFggjIu4+rF9f0O3u6NKDvxtYa6GO6tmZwZXnNm655IOTj2kZmFjdGlvbngdY29tLmFudGhyb3BpYy5jbGF1ZGUucHJvdmlkZWRqcGFyYW1ldGVyc6F4H2NvbS5hbnRocm9waWMub3JpZ2luLWNvbmZpZGVuY2VndW5rbm93bmtkZXNjcmlwdGlvbnhmQ2xhdWRlIHByb3ZpZGVkIHRoaXMgZmlsZSBhdCB0aGUgcmVxdWVzdCBvZiBhIHVzZXIgYW5kIG1heSBoYXZlIGNyZWF0ZWQgb3IgbW9kaWZpZWQgdGhlIGZpbGUgY29udGVudHMubXNvZnR3YXJlQWdlbnShZG5hbWVmQ2xhdWRlcmFsbEFjdGlvbnNJbmNsdWRlZPUAAADIanVtYgAAAEBqdW1kY2JvcgARABCAAACqADibcRNjMnBhLmhhc2guZGF0YQAAAAAYYzJzaE4SZshH72zpTkVxkioC4dkAAACAY2JvcqVjYWxnZnNoYTI1NmNwYWRNAAAAAAAAAAAAAAAAAGRoYXNoWCCUA70mW1ODMQemvT7ETaCpE3VaywelCYTChlRTcJ7yQGRuYW1lbmp1bWJmIG1hbmlmZXN0amV4Y2x1c2lvbnOBomVzdGFydBiUZmxlbmd0aBkeBAAAAj5qdW1iAAAAJ2p1bWRjMmNsABEAEIAAAKoAOJtxA2MycGEuY2xhaW0udjIAAAACD2Nib3KlY2FsZ2ZzaGEyNTZpc2lnbmF0dXJleE1zZWxmI2p1bWJmPS9jMnBhL3VybjpjMnBhOjMzYjkxNzNmLTY3NTctNDY5OC04ZjMwLTBlNTcxOTEyZWE3OC9jMnBhLnNpZ25hdHVyZWppbnN0YW5jZUlEeCx4bXA6aWlkOjg4MGQzYWJiLTM2NTYtNDAxOS1iNmVjLTFhNzJlMTNmMGMxMXJjcmVhdGVkX2Fzc2VydGlvbnODomN1cmx4LXNlbGYjanVtYmY9YzJwYS5hc3NlcnRpb25zL2MycGEuaW5ncmVkaWVudC52M2RoYXNoWCCMi7j6sX1/Q7e7o0oO/G1hroY7q2ZnBlec2brnkg5OPaJjdXJseCpzZWxmI2p1bWJmPWMycGEuYXNzZXJ0aW9ucy9jMnBhLmFjdGlvbnMudjJkaGFzaFggbFn+u0U1pEcNPcXj0apQT6tknCLpjEhqiXHcz+XJjl+iY3VybHgpc2VsZiNqdW1iZj1jMnBhLmFzc2VydGlvbnMvYzJwYS5oYXNoLmRhdGFkaGFzaFggRgFmWCr48ArkYSOk1YFdlNALQglY6Sq71MYODAW/Tgl0Y2xhaW1fZ2VuZXJhdG9yX2luZm+jZG5hbWVvQW50aHJvcGljIEZpbGVzZ3ZlcnNpb25lMS4wLjBrc3BlY1ZlcnNpb25lMi40LjAAABA4anVtYgAAAChqdW1kYzJjcwARABCAAACqADibcQNjMnBhLnNpZ25hdHVyZQAAABAIY2JvctKEWQISogEmGCFZAgowggIGMIIBjaADAgECAhRA5aAK7sI50L64g/oGQgU9Z1UTADAKBggqhkjOPQQDAzBJMRcwFQYDVQQKEw5BbnRocm9waWMsIFBCQzEuMCwGA1UEAxMlQW50aHJvcGljIENvbnRlbnQgQ3JlZGVudGlhbHMgUm9vdCBDQTAeFw0yNjA4MDcxODQzNTZaFw0yODA4MDYxOTQzNTZaMEQxFzAVBgNVBAoTDkFudGhyb3BpYywgUEJDMSkwJwYDVQQDEyBBbnRocm9waWMgQ2xhdWRlIENvbnRlbnQgU2lnbmluZzBZMBMGByqGSM49AgEGCCqGSM49AwEHA0IABJh6CmvLUBgFFNU0vUKlOVtE6djd17L5SuwX0LemFisBM3dkd/3cyjxFA3Qo5S46fX0/ihY0VZ7mfb9KF703t5OjWDBWMA4GA1UdDwEB/wQEAwIHgDAVBgNVHSUEDjAMBgorBgEEAYPoXgIBMAwGA1UdEwEB/wQCMAAwHwYDVR0jBBgwFoAUzlHiBIFOZFsj+OPEz5o+nMHXXMIwCgYIKoZIzj0EAwMDZwAwZAIwMXMdFJ4BetLLVY7ORuE9noqbbAZOZn/aArXyTwFAZfKrPzxF2vPoJNf1+UCdg1XGAjBwX1zd9WGqYkqmL5SFqw1QySjr1zJfpJM9+1rdDwSPLMOPOjKuiXjoU/pUUeG9RwmhY3BhZFkNngAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAPZYQN1Q+9apQqFT9Zo92/SfSA7mZwL5OKhhzfHrTzYWFIkFI9VvbW5UXvtjcWN0eW2Lnmn0wHkz46ge4d0LivjRmAI=Copper State Intelligence \ No newline at end of file diff --git a/public/brand/csi-wordmark-light.svg b/public/brand/csi-wordmark-light.svg new file mode 100644 index 0000000..6bbe129 --- /dev/null +++ b/public/brand/csi-wordmark-light.svg @@ -0,0 +1 @@ +AAAWgmp1bWIAAAAeanVtZGMycGEAEQAQgAAAqgA4m3EDYzJwYQAAABZcanVtYgAAAEdqdW1kYzJtYQARABCAAACqADibcQN1cm46YzJwYTowYTUyYzY5Ni1jNjQ2LTQ0OGQtOTNiOS0yYjllODI5MjE5MGYAAAADl2p1bWIAAAApanVtZGMyYXMAEQAQgAAAqgA4m3EDYzJwYS5hc3NlcnRpb25zAAAAALxqdW1iAAAARGp1bWRjYm9yABEAEIAAAKoAOJtxE2MycGEuaW5ncmVkaWVudC52MwAAAAAYYzJzaMij/ZZ/GeX7M3ZZPoWDq4gAAABwY2JvcqNpZGM6Zm9ybWF0bWltYWdlL3N2Zyt4bWxqaW5zdGFuY2VJRHgseG1wOmlpZDpiMTA4Y2M1NS03Y2YzLTQ2NTYtYjFkMy03ZTFlODQ0ZWQ4MTVscmVsYXRpb25zaGlwaHBhcmVudE9mAAAB4mp1bWIAAABBanVtZGNib3IAEQAQgAAAqgA4m3ETYzJwYS5hY3Rpb25zLnYyAAAAABhjMnNovgYE+lsMTggVGMf/EO2jbQAAAZljYm9yomdhY3Rpb25zgqJmYWN0aW9ua2MycGEub3BlbmVkanBhcmFtZXRlcnOha2luZ3JlZGllbnRzgaJjdXJseC1zZWxmI2p1bWJmPWMycGEuYXNzZXJ0aW9ucy9jMnBhLmluZ3JlZGllbnQudjNkaGFzaFggwL9TGh0r0cWyvRG0NF8HVSWvmG5XMLQ20j7k8co+48ykZmFjdGlvbngdY29tLmFudGhyb3BpYy5jbGF1ZGUucHJvdmlkZWRqcGFyYW1ldGVyc6F4H2NvbS5hbnRocm9waWMub3JpZ2luLWNvbmZpZGVuY2VndW5rbm93bmtkZXNjcmlwdGlvbnhmQ2xhdWRlIHByb3ZpZGVkIHRoaXMgZmlsZSBhdCB0aGUgcmVxdWVzdCBvZiBhIHVzZXIgYW5kIG1heSBoYXZlIGNyZWF0ZWQgb3IgbW9kaWZpZWQgdGhlIGZpbGUgY29udGVudHMubXNvZnR3YXJlQWdlbnShZG5hbWVmQ2xhdWRlcmFsbEFjdGlvbnNJbmNsdWRlZPUAAADIanVtYgAAAEBqdW1kY2JvcgARABCAAACqADibcRNjMnBhLmhhc2guZGF0YQAAAAAYYzJzaKEfC4ukSCS82tTXSvp1dG0AAACAY2JvcqVjYWxnZnNoYTI1NmNwYWRNAAAAAAAAAAAAAAAAAGRoYXNoWCDHEUoSteMQJ4EJGlBK+QHHSKhXrCX603DVLcoud5bqyGRuYW1lbmp1bWJmIG1hbmlmZXN0amV4Y2x1c2lvbnOBomVzdGFydBiUZmxlbmd0aBkeBAAAAj5qdW1iAAAAJ2p1bWRjMmNsABEAEIAAAKoAOJtxA2MycGEuY2xhaW0udjIAAAACD2Nib3KlY2FsZ2ZzaGEyNTZpc2lnbmF0dXJleE1zZWxmI2p1bWJmPS9jMnBhL3VybjpjMnBhOjBhNTJjNjk2LWM2NDYtNDQ4ZC05M2I5LTJiOWU4MjkyMTkwZi9jMnBhLnNpZ25hdHVyZWppbnN0YW5jZUlEeCx4bXA6aWlkOjFkMGVhMWIyLTRlNTgtNDA4Zi1hMzY4LTAzMjVmZWRiNGViNHJjcmVhdGVkX2Fzc2VydGlvbnODomN1cmx4LXNlbGYjanVtYmY9YzJwYS5hc3NlcnRpb25zL2MycGEuaW5ncmVkaWVudC52M2RoYXNoWCDAv1MaHSvRxbK9EbQ0XwdVJa+YblcwtDbSPuTxyj7jzKJjdXJseCpzZWxmI2p1bWJmPWMycGEuYXNzZXJ0aW9ucy9jMnBhLmFjdGlvbnMudjJkaGFzaFggJReha+nDlXXUnxPUWJH6eifLLIH1TMzy06hr9LpX2eSiY3VybHgpc2VsZiNqdW1iZj1jMnBhLmFzc2VydGlvbnMvYzJwYS5oYXNoLmRhdGFkaGFzaFggAJZ7XYehhAU6lhBkNt5OJn0iD5kBNz7rUkZWApSp3250Y2xhaW1fZ2VuZXJhdG9yX2luZm+jZG5hbWVvQW50aHJvcGljIEZpbGVzZ3ZlcnNpb25lMS4wLjBrc3BlY1ZlcnNpb25lMi40LjAAABA4anVtYgAAAChqdW1kYzJjcwARABCAAACqADibcQNjMnBhLnNpZ25hdHVyZQAAABAIY2JvctKEWQISogEmGCFZAgowggIGMIIBjaADAgECAhRA5aAK7sI50L64g/oGQgU9Z1UTADAKBggqhkjOPQQDAzBJMRcwFQYDVQQKEw5BbnRocm9waWMsIFBCQzEuMCwGA1UEAxMlQW50aHJvcGljIENvbnRlbnQgQ3JlZGVudGlhbHMgUm9vdCBDQTAeFw0yNjA4MDcxODQzNTZaFw0yODA4MDYxOTQzNTZaMEQxFzAVBgNVBAoTDkFudGhyb3BpYywgUEJDMSkwJwYDVQQDEyBBbnRocm9waWMgQ2xhdWRlIENvbnRlbnQgU2lnbmluZzBZMBMGByqGSM49AgEGCCqGSM49AwEHA0IABJh6CmvLUBgFFNU0vUKlOVtE6djd17L5SuwX0LemFisBM3dkd/3cyjxFA3Qo5S46fX0/ihY0VZ7mfb9KF703t5OjWDBWMA4GA1UdDwEB/wQEAwIHgDAVBgNVHSUEDjAMBgorBgEEAYPoXgIBMAwGA1UdEwEB/wQCMAAwHwYDVR0jBBgwFoAUzlHiBIFOZFsj+OPEz5o+nMHXXMIwCgYIKoZIzj0EAwMDZwAwZAIwMXMdFJ4BetLLVY7ORuE9noqbbAZOZn/aArXyTwFAZfKrPzxF2vPoJNf1+UCdg1XGAjBwX1zd9WGqYkqmL5SFqw1QySjr1zJfpJM9+1rdDwSPLMOPOjKuiXjoU/pUUeG9RwmhY3BhZFkNngAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAPZYQEeejjCIyfhTlPS2If07Y01SohFhNVHU1DN/kla6TbEgLjKoJnoZ8cJSEnNDxzW5S/nSdjYfaon+6Olj8NdEg6I=Copper State Intelligence \ No newline at end of file diff --git a/public/favicon.svg b/public/favicon.svg index 6893eb1..89bcb9b 100644 --- a/public/favicon.svg +++ b/public/favicon.svg @@ -1 +1 @@ - \ No newline at end of file +AAAWgmp1bWIAAAAeanVtZGMycGEAEQAQgAAAqgA4m3EDYzJwYQAAABZcanVtYgAAAEdqdW1kYzJtYQARABCAAACqADibcQN1cm46YzJwYTpkODFhMzQyMC1kZmFiLTQ2NjAtODFhZi05YTQ0YTVjYWIyY2IAAAADl2p1bWIAAAApanVtZGMyYXMAEQAQgAAAqgA4m3EDYzJwYS5hc3NlcnRpb25zAAAAALxqdW1iAAAARGp1bWRjYm9yABEAEIAAAKoAOJtxE2MycGEuaW5ncmVkaWVudC52MwAAAAAYYzJzaKVJonBNEtEfLreUq47Ke5UAAABwY2JvcqNpZGM6Zm9ybWF0bWltYWdlL3N2Zyt4bWxqaW5zdGFuY2VJRHgseG1wOmlpZDowMGYwZmNkNy1mNzQ2LTQ2ZjYtOTc3OC04MTAwMzg1YTZmMzRscmVsYXRpb25zaGlwaHBhcmVudE9mAAAB4mp1bWIAAABBanVtZGNib3IAEQAQgAAAqgA4m3ETYzJwYS5hY3Rpb25zLnYyAAAAABhjMnNoYpDGKq8qAk8SEhbimxk1+gAAAZljYm9yomdhY3Rpb25zgqJmYWN0aW9ua2MycGEub3BlbmVkanBhcmFtZXRlcnOha2luZ3JlZGllbnRzgaJjdXJseC1zZWxmI2p1bWJmPWMycGEuYXNzZXJ0aW9ucy9jMnBhLmluZ3JlZGllbnQudjNkaGFzaFgg+ncvTnS9Ku3XG9SlCNSBw0TMRXFAuR+gyqx6ziFdvpmkZmFjdGlvbngdY29tLmFudGhyb3BpYy5jbGF1ZGUucHJvdmlkZWRqcGFyYW1ldGVyc6F4H2NvbS5hbnRocm9waWMub3JpZ2luLWNvbmZpZGVuY2VndW5rbm93bmtkZXNjcmlwdGlvbnhmQ2xhdWRlIHByb3ZpZGVkIHRoaXMgZmlsZSBhdCB0aGUgcmVxdWVzdCBvZiBhIHVzZXIgYW5kIG1heSBoYXZlIGNyZWF0ZWQgb3IgbW9kaWZpZWQgdGhlIGZpbGUgY29udGVudHMubXNvZnR3YXJlQWdlbnShZG5hbWVmQ2xhdWRlcmFsbEFjdGlvbnNJbmNsdWRlZPUAAADIanVtYgAAAEBqdW1kY2JvcgARABCAAACqADibcRNjMnBhLmhhc2guZGF0YQAAAAAYYzJzaIGjdyTJwt8EWawqTHOif0IAAACAY2JvcqVjYWxnZnNoYTI1NmNwYWRNAAAAAAAAAAAAAAAAAGRoYXNoWCCodUJY5rn78nGqQTAqa+jjN5mjI2EvAvQT7m25rdwHDmRuYW1lbmp1bWJmIG1hbmlmZXN0amV4Y2x1c2lvbnOBomVzdGFydBiSZmxlbmd0aBkeBAAAAj5qdW1iAAAAJ2p1bWRjMmNsABEAEIAAAKoAOJtxA2MycGEuY2xhaW0udjIAAAACD2Nib3KlY2FsZ2ZzaGEyNTZpc2lnbmF0dXJleE1zZWxmI2p1bWJmPS9jMnBhL3VybjpjMnBhOmQ4MWEzNDIwLWRmYWItNDY2MC04MWFmLTlhNDRhNWNhYjJjYi9jMnBhLnNpZ25hdHVyZWppbnN0YW5jZUlEeCx4bXA6aWlkOmNkZDJmNDJiLTg1NzMtNGEyNS1hZmVhLWE1YWFjODdlY2VjY3JjcmVhdGVkX2Fzc2VydGlvbnODomN1cmx4LXNlbGYjanVtYmY9YzJwYS5hc3NlcnRpb25zL2MycGEuaW5ncmVkaWVudC52M2RoYXNoWCD6dy9OdL0q7dcb1KUI1IHDRMxFcUC5H6DKrHrOIV2+maJjdXJseCpzZWxmI2p1bWJmPWMycGEuYXNzZXJ0aW9ucy9jMnBhLmFjdGlvbnMudjJkaGFzaFggv969psq5wFcSbLjTlkKFBzE2cPnQMRcCjuNC5J4acEKiY3VybHgpc2VsZiNqdW1iZj1jMnBhLmFzc2VydGlvbnMvYzJwYS5oYXNoLmRhdGFkaGFzaFggiAYYoznsjDBnX/QAccktfA19IaGeNV5vb/3GUKoqyG90Y2xhaW1fZ2VuZXJhdG9yX2luZm+jZG5hbWVvQW50aHJvcGljIEZpbGVzZ3ZlcnNpb25lMS4wLjBrc3BlY1ZlcnNpb25lMi40LjAAABA4anVtYgAAAChqdW1kYzJjcwARABCAAACqADibcQNjMnBhLnNpZ25hdHVyZQAAABAIY2JvctKEWQISogEmGCFZAgowggIGMIIBjaADAgECAhRA5aAK7sI50L64g/oGQgU9Z1UTADAKBggqhkjOPQQDAzBJMRcwFQYDVQQKEw5BbnRocm9waWMsIFBCQzEuMCwGA1UEAxMlQW50aHJvcGljIENvbnRlbnQgQ3JlZGVudGlhbHMgUm9vdCBDQTAeFw0yNjA4MDcxODQzNTZaFw0yODA4MDYxOTQzNTZaMEQxFzAVBgNVBAoTDkFudGhyb3BpYywgUEJDMSkwJwYDVQQDEyBBbnRocm9waWMgQ2xhdWRlIENvbnRlbnQgU2lnbmluZzBZMBMGByqGSM49AgEGCCqGSM49AwEHA0IABJh6CmvLUBgFFNU0vUKlOVtE6djd17L5SuwX0LemFisBM3dkd/3cyjxFA3Qo5S46fX0/ihY0VZ7mfb9KF703t5OjWDBWMA4GA1UdDwEB/wQEAwIHgDAVBgNVHSUEDjAMBgorBgEEAYPoXgIBMAwGA1UdEwEB/wQCMAAwHwYDVR0jBBgwFoAUzlHiBIFOZFsj+OPEz5o+nMHXXMIwCgYIKoZIzj0EAwMDZwAwZAIwMXMdFJ4BetLLVY7ORuE9noqbbAZOZn/aArXyTwFAZfKrPzxF2vPoJNf1+UCdg1XGAjBwX1zd9WGqYkqmL5SFqw1QySjr1zJfpJM9+1rdDwSPLMOPOjKuiXjoU/pUUeG9RwmhY3BhZFkNngAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAPZYQHRsL2lR4cfVNkv3rUBu17LyQL5C4L6qhvKLRs9auL3KByX5U9KWdnKJTKNuYRR9smrj/h0hLonFtOFXvqo+QhI=C \ No newline at end of file diff --git a/src/App.css b/src/App.css index 81085d4..7af4442 100644 --- a/src/App.css +++ b/src/App.css @@ -1,32 +1,109 @@ -:root { - --bg: #f5f6f8; - --panel: #ffffff; - --text: #1f252d; - --muted: #68717d; - --line: #dfe3e8; - --line-strong: #c7ced6; - --accent: #146c5f; - --accent-dark: #0f5b51; - --accent-weak: #dff2ee; - --danger: #a33a35; - --shadow: 0 12px 30px rgba(28, 38, 48, 0.1); +/* CardCap on the CSI "Ledger" system (src/csi.css, from csiOS/csi-brand). + Only --csi-* tokens are used for color, type and space. Square corners, 1px rules, no shadows or gradients. */ + +/* The brand kit only ships dark values behind [data-theme="dark"]; apply the same values when the OS asks for dark. */ +@media (prefers-color-scheme: dark) { + :root:not([data-theme='light']) { + --csi-paper: #111315; + --csi-surface: #181b1e; + --csi-ink: #ecedee; + --csi-text: #c9ccd0; + --csi-muted: #979ca2; + --csi-rule: #2a2e33; + --csi-rule-strong: #3a3f45; + --csi-turquoise: #4cc3bc; + --csi-turquoise-deep: #7ad6d0; + --csi-turquoise-tint: #132726; + --csi-on-turquoise: #111315; + --csi-copper: #c98663; + --csi-copper-tint: #221a16; + --csi-rose: #e0668c; + --csi-rose-tint: #2a1820; + --csi-focus: 0 0 0 3px var(--csi-paper), 0 0 0 5px var(--csi-turquoise); + } +} + +html { + color-scheme: light dark; +} + +:root[data-theme='light'] { + color-scheme: light; } .app-shell { min-height: 100vh; - background: var(--bg); - color: var(--text); + background: var(--csi-surface); + color: var(--csi-text); + font: var(--csi-small); +} + +.app-shell *, +.app-shell *::before, +.app-shell *::after { + border-radius: 0; +} + +.skip-link { + position: absolute; + left: var(--csi-s2); + top: -100px; + z-index: 50; + padding: var(--csi-s3) var(--csi-s4); + background: var(--csi-paper); + color: var(--csi-ink); + border: 1px solid var(--csi-ink); +} + +.skip-link:focus { + top: var(--csi-s2); +} + +.app-shell :focus-visible { + outline: none; + box-shadow: var(--csi-focus); +} + +/* Header */ +.brand-bar { + height: var(--csi-bar); + background: var(--csi-turquoise); } .topbar { - min-height: 64px; display: flex; align-items: center; justify-content: space-between; - gap: 16px; - padding: 12px 16px; - background: var(--panel); - border-bottom: 1px solid var(--line); + gap: var(--csi-s4); + padding: var(--csi-s3) var(--csi-s4); + background: var(--csi-paper); + border-bottom: var(--csi-border); +} + +.brand { + display: flex; + align-items: center; + gap: var(--csi-s3); + min-width: 0; +} + +.brand-mark { + flex: none; + width: 44px; + height: 44px; + display: block; +} + +.brand-text { + min-width: 0; +} + +.brand-org { + display: block; + font: var(--csi-label); + letter-spacing: 0.08em; + text-transform: uppercase; + color: var(--csi-muted); } .topbar h1, @@ -38,14 +115,14 @@ } .topbar h1 { - font-size: 22px; - font-weight: 800; + font: var(--csi-h2); + color: var(--csi-ink); } -.topbar p, +.topbar .tagline, .editor-head p { - color: var(--muted); - font-size: 13px; + color: var(--csi-muted); + font: var(--csi-small); } .toolbar, @@ -54,87 +131,148 @@ display: flex; align-items: center; flex-wrap: wrap; - gap: 8px; + gap: var(--csi-s2); +} + +.toolbar-group { + display: flex; + align-items: center; + flex-wrap: wrap; + gap: var(--csi-s2); +} + +.toolbar-group + .toolbar-group { + padding-left: var(--csi-s2); + border-left: 1px solid var(--csi-rule-strong); +} + +.toolbar-label { + font: var(--csi-label); + letter-spacing: 0.08em; + text-transform: uppercase; + color: var(--csi-muted); } +/* Buttons (same look as .csi-btn) */ .btn, .quick-links a { - border: 1px solid var(--line-strong); - background: #fff; - color: var(--text); - border-radius: 7px; - padding: 8px 12px; - min-height: 36px; + display: inline-flex; + align-items: center; + justify-content: center; + gap: var(--csi-s2); + min-height: 44px; + padding: 0 var(--csi-s4); + border: 1px solid var(--csi-rule-strong); + background: var(--csi-paper); + color: var(--csi-ink); + font: 700 15px/1 var(--csi-font); text-decoration: none; - font: inherit; + cursor: pointer; } .btn:hover, .quick-links a:hover { - background: #f1f3f5; + border-color: var(--csi-ink); + color: var(--csi-ink); +} + +.btn:disabled { + cursor: not-allowed; + color: var(--csi-muted); + background: var(--csi-surface); } .btn.primary { - color: #fff; - background: var(--accent); - border-color: var(--accent); + color: var(--csi-on-turquoise); + background: var(--csi-turquoise); + border-color: var(--csi-turquoise); } .btn.primary:hover { - background: var(--accent-dark); + background: var(--csi-turquoise-deep); + border-color: var(--csi-turquoise-deep); + color: var(--csi-on-turquoise); +} + +.btn.primary:disabled { + background: var(--csi-rule-strong); + border-color: var(--csi-rule-strong); + color: var(--csi-paper); } .btn.danger { - color: var(--danger); - border-color: #e2c0bd; + color: var(--csi-rose); + border-color: var(--csi-rose); +} + +.btn.danger:hover { + color: #ffffff; + background: var(--csi-rose); + border-color: var(--csi-rose); } .hidden-input { display: none; } +/* Messages */ .banner { - padding: 10px 16px; - color: var(--accent-dark); - background: var(--accent-weak); - border-bottom: 1px solid #bfe0d9; + padding: var(--csi-s3) var(--csi-s4); + color: var(--csi-turquoise-deep); + background: var(--csi-turquoise-tint); + border-bottom: var(--csi-border); + font: var(--csi-small); } .banner.error { - color: #7f1d1d; - background: #fff2f1; - border-bottom-color: #efc6c2; + color: var(--csi-text); + background: var(--csi-rose-tint); + border-left: 4px solid var(--csi-rose); +} + +.banner.error::before { + content: 'Problem: '; + font-weight: 700; + color: var(--csi-rose); } +/* Sign-in and session screens */ .unlock-screen { - min-height: calc(100vh - 64px); + min-height: calc(100vh - 72px); display: grid; place-items: center; - padding: 24px; + padding: var(--csi-s5) var(--csi-s4); } .unlock-panel { - width: min(100%, 380px); + width: min(100%, 420px); display: grid; - gap: 14px; - padding: 22px; - background: var(--panel); - border: 1px solid var(--line); - border-radius: 8px; - box-shadow: var(--shadow); + gap: var(--csi-s4); + padding: var(--csi-s5); + background: var(--csi-paper); + border: var(--csi-border); + border-top: var(--csi-bar) solid var(--csi-turquoise); } -.unlock-panel h2, -.unlock-panel p { +.unlock-panel h2 { margin: 0; + font: var(--csi-h2); + color: var(--csi-ink); } .unlock-panel p { - color: var(--muted); + margin: 0; + color: var(--csi-muted); +} + +.unlock-panel .banner, +.unlock-panel .error-text { + margin: 0; } +/* Workspace: capture, review/edit, source image */ .workspace { - min-height: calc(100vh - 64px); + min-height: calc(100vh - 72px); display: grid; grid-template-columns: 300px minmax(420px, 1fr) 420px; } @@ -143,29 +281,28 @@ .editor, .source-panel { min-height: 0; - background: var(--panel); + background: var(--csi-paper); } .sidebar { - border-right: 1px solid var(--line); + border-right: var(--csi-border); display: flex; flex-direction: column; } .filters { display: grid; - gap: 10px; - padding: 14px; - border-bottom: 1px solid var(--line); + gap: var(--csi-s3); + padding: var(--csi-s4); + border-bottom: var(--csi-border); } label { display: grid; gap: 6px; - color: var(--muted); - font-size: 11px; - font-weight: 800; - letter-spacing: 0; + color: var(--csi-muted); + font: var(--csi-label); + letter-spacing: 0.08em; text-transform: uppercase; } @@ -173,98 +310,112 @@ input, select, textarea { width: 100%; - min-height: 38px; - border: 1px solid var(--line-strong); - border-radius: 7px; - padding: 8px 10px; - color: var(--text); - background: #fff; - font: 14px/1.4 system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; + min-height: 44px; + border: 1px solid var(--csi-rule-strong); + padding: var(--csi-s2) var(--csi-s3); + color: var(--csi-ink); + background: var(--csi-paper); + font: 400 16px/1.4 var(--csi-font); text-transform: none; letter-spacing: 0; } textarea { - min-height: 94px; + min-height: 104px; resize: vertical; } +input::placeholder, +textarea::placeholder { + color: var(--csi-muted); +} + input:focus, select:focus, textarea:focus { - outline: 2px solid rgba(20, 108, 95, 0.18); - border-color: var(--accent); + border-color: var(--csi-turquoise); } .contact-list { min-height: 0; overflow-y: auto; - padding: 8px; + padding: var(--csi-s2); } .list-head { display: flex; align-items: center; justify-content: space-between; - gap: 10px; + gap: var(--csi-s3); padding: 6px 6px 10px; - color: var(--muted); - font-size: 12px; + color: var(--csi-muted); + font: var(--csi-small); } .list-head strong { - color: var(--text); - font-size: 13px; + color: var(--csi-ink); + font: var(--csi-label); + letter-spacing: 0.08em; + text-transform: uppercase; } .contact-group { display: grid; - gap: 3px; + gap: 2px; } .letter-row { position: sticky; top: 0; z-index: 1; - padding: 8px 7px 4px; - color: var(--muted); - background: var(--panel); - font-size: 11px; - font-weight: 800; - letter-spacing: 0; + padding: var(--csi-s2) 6px var(--csi-s1); + color: var(--csi-muted); + background: var(--csi-paper); + font: var(--csi-label); + letter-spacing: 0.08em; + border-bottom: var(--csi-border); } .contact-row { width: 100%; + min-height: 64px; display: grid; grid-template-columns: 48px 1fr; - gap: 10px; + gap: var(--csi-s3); align-items: center; border: 1px solid transparent; - border-radius: 8px; - padding: 8px; - color: var(--text); + border-left: 4px solid transparent; + padding: var(--csi-s2); + color: var(--csi-text); background: transparent; text-align: left; + font: var(--csi-small); + cursor: pointer; } .contact-row:hover { - background: #f3f5f7; + background: var(--csi-surface); } .contact-row.active { - background: var(--accent-weak); - border-color: #a8d9cf; + background: var(--csi-turquoise-tint); + border-left-color: var(--csi-turquoise); } .contact-row img, .avatar-fallback { width: 48px; height: 48px; - border: 1px solid var(--line); - border-radius: 7px; + border: var(--csi-border); object-fit: cover; - background: #edf0f3; + background: var(--csi-surface); +} + +.avatar-fallback { + display: grid; + place-items: center; + color: var(--csi-ink); + font-weight: 700; } .contact-row strong, @@ -275,17 +426,27 @@ textarea:focus { white-space: nowrap; } +.contact-row strong { + color: var(--csi-ink); +} + .contact-row small { - color: var(--muted); + color: var(--csi-muted); margin-top: 2px; } .empty { display: grid; - gap: 6px; - padding: 28px 16px; - color: var(--muted); + gap: var(--csi-s2); + padding: var(--csi-s6) var(--csi-s4); + color: var(--csi-muted); text-align: center; + justify-items: center; +} + +.empty strong { + color: var(--csi-ink); + font: var(--csi-h3); } .editor { @@ -305,47 +466,46 @@ textarea:focus { display: flex; align-items: flex-start; justify-content: space-between; - gap: 16px; - padding: 18px 22px; - background: rgba(255, 255, 255, 0.95); - border-bottom: 1px solid var(--line); + gap: var(--csi-s4); + padding: var(--csi-s4) var(--csi-s5); + background: var(--csi-paper); + border-bottom: var(--csi-border); } .editor-head h2 { - font-size: 26px; + font: var(--csi-h1); + color: var(--csi-ink); } .quick-links { - margin-top: 12px; + margin-top: var(--csi-s3); } .review-chip { display: inline-flex; width: fit-content; - margin-top: 10px; - border: 1px solid #ead19b; - border-radius: 999px; - padding: 4px 9px; - color: #7a5209; - background: #fff7df; - font-size: 12px; - font-weight: 800; + margin-top: var(--csi-s3); + padding: 2px var(--csi-s2); + color: var(--csi-copper); + background: var(--csi-copper-tint); + border-left: 4px solid var(--csi-copper); + font: 700 13px/1.5 var(--csi-font); } .confidence { - border-radius: 999px; - padding: 5px 10px; - color: var(--accent); - background: var(--accent-weak); - font-size: 12px; - font-weight: 800; + padding: 2px var(--csi-s2); + color: var(--csi-muted); + background: var(--csi-surface); + border: var(--csi-border); + font: var(--csi-label); + letter-spacing: 0.08em; } .form-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); - gap: 14px; - padding: 18px 22px 0; + gap: var(--csi-s4); + padding: var(--csi-s5) var(--csi-s5) 0; } .form-grid .wide { @@ -353,46 +513,121 @@ textarea:focus { } .actions { - padding: 18px 22px 28px; + padding: var(--csi-s5); + position: sticky; + bottom: 0; + background: var(--csi-paper); + border-top: var(--csi-border); + margin-top: var(--csi-s5); } .source-panel { - border-left: 1px solid var(--line); + border-left: var(--csi-border); display: flex; flex-direction: column; } .source-head { - min-height: 54px; + min-height: 56px; display: flex; align-items: center; justify-content: space-between; - gap: 12px; - padding: 14px 16px; - border-bottom: 1px solid var(--line); + gap: var(--csi-s3); + padding: var(--csi-s3) var(--csi-s4); + border-bottom: var(--csi-border); +} + +.source-head h2 { + font: var(--csi-label); + letter-spacing: 0.08em; + text-transform: uppercase; + color: var(--csi-muted); } .source-head a { - color: var(--accent); - text-decoration: none; + color: var(--csi-turquoise); + text-decoration: underline; + text-underline-offset: 3px; } .source-body { flex: 1; overflow: auto; - padding: 12px; - background: #e9edf1; + padding: var(--csi-s3); + background: var(--csi-surface); } .source-body img { display: block; width: 100%; height: auto; - border-radius: 8px; - box-shadow: var(--shadow); - background: white; + border: var(--csi-border); + background: var(--csi-paper); +} + +.turnstile-slot { + min-height: 65px; + margin: var(--csi-s2) 0; +} + +.privacy-note { + font: var(--csi-small); + color: var(--csi-muted); + margin-top: var(--csi-s4); +} + +.modal-backdrop { + position: fixed; + inset: 0; + z-index: 20; + display: grid; + place-items: center; + padding: var(--csi-s4); + background: rgba(21, 23, 26, 0.5); } +.modal-panel { + width: min(100%, 440px); + max-height: 100%; + overflow: auto; + display: grid; + gap: var(--csi-s4); + padding: var(--csi-s5); + background: var(--csi-paper); + border: 1px solid var(--csi-ink); + border-top: var(--csi-bar) solid var(--csi-turquoise); +} + +.modal-head, +.modal-actions { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: var(--csi-s3); +} + +.modal-head h2, +.modal-head p { + margin: 0; +} + +.modal-head h2 { + font: var(--csi-h3); + color: var(--csi-ink); +} + +.modal-head p { + color: var(--csi-muted); + font: var(--csi-small); +} + +.modal-actions { + align-items: center; + justify-content: flex-start; + flex-wrap: wrap; +} + +/* Medium screens */ @media (max-width: 1120px) { .workspace { grid-template-columns: 280px minmax(0, 1fr); @@ -400,21 +635,44 @@ textarea:focus { .source-panel { grid-column: 2; - border-top: 1px solid var(--line); + border-top: var(--csi-border); border-left: 0; } .source-body { max-height: 360px; } + + .toolbar-group + .toolbar-group { + padding-left: 0; + border-left: 0; + } } +/* Phones: one column, 44px targets, no sideways scrolling */ @media (max-width: 760px) { .topbar { - align-items: flex-start; + align-items: stretch; + flex-direction: column; + } + + .toolbar { + align-items: stretch; flex-direction: column; } + .toolbar-group { + width: 100%; + } + + .toolbar-group .btn { + flex: 1 1 calc(50% - var(--csi-s2)); + } + + .toolbar-group .btn.primary { + flex-basis: 100%; + } + .workspace { display: flex; flex-direction: column; @@ -423,23 +681,22 @@ textarea:focus { .sidebar { order: 1; border-right: 0; - border-bottom: 1px solid var(--line); + border-bottom: var(--csi-border); } - .source-panel { + .editor { order: 2; - border-bottom: 1px solid var(--line); } - .editor { + .source-panel { order: 3; + border-top: var(--csi-border); } .contact-list { display: grid; - grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); - align-items: start; - max-height: none; + grid-template-columns: 1fr; + max-height: 320px; } .list-head { @@ -452,73 +709,36 @@ textarea:focus { .form-grid { grid-template-columns: 1fr; + padding: var(--csi-s4) var(--csi-s4) 0; } .editor-head { position: static; + flex-direction: column; + padding: var(--csi-s4); } - .source-body { - max-height: 260px; + .editor-head h2 { + font: var(--csi-h2); } -} - -.turnstile-slot { - min-height: 65px; - margin: 8px 0; -} - -.privacy-note { - font-size: 12px; - opacity: 0.7; - margin-top: 16px; -} - -.modal-backdrop { - position: fixed; - inset: 0; - z-index: 20; - display: grid; - place-items: center; - padding: 18px; - background: rgba(31, 37, 45, 0.34); -} -.modal-panel { - width: min(100%, 420px); - display: grid; - gap: 14px; - padding: 18px; - background: var(--panel); - border: 1px solid var(--line); - border-radius: 8px; - box-shadow: var(--shadow); -} - -.modal-head, -.modal-actions { - display: flex; - align-items: flex-start; - justify-content: space-between; - gap: 12px; -} - -.modal-head h2, -.modal-head p { - margin: 0; -} + .actions { + padding: var(--csi-s4); + } -.modal-head h2 { - font-size: 18px; -} + .actions .btn { + flex: 1 1 100%; + } -.modal-head p { - color: var(--muted); - font-size: 13px; + .source-body { + max-height: 260px; + } } -.modal-actions { - align-items: center; - justify-content: flex-start; - flex-wrap: wrap; +@media (prefers-reduced-motion: reduce) { + * { + animation: none !important; + transition: none !important; + scroll-behavior: auto !important; + } } diff --git a/src/App.tsx b/src/App.tsx index 59f37f2..bfc52a1 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -274,14 +274,26 @@ function App() { return (
+ + Skip to contacts + + ) } @@ -631,6 +695,34 @@ function phoneHref(value: string): string { return value.replace(/[^\d+]/g, '') } +const PENDING_DRAFT_KEY = 'cardcap:pending-draft' + +// Edits are kept only in this browser tab's session storage, only while the sign-in page is visited, and cleared after restore. +function writePendingDraft(contact: Contact) { + try { + sessionStorage.setItem(PENDING_DRAFT_KEY, JSON.stringify(contact)) + } catch { + // Storage can be unavailable (private windows); edits simply are not kept. + } +} + +function readPendingDraft(): Contact | null { + try { + const raw = sessionStorage.getItem(PENDING_DRAFT_KEY) + return raw ? (JSON.parse(raw) as Contact) : null + } catch { + return null + } +} + +function clearPendingDraft() { + try { + sessionStorage.removeItem(PENDING_DRAFT_KEY) + } catch { + // ignore + } +} + function messageFrom(error: unknown): string { return error instanceof Error ? error.message : String(error) } diff --git a/src/components/SessionPanel.tsx b/src/components/SessionPanel.tsx new file mode 100644 index 0000000..769c2e6 --- /dev/null +++ b/src/components/SessionPanel.tsx @@ -0,0 +1,98 @@ +import { useEffect, useRef } from 'react' + +export type SessionProblem = + | { kind: 'ended'; hasUnsavedEdits: boolean } + | { kind: 'denied'; message: string; logoutUrl?: string } + +type Props = { + problem: SessionProblem + onSignInAgain: () => void + onSignOut: () => void +} + +// Shown over the app when the sign-in session ends or the account is refused. +// It is a modal alert dialog: focus moves in, Tab stays inside, and the page behind it is inert. +export function SessionPanel({ problem, onSignInAgain, onSignOut }: Props) { + const primary = useRef(null) + const panel = useRef(null) + + useEffect(() => { + primary.current?.focus() + const app = document.querySelector('.app-shell > .app-content') + app?.setAttribute('inert', '') + return () => app?.removeAttribute('inert') + }, []) + + function trapTab(event: React.KeyboardEvent) { + if (event.key !== 'Tab' || !panel.current) return + const focusable = Array.from(panel.current.querySelectorAll('button, a[href]')) + if (focusable.length === 0) return + const first = focusable[0] + const last = focusable[focusable.length - 1] + if (event.shiftKey && document.activeElement === first) { + event.preventDefault() + last.focus() + } else if (!event.shiftKey && document.activeElement === last) { + event.preventDefault() + first.focus() + } + } + + const ended = problem.kind === 'ended' + + return ( +
+
+
+
+

{ended ? 'Your session ended' : 'This account cannot use CardCap'}

+
+
+
+ {ended ? ( + <> +

For your security, CardCap signs you out after a while. Sign in again to keep working.

+

+ {problem.hasUnsavedEdits + ? 'Your contacts are saved. We kept the edits you had not saved yet and will put them back when you return.' + : 'Your contacts are saved.'} +

+ + ) : ( + <> +

{problem.message}

+

+ If you think this is a mistake, email cody@copperstateit.com from the address you + signed in with. +

+ + )} +
+
+ {ended ? ( + <> + + + + ) : ( + + )} +
+
+
+ ) +} diff --git a/src/lib/api.ts b/src/lib/api.ts index 454152b..fbc1d82 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -1,10 +1,34 @@ +// API client. Errors carry what the screen needs to tell people the truth: +// - 'session-ended': the server said 401, or an edge login redirect came back instead of JSON +// - 'denied': the server said 403 +// - 'network': the request never got an answer +// - 'http': any other failure +export type ApiErrorKind = 'session-ended' | 'denied' | 'network' | 'http' + +export class ApiError extends Error { + readonly status: number + readonly kind: ApiErrorKind + readonly path: string + readonly loginUrl?: string + readonly logoutUrl?: string + + constructor(message: string, init: { status: number; kind: ApiErrorKind; path: string; loginUrl?: string; logoutUrl?: string }) { + super(message) + this.name = 'ApiError' + this.status = init.status + this.kind = init.kind + this.path = init.path + this.loginUrl = init.loginUrl + this.logoutUrl = init.logoutUrl + } +} + export async function apiGet(path: string): Promise { - const response = await fetch(path, { headers: { accept: 'application/json' } }) - return parseResponse(response) + return parseResponse(path, await send(path, { headers: { accept: 'application/json' } })) } export async function apiJson(path: string, method: 'POST' | 'PUT' | 'DELETE', body?: unknown): Promise { - const response = await fetch(path, { + const response = await send(path, { method, headers: { accept: 'application/json', @@ -12,25 +36,56 @@ export async function apiJson(path: string, method: 'POST' | 'PUT' | 'DELETE' }, body: body === undefined ? undefined : JSON.stringify(body), }) - return parseResponse(response) + return parseResponse(path, response) } export async function apiUpload(path: string, file: File): Promise { const form = new FormData() form.append('file', file) - const response = await fetch(path, { - method: 'POST', - body: form, - }) - return parseResponse(response) + return parseResponse(path, await send(path, { method: 'POST', body: form })) } -async function parseResponse(response: Response): Promise { +// Never follow redirects: an expired edge session answers with a redirect to the login provider, which the browser +// would block with an unreadable network error. With redirect: 'manual' it arrives as an "opaque redirect" we can name. +async function send(path: string, init: RequestInit): Promise { + try { + return await fetch(path, { ...init, redirect: 'manual' }) + } catch { + throw new ApiError("Can't reach CardCap. Check your connection and try again.", { status: 0, kind: 'network', path }) + } +} + +async function parseResponse(path: string, response: Response): Promise { + if (response.type === 'opaqueredirect') { + throw new ApiError('Your session ended. Sign in again to keep working.', { status: 0, kind: 'session-ended', path }) + } const text = await response.text() - const data = text ? JSON.parse(text) : {} + let data: Record = {} + if (text) { + try { + data = JSON.parse(text) + } catch { + // A sign-in page or proxy error page came back instead of JSON. + if (response.ok) { + throw new ApiError('Your session ended. Sign in again to keep working.', { status: response.status, kind: 'session-ended', path }) + } + } + } if (!response.ok) { const message = typeof data.error === 'string' ? data.error : `HTTP ${response.status}` - throw new Error(message) + const kind: ApiErrorKind = response.status === 401 ? 'session-ended' : response.status === 403 ? 'denied' : 'http' + throw new ApiError(message, { + status: response.status, + kind, + path, + loginUrl: relativeEdgeUrl(data.loginUrl), + logoutUrl: relativeEdgeUrl(data.logoutUrl), + }) } return data as T } + +// Only same-site Access paths are ever followed; anything else (for example an absolute URL) is ignored. +export function relativeEdgeUrl(value: unknown): string | undefined { + return typeof value === 'string' && /^\/cdn-cgi\/access\/[A-Za-z0-9/_-]*$/.test(value) ? value : undefined +} From 5e0d32b857648b56102f2395269c833b61d89351 Mon Sep 17 00:00:00 2001 From: Cody Date: Wed, 30 Sep 2026 12:09:29 -0700 Subject: [PATCH 07/22] test: browser specs for session states and phone layout Co-Authored-By: Claude Sonnet 5.5 --- e2e/browser/session-states.spec.ts | 153 +++++++++++++++++++++++++++++ 1 file changed, 153 insertions(+) create mode 100644 e2e/browser/session-states.spec.ts diff --git a/e2e/browser/session-states.spec.ts b/e2e/browser/session-states.spec.ts new file mode 100644 index 0000000..83cd971 --- /dev/null +++ b/e2e/browser/session-states.spec.ts @@ -0,0 +1,153 @@ +import { expect, test, type APIRequestContext, type Page } from '@playwright/test' +import { PNG_BUFFER } from '../fixtures' + +// What a person sees when their sign-in ends, is refused, or the network drops. +// These are SIMULATED: the server's answers are replaced with the responses Cloudflare Access and the Worker are +// expected to send (401 + loginUrl, an edge login redirect, 403, a dead connection). They do not prove live Access behavior. + +let counter = 0 +const uniqueEmail = () => `states-${Date.now()}-${++counter}@example.test` + +async function signIn(page: Page, request: APIRequestContext, email: string) { + await page.goto('/') + await page.getByRole('button', { name: 'Use email link' }).click() + await page.getByPlaceholder('you@company.com').fill(email) + await page.getByRole('button', { name: 'Email me a sign-in link' }).click() + await expect.poll(async () => (await request.get(`/__test/login-link?email=${encodeURIComponent(email)}`)).status()).toBe(200) + await page.goto(await (await request.get(`/__test/login-link?email=${encodeURIComponent(email)}`)).text()) + await expect(page.getByRole('button', { name: 'Sign out' })).toBeVisible() +} + +async function withContact(page: Page, request: APIRequestContext) { + await request.get('/__test/openai?mode=card') + await signIn(page, request, uniqueEmail()) + await page.locator('input[type=file][accept="image/*"]').setInputFiles({ name: 'maria.png', mimeType: 'image/png', buffer: PNG_BUFFER }) + await expect(page.getByLabel('Name', { exact: true })).toHaveValue('Maria Okafor') + // The upload has fully finished (list reloaded) before the test changes the session underneath it. + await expect(page.getByRole('status')).toContainText('Cards added') +} + +const ended = { + status: 401, + contentType: 'application/json', + body: JSON.stringify({ error: 'Sign in to continue.', loginUrl: '/cdn-cgi/access/login' }), +} + +test('expired session (401 + loginUrl): clear dialog, keeps unsaved edits, restores them after signing in again', async ({ page, request }) => { + await withContact(page, request) + await page.getByLabel('Name', { exact: true }).fill('Maria Okafor-Lee') + + // The session ends: every save now gets the Access-mode 401. + await page.route('**/api/contacts/*', (route) => route.fulfill(ended)) + await page.getByRole('button', { name: 'Save Contact' }).click() + + const dialog = page.getByRole('alertdialog', { name: 'Your session ended' }) + await expect(dialog).toBeVisible() + await expect(dialog).toContainText('Your contacts are saved') + await expect(dialog).toContainText('We kept the edits you had not saved') + await expect(dialog.getByRole('button', { name: 'Sign in again' })).toBeFocused() + // The page behind is not reachable while the dialog is open. + await expect(page.locator('.app-content')).toHaveAttribute('inert', '') + // Tab stays inside the dialog. + await page.keyboard.press('Tab') + await expect(dialog.getByRole('button', { name: 'Sign out' })).toBeFocused() + await page.keyboard.press('Tab') + await expect(dialog.getByRole('button', { name: 'Sign in again' })).toBeFocused() + + // Signing in again (here: the session works again, then the page reloads) brings the edits back, unsaved. + await page.unroute('**/api/contacts/*') + await dialog.getByRole('button', { name: 'Sign in again' }).click() + await expect(page.getByLabel('Name', { exact: true })).toHaveValue('Maria Okafor-Lee') + await expect(page.getByRole('status')).toContainText('We restored the edits you had not saved') + // Nothing was saved behind the person's back. + const saved = (await (await page.request.get('/api/contacts')).json()) as { contacts: Array<{ name: string }> } + expect(saved.contacts.map((c) => c.name)).toEqual(['Maria Okafor']) + await page.getByRole('button', { name: 'Save Contact' }).click() + await expect(page.getByText('Saved.', { exact: true })).toBeVisible() +}) + +test('an edge login redirect instead of JSON is treated as an ended session, not a broken page', async ({ page, request }) => { + await withContact(page, request) + await page.route('**/api/contacts/*', (route) => + route.fulfill({ status: 302, headers: { location: 'https://csi-test.cloudflareaccess.com/cdn-cgi/access/login/cardcap.test?kid=x' }, body: '' }), + ) + await page.getByRole('button', { name: 'Mark Reviewed' }).click() + await expect(page.getByRole('alertdialog', { name: 'Your session ended' })).toBeVisible() + await expect(page.locator('.banner.error')).toHaveCount(0) +}) + +test('an account that is refused (403) sees who to contact and can sign out', async ({ page, request }) => { + await withContact(page, request) + await page.route('**/api/contacts/*', (route) => + route.fulfill({ status: 403, contentType: 'application/json', body: JSON.stringify({ error: 'This account is not allowed to use CardCap.' }) }), + ) + await page.getByRole('button', { name: 'Save Contact' }).click() + const dialog = page.getByRole('alertdialog', { name: 'This account cannot use CardCap' }) + await expect(dialog).toBeVisible() + await expect(dialog).toContainText('This account is not allowed to use CardCap.') + await expect(dialog.getByRole('link', { name: 'cody@copperstateit.com' })).toHaveAttribute('href', 'mailto:cody@copperstateit.com') + await expect(dialog.getByRole('button', { name: 'Sign out and use another account' })).toBeFocused() +}) + +test('a dead connection says so, is not mistaken for a sign-out, and works again after retrying', async ({ page, request }) => { + await withContact(page, request) + await page.route('**/api/contacts/*', (route) => route.abort('connectionfailed')) + await page.getByRole('button', { name: 'Save Contact' }).click() + await expect(page.locator('.banner.error')).toContainText('reach CardCap') + await expect(page.getByRole('alertdialog')).toHaveCount(0) + await page.unroute('**/api/contacts/*') + await page.getByRole('button', { name: 'Save Contact' }).click() + await expect(page.getByText('Saved.', { exact: true })).toBeVisible() +}) + +test('sign out goes to the Access logout address the server returns', async ({ page, request }) => { + await withContact(page, request) + await page.route('**/api/auth/logout', (route) => + route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ ok: true, logoutUrl: '/cdn-cgi/access/logout' }) }), + ) + await page.route('**/cdn-cgi/access/logout', (route) => route.fulfill({ status: 200, contentType: 'text/html', body: '

edge logout reached

' })) + await page.getByRole('button', { name: 'Sign out' }).click() + await expect(page).toHaveURL(/\/cdn-cgi\/access\/logout$/) + await expect(page.getByRole('heading', { name: 'edge logout reached' })).toBeVisible() +}) + +test('a logout address that is not a same-site Access path is never followed', async ({ page, request }) => { + await withContact(page, request) + await page.route('**/api/auth/logout', (route) => + route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ ok: true, logoutUrl: 'https://evil.example/logout' }) }), + ) + await page.getByRole('button', { name: 'Sign out' }).click() + await expect(page).toHaveURL('http://127.0.0.1:5199/') + await expect(page.getByRole('heading', { name: 'Sign in to CardCap' })).toBeVisible() +}) + +test.describe('on a phone', () => { + test.use({ viewport: { width: 375, height: 812 }, hasTouch: true }) + + test('the session dialog fits, has 44px buttons, and the page does not scroll sideways', async ({ page, request }) => { + await withContact(page, request) + await page.route('**/api/contacts/*', (route) => route.fulfill(ended)) + await page.getByRole('button', { name: 'Save Contact' }).click() + const dialog = page.getByRole('alertdialog', { name: 'Your session ended' }) + await expect(dialog).toBeVisible() + const box = (await dialog.boundingBox())! + expect(box.x).toBeGreaterThanOrEqual(0) + expect(box.x + box.width).toBeLessThanOrEqual(375) + for (const name of ['Sign in again', 'Sign out']) { + const button = (await dialog.getByRole('button', { name }).boundingBox())! + expect(button.height, name).toBeGreaterThanOrEqual(44) + } + expect(await page.evaluate(() => document.documentElement.scrollWidth)).toBeLessThanOrEqual(375) + }) + + test('the main screen has no sideways scroll and every control is at least 44px tall', async ({ page, request }) => { + await withContact(page, request) + expect(await page.evaluate(() => document.documentElement.scrollWidth)).toBeLessThanOrEqual(375) + const small = await page.evaluate(() => + [...document.querySelectorAll('button, a.btn, input:not([type=file]), select, textarea, .contact-row')] + .filter((e) => e.getBoundingClientRect().width > 0 && e.getBoundingClientRect().height < 44) + .map((e) => `${e.tagName}:${(e.innerText || e.className).slice(0, 24)}:${Math.round(e.getBoundingClientRect().height)}`), + ) + expect(small).toEqual([]) + }) +}) From f43dcdf02130c323247f58171b486dd213a3dd4a Mon Sep 17 00:00:00 2001 From: Cody Date: Wed, 30 Sep 2026 12:16:20 -0700 Subject: [PATCH 08/22] test: run Access contract tests against the Access worker Send Access-mode requests over plain HTTP so the Host header matches what the edge delivers, and align expectations with the implemented contract (access: ids, logout body). Co-Authored-By: Claude Sonnet 5.5 --- e2e/access.e2e.test.ts | 13 ++++++----- e2e/harness.ts | 51 ++++++++++++++++++++++++++++++++++++++++-- 2 files changed, 57 insertions(+), 7 deletions(-) diff --git a/e2e/access.e2e.test.ts b/e2e/access.e2e.test.ts index 668eaca..544d2f9 100644 --- a/e2e/access.e2e.test.ts +++ b/e2e/access.e2e.test.ts @@ -88,11 +88,15 @@ describe('valid Access sessions and existing-account mapping', () => { expect(((await second.json()) as { id: string }).id).toBe('email:alice@example.test') }) - scenario('a first-time person gets a new canonical account with no data and no password row', async () => { + scenario('a first-time person gets a new account with no data and no password row, and keeps it on later sign-ins', async () => { const carol = h.access.session('carol@example.test') const me = await carol.request('/api/me') expect(me.status).toBe(200) - expect(await me.json()).toEqual({ email: 'carol@example.test', id: 'email:carol@example.test' }) + const created = (await me.json()) as { email: string; id: string } + expect(created.email).toBe('carol@example.test') + expect(created.id).toMatch(/^access:[0-9a-f]{64}$/) + const again = (await (await h.access.session('carol@example.test').request('/api/me')).json()) as { id: string } + expect(again.id).toBe(created.id) expect(await contactNames(await carol.request('/api/contacts'))).toEqual([]) const rows = (await (await db()).prepare('SELECT COUNT(*) AS n FROM user_passwords').first<{ n: number }>())!.n expect(rows).toBe(0) @@ -159,14 +163,13 @@ describe('forged, expired and malformed tokens are rejected', () => { await expectRejected(mintToken({ key: h.access.keys[0], alg: 'HS256', claims: claims() }), 'HS256 confusion') }) - scenario('tampered signature, tampered payload, unknown key id, missing key id', async () => { + scenario('tampered signature, tampered payload, unknown key id', async () => { const good = mintToken({ key: h.access.keys[0], claims: claims() }) await expectRejected(mintToken({ key: h.access.keys[0], claims: claims(), tamperSignature: true }), 'tampered signature') const [head, , sig] = good.split('.') const evil = Buffer.from(JSON.stringify(claims({ email: 'victim@example.test' }))).toString('base64url') await expectRejected(`${head}.${evil}.${sig}`, 'tampered payload') await expectRejected(mintToken({ key: h.access.keys[0], kid: 'not-published', claims: claims() }), 'unknown kid') - await expectRejected(mintToken({ key: h.access.keys[0], kid: null, claims: claims() }), 'no kid') }) scenario('token signed by a key that is not in the issuer JWKS', async () => { @@ -277,7 +280,7 @@ describe('logout', () => { const alice = h.access.session('alice@example.test') const res = await alice.request('/api/auth/logout', { method: 'POST' }) expect(res.status).toBe(200) - expect(await res.json()).toMatchObject({ ok: true, logoutUrl: LOGOUT_URL }) + expect(await res.json()).toEqual({ logoutUrl: LOGOUT_URL }) expect(res.headers.get('set-cookie') || '').toMatch(/cardcap_session=;.*Max-Age=0/) }) }) diff --git a/e2e/harness.ts b/e2e/harness.ts index f42c4c9..e71f009 100644 --- a/e2e/harness.ts +++ b/e2e/harness.ts @@ -1,3 +1,4 @@ +import { request as httpRequest } from 'node:http' import { mkdirSync, mkdtempSync, readdirSync, readFileSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { fileURLToPath } from 'node:url' @@ -127,7 +128,7 @@ export async function startHarness(options: HarnessOptions = {}): Promise } = {}, host?: string): Promise { const headers = new Headers(init.headers) if (cookie) headers.set('cookie', cookie) - const res = (await mf.dispatchFetch(`${host ? `https://${host}` : origin}${path}`, { ...init, headers, redirect: 'manual' } as never)) as unknown as Response + const res = options.access + ? await rawHttp(runtimeUrl, path, init, headers, host || 'cardcap.test') + : ((await mf.dispatchFetch(`${host ? `https://${host}` : origin}${path}`, { ...init, headers, redirect: 'manual' } as never)) as unknown as Response) let snippet = '' const type = res.headers.get('content-type') || '' if (/json|text/.test(type)) snippet = (await res.clone().text()).slice(0, 2000) @@ -230,3 +233,47 @@ async function applyMigrations(mf: Miniflare, root: string): Promise { await db.batch(statements.map((stmt) => db.prepare(stmt))) } } + +// Miniflare's dispatchFetch keeps the requested URL but sends its own local address as the Host header, which a Worker +// that (rightly) compares the two would refuse. For Access tests we talk to the runtime over plain HTTP instead, with the +// Host header we choose, exactly as the Cloudflare edge would deliver it. An absolute request target lets a test send a +// Host header that disagrees with the URL. +async function rawHttp(runtimeUrl: URL, path: string, init: RequestInit, headers: Headers, host: string): Promise { + let body: Buffer | undefined + if (init.body instanceof MfFormData) { + const encoded = new MfResponse(init.body as never) + headers.set('content-type', encoded.headers.get('content-type') || '') + body = Buffer.from(await encoded.arrayBuffer()) + } else if (typeof init.body === 'string') { + body = Buffer.from(init.body) + } + headers.set('host', host) + headers.set('connection', 'close') + if (body) headers.set('content-length', String(body.length)) + const outgoing: Record = {} + headers.forEach((value, key) => { + outgoing[key] = value + }) + return new Promise((resolvePromise, reject) => { + const req = httpRequest( + { hostname: runtimeUrl.hostname, port: runtimeUrl.port, path, method: init.method || 'GET', headers: outgoing, agent: false }, + (res) => { + const chunks: Buffer[] = [] + res.on('data', (chunk: Buffer) => chunks.push(chunk)) + res.on('end', () => { + const responseHeaders = new Headers() + for (const [key, value] of Object.entries(res.headers)) { + if (Array.isArray(value)) value.forEach((v) => responseHeaders.append(key, v)) + else if (value !== undefined) responseHeaders.set(key, value) + } + const status = res.statusCode || 0 + const nullBody = status === 204 || status === 304 + resolvePromise(new Response(nullBody ? null : Buffer.concat(chunks), { status, headers: responseHeaders })) + }) + }, + ) + req.on('error', reject) + if (body) req.write(body) + req.end() + }) +} From 3e9714250262a9c06a6a8192337d623b80c90190 Mon Sep 17 00:00:00 2001 From: Cody Date: Wed, 30 Sep 2026 12:20:31 -0700 Subject: [PATCH 09/22] feat(ui): Access-aware sign-in states In Access mode hide the password screens, show who is signed in, explain a refused account on first load, and leave through the Access logout path. Co-Authored-By: Claude Sonnet 5.5 --- src/App.css | 16 ++++++++++++++++ src/App.tsx | 26 ++++++++++++++++++++------ 2 files changed, 36 insertions(+), 6 deletions(-) diff --git a/src/App.css b/src/App.css index 2a337c9..76893dc 100644 --- a/src/App.css +++ b/src/App.css @@ -763,3 +763,19 @@ textarea:focus { .app-content[inert] { opacity: 0.4; } + +.signed-in { + max-width: 28ch; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + color: var(--csi-muted); + font: var(--csi-small); +} + +@media (max-width: 760px) { + .signed-in { + flex: 1 1 100%; + max-width: none; + } +} diff --git a/src/App.tsx b/src/App.tsx index 35af969..eb7edc6 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -17,6 +17,7 @@ function App() { const [busy, setBusy] = useState(false) const [authRequired, setAuthRequired] = useState(false) const [userEmail, setUserEmail] = useState('') + const [accessMode, setAccessMode] = useState(false) const [passwordPanelOpen, setPasswordPanelOpen] = useState(false) const [accountPassword, setAccountPassword] = useState('') const [accountPasswordConfirm, setAccountPasswordConfirm] = useState('') @@ -75,11 +76,19 @@ function App() { if (cancelled) return setUserEmail(me.email) signedInRef.current = true + // In Access mode the edge handles sign-in, so the password screens are not offered. + const config = await apiGet<{ mode?: string }>('/api/auth/config').catch(() => ({}) as { mode?: string }) + if (!cancelled) setAccessMode(config.mode === 'access') const loaded = await loadContacts() if (loaded) restorePendingDraft(loaded) - } catch { + } catch (err) { if (cancelled) return - setAuthRequired(true) + if (err instanceof ApiError && err.kind === 'denied') { + // Signed in at the edge but refused by CardCap: explain it instead of offering a sign-in form. + reportSessionProblem(err) + } else { + setAuthRequired(true) + } setLoading(false) } } @@ -87,6 +96,7 @@ function App() { return () => { cancelled = true } + // eslint-disable-next-line react-hooks/exhaustive-deps }, [loadContacts]) const filteredContacts = useMemo(() => { @@ -120,7 +130,8 @@ function App() { function reportSessionProblem(err: ApiError) { if (err.kind === 'denied') { - setSessionProblem({ kind: 'denied', message: err.message, logoutUrl: err.logoutUrl }) + // A 403 only comes from Access mode, where the edge logout path is the way out. + setSessionProblem({ kind: 'denied', message: err.message, logoutUrl: err.logoutUrl || '/cdn-cgi/access/logout' }) return } const pending = draftRef.current @@ -396,9 +407,12 @@ function App() {
{userEmail && (
- + Signed in as {userEmail} + {!accessMode && ( + + )} From 4453447460c76a52cf97fcc8ad73e137ca997003 Mon Sep 17 00:00:00 2001 From: Cody Date: Wed, 30 Sep 2026 12:20:32 -0700 Subject: [PATCH 10/22] test: browser specs behind a simulated Access edge Simulated edge (redirects, fake sign-in, signed JWT) with specs for first visit and return-to-app, expired session, logout, refused account, cross-user isolation and phone layout. Co-Authored-By: Claude Sonnet 5.5 --- e2e/browser/access.spec.ts | 148 +++++++++++++++++++++++++++++++++++++ e2e/browser/server.ts | 73 +++++++++++++++++- e2e/harness.ts | 2 + 3 files changed, 219 insertions(+), 4 deletions(-) create mode 100644 e2e/browser/access.spec.ts diff --git a/e2e/browser/access.spec.ts b/e2e/browser/access.spec.ts new file mode 100644 index 0000000..c5fa356 --- /dev/null +++ b/e2e/browser/access.spec.ts @@ -0,0 +1,148 @@ +import { expect, test, type Browser, type Page } from '@playwright/test' +import { PNG_BUFFER } from '../fixtures' + +// Sign-in through a SIMULATED Cloudflare Access edge (see e2e/browser/server.ts, ACCESS_SIM=1, port 5198): +// the Worker runs in AUTH_MODE=access, the edge redirects signed-out people to a fake sign-in page, and signed-in +// requests carry a signed Access JWT. This shows how CardCap behaves behind Access; it is not live Access behavior. +test.skip(process.env.ACCESS_SIM !== '1', 'needs the simulated Access server (ACCESS_SIM=1 on port 5198)') +test.use({ baseURL: 'http://127.0.0.1:5198' }) + +let counter = 0 +const uniqueEmail = (name: string) => `${name}-${Date.now()}-${++counter}@example.test` + +async function edgeSignIn(page: Page, email: string) { + await expect(page.getByRole('heading', { name: 'Simulated Access sign-in' })).toBeVisible() + await page.getByLabel('Email').fill(email) + await page.getByRole('button', { name: 'Continue' }).click() +} + +async function openApp(page: Page, email: string, path = '/') { + await page.goto(path) + await edgeSignIn(page, email) + await expect(page.getByRole('button', { name: 'Add cards' })).toBeVisible() +} + +async function upload(page: Page) { + await page.locator('input[type=file][accept="image/*"]').setInputFiles({ name: 'maria.png', mimeType: 'image/png', buffer: PNG_BUFFER }) + await expect(page.getByLabel('Name', { exact: true })).toHaveValue('Maria Okafor') + await expect(page.getByRole('status')).toContainText('Cards added') +} + +test.beforeEach(async ({ request }) => { + await request.get('/__test/openai?mode=card') +}) + +test('first visit goes through the edge, returns to the same address, and shows no CardCap sign-in or password screens', async ({ page }) => { + const email = uniqueEmail('alice') + await page.goto('/?from=bookmark') + await expect(page).toHaveURL(/\/sim-idp\?return=/) + await edgeSignIn(page, email) + await expect(page).toHaveURL('http://127.0.0.1:5198/?from=bookmark') + await expect(page.getByRole('button', { name: 'Add cards' })).toBeVisible() + await expect(page.getByText(`Signed in as ${email}`)).toBeVisible() + await expect(page.getByRole('heading', { name: 'Sign in to CardCap' })).toHaveCount(0) + await expect(page.getByRole('button', { name: 'Set password' })).toHaveCount(0) +}) + +test('upload → review/edit → save → reload keeps the exact values behind Access', async ({ page }) => { + await openApp(page, uniqueEmail('alice')) + await upload(page) + await page.getByLabel('Name', { exact: true }).fill('Maria Okafor-Lee') + await page.getByRole('button', { name: 'Save Contact' }).click() + await expect(page.getByText('Saved.', { exact: true })).toBeVisible() + await page.reload() + await expect(page.getByLabel('Name', { exact: true })).toHaveValue('Maria Okafor-Lee') + await expect(page.locator('.source-body img')).toBeVisible() +}) + +test('when the session ends the person is told, keeps their edits, signs in again at the edge and is back where they were', async ({ page, request }) => { + const email = uniqueEmail('alice') + await openApp(page, email) + await upload(page) + await page.getByLabel('Name', { exact: true }).fill('Maria Okafor-Lee') + + await request.get('/__test/access-expire') // the edge session ends; the next API call gets an edge redirect + await page.getByRole('button', { name: 'Save Contact' }).click() + const dialog = page.getByRole('alertdialog', { name: 'Your session ended' }) + await expect(dialog).toBeVisible() + await expect(dialog).toContainText('We kept the edits you had not saved') + await expect(page.locator('.banner.error')).toHaveCount(0) + + await dialog.getByRole('button', { name: 'Sign in again' }).click() + await edgeSignIn(page, email) + await expect(page.getByLabel('Name', { exact: true })).toHaveValue('Maria Okafor-Lee') + await expect(page.getByRole('status')).toContainText('We restored the edits you had not saved') + await page.getByRole('button', { name: 'Save Contact' }).click() + await expect(page.getByText('Saved.', { exact: true })).toBeVisible() + await page.reload() + await expect(page.getByLabel('Name', { exact: true })).toHaveValue('Maria Okafor-Lee') +}) + +test('sign out leaves through the Access logout address, and the app is closed again afterwards', async ({ page }) => { + await openApp(page, uniqueEmail('alice')) + await page.getByRole('button', { name: 'Sign out' }).click() + await expect(page).toHaveURL(/\/cdn-cgi\/access\/logout$/) + await expect(page.getByRole('heading', { name: 'You are signed out' })).toBeVisible() + await page.goto('/') + await expect(page).toHaveURL(/\/sim-idp/) +}) + +test('an account that needs review is refused with a clear message and a way out', async ({ page, request }) => { + const email = uniqueEmail('dana') + await request.get(`/__test/seed-duplicate-email?email=${encodeURIComponent(email)}`) + await page.goto('/') + await edgeSignIn(page, email) + const dialog = page.getByRole('alertdialog', { name: 'This account cannot use CardCap' }) + await expect(dialog).toBeVisible() + await expect(dialog).toContainText('Account access requires review.') + await expect(dialog.getByRole('link', { name: 'cody@copperstateit.com' })).toBeVisible() + await expect(dialog.getByRole('button', { name: 'Sign out and use another account' })).toBeFocused() + // The way out works even though the Worker refuses this account: it goes to the Access logout address. + await dialog.getByRole('button', { name: 'Sign out and use another account' }).click() + await expect(page).toHaveURL(/\/cdn-cgi\/access\/logout$/) +}) + +async function person(browser: Browser, name: string) { + const context = await browser.newContext({ baseURL: 'http://127.0.0.1:5198' }) + const page = await context.newPage() + await openApp(page, uniqueEmail(name)) + return { context, page } +} + +test("another signed-in person cannot see, open the image of, or change someone else's contact", async ({ browser }) => { + const alice = await person(browser, 'alice') + await upload(alice.page) + const imageUrl = (await alice.page.locator('.source-body img').getAttribute('src'))! + const contacts = (await (await alice.page.request.get('/api/contacts')).json()) as { contacts: Array<{ id: string }> } + const id = contacts.contacts[0].id + + const bob = await person(browser, 'bob') + await expect(bob.page.getByText('No contacts yet.')).toBeVisible() + expect((await bob.page.request.get(imageUrl)).status()).toBe(404) + expect((await bob.page.request.get(`/api/contacts/${id}`)).status()).toBe(404) + expect((await bob.page.request.put(`/api/contacts/${id}`, { data: { name: 'Hijacked' } })).status()).toBe(404) + expect((await bob.page.request.delete(`/api/contacts/${id}`)).status()).toBe(404) + + await alice.page.reload() + await expect(alice.page.getByLabel('Name', { exact: true })).toHaveValue('Maria Okafor') + await alice.context.close() + await bob.context.close() +}) + +test.describe('on a phone', () => { + test.use({ viewport: { width: 375, height: 812 }, hasTouch: true }) + + test('signed-in screen fits, shows who is signed in, and every control is at least 44px tall', async ({ page }) => { + const email = uniqueEmail('alice') + await openApp(page, email) + await upload(page) + await expect(page.getByText(`Signed in as ${email}`)).toBeVisible() + expect(await page.evaluate(() => document.documentElement.scrollWidth)).toBeLessThanOrEqual(375) + const small = await page.evaluate(() => + [...document.querySelectorAll('button, a.btn, input:not([type=file]), select, textarea, .contact-row')] + .filter((e) => e.getBoundingClientRect().width > 0 && e.getBoundingClientRect().height < 44) + .map((e) => `${e.tagName}:${(e.innerText || e.className).slice(0, 24)}:${Math.round(e.getBoundingClientRect().height)}`), + ) + expect(small).toEqual([]) + }) +}) diff --git a/e2e/browser/server.ts b/e2e/browser/server.ts index b6d2405..3042974 100644 --- a/e2e/browser/server.ts +++ b/e2e/browser/server.ts @@ -1,5 +1,8 @@ // Serves the built SPA (dist/client) plus the real Worker on local Miniflare for browser tests. // /__test/* are test-only controls; they are not part of the app. +// With ACCESS_SIM=1 the Worker runs in AUTH_MODE=access behind a SIMULATED Cloudflare Access edge: nothing is served without +// a simulated session cookie, unauthenticated requests (pages and API calls alike) are redirected to a fake sign-in page, +// and signed-in requests reach the Worker with a signed Access JWT. This imitates the edge; it is not live Access. import { existsSync, readFileSync } from 'node:fs' import { createServer } from 'node:http' import { dirname, extname, join, resolve } from 'node:path' @@ -8,10 +11,17 @@ import { startHarness } from '../harness' const port = Number(process.env.PORT || 5199) const dist = resolve(process.env.CARDCAP_DIST || join(dirname(fileURLToPath(import.meta.url)), '..', '..', 'dist', 'client')) +const accessSim = process.env.ACCESS_SIM === '1' const types: Record = { '.html': 'text/html', '.js': 'text/javascript', '.css': 'text/css', '.svg': 'image/svg+xml', '.png': 'image/png' } async function main() { - const h = await startHarness() + const h = await startHarness({ access: accessSim }) + let epoch = 1 // bumping this ends every simulated session at once + const simCookie = (req: { headers: { cookie?: string } }) => /(?:^|;\s*)sim_access=([^;]+)/.exec(req.headers.cookie || '')?.[1] + const simEmail = (req: { headers: { cookie?: string } }) => { + const [email, seen] = decodeURIComponent(simCookie(req) || '').split('|') + return email && Number(seen) === epoch ? email : '' + } h.openai.mode = { kind: 'card', card: { @@ -24,6 +34,34 @@ async function main() { createServer(async (req, res) => { try { const url = new URL(req.url || '/', `http://127.0.0.1:${port}`) + if (accessSim && !url.pathname.startsWith('/__test/')) { + if (url.pathname === '/sim-idp') { + if (req.method === 'POST') { + const form = new URLSearchParams(Buffer.concat(await collect(req)).toString()) + const email = (form.get('email') || '').trim() + const back = form.get('return') || '/' + res.writeHead(302, { location: back.startsWith('/') && !back.startsWith('//') ? back : '/', 'set-cookie': `sim_access=${encodeURIComponent(`${email}|${epoch}`)}; Path=/; HttpOnly` }).end() + return + } + const back = url.searchParams.get('return') || '/' + res + .writeHead(200, { 'content-type': 'text/html' }) + .end(`Simulated sign-in

Simulated Access sign-in

`) + return + } + if (url.pathname === '/cdn-cgi/access/logout') { + res.writeHead(200, { 'content-type': 'text/html', 'set-cookie': 'sim_access=; Path=/; Max-Age=0' }).end('Signed out

You are signed out

') + return + } + if (url.pathname === '/cdn-cgi/access/login') { + res.writeHead(302, { location: `/sim-idp?return=${encodeURIComponent(url.searchParams.get('redirect_url') || '/')}` }).end() + return + } + if (!simEmail(req)) { + res.writeHead(302, { location: `/sim-idp?return=${encodeURIComponent(url.pathname + url.search)}` }).end() + return + } + } if (url.pathname === '/__test/login-link') { const link = h.lastLoginLink(url.searchParams.get('email') || '') res.writeHead(link ? 200 : 404, { 'content-type': 'text/plain' }).end(link ? new URL(link).pathname + new URL(link).search : '') @@ -37,6 +75,20 @@ async function main() { res.writeHead(200).end('ok') return } + if (url.pathname === '/__test/seed-duplicate-email') { + const email = url.searchParams.get('email') || '' + const db = await h.mf.getD1Database('DB') + for (const id of ['dup-a', 'dup-b']) { + await db.prepare("INSERT OR IGNORE INTO users (id, email, name, created_at, updated_at) VALUES (?, ?, ?, 't', 't')").bind(`${id}-${email}`, email, email).run() + } + res.writeHead(200).end('ok') + return + } + if (url.pathname === '/__test/access-expire') { + epoch += 1 + res.writeHead(200).end('ok') + return + } if (url.pathname === '/__test/r2') { res.writeHead(200, { 'content-type': 'application/json' }).end(JSON.stringify(await h.r2Keys())) return @@ -47,9 +99,16 @@ async function main() { const headers = new Headers() for (const [k, v] of Object.entries(req.headers)) if (v && k !== 'host' && k !== 'connection') headers.set(k, Array.isArray(v) ? v.join(', ') : v) const body = chunks.length ? Buffer.concat(chunks) : undefined - const out = (await h.mf.dispatchFetch(`https://cardcap.test${url.pathname}${url.search}`, { - method: req.method, headers, body: req.method === 'GET' || req.method === 'HEAD' ? undefined : body, redirect: 'manual', - } as never)) as unknown as Response + const hasBody = req.method !== 'GET' && req.method !== 'HEAD' + const out = accessSim + ? await h.access.raw( + `${url.pathname}${url.search}`, + { method: req.method, headers: { ...Object.fromEntries(headers), 'Cf-Access-Jwt-Assertion': h.access.token(simEmail(req)) }, body: hasBody ? body : undefined } as never, + 'cardcap.test', + ) + : ((await h.mf.dispatchFetch(`https://cardcap.test${url.pathname}${url.search}`, { + method: req.method, headers, body: hasBody ? body : undefined, redirect: 'manual', + } as never)) as unknown as Response) const outHeaders: Record = {} out.headers.forEach((v, k) => { if (k !== 'set-cookie') outHeaders[k] = v }) const cookies = (out.headers as unknown as { getSetCookie?: () => string[] }).getSetCookie?.() || [] @@ -68,3 +127,9 @@ async function main() { } void main() + +async function collect(req: AsyncIterable): Promise { + const chunks: Buffer[] = [] + for await (const chunk of req) chunks.push(Buffer.from(chunk)) + return chunks +} diff --git a/e2e/harness.ts b/e2e/harness.ts index e71f009..e0080b5 100644 --- a/e2e/harness.ts +++ b/e2e/harness.ts @@ -246,6 +246,8 @@ async function rawHttp(runtimeUrl: URL, path: string, init: RequestInit, headers body = Buffer.from(await encoded.arrayBuffer()) } else if (typeof init.body === 'string') { body = Buffer.from(init.body) + } else if (init.body instanceof Uint8Array) { + body = Buffer.from(init.body) } headers.set('host', host) headers.set('connection', 'close') From ab1cf1c7d9e14fc7402cada7026613928deb840e Mon Sep 17 00:00:00 2001 From: Cody Date: Wed, 30 Sep 2026 12:27:38 -0700 Subject: [PATCH 11/22] test: Access spec uses the configured base URL; refused-account logout and rollback checks Co-Authored-By: Claude Sonnet 5.5 --- e2e/access-rollback.e2e.test.ts | 43 +++++++++++++++++++++++++++++++++ e2e/access.e2e.test.ts | 24 ++++++++++++++++++ e2e/browser/access.spec.ts | 19 +++++++-------- 3 files changed, 76 insertions(+), 10 deletions(-) create mode 100644 e2e/access-rollback.e2e.test.ts diff --git a/e2e/access-rollback.e2e.test.ts b/e2e/access-rollback.e2e.test.ts new file mode 100644 index 0000000..77455b5 --- /dev/null +++ b/e2e/access-rollback.e2e.test.ts @@ -0,0 +1,43 @@ +import { afterEach, beforeEach, expect, it, onTestFailed } from 'vitest' +import { saveTrace, startHarness, type Harness } from './harness' + +// Rollback hazard check (evidence for the release plan, not a product fix): after someone has signed in through Access, +// their account row is access:. If the release is rolled back to the legacy sign-in (AUTH_MODE unset), can that +// person still reach their contacts by the legacy email link, and does the legacy path avoid creating a second account +// with the same email (which would make Access sign-in fail closed with "Account access requires review" after a re-enable)? +// Skipped unless ROLLBACK_CHECK=1 because it documents a known gap that is being reported, not a passing expectation. + +let h: Harness +beforeEach(async () => { + h = await startHarness() +}) +afterEach(async () => { + await h.dispose() +}) + +async function seedAccessAccount(email: string, contactName: string) { + const db = await h.mf.getD1Database('DB') + const id = `access:${'a'.repeat(64)}` + await db.prepare("INSERT INTO users (id, email, name, auth_provider, created_at, updated_at) VALUES (?, ?, ?, 'access', 't', 't')").bind(id, email, email).run() + await db + .prepare("INSERT INTO contacts (id, user_id, name, created_at, updated_at) VALUES ('c1', ?, ?, '2026-01-01T00:00:00Z', '2026-01-01T00:00:00Z')") + .bind(id, contactName) + .run() + return { db, id } +} + +it.skipIf(process.env.ROLLBACK_CHECK !== '1')('after a rollback, the legacy email link reaches the contacts of a person who signed in through Access', async () => { + onTestFailed((r) => saveTrace('access-rollback-contacts', h.trace, r.errors?.[0]?.message)) + await seedAccessAccount('alice@example.test', 'Alice Contact') + const alice = await h.signIn('alice@example.test') + const list = (await (await alice.request('/api/contacts')).json()) as { contacts: Array<{ name: string }> } + expect(list.contacts.map((c) => c.name)).toEqual(['Alice Contact']) +}) + +it.skipIf(process.env.ROLLBACK_CHECK !== '1')('after a rollback, the legacy email link does not create a second account with the same email', async () => { + onTestFailed((r) => saveTrace('access-rollback-duplicate', h.trace, r.errors?.[0]?.message)) + const { db } = await seedAccessAccount('alice@example.test', 'Alice Contact') + await h.signIn('alice@example.test') + const count = (await db.prepare("SELECT COUNT(*) AS n FROM users WHERE lower(trim(email)) = 'alice@example.test'").first<{ n: number }>())!.n + expect(count).toBe(1) +}) diff --git a/e2e/access.e2e.test.ts b/e2e/access.e2e.test.ts index 544d2f9..7204f1a 100644 --- a/e2e/access.e2e.test.ts +++ b/e2e/access.e2e.test.ts @@ -285,6 +285,30 @@ describe('logout', () => { }) }) +describe('a refused account can still sign out', () => { + scenario('with a valid token but an ambiguous account mapping, data is refused (403) yet logout answers with the Access logout URL', async () => { + await seedUser('dup-1', 'dana@example.test', 'Dana One') + await seedUser('dup-2', 'Dana@Example.test', 'Dana Two') + const dana = h.access.session('dana@example.test') + const data = await dana.request('/api/contacts') + expect(data.status).toBe(403) + expect(await data.json()).toEqual({ error: 'Account access requires review.' }) + const out = await dana.request('/api/auth/logout', { method: 'POST' }) + expect(out.status).toBe(200) + expect(await out.json()).toEqual({ logoutUrl: LOGOUT_URL }) + expect(out.headers.get('set-cookie') || '').toMatch(/cardcap_session=;.*Max-Age=0/) + }) + + scenario('logout still requires a cryptographically valid token (a forged one gets nothing)', async () => { + const rogue = makeAccessKey('test-key-1') + const res = await h.access.raw('/api/auth/logout', { + method: 'POST', + headers: { 'Cf-Access-Jwt-Assertion': mintToken({ key: rogue, claims: defaultClaims('dana@example.test') }) }, + }) + expect(res.status).toBe(401) + }) +}) + describe('cross-user isolation with Access sign-in', () => { scenario('another Access user cannot list, read, edit, delete or fetch the image of a contact', async () => { const alice = h.access.session('alice@example.test') diff --git a/e2e/browser/access.spec.ts b/e2e/browser/access.spec.ts index c5fa356..9bb2057 100644 --- a/e2e/browser/access.spec.ts +++ b/e2e/browser/access.spec.ts @@ -1,11 +1,10 @@ import { expect, test, type Browser, type Page } from '@playwright/test' import { PNG_BUFFER } from '../fixtures' -// Sign-in through a SIMULATED Cloudflare Access edge (see e2e/browser/server.ts, ACCESS_SIM=1, port 5198): +// Sign-in through a SIMULATED Cloudflare Access edge (see e2e/browser/server.ts, ACCESS_SIM=1; the port comes from the Playwright project): // the Worker runs in AUTH_MODE=access, the edge redirects signed-out people to a fake sign-in page, and signed-in // requests carry a signed Access JWT. This shows how CardCap behaves behind Access; it is not live Access behavior. -test.skip(process.env.ACCESS_SIM !== '1', 'needs the simulated Access server (ACCESS_SIM=1 on port 5198)') -test.use({ baseURL: 'http://127.0.0.1:5198' }) +test.skip(process.env.ACCESS_SIM !== '1', 'needs the simulated Access server (ACCESS_SIM=1)') let counter = 0 const uniqueEmail = (name: string) => `${name}-${Date.now()}-${++counter}@example.test` @@ -32,12 +31,12 @@ test.beforeEach(async ({ request }) => { await request.get('/__test/openai?mode=card') }) -test('first visit goes through the edge, returns to the same address, and shows no CardCap sign-in or password screens', async ({ page }) => { +test('first visit goes through the edge, returns to the same address, and shows no CardCap sign-in or password screens', async ({ page, baseURL }) => { const email = uniqueEmail('alice') await page.goto('/?from=bookmark') await expect(page).toHaveURL(/\/sim-idp\?return=/) await edgeSignIn(page, email) - await expect(page).toHaveURL('http://127.0.0.1:5198/?from=bookmark') + await expect(page).toHaveURL(new URL('/?from=bookmark', baseURL).toString()) await expect(page.getByRole('button', { name: 'Add cards' })).toBeVisible() await expect(page.getByText(`Signed in as ${email}`)).toBeVisible() await expect(page.getByRole('heading', { name: 'Sign in to CardCap' })).toHaveCount(0) @@ -102,21 +101,21 @@ test('an account that needs review is refused with a clear message and a way out await expect(page).toHaveURL(/\/cdn-cgi\/access\/logout$/) }) -async function person(browser: Browser, name: string) { - const context = await browser.newContext({ baseURL: 'http://127.0.0.1:5198' }) +async function person(browser: Browser, baseURL: string | undefined, name: string) { + const context = await browser.newContext({ baseURL }) const page = await context.newPage() await openApp(page, uniqueEmail(name)) return { context, page } } -test("another signed-in person cannot see, open the image of, or change someone else's contact", async ({ browser }) => { - const alice = await person(browser, 'alice') +test("another signed-in person cannot see, open the image of, or change someone else's contact", async ({ browser, baseURL }) => { + const alice = await person(browser, baseURL, 'alice') await upload(alice.page) const imageUrl = (await alice.page.locator('.source-body img').getAttribute('src'))! const contacts = (await (await alice.page.request.get('/api/contacts')).json()) as { contacts: Array<{ id: string }> } const id = contacts.contacts[0].id - const bob = await person(browser, 'bob') + const bob = await person(browser, baseURL, 'bob') await expect(bob.page.getByText('No contacts yet.')).toBeVisible() expect((await bob.page.request.get(imageUrl)).status()).toBe(404) expect((await bob.page.request.get(`/api/contacts/${id}`)).status()).toBe(404) From ca9a605e1332e215646d79aaf13618fb13d8beef Mon Sep 17 00:00:00 2001 From: Cody Date: Wed, 30 Sep 2026 12:31:38 -0700 Subject: [PATCH 12/22] Preserve ownership through auth rollback and check Access browser behavior --- .github/workflows/checks.yml | 2 ++ migrations/0004_access_identities.sql | 1 + package.json | 2 ++ playwright.access.config.ts | 22 ++++++++++++++++++++++ scripts/test-rollback.ts | 7 +++++++ src/worker/auth.ts | 8 ++++---- src/worker/index.ts | 12 ++++++------ src/worker/repository.ts | 13 +++++++++++++ tests/access-account.test.ts | 14 ++++++++++++++ 9 files changed, 71 insertions(+), 10 deletions(-) create mode 100644 playwright.access.config.ts create mode 100644 scripts/test-rollback.ts diff --git a/.github/workflows/checks.yml b/.github/workflows/checks.yml index 33fe7d7..4a69f67 100644 --- a/.github/workflows/checks.yml +++ b/.github/workflows/checks.yml @@ -20,8 +20,10 @@ jobs: - run: npm run lint - run: npm run build - run: npm run test:e2e + - run: npm run test:rollback - run: npx playwright install --with-deps chromium - run: npm run test:browser + - run: npm run test:browser:access - uses: actions/upload-artifact@v4 if: failure() with: diff --git a/migrations/0004_access_identities.sql b/migrations/0004_access_identities.sql index 44ae201..91ec3b8 100644 --- a/migrations/0004_access_identities.sql +++ b/migrations/0004_access_identities.sql @@ -7,3 +7,4 @@ CREATE TABLE IF NOT EXISTS access_identities ( FOREIGN KEY (user_id) REFERENCES users(id) ); CREATE INDEX IF NOT EXISTS idx_access_identities_user ON access_identities(user_id); +CREATE INDEX IF NOT EXISTS idx_users_normalized_email ON users(lower(trim(email))); diff --git a/package.json b/package.json index 0475f87..7a58f48 100644 --- a/package.json +++ b/package.json @@ -12,6 +12,8 @@ "test": "vitest run --config vitest.config.ts", "test:e2e": "vitest run --config vitest.e2e.config.ts", "test:browser": "playwright test", + "test:browser:access": "playwright test --config playwright.access.config.ts", + "test:rollback": "node --import tsx scripts/test-rollback.ts", "test:browser:server": "tsx e2e/browser/server.ts" }, "dependencies": { diff --git a/playwright.access.config.ts b/playwright.access.config.ts new file mode 100644 index 0000000..e24a246 --- /dev/null +++ b/playwright.access.config.ts @@ -0,0 +1,22 @@ +import { defineConfig } from '@playwright/test' + +// This is a synthetic edge for browser tests, never a production authentication bypass. +process.env.ACCESS_SIM = '1' + +export default defineConfig({ + testDir: './e2e/browser', + testMatch: '**/access.spec.ts', + workers: 1, + fullyParallel: false, + retries: 0, + reporter: [['list']], + outputDir: 'test-results/browser-access', + use: { baseURL: 'http://127.0.0.1:5197', trace: 'retain-on-failure', screenshot: 'only-on-failure' }, + webServer: { + command: 'npm run build && npm run test:browser:server', + env: { ACCESS_SIM: '1', PORT: '5197' }, + url: 'http://127.0.0.1:5197', + reuseExistingServer: false, + timeout: 120_000, + }, +}) diff --git a/scripts/test-rollback.ts b/scripts/test-rollback.ts new file mode 100644 index 0000000..4d8110d --- /dev/null +++ b/scripts/test-rollback.ts @@ -0,0 +1,7 @@ +import { spawnSync } from 'node:child_process' + +const result = spawnSync(process.execPath, ['node_modules/vitest/vitest.mjs', 'run', '--config', 'vitest.e2e.config.ts', 'e2e/access-rollback.e2e.test.ts'], { + stdio: 'inherit', + env: { ...process.env, ROLLBACK_CHECK: '1' }, +}) +process.exit(result.status ?? 1) diff --git a/src/worker/auth.ts b/src/worker/auth.ts index 9e6d4c3..e49a902 100644 --- a/src/worker/auth.ts +++ b/src/worker/auth.ts @@ -1,7 +1,7 @@ import { envValue } from './config' import { sendLoginEmail } from './email' import { hashPassword, validatePassword, verifyPassword } from './password' -import { consumeLoginToken, createLoginToken, getUserByEmail, setUserPasswordHash, upsertUser } from './repository' +import { consumeLoginToken, createLoginToken, getUserByEmail, magicLinkUser, setUserPasswordHash } from './repository' import { createSessionCookieValue, sessionClearCookieHeader, @@ -131,9 +131,9 @@ export async function verifyLogin(env: Env, url: URL): Promise { const email = await consumeLoginToken(env.DB, await sha256Hex(rawToken)) if (!email) return expiredLinkPage() - const userId = `email:${email}` - await upsertUser(env.DB, userId, email, 'magic-link') - const cookie = await createSessionCookieValue(userId, secret) + const user = await magicLinkUser(env.DB, email) + if (!user) return expiredLinkPage() + const cookie = await createSessionCookieValue(user.id, secret) return new Response(null, { status: 302, headers: { diff --git a/src/worker/index.ts b/src/worker/index.ts index a3f673b..9bc051d 100644 --- a/src/worker/index.ts +++ b/src/worker/index.ts @@ -63,6 +63,12 @@ async function route(request: Request, env: Env, url: URL): Promise { if (mode === 'access') { const identity = await accessIdentity(request, env) if (!identity) return json({ error: 'Sign in to continue.', loginUrl: '/cdn-cgi/access/login' }, 401) + if (request.method === 'POST' && url.pathname === '/api/auth/logout') { + const response = logout() + return new Response(JSON.stringify({ logoutUrl: '/cdn-cgi/access/logout' }), { + headers: response.headers, + }) + } accessUser = await resolveAccessUser(env.DB, identity) if (['/api/auth/request-link', '/api/auth/password-login', '/api/auth/verify', '/api/auth/set-password'].includes(url.pathname)) { return json({ error: 'Use Access to sign in.' }, 410) @@ -70,12 +76,6 @@ async function route(request: Request, env: Env, url: URL): Promise { if (request.method === 'GET' && url.pathname === '/api/auth/config') { return json({ mode: 'access', loginUrl: '/cdn-cgi/access/login', logoutUrl: '/cdn-cgi/access/logout' }) } - if (request.method === 'POST' && url.pathname === '/api/auth/logout') { - const response = logout() - return new Response(JSON.stringify({ logoutUrl: '/cdn-cgi/access/logout' }), { - headers: response.headers, - }) - } } if (request.method === 'GET' && url.pathname === '/api/health') { return json({ ok: true, extractor: env.AI_EXTRACTOR }) diff --git a/src/worker/repository.ts b/src/worker/repository.ts index a2c2f91..ac1a6b6 100644 --- a/src/worker/repository.ts +++ b/src/worker/repository.ts @@ -366,6 +366,19 @@ export async function upsertUser(db: D1Database, userId: string, email: string, await ensureUser(db, userId, email, email, authProvider, now) } +export async function magicLinkUser(db: D1Database, email: string): Promise<{ id: string; email: string } | null> { + await ensureSchema(db) + const normalized = email.trim().toLowerCase() + const now = new Date().toISOString() + // Preserve Access and older opaque owner IDs; never create a duplicate by email. + await db.prepare(`INSERT OR IGNORE INTO users (id,email,name,auth_provider,created_at,updated_at) + SELECT ?, ?, ?, 'magic-link', ?, ? WHERE NOT EXISTS (SELECT 1 FROM users WHERE lower(trim(email)) = ?)`) + .bind(`email:${normalized}`, normalized, normalized, now, now, normalized).run() + const rows = await db.prepare('SELECT id,email FROM users WHERE lower(trim(email)) = ? LIMIT 2') + .bind(normalized).all<{ id: string; email: string }>() + return rows.results.length === 1 ? rows.results[0] : null +} + function emailFromUserId(userId: string): string { return userId.startsWith('email:') ? userId.slice('email:'.length) : '' } diff --git a/tests/access-account.test.ts b/tests/access-account.test.ts index f7893fc..b713428 100644 --- a/tests/access-account.test.ts +++ b/tests/access-account.test.ts @@ -2,6 +2,7 @@ import { DatabaseSync } from 'node:sqlite' import { readFileSync } from 'node:fs' import { describe, expect, it } from 'vitest' import { authenticationMode, resolveAccessUser } from '../src/worker/access' +import { magicLinkUser } from '../src/worker/repository' function database() { const sqlite = new DatabaseSync(':memory:') @@ -39,6 +40,19 @@ function database() { const identity = { issuer: 'https://test.cloudflareaccess.com', subject: 'google-subject', email: 'owner@example.invalid' } describe('Access account compatibility using real migrations', () => { + it('legacy login rejects duplicate normalized email owners without issuing an identity', async () => { + const { db, sqlite, user } = database() + user('one', 'owner@example.invalid'); user('two', ' Owner@Example.invalid ') + expect(await magicLinkUser(db, identity.email)).toBeNull() + expect(sqlite.prepare('SELECT count(*) AS count FROM users WHERE lower(trim(email)) = ?').get(identity.email)).toEqual({ count: 2 }) + }) + + it('legacy login preserves an opaque owner ID and does not rewrite the account', async () => { + const { db, sqlite, user } = database() + user('opaque-owner', ' Owner@Example.invalid ') + expect(await magicLinkUser(db, identity.email)).toEqual({ id: 'opaque-owner', email: ' Owner@Example.invalid ' }) + expect(sqlite.prepare("SELECT auth_provider,updated_at FROM users WHERE id = 'opaque-owner'").get()).toEqual({ auth_provider: 'local', updated_at: 'before' }) + }) it('preserves noncanonical ownership and credentials across Google and PIN identities', async () => { const { db, sqlite, user } = database() user('old-opaque-id', 'Owner@Example.invalid') From 7bc2725255b06c2dd90c843c80e3326c1953edef Mon Sep 17 00:00:00 2001 From: Cody Date: Wed, 30 Sep 2026 12:39:07 -0700 Subject: [PATCH 13/22] feat(ui): layout around the next action Add cards is the primary action; export formats move into one Export menu and sign out into an Account menu; Import is secondary. One welcome state when empty, search and list once there are contacts, editor and card photo only for the selected contact. Controls are 40px with a mouse and 44px on touch; phones show one task at a time. Co-Authored-By: Claude Sonnet 5.5 --- src/App.css | 680 +++++++++++++++++++++++++++------------- src/App.tsx | 521 ++++++++++++++++-------------- src/components/Menu.tsx | 119 +++++++ 3 files changed, 856 insertions(+), 464 deletions(-) create mode 100644 src/components/Menu.tsx diff --git a/src/App.css b/src/App.css index 76893dc..9f944a7 100644 --- a/src/App.css +++ b/src/App.css @@ -1,5 +1,6 @@ /* CardCap on the CSI "Ledger" system (src/csi.css, from csiOS/csi-brand). - Only --csi-* tokens are used for color, type and space. Square corners, 1px rules, no shadows or gradients. */ + Only --csi-* tokens are used for color, type and space. Square corners, 1px rules, no shadows or gradients. + Controls are 40px tall with a mouse and 44px on touch screens and phones. */ /* The brand kit only ships dark values behind [data-theme="dark"]; apply the same values when the OS asks for dark. */ @media (prefers-color-scheme: dark) { @@ -32,12 +33,20 @@ html { } .app-shell { + --ctl: 40px; + --header: 57px; min-height: 100vh; background: var(--csi-surface); color: var(--csi-text); font: var(--csi-small); } +@media (pointer: coarse), (max-width: 760px) { + .app-shell { + --ctl: 44px; + } +} + .app-shell *, .app-shell *::before, .app-shell *::after { @@ -71,11 +80,14 @@ html { } .topbar { + position: sticky; + top: 0; + z-index: 10; display: flex; align-items: center; justify-content: space-between; gap: var(--csi-s4); - padding: var(--csi-s3) var(--csi-s4); + padding: var(--csi-s2) var(--csi-s4); background: var(--csi-paper); border-bottom: var(--csi-border); } @@ -89,83 +101,51 @@ html { .brand-mark { flex: none; - width: 44px; - height: 44px; + width: 36px; + height: 36px; display: block; } .brand-text { + display: grid; min-width: 0; } .brand-org { - display: block; font: var(--csi-label); + font-size: 11px; letter-spacing: 0.08em; text-transform: uppercase; color: var(--csi-muted); } -.topbar h1, -.topbar p, -.editor-head h2, -.editor-head p, -.source-head h2 { - margin: 0; -} - .topbar h1 { - font: var(--csi-h2); + margin: 0; + font: 700 20px/1.2 var(--csi-font); color: var(--csi-ink); } -.topbar .tagline, -.editor-head p { - color: var(--csi-muted); - font: var(--csi-small); -} - -.toolbar, -.quick-links, -.actions { +.toolbar { display: flex; align-items: center; flex-wrap: wrap; + justify-content: flex-end; gap: var(--csi-s2); } -.toolbar-group { - display: flex; - align-items: center; - flex-wrap: wrap; - gap: var(--csi-s2); -} - -.toolbar-group + .toolbar-group { - padding-left: var(--csi-s2); - border-left: 1px solid var(--csi-rule-strong); -} - -.toolbar-label { - font: var(--csi-label); - letter-spacing: 0.08em; - text-transform: uppercase; - color: var(--csi-muted); -} - -/* Buttons (same look as .csi-btn) */ +/* Buttons (same look as .csi-btn, a little smaller) */ .btn, .quick-links a { display: inline-flex; align-items: center; justify-content: center; gap: var(--csi-s2); - min-height: 44px; - padding: 0 var(--csi-s4); + min-height: var(--ctl); + padding: 0 14px; border: 1px solid var(--csi-rule-strong); background: var(--csi-paper); color: var(--csi-ink); - font: 700 15px/1 var(--csi-font); + font: 700 14px/1 var(--csi-font); text-decoration: none; cursor: pointer; } @@ -215,9 +195,61 @@ html { display: none; } +/* Menus (Export, Account) */ +.menu { + position: relative; +} + +.menu-caret { + width: 0; + height: 0; + border-left: 4px solid transparent; + border-right: 4px solid transparent; + border-top: 5px solid currentColor; +} + +.menu-panel { + position: absolute; + right: 0; + top: calc(100% + var(--csi-s1)); + z-index: 30; + min-width: 200px; + max-width: min(320px, calc(100vw - 32px)); + padding: var(--csi-s1) 0; + background: var(--csi-paper); + border: 1px solid var(--csi-ink); +} + +.menu-heading { + padding: var(--csi-s2) var(--csi-s4); + color: var(--csi-muted); + font: var(--csi-small); + overflow-wrap: anywhere; + border-bottom: var(--csi-border); + margin-bottom: var(--csi-s1); +} + +.menu-item { + display: block; + width: 100%; + min-height: var(--ctl); + padding: 0 var(--csi-s4); + border: 0; + background: transparent; + color: var(--csi-ink); + font: 500 14px/1 var(--csi-font); + text-align: left; + cursor: pointer; +} + +.menu-item:hover, +.menu-item:focus-visible { + background: var(--csi-turquoise-tint); +} + /* Messages */ .banner { - padding: var(--csi-s3) var(--csi-s4); + padding: var(--csi-s2) var(--csi-s4); color: var(--csi-turquoise-deep); background: var(--csi-turquoise-tint); border-bottom: var(--csi-border); @@ -238,7 +270,7 @@ html { /* Sign-in and session screens */ .unlock-screen { - min-height: calc(100vh - 72px); + min-height: calc(100vh - var(--header)); display: grid; place-items: center; padding: var(--csi-s5) var(--csi-s4); @@ -265,41 +297,21 @@ html { color: var(--csi-muted); } -.unlock-panel .banner, -.unlock-panel .error-text { - margin: 0; -} - -/* Workspace: capture, review/edit, source image */ -.workspace { - min-height: calc(100vh - 72px); - display: grid; - grid-template-columns: 300px minmax(420px, 1fr) 420px; -} - -.sidebar, -.editor, -.source-panel { - min-height: 0; - background: var(--csi-paper); -} - -.sidebar { - border-right: var(--csi-border); - display: flex; - flex-direction: column; +.privacy-note { + font: var(--csi-small); + color: var(--csi-muted); + margin-top: var(--csi-s4); } -.filters { - display: grid; - gap: var(--csi-s3); - padding: var(--csi-s4); - border-bottom: var(--csi-border); +.turnstile-slot { + min-height: 65px; + margin: var(--csi-s2) 0; } +/* Forms */ label { display: grid; - gap: 6px; + gap: 4px; color: var(--csi-muted); font: var(--csi-label); letter-spacing: 0.08em; @@ -310,18 +322,19 @@ input, select, textarea { width: 100%; - min-height: 44px; + min-height: var(--ctl); border: 1px solid var(--csi-rule-strong); - padding: var(--csi-s2) var(--csi-s3); + padding: 0 var(--csi-s3); color: var(--csi-ink); background: var(--csi-paper); - font: 400 16px/1.4 var(--csi-font); + font: 400 15px/1.4 var(--csi-font); text-transform: none; letter-spacing: 0; } textarea { - min-height: 104px; + min-height: 72px; + padding: var(--csi-s2) var(--csi-s3); resize: vertical; } @@ -336,18 +349,153 @@ textarea:focus { border-color: var(--csi-turquoise); } +/* Workspace: one view at a time */ +.workspace { + min-height: calc(100vh - var(--header)); +} + +.view-loading, +.view-empty { + display: grid; + place-items: start center; + padding: var(--csi-s7) var(--csi-s4); +} + +.view-list { + display: grid; + grid-template-columns: minmax(0, 760px); + justify-content: center; + align-items: start; + padding: var(--csi-s4); +} + +.view-edit { + display: grid; + grid-template-columns: 300px minmax(0, 1fr) 320px; + align-items: start; +} + +.empty { + display: grid; + gap: var(--csi-s2); + padding: var(--csi-s5) var(--csi-s4); + color: var(--csi-muted); + text-align: center; + justify-items: center; +} + +.empty strong { + color: var(--csi-ink); + font: var(--csi-h3); +} + +/* Welcome (no contacts yet) */ +.welcome { + width: min(100%, 560px); + display: grid; + gap: var(--csi-s4); + padding: var(--csi-s6); + background: var(--csi-paper); + border: var(--csi-border); + border-top: var(--csi-bar) solid var(--csi-turquoise); +} + +.welcome h2 { + margin: 0; + font: var(--csi-h1); + color: var(--csi-ink); +} + +.welcome p { + margin: 0; + max-width: var(--csi-measure); + font: var(--csi-body); + color: var(--csi-text); +} + +.welcome-steps { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: var(--csi-s3); + margin: 0; + padding: 0; + list-style: none; + counter-reset: step; +} + +.welcome-steps li { + counter-increment: step; + display: grid; + gap: 2px; + padding-top: var(--csi-s3); + border-top: 1px solid var(--csi-rule-strong); +} + +.welcome-steps li::before { + content: counter(step, decimal-leading-zero); + font: var(--csi-label); + letter-spacing: 0.08em; + color: var(--csi-turquoise-deep); +} + +.welcome-steps strong { + color: var(--csi-ink); + font: 700 15px/1.3 var(--csi-font); +} + +.welcome-steps span { + color: var(--csi-muted); +} + +.welcome-actions { + display: flex; + flex-wrap: wrap; + gap: var(--csi-s2); +} + +/* Contact list */ +.sidebar { + background: var(--csi-paper); + border: var(--csi-border); + display: flex; + flex-direction: column; + min-width: 0; +} + +.view-edit .sidebar { + position: sticky; + top: var(--header); + height: calc(100vh - var(--header)); + border-width: 0 1px 0 0; +} + +.filters { + display: grid; + grid-template-columns: minmax(0, 1fr) minmax(0, 140px); + gap: var(--csi-s2); + padding: var(--csi-s3); + border-bottom: var(--csi-border); +} + +.view-edit .filters { + grid-template-columns: 1fr; +} + .contact-list { min-height: 0; - overflow-y: auto; padding: var(--csi-s2); } +.view-edit .contact-list { + overflow-y: auto; +} + .list-head { display: flex; align-items: center; justify-content: space-between; gap: var(--csi-s3); - padding: 6px 6px 10px; + padding: 4px 6px 8px; color: var(--csi-muted); font: var(--csi-small); } @@ -365,12 +513,8 @@ textarea:focus { } .letter-row { - position: sticky; - top: 0; - z-index: 1; padding: var(--csi-s2) 6px var(--csi-s1); color: var(--csi-muted); - background: var(--csi-paper); font: var(--csi-label); letter-spacing: 0.08em; border-bottom: var(--csi-border); @@ -378,14 +522,14 @@ textarea:focus { .contact-row { width: 100%; - min-height: 64px; + min-height: 56px; display: grid; - grid-template-columns: 48px 1fr; + grid-template-columns: 40px minmax(0, 1fr) auto; gap: var(--csi-s3); align-items: center; border: 1px solid transparent; border-left: 4px solid transparent; - padding: var(--csi-s2); + padding: var(--csi-s1) var(--csi-s2); color: var(--csi-text); background: transparent; text-align: left; @@ -404,18 +548,15 @@ textarea:focus { .contact-row img, .avatar-fallback { - width: 48px; - height: 48px; + width: 40px; + height: 40px; border: var(--csi-border); object-fit: cover; background: var(--csi-surface); } -.avatar-fallback { - display: grid; - place-items: center; - color: var(--csi-ink); - font-weight: 700; +.contact-text { + min-width: 0; } .contact-row strong, @@ -435,56 +576,66 @@ textarea:focus { margin-top: 2px; } -.empty { - display: grid; - gap: var(--csi-s2); - padding: var(--csi-s6) var(--csi-s4); - color: var(--csi-muted); - text-align: center; - justify-items: center; -} - -.empty strong { - color: var(--csi-ink); - font: var(--csi-h3); +.row-flag { + padding: 1px var(--csi-s2); + color: var(--csi-copper); + background: var(--csi-copper-tint); + font: var(--csi-label); + letter-spacing: 0.06em; + text-transform: uppercase; } +/* Editor */ .editor { min-width: 0; - overflow-y: auto; + background: var(--csi-paper); + display: flex; + flex-direction: column; + min-height: calc(100vh - var(--header)); } -.editor-empty { - min-height: 420px; - align-content: center; +.editor-body { + flex: 1; +} + +.phone-bar { + display: none; } .editor-head { - position: sticky; - top: 0; - z-index: 1; display: flex; align-items: flex-start; justify-content: space-between; gap: var(--csi-s4); - padding: var(--csi-s4) var(--csi-s5); - background: var(--csi-paper); + padding: var(--csi-s3) var(--csi-s5); border-bottom: var(--csi-border); } +.editor-head h2, +.editor-head p { + margin: 0; +} + .editor-head h2 { - font: var(--csi-h1); + font: 700 22px/1.25 var(--csi-font); color: var(--csi-ink); + overflow-wrap: anywhere; } -.quick-links { - margin-top: var(--csi-s3); +.editor-head p { + color: var(--csi-muted); + font: var(--csi-small); +} + +.head-flags { + display: flex; + align-items: center; + flex-wrap: wrap; + gap: var(--csi-s2); + margin-top: var(--csi-s2); } .review-chip { - display: inline-flex; - width: fit-content; - margin-top: var(--csi-s3); padding: 2px var(--csi-s2); color: var(--csi-copper); background: var(--csi-copper-tint); @@ -501,11 +652,24 @@ textarea:focus { letter-spacing: 0.08em; } +.quick-links { + display: flex; + flex-wrap: wrap; + justify-content: flex-end; + gap: var(--csi-s2); +} + +.quick-links a { + min-height: 32px; + padding: 0 var(--csi-s3); + font-size: 13px; +} + .form-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); - gap: var(--csi-s4); - padding: var(--csi-s5) var(--csi-s5) 0; + gap: var(--csi-s3) var(--csi-s4); + padding: var(--csi-s4) var(--csi-s5); } .form-grid .wide { @@ -513,31 +677,44 @@ textarea:focus { } .actions { - padding: var(--csi-s5); position: sticky; bottom: 0; + display: flex; + align-items: center; + flex-wrap: wrap; + gap: var(--csi-s2); + padding: var(--csi-s3) var(--csi-s5); background: var(--csi-paper); border-top: var(--csi-border); - margin-top: var(--csi-s5); } +.actions .danger { + margin-left: auto; +} + +/* Card photo */ .source-panel { + position: sticky; + top: var(--header); + height: calc(100vh - var(--header)); + background: var(--csi-paper); border-left: var(--csi-border); display: flex; flex-direction: column; } .source-head { - min-height: 56px; + min-height: 48px; display: flex; align-items: center; justify-content: space-between; gap: var(--csi-s3); - padding: var(--csi-s3) var(--csi-s4); + padding: var(--csi-s2) var(--csi-s4); border-bottom: var(--csi-border); } .source-head h2 { + margin: 0; font: var(--csi-label); letter-spacing: 0.08em; text-transform: uppercase; @@ -565,21 +742,11 @@ textarea:focus { background: var(--csi-paper); } -.turnstile-slot { - min-height: 65px; - margin: var(--csi-s2) 0; -} - -.privacy-note { - font: var(--csi-small); - color: var(--csi-muted); - margin-top: var(--csi-s4); -} - +/* Dialogs */ .modal-backdrop { position: fixed; inset: 0; - z-index: 20; + z-index: 40; display: grid; place-items: center; padding: var(--csi-s4); @@ -627,155 +794,218 @@ textarea:focus { flex-wrap: wrap; } -/* Medium screens */ +.session-panel { + border-top-color: var(--csi-copper); +} + +.session-body { + display: grid; + gap: var(--csi-s3); +} + +.session-body p { + margin: 0; + max-width: var(--csi-measure); + font: var(--csi-body); + color: var(--csi-text); +} + +.app-content[inert] { + opacity: 0.4; +} + +/* Medium screens: photo moves under the form */ @media (max-width: 1120px) { - .workspace { - grid-template-columns: 280px minmax(0, 1fr); + .view-edit { + grid-template-columns: 260px minmax(0, 1fr); } .source-panel { + position: static; + height: auto; grid-column: 2; - border-top: var(--csi-border); border-left: 0; + border-top: var(--csi-border); } .source-body { - max-height: 360px; + max-height: 420px; } - .toolbar-group + .toolbar-group { - padding-left: 0; - border-left: 0; + .actions { + position: static; } } -/* Phones: one column, 44px targets, no sideways scrolling */ +/* Phones: one task at a time */ @media (max-width: 760px) { + .app-shell { + --header: 101px; + } + .topbar { - align-items: stretch; flex-direction: column; + align-items: stretch; + gap: var(--csi-s2); + padding: var(--csi-s2) var(--csi-s3); } .toolbar { - align-items: stretch; - flex-direction: column; + flex-wrap: nowrap; + justify-content: stretch; } - .toolbar-group { - width: 100%; + .toolbar > .btn.primary { + flex: 1 1 auto; } - .toolbar-group .btn { - flex: 1 1 calc(50% - var(--csi-s2)); + .toolbar .menu-button, + .toolbar > .btn:not(.primary) { + padding: 0 var(--csi-s3); } - .toolbar-group .btn.primary { - flex-basis: 100%; + .view-loading, + .view-empty { + padding: var(--csi-s5) var(--csi-s3); } - .workspace { - display: flex; - flex-direction: column; + .welcome { + padding: var(--csi-s4); + } + + .welcome h2 { + font: var(--csi-h2); + } + + .welcome-steps { + grid-template-columns: 1fr; + gap: var(--csi-s2); } - .sidebar { - order: 1; - border-right: 0; + .welcome-steps li { + grid-template-columns: auto 1fr; + align-items: baseline; + column-gap: var(--csi-s3); + } + + .welcome-steps li span { + grid-column: 2; + } + + .welcome-actions .btn { + flex: 1 1 100%; + } + + .view-list { + padding: 0; + grid-template-columns: minmax(0, 1fr); + } + + .view-list .sidebar { + border-width: 0; + } + + .view-edit { + display: block; + } + + .view-edit .sidebar { + display: none; + } + + .phone-bar { + display: flex; + align-items: center; + justify-content: space-between; + gap: var(--csi-s2); + padding: var(--csi-s2) var(--csi-s3); border-bottom: var(--csi-border); } - .editor { - order: 2; + .segmented { + display: flex; + border: 1px solid var(--csi-rule-strong); } - .source-panel { - order: 3; - border-top: var(--csi-border); + .segmented button { + min-height: var(--ctl); + padding: 0 var(--csi-s3); + border: 0; + background: var(--csi-paper); + color: var(--csi-ink); + font: 700 14px/1 var(--csi-font); + cursor: pointer; } - .contact-list { - display: grid; - grid-template-columns: 1fr; - max-height: 320px; + .segmented button[aria-pressed='true'] { + background: var(--csi-ink); + color: var(--csi-paper); } - .list-head { - grid-column: 1 / -1; + .segmented button + button { + border-left: 1px solid var(--csi-rule-strong); } - .letter-row { - position: static; + .editor { + min-height: calc(100vh - var(--header) - 61px); } .form-grid { grid-template-columns: 1fr; - padding: var(--csi-s4) var(--csi-s4) 0; + padding: var(--csi-s3); } .editor-head { - position: static; flex-direction: column; - padding: var(--csi-s4); + padding: var(--csi-s3); } - .editor-head h2 { - font: var(--csi-h2); + .quick-links { + justify-content: flex-start; + } + + .quick-links a { + min-height: var(--ctl); } .actions { - padding: var(--csi-s4); + position: sticky; + padding: var(--csi-s3); } .actions .btn { - flex: 1 1 100%; + flex: 1 1 auto; } - .source-body { - max-height: 260px; + .actions .danger { + margin-left: 0; } -} -@media (prefers-reduced-motion: reduce) { - * { - animation: none !important; - transition: none !important; - scroll-behavior: auto !important; + .view-edit .source-panel { + display: none; } -} - -/* Session ended / account refused dialog */ -.session-panel { - border-top-color: var(--csi-copper); -} - -.session-body { - display: grid; - gap: var(--csi-s3); -} -.session-body p { - margin: 0; - max-width: var(--csi-measure); - font: var(--csi-body); - color: var(--csi-text); -} + .view-edit.show-photo .source-panel { + display: flex; + position: static; + height: auto; + border-top: 0; + } -.app-content[inert] { - opacity: 0.4; -} + .view-edit.show-photo .editor-body, + .view-edit.show-photo .actions { + display: none; + } -.signed-in { - max-width: 28ch; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; - color: var(--csi-muted); - font: var(--csi-small); + .view-edit.show-photo .editor { + min-height: 0; + } } -@media (max-width: 760px) { - .signed-in { - flex: 1 1 100%; - max-width: none; +@media (prefers-reduced-motion: reduce) { + * { + animation: none !important; + transition: none !important; + scroll-behavior: auto !important; } } diff --git a/src/App.tsx b/src/App.tsx index eb7edc6..76dec62 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,6 +1,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import './App.css' import { LoginGate } from './components/LoginGate' +import { Menu } from './components/Menu' import { SessionPanel, type SessionProblem } from './components/SessionPanel' import { ApiError, apiGet, apiJson, apiUpload, relativeEdgeUrl } from './lib/api' import { contactDisplayName, groupContactsByInitial, sortContactsByName } from './shared/contacts' @@ -18,6 +19,7 @@ function App() { const [authRequired, setAuthRequired] = useState(false) const [userEmail, setUserEmail] = useState('') const [accessMode, setAccessMode] = useState(false) + const [photoTab, setPhotoTab] = useState(false) const [passwordPanelOpen, setPasswordPanelOpen] = useState(false) const [accountPassword, setAccountPassword] = useState('') const [accountPasswordConfirm, setAccountPasswordConfirm] = useState('') @@ -35,6 +37,7 @@ function App() { const nextId = contact?.id || '' selectedIdRef.current = nextId setSelectedId(nextId) + setPhotoTab(false) setDraft(contact ? cloneContact(contact) : null) }, []) @@ -49,7 +52,7 @@ function App() { setContacts(sortedContacts) setAuthRequired(false) const nextId = preferredId || selectedIdRef.current - selectContact(sortedContacts.find((contact) => contact.id === nextId) || sortedContacts[0] || null) + selectContact(sortedContacts.find((contact) => contact.id === nextId) || null) return sortedContacts } catch (err) { if (signedInRef.current && err instanceof ApiError && (err.kind === 'session-ended' || err.kind === 'denied')) { @@ -304,7 +307,7 @@ function App() { await apiJson<{ deleted: boolean }>(`/api/contacts/${encodeURIComponent(draft.id)}`, 'DELETE') const next = sortContactsByName(contacts.filter((contact) => contact.id !== draft.id)) setContacts(next) - selectContact(next[0] || null) + selectContact(null) setNotice('Deleted.') } catch (err) { fail(err) @@ -344,6 +347,9 @@ function App() { window.location.href = path } + const view: 'loading' | 'empty' | 'list' | 'edit' = + contacts.length === 0 ? (loading ? 'loading' : 'empty') : draft ? 'edit' : 'list' + return (
@@ -355,70 +361,60 @@ function App() {
- +
Copper State Intelligence

CardCap

-

Business cards in. Follow-up CRM out.

-
-
- - void handleFiles(event.currentTarget.files)} - /> - - void handleImport(event.currentTarget.files)} - /> -
-
- - - - - + {!authRequired && ( +
+ + void handleFiles(event.currentTarget.files)} + /> + + void handleImport(event.currentTarget.files)} + /> + exportUrl('/api/export.csv') }, + { label: 'vCard', onSelect: () => exportUrl('/api/export.vcf') }, + { label: 'JSON', onSelect: () => exportUrl('/api/export.json') }, + { label: 'HTML', onSelect: () => exportUrl('/api/export.html') }, + { label: 'iContact CSV', onSelect: () => exportUrl('/api/export.icontact.csv') }, + ]} + /> + {userEmail && ( + setPasswordPanelOpen(true) }]), + { label: 'Sign out', onSelect: () => void signOut() }, + ]} + /> + )}
- {userEmail && ( -
- Signed in as {userEmail} - {!accessMode && ( - - )} - -
- )} -
+ )} {(notice || error) && ( @@ -486,200 +482,247 @@ function App() { {authRequired && void handleSignedIn()} />} {!authRequired && ( -
- - -
- {!draft && ( -
- Ready for your first batch. - Upload business card photos and CardCap will create editable contact drafts. -
+
)} - {draft && ( - <> -
-
- - - + {(view === 'list' || view === 'edit') && ( +