Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -242,6 +242,14 @@ unsubscribe/confirm keep purpose tokens and never accept the session.
- Banner: `#sessionBanner` ("Not you?" → `/session/logout`)
- Characterization: `node --test worker/test/session_pins.test.mjs test/session_pins_client.test.mjs`

## Microsoft Clarity (optional heatmaps)

Dormant until a project id is set. Loader: `site/clarity.js` (all public pages).
Config: `window.CROL_CLARITY_PROJECT_ID`, meta `crol-clarity-project-id`, or
`CONFIGURED_PROJECT_ID` in that file — leave empty to keep off. Skips on DNT/GPC;
masks form inputs; operator must set dashboard Masking mode to **Strict**.
Characterization: `node --test test/clarity.test.mjs`. Privacy copy: About → Privacy.

## Maintaining this file

Keep this file for knowledge useful to almost every future agent session in this project.
Expand Down
4 changes: 3 additions & 1 deletion SECURITY.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,9 @@ prefer otherwise.
- **Admin routes** are bearer-key-gated and 404 when unconfigured; their obscurity is not a
defense.
- **The static site** is dependency-free vanilla JS on GitHub Pages: no build pipeline to
poison, no third-party scripts beyond Cloudflare's cookieless analytics beacon.
poison. Third-party scripts are limited to Cloudflare's cookieless analytics beacon and,
when a project id is configured, Microsoft Clarity (masked heatmaps; skipped on
Do Not Track / Global Privacy Control — see `site/clarity.js`).

- **The notices mirror (D1)** holds only already-public City Record data plus the raw source
rows; nothing personal ever enters it. **Queues** carry subscription *keys*, not addresses.
Expand Down
11 changes: 3 additions & 8 deletions site/about.html
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@
<script src="i18n.js?v=__I18N_ASSET_VERSION__"></script>
<script defer src="beta_flags.js?v=1.0.0"></script>
<script defer src="analytics.js?v=1.2.0"></script>
<script defer src="clarity.js?v=1.0.0"></script>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Playfair+Display:wght@700;900&family=Spectral:ital,wght@0,400;0,600;0,800;1,400&family=Spectral+SC:wght@600&display=swap" rel="stylesheet">
Expand Down Expand Up @@ -200,7 +201,7 @@ <h2 data-i18n="about_h_feedback">Send feedback</h2>
<textarea id="fbmessage" maxlength="2000" placeholder="What happened, what you'd want, or anything else — the more specific the better." data-i18n-placeholder="about_ph_message"></textarea>

<label for="fbemail"><span data-i18n="about_label_email">Email</span> <span class="opt" data-i18n="about_label_email_opt">— optional, only if you'd like a reply</span></label>
<input type="email" id="fbemail" autocomplete="email" placeholder="you@example.com" data-i18n-placeholder="email_placeholder">
<input type="email" id="fbemail" data-clarity-mask="true" autocomplete="email" placeholder="you@example.com" data-i18n-placeholder="email_placeholder">

<div class="cf-turnstile" data-sitekey="0x4AAAAAADtj-Mskk_RnVd5K" data-action="feedback-v1" style="margin:14px 0 6px"></div>

Expand All @@ -211,13 +212,7 @@ <h2 data-i18n="about_h_feedback">Send feedback</h2>

<h2 id="privacy" data-i18n="about_h_privacy">Privacy</h2>
<p class="src" data-i18n="about_p_privacy_intro">CityScroll has no password accounts and no ad tech. It does not track you across other sites.</p>
<ul class="src" style="margin:8px 0;padding-left:20px" data-i18n-html="about_li_privacy_html">
<li><b>Searches and filters</b> use NYC Open Data.</li>
<li><b>The "Ask" box</b> lets you search in plain English. Your text is sent to Anthropic's Claude, which turns it into filters. We do not save your text.</li>
<li><b>Subscribing or sending feedback</b> saves what you send us. This includes your alert or message and your email, if you share one. We also keep some basic info about your request, like your IP address and browser. We keep this for a short time to stop spam and abuse. Every alert email has a one-click unsubscribe link.</li>
<li><b>Email links and pins</b> — clicking a link from your alert email signs you in on that device so your pinned items can follow you. A short banner says you are signed in and lets you sign out ("Not you?"). Changing or canceling a subscription still needs its own email link.</li>
<li><b>Page views</b> are tracked with Cloudflare Web Analytics. It uses no cookies and only shows totals. It counts visits. It does not know who you are or follow you to other sites.</li>
</ul>
<ul class="src" style="margin:8px 0;padding-left:20px" data-i18n-html="about_li_privacy_html"><li><b>Searches and filters</b> use NYC Open Data.</li><li><b>The "Ask" box</b> lets you search in plain English. Your text is sent to Anthropic's Claude, which turns it into filters. We do not save your text.</li><li><b>Subscribing or sending feedback</b> saves what you send us. This includes your alert or message and your email, if you share one. We also keep some basic info about your request, like your IP address and browser. We keep this for a short time to stop spam and abuse. Every alert email has a one-click unsubscribe link.</li><li><b>Email links and pins</b> — clicking a link from your alert email signs you in on that device so your pinned items can follow you. A short banner says you are signed in and lets you sign out ("Not you?"). Changing or canceling a subscription still needs its own email link.</li><li><b>Page views</b> are tracked with Cloudflare Web Analytics. It uses no cookies and only shows totals. It counts visits. It does not know who you are or follow you to other sites.</li><li><b>Click and scroll maps</b> (Microsoft Clarity, when turned on) show where people tap or scroll so we can fix hard-to-use spots. Typed text and form fields — including email — are masked and never sent as plain text. Clarity does not load if your browser signals Do Not Track or Global Privacy Control. It is not used for ads or personal profiles.</li></ul>

<h2 data-i18n="about_h_content">About our content</h2>
<p data-i18n-html="about_p_content_html">An AI assistant (Claude) drafts this site's copy — headings, explanations, pages like this one. A human editor checks it before it goes live. The data is not AI-generated. Every notice, dollar figure, and date comes straight from <a href="https://data.cityofnewyork.us/City-Government/City-Record-Online/dg92-zbpx" target="_blank" rel="noopener noreferrer">NYC Open Data<span class="sr-only"> (opens in new tab)</span></a>, unedited.</p>
Expand Down
1 change: 1 addition & 0 deletions site/api.html
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@
<script src="i18n.js?v=__I18N_ASSET_VERSION__"></script>
<script defer src="beta_flags.js?v=1.0.0"></script>
<script defer src="analytics.js?v=1.2.0"></script>
<script defer src="clarity.js?v=1.0.0"></script>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Playfair+Display:wght@700;900&family=Spectral:ital,wght@0,400;0,600;0,800;1,400&family=Spectral+SC:wght@600&display=swap" rel="stylesheet">
Expand Down
1 change: 1 addition & 0 deletions site/changelog.html
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@
<script src="i18n.js?v=__I18N_ASSET_VERSION__"></script>
<script defer src="beta_flags.js?v=1.0.0"></script>
<script defer src="analytics.js?v=1.2.0"></script>
<script defer src="clarity.js?v=1.0.0"></script>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Playfair+Display:wght@700;900&family=Spectral:ital,wght@0,400;0,600;0,800;1,400&family=Spectral+SC:wght@600&display=swap" rel="stylesheet">
Expand Down
133 changes: 133 additions & 0 deletions site/clarity.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
// Microsoft Clarity loader — heatmaps and session interaction patterns.
// Dormant until a project id is configured. Never blocks page function if blocked/down.
//
// Config (either works; empty keeps Clarity off):
// 1. window.CROL_CLARITY_PROJECT_ID = "<id>" (set before this script)
// 2. <meta name="crol-clarity-project-id" content="<id>">
// 3. Edit CONFIGURED_PROJECT_ID below after creating a project at clarity.microsoft.com
//
// Privacy posture:
// - Skip entirely when the browser signals Do Not Track or Global Privacy Control
// - Mask all form inputs/textareas/selects before load (email fields never plain-text)
// - Operator must set project Masking mode to Strict in the Clarity dashboard
// - Fail-soft: missing id, network errors, and ad-blockers leave the site fully usable
(function (root) {
"use strict";

// Operator: paste the Clarity project id between the quotes to enable. Leave empty for dormant.
const CONFIGURED_PROJECT_ID = "";

const TAG_ORIGIN = "https://www.clarity.ms/tag/";
const META_NAME = "crol-clarity-project-id";

function resolveProjectId(win, doc) {
const fromWindow = win && typeof win.CROL_CLARITY_PROJECT_ID === "string"
? win.CROL_CLARITY_PROJECT_ID.trim()
: "";
if (fromWindow) return fromWindow;
const configured = String(CONFIGURED_PROJECT_ID || "").trim();
if (configured) return configured;
try {
const meta = doc && doc.querySelector && doc.querySelector(`meta[name="${META_NAME}"]`);
return meta && meta.content ? String(meta.content).trim() : "";
} catch {
return "";
}
}

function prefersNoTracking(nav) {
if (!nav) return false;
const dnt = nav.doNotTrack;
if (dnt === "1" || dnt === "yes") return true;
if (nav.msDoNotTrack === "1") return true;
// Global Privacy Control (Sec-GPC / navigator.globalPrivacyControl)
if (nav.globalPrivacyControl === true) return true;
return false;
}

function shouldLoad({ projectId, navigator: nav }) {
if (!projectId) return false;
if (prefersNoTracking(nav)) return false;
return true;
}

function applyInputMasking(doc) {
if (!doc || !doc.querySelectorAll) return 0;
let count = 0;
try {
const nodes = doc.querySelectorAll("input, textarea, select");
for (const el of nodes) {
if (el.getAttribute("data-clarity-mask") === "true") continue;
el.setAttribute("data-clarity-mask", "true");
count += 1;
}
// Belt-and-suspenders on known email capture fields
for (const id of ["adest", "fbemail"]) {
const el = doc.getElementById(id);
if (el) el.setAttribute("data-clarity-mask", "true");
}
} catch {
// Masking is best-effort; never break the page.
}
return count;
}

function injectClarityScript(doc, projectId) {
if (!doc || !doc.createElement) return null;
// Official Clarity bootstrap: queue calls until the tag arrives.
const w = root;
w.clarity = w.clarity || function () {
(w.clarity.q = w.clarity.q || []).push(arguments);
};
const script = doc.createElement("script");
script.async = true;
script.src = TAG_ORIGIN + encodeURIComponent(projectId);
script.setAttribute("data-crol-clarity", "1");
// Failure-tolerant: onerror is a no-op; ad-blockers fail silently.
script.onerror = function () { /* site remains fully functional */ };
const anchor = doc.head || doc.documentElement || doc.body;
if (!anchor) return null;
anchor.appendChild(script);
return script;
}

function boot(options) {
const win = (options && options.window) || root;
const doc = (options && options.document) || (win && win.document);
const nav = (options && options.navigator) || (win && win.navigator);
const projectId = resolveProjectId(win, doc);
if (!shouldLoad({ projectId, navigator: nav })) {
return { loaded: false, projectId: projectId || "", reason: !projectId ? "unconfigured" : "opt-out" };
}
applyInputMasking(doc);
try {
injectClarityScript(doc, projectId);
return { loaded: true, projectId, reason: "ok" };
} catch {
return { loaded: false, projectId, reason: "error" };
}
}

const api = Object.freeze({
CONFIGURED_PROJECT_ID,
META_NAME,
TAG_ORIGIN,
resolveProjectId,
prefersNoTracking,
shouldLoad,
applyInputMasking,
injectClarityScript,
boot,
});

root.CROLClarity = api;

// Auto-boot in the browser after the document is interactive enough for querySelector.
if (typeof document !== "undefined") {
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", function () { boot(); }, { once: true });
} else {
boot();
}
}
})(typeof window !== "undefined" ? window : globalThis);
1 change: 1 addition & 0 deletions site/data.html
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@
<script src="i18n.js?v=__I18N_ASSET_VERSION__"></script>
<script defer src="beta_flags.js?v=1.0.0"></script>
<script defer src="analytics.js?v=1.2.0"></script>
<script defer src="clarity.js?v=1.0.0"></script>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Playfair+Display:wght@700;900&family=Spectral:ital,wght@0,400;0,600;0,800;1,400&family=Spectral+SC:wght@600&display=swap" rel="stylesheet">
Expand Down
2 changes: 1 addition & 1 deletion site/i18n.js
Original file line number Diff line number Diff line change
Expand Up @@ -1279,7 +1279,7 @@ const STRINGS = {
about_foot_html: "CityScroll · a search interface over <a href=\"https://a856-cityrecord.nyc.gov/\" target=\"_blank\" rel=\"noopener noreferrer\">The City Record<span class=\"sr-only\"> (opens in new tab)</span></a> · <a href=\"changelog.html\">Changelog</a> · <a href=\"stats.html\">Stats</a> · <a href=\"index.html\">Home</a>",
about_h_privacy: "Privacy",
about_p_privacy_intro: "CityScroll has no password accounts and no ad tech. It does not track you across other sites.",
about_li_privacy_html: "<li><b>Searches and filters</b> use NYC Open Data.</li><li><b>The \"Ask\" box</b> lets you search in plain English. Your text is sent to Anthropic's Claude, which turns it into filters. We do not save your text.</li><li><b>Subscribing or sending feedback</b> saves what you send us. This includes your alert or message and your email, if you share one. We also keep some basic info about your request, like your IP address and browser. We keep this for a short time to stop spam and abuse. Every alert email has a one-click unsubscribe link.</li><li><b>Email links and pins</b> — clicking a link from your alert email signs you in on that device so your pinned items can follow you. A short banner says you are signed in and lets you sign out (\"Not you?\"). Changing or canceling a subscription still needs its own email link.</li><li><b>Page views</b> are tracked with Cloudflare Web Analytics. It uses no cookies and only shows totals. It counts visits. It does not know who you are or follow you to other sites.</li>",
about_li_privacy_html: "<li><b>Searches and filters</b> use NYC Open Data.</li><li><b>The \"Ask\" box</b> lets you search in plain English. Your text is sent to Anthropic's Claude, which turns it into filters. We do not save your text.</li><li><b>Subscribing or sending feedback</b> saves what you send us. This includes your alert or message and your email, if you share one. We also keep some basic info about your request, like your IP address and browser. We keep this for a short time to stop spam and abuse. Every alert email has a one-click unsubscribe link.</li><li><b>Email links and pins</b> — clicking a link from your alert email signs you in on that device so your pinned items can follow you. A short banner says you are signed in and lets you sign out (\"Not you?\"). Changing or canceling a subscription still needs its own email link.</li><li><b>Page views</b> are tracked with Cloudflare Web Analytics. It uses no cookies and only shows totals. It counts visits. It does not know who you are or follow you to other sites.</li><li><b>Click and scroll maps</b> (Microsoft Clarity, when turned on) show where people tap or scroll so we can fix hard-to-use spots. Typed text and form fields — including email — are masked and never sent as plain text. Clarity does not load if your browser signals Do Not Track or Global Privacy Control. It is not used for ads or personal profiles.</li>",
session_signed_in: "Signed in via your email link — your pins can follow you on this device.",
session_not_you: "Not you?",
session_dismiss: "Dismiss",
Expand Down
Loading
Loading