What we keep, what we don’t, and where the code lives.
What we keep
Two things, and they are different. On this device: a count of the scripts you have completed. That count never leaves the browser, and the only count this site will ever show you is your own.
On a server, when you press “count my call”: one row saying that a call was made on that card, and when, plus the SHA‑256 fingerprint hash described below. Nothing about who called, from where, or what was said. Those totals are not published and no visitor can read them. You can switch this off entirely under “Your count” below — it is on unless you turn it off.
If you report a correction on the about page — and only if you do — that report is stored on a server, so a person can read it. Specifically: which of the five categories you chose, which card or page you named, the source link you picked, the short values you typed (the figure or name as we print it, and as the source gives it), and the same SHA‑256 fingerprint hash described below, which is used to stop one device filing the same report over and over. That is the whole record.
The reply address is optional and the form works without it. If you do give one it is stored for one purpose — answering you — and it is deleted when the report is resolved. Nobody is added to a list; there is no list.
The fingerprint is a SHA‑256 hash of three coarse browser details — user agent, language, screen size. It is kept alongside the record, not discarded; it exists to stop one device running the count up. It is not a name and cannot be turned back into one — many thousands of people share the same value.
What we don’t
No email, no account, no cookies, no third-party trackers.
A correction report changes none of that. You still do not need an account, an email address, or a name to file one, and nothing about filing one is used to identify you or to follow you anywhere. Submitted reports are not readable by visitors and are never published as sent — if one turns out to be right, what appears is a dated entry in the corrections log, written in our own words.
What funds this
Nothing, and that is the point: there is no revenue here that could be earned from knowing anything about you.
- Nothing is sold here. No product, no paid tier, no merchandise, no payment of any kind is accepted.
- There is no donation link, and no advertising or sponsored placement anywhere on the site.
- There is no account and no login, and no email address is ever asked for or collected.
- No analytics, no advertising pixels, no third-party trackers, and no cookies used to identify you.
- No funder, no sponsor, no PAC, no advocacy organization. Nobody is paid to work on it.
Your count (this device)
Turning this off clears your count and stops new ones.
Where the code lives
Above are civic-record.ts and fingerprint.ts, byte-for-byte as shipped — not a summary of them, not a description of what they do. They are printed here rather than linked because this is the disclosure that matters: these two files are the entire surface that touches anything of yours, and you can read every line of them without leaving this page or trusting anything we say about them.
Codes like CIVIC-02 or D-23 in the comments are internal requirement IDs from the project’s planning documents. The rest of the build is not published; what governs your data is.
src/lib/civic-record.ts
/**
* Civic record (CIVIC-01..05) — on-device-only, anonymous, opt-out-able log of
* completed tactics. Default-on per CIVIC-02; opt-out wipes the count and
* future writes are no-ops until re-enabled.
*
* v3 shape (BUG-02 honesty fix): counts are stored PER TACTIC so copy never has
* to claim a device-wide number came "from this script". The device-wide total
* is kept alongside so a v2 record (which only had a total) migrates without
* losing the user's history — v2 totals cannot be attributed to any tactic, so
* they survive only in the total.
*
* Privacy posture (CIVIC-03, CIVIC-05):
* - localStorage only — no cookies, no server transmission of this record.
* - Anonymous — only the tactic_id and a timestamp are stored locally;
* the rate-limit fingerprint hash is computed fresh on each completion
* by computeFingerprint() (see fingerprint.ts) and never persisted.
* - User-controlled — opt-out toggle on /privacy clears local data.
* - Transparent — /privacy renders the byte-identical source of THIS file
* and of fingerprint.ts (CIVIC-04 snapshot via embed-civic-record-source).
*/
export const CIVIC_RECORD_KEY = '86dilley:civic-record-v2';
export const CIVIC_OPT_OUT_KEY = '86dilley:civic-opt-out';
export interface CivicRecordV3 {
_v: 3;
/** Device-wide total, including migrated v2 counts that predate per-tactic tracking. */
count: number;
/** Per-tactic completion counts (v3+ writes only). */
counts: Record<string, number>;
last_by_tactic: Record<string, string>;
}
function emptyRecord(): CivicRecordV3 {
return { _v: 3, count: 0, counts: {}, last_by_tactic: {} };
}
function readRecord(): CivicRecordV3 {
try {
const raw = globalThis.localStorage?.getItem(CIVIC_RECORD_KEY);
if (!raw) return emptyRecord();
const parsed = JSON.parse(raw);
if (
parsed &&
parsed._v === 3 &&
typeof parsed.count === 'number' &&
parsed.counts &&
parsed.last_by_tactic
) {
return parsed as CivicRecordV3;
}
// Graceful v2 migration: the v2 shape had only a device-wide total, so the
// total carries over and per-tactic counts start empty (honest — we cannot
// attribute old completions to specific tactics).
if (
parsed &&
parsed._v === 2 &&
typeof parsed.count === 'number' &&
parsed.last_by_tactic
) {
return {
_v: 3,
count: parsed.count,
counts: {},
last_by_tactic: parsed.last_by_tactic,
};
}
return emptyRecord();
} catch {
return emptyRecord();
}
}
function writeRecord(record: CivicRecordV3): void {
try {
globalThis.localStorage?.setItem(CIVIC_RECORD_KEY, JSON.stringify(record));
} catch {
// Quota exceeded or storage disabled — silent no-op.
}
}
export function recordCompletion(tacticId: string): void {
if (!isRecordingEnabled()) return;
const record = readRecord();
record.count += 1;
record.counts[tacticId] = (record.counts[tacticId] ?? 0) + 1;
record.last_by_tactic[tacticId] = new Date().toISOString();
writeRecord(record);
}
/** Device-wide completion total (includes migrated v2 history). */
export function getCompletionCount(): number {
return readRecord().count;
}
/** Completions of one specific tactic on this device (v3+ writes only). */
export function getTacticCompletionCount(tacticId: string): number {
return readRecord().counts[tacticId] ?? 0;
}
export function clearRecord(): void {
try {
globalThis.localStorage?.removeItem(CIVIC_RECORD_KEY);
} catch {
// noop
}
}
export function isRecordingEnabled(): boolean {
try {
return globalThis.localStorage?.getItem(CIVIC_OPT_OUT_KEY) !== '1';
} catch {
// If localStorage is unavailable, default to enabled (CIVIC-02 default-on).
// No data is written in this branch, so privacy posture is preserved.
return true;
}
}
export function setRecordingEnabled(value: boolean): void {
try {
if (value) {
globalThis.localStorage?.removeItem(CIVIC_OPT_OUT_KEY);
} else {
globalThis.localStorage?.setItem(CIVIC_OPT_OUT_KEY, '1');
globalThis.localStorage?.removeItem(CIVIC_RECORD_KEY);
}
} catch {
// noop
}
}
src/lib/fingerprint.ts
/**
* Anonymous fingerprint hash for completion rate-limiting (CIVIC-05, D-23).
*
* Algorithm:
* inputs = [navigator.userAgent, navigator.language, `${screen.width}x${screen.height}`]
* payload = inputs.join('|') // literal pipe separator
* hash = SHA-256(UTF-8(payload)) // crypto.subtle.digest
* output = hash bytes -> 64-char lowercase hex
*
* Properties:
* - 3 low-entropy inputs (UA + lang + screen) — stable enough for 1/min rate-limit
* on the same device + same day; blurry across devices/networks/browsers.
* - Never persisted to localStorage.
* - Never sent to server in plain form (only the hash leaves the client).
* - Not reversible to PII (no rainbow-attack path on UA + lang + screen).
* - No canvas, no plugin enumeration, no font probing — off the standard
* browser-fingerprinting tracking surface.
*
* /privacy renders the byte-identical source of THIS file via the
* embed-civic-record-source build script (Plan 01-06, CIVIC-04). Every line
* here is publicly inspectable.
*/
export async function computeFingerprint(): Promise<string> {
if (typeof navigator === 'undefined' || typeof screen === 'undefined') {
throw new Error('fingerprint requires browser environment (navigator + screen)');
}
const inputs = [
navigator.userAgent,
navigator.language,
`${screen.width}x${screen.height}`,
];
const payload = inputs.join('|');
const bytes = new TextEncoder().encode(payload);
const hashBuffer = await crypto.subtle.digest('SHA-256', bytes);
return Array.from(new Uint8Array(hashBuffer))
.map((b) => b.toString(16).padStart(2, '0'))
.join('');
}