@atelic-action/ui 2.2.1 → 3.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -41,7 +41,7 @@ Every export, grouped by the entry it is imported from. The gallery column links
41
41
  | `Paragraph`, `Runs`, `ArtifactAnchor`, `isEmpty`, `ArtifactLink`, `ArtifactParagraph` | `@atelic-action/ui/artifact` | Plain data text a routine can write as JSON: a paragraph, its runs alone, one link, the empty test, and the two types | [#paragraph](https://ui.atelic.me/#paragraph) |
42
42
  | `ArtifactShell` | `@atelic-action/ui/artifact` | The chrome around a document page: skip link, dark header, credit band, watermark | [#site-header](https://ui.atelic.me/#site-header) (its dark bar) |
43
43
  | `AppErrorBoundary`, `ErrorFallback` | `@atelic-action/ui/artifact` | The error boundary and the page it falls back to | none |
44
- | `buildPageHead`, `canonicalUrl`, `newTabProps`, the `ArtifactConfig` types | `@atelic-action/ui/artifact` | A page's head, a link's new tab props, and a site's contract | none |
44
+ | `buildPageHead`, `canonicalURL`, `newTabProps`, the `ArtifactConfig` types | `@atelic-action/ui/artifact` | A page's head, a link's new tab props, and a site's contract | none |
45
45
  | `mintToken`, `sealReader`, `openReader`, `readerId`, `readerLink`, `whoIsReading` | `@atelic-action/ui/artifact` and `@atelic-action/ui/artifact/readers` | Who opened a document; the second entry is the same six, built, for what Node runs | none |
46
46
  | `Masthead`, `TitleCard`, `Footer`, `FailurePage` | `@atelic-action/ui/email` | An email's frame: the wordmark row, the title card, the foot, the failure page | [#email](https://ui.atelic.me/#email) |
47
47
  | `Card`, `Row`, `FoldRow`, `Eyebrow`, `SubEyebrow`, `Fold`, `BigFold` | `@atelic-action/ui/email` | The containers and labels an email's sections sit in | [#email](https://ui.atelic.me/#email) |
@@ -338,7 +338,7 @@ The two pieces a reader sees are ordinary components, in `@atelic-action/ui/comp
338
338
 
339
339
  - **The pieces:** `GradeChip` and `gradeLabel` with the `WriteupGrade` type, `SignOff` with the `WriteupPerson` type, `Shot` with the `WriteupShot` type, and the plain data text (`Paragraph`, `Runs`, `ArtifactAnchor`, and `isEmpty`, with the `ArtifactLink` and `ArtifactParagraph` types: a paragraph is a string or runs of text and `{ text, href }` links, so a routine can write a document as JSON).
340
340
  - **The shell:** `ArtifactShell`, the `.atelic-ui` scope with the skip link, the dark header, the credit band, and the watermark around a page.
341
- - **The contract and the head:** the `ArtifactConfig` types, and `buildPageHead` and `canonicalUrl` with their `PageMeta` type.
341
+ - **The contract and the head:** the `ArtifactConfig` types, and `buildPageHead` and `canonicalURL` with their `PageMeta` type.
342
342
  - **Who opened it:** `mintToken`, `sealReader`, `openReader`, `readerId`, `readerLink`, and `whoIsReading`. The same six are also built and exported alone as `@atelic-action/ui/artifact/readers`, because a Playwright spec and a script run under Node, which compiles nothing inside `node_modules`.
343
343
  - **The error boundary:** `AppErrorBoundary` and its `ErrorFallback`.
344
344
  - **`newTabProps`,** for the links a content module writes.
@@ -429,7 +429,7 @@ import { SiteShell } from "@atelic-action/ui/chrome";
429
429
  import { CTASection, Hero } from "@atelic-action/ui/sections";
430
430
 
431
431
  <SiteShell site={site} currentPath={pathname} heroNav>
432
- <Hero title="Bread worth the early line." primaryCta={site.cta} />
432
+ <Hero title="Bread worth the early line." primaryCTA={site.cta} />
433
433
  <CTASection site={site} title="Skip the line, keep the bread." />
434
434
  </SiteShell>;
435
435
  ```
@@ -456,7 +456,7 @@ The provider stays in the source entry on purpose. A built copy of it would be a
456
456
 
457
457
  ### SiteShell
458
458
 
459
- `SiteShell` (in `@atelic-action/ui/chrome`) is the page wrapper: the `.atelic-ui` scope around the skip link, the header, `<main>`, the sticky CTA bar, the footer with its credit band, and the watermark, all mapped from the `SiteConfig`. It takes `site`, `currentPath` (the package imports no router), `basePath`, and `nav` (defaults to `site.nav`), plus `heroNav`, `stickyCta`, `footer`, `pageNav`, `navOnDark`, `hideNavCta`, and `credit` as before. A site keeps a wrapper a few lines long:
459
+ `SiteShell` (in `@atelic-action/ui/chrome`) is the page wrapper: the `.atelic-ui` scope around the skip link, the header, `<main>`, the sticky CTA bar, the footer with its credit band, and the watermark, all mapped from the `SiteConfig`. It takes `site`, `currentPath` (the package imports no router), `basePath`, and `nav` (defaults to `site.nav`), plus `heroNav`, `stickyCTA`, `footer`, `pageNav`, `navOnDark`, `hideNavCTA`, and `credit` as before. A site keeps a wrapper a few lines long:
460
460
 
461
461
  ```tsx
462
462
  import { SiteShell as Shell, type SiteShellProps } from "@atelic-action/ui/chrome";
@@ -795,7 +795,7 @@ function RootLayout() {
795
795
 
796
796
  | Prop | What it is |
797
797
  |---|---|
798
- | `config` | An `AppConfig`: `name` (aria labels), `mark` (the wordmark), `storagePrefix` (every storage key and cookie), `publicRoutes` (exact paths a signed out visitor may load), `publicPages` (prefix patterns ending in `/*`, rendered in the public chrome), and `apiBaseUrl`, which the app resolves itself (`env.VITE_API_BASE_URL ?? "https://api.example.com"`), since the package never reads `import.meta.env` |
798
+ | `config` | An `AppConfig`: `name` (aria labels), `mark` (the wordmark), `storagePrefix` (every storage key and cookie), `publicRoutes` (exact paths a signed out visitor may load), `publicPages` (prefix patterns ending in `/*`, rendered in the public chrome), and `apiBaseURL`, which the app resolves itself (`env.VITE_API_BASE_URL ?? "https://api.example.com"`), since the package never reads `import.meta.env` |
799
799
  | `nav` | The `NavItem[]` every nav surface reads, sub pages as `children`; the one place a destination is declared |
800
800
  | `queryClient` | Optional; defaults to the shell's, whose mutation cache raises `meta.toast` |
801
801
  | `toaster` | Optional; sonner's `Toaster` at the top right by default, `false` for none |
@@ -862,7 +862,7 @@ Delete `src/shared/`, `src/stores/authStore.ts`, `src/stores/unitsStore.ts`, and
862
862
 
863
863
  Then the handful of edits the seam asks for:
864
864
 
865
- 1. **`src/app/config.ts`** resolves `apiBaseUrl` itself (`env.VITE_API_BASE_URL ?? "https://api.example.com"`).
865
+ 1. **`src/app/config.ts`** resolves `apiBaseURL` itself (`env.VITE_API_BASE_URL ?? "https://api.example.com"`).
866
866
  2. **`src/app/types.ts`** registers its `User` (above).
867
867
  3. **`src/main.tsx`** wraps the router in `AppProvider`, and passes `showDetails={import.meta.env.DEV}` to `ErrorFallback`, which no longer reads the environment.
868
868
  4. **`src/routes/__root.tsx`** keeps only `AuthGuard` and `AppLayout` around the `Outlet`; the query client, the rail state, the user, and the toaster are the provider's.
@@ -30,4 +30,4 @@ export declare function whoIsReading(visit: {
30
30
  senderEmail: string;
31
31
  }): Promise<string | undefined>;
32
32
  /** The link to send a reader: the artifact's page with their token on it. */
33
- export declare function readerLink(siteUrl: string, token: string, path?: string): string;
33
+ export declare function readerLink(siteURL: string, token: string, path?: string): string;
@@ -25,13 +25,13 @@
25
25
  */
26
26
  const encoder = new TextEncoder();
27
27
  const decoder = new TextDecoder();
28
- function toBase64Url(bytes) {
28
+ function toBase64URL(bytes) {
29
29
  return btoa(String.fromCharCode(...bytes))
30
30
  .replaceAll("+", "-")
31
31
  .replaceAll("/", "_")
32
32
  .replaceAll("=", "");
33
33
  }
34
- function fromBase64Url(text) {
34
+ function fromBase64URL(text) {
35
35
  const padded = text.replaceAll("-", "+").replaceAll("_", "/");
36
36
  return Uint8Array.from(atob(padded), (char) => char.charCodeAt(0));
37
37
  }
@@ -51,7 +51,7 @@ async function keyFor(token) {
51
51
  }
52
52
  /** A fresh token for one reader's links: twelve characters, safe in a URL. */
53
53
  export function mintToken() {
54
- return toBase64Url(crypto.getRandomValues(new Uint8Array(9)));
54
+ return toBase64URL(crypto.getRandomValues(new Uint8Array(9)));
55
55
  }
56
56
  /** Seal a reader's email under their token: the entry site.config.ts lists. */
57
57
  export async function sealReader(email, token) {
@@ -60,7 +60,7 @@ export async function sealReader(email, token) {
60
60
  const packed = new Uint8Array(iv.length + sealed.length);
61
61
  packed.set(iv);
62
62
  packed.set(sealed, iv.length);
63
- return { id: await readerId(token), sealed: toBase64Url(packed) };
63
+ return { id: await readerId(token), sealed: toBase64URL(packed) };
64
64
  }
65
65
  /** The email a link's token opens, or undefined when it opens nobody's. */
66
66
  export async function openReader(readers, token) {
@@ -71,7 +71,7 @@ export async function openReader(readers, token) {
71
71
  if (!reader)
72
72
  return undefined;
73
73
  try {
74
- const packed = fromBase64Url(reader.sealed);
74
+ const packed = fromBase64URL(reader.sealed);
75
75
  const opened = await crypto.subtle.decrypt({ name: "AES-GCM", iv: packed.slice(0, 12) }, await keyFor(token), packed.slice(12));
76
76
  return decoder.decode(opened);
77
77
  }
@@ -110,8 +110,8 @@ export async function whoIsReading(visit) {
110
110
  return openReader(visit.readers, token);
111
111
  }
112
112
  /** The link to send a reader: the artifact's page with their token on it. */
113
- export function readerLink(siteUrl, token, path = "/writeup") {
114
- const url = new URL(path, siteUrl);
113
+ export function readerLink(siteURL, token, path = "/writeup") {
114
+ const url = new URL(path, siteURL);
115
115
  url.searchParams.set("k", token);
116
116
  return url.toString();
117
117
  }
@@ -11,10 +11,12 @@
11
11
  * up anywhere else.
12
12
  *
13
13
  * Public content is the short list that is safe to ship to every page: the
14
- * 404's copy, the sealed proposal (ciphertext by design, GATE.md), and the
15
- * heads in `*.meta.ts`, which carry a title and nothing a snapshot produced.
16
- * Every other module under src/content/ is treated as private, so a new
17
- * artifact starts behind the wall.
14
+ * 404's copy, the sealed proposal (ciphertext by design, GATE.md), the note
15
+ * (built from public signals only, the practice's FOOTPRINT.md, Privacy,
16
+ * and meant to be read at /notes with no sign in, which a host opens through
17
+ * the gate's `open` option), and the heads in `*.meta.ts`, which carry a title and
18
+ * nothing a snapshot produced. Every other module under src/content/ is
19
+ * treated as private, so a new artifact starts behind the wall.
18
20
  */
19
21
  import type { Plugin } from "vite";
20
22
  /** Where the client build writes the chunks the wall holds back. */
@@ -1,6 +1,6 @@
1
1
  /** Where the client build writes the chunks the wall holds back. */
2
2
  export const DOC_DIR = "assets/doc";
3
- const PUBLIC_CONTENT = /\/src\/content\/(not-found\.tsx|proposal[^/]*|[^/]+\.meta\.ts)$/;
3
+ const PUBLIC_CONTENT = /\/src\/content\/(not-found\.tsx|proposal[^/]*|notes\.(?:tsx|json)|[^/]+\.meta\.ts)$/;
4
4
  /** Whether a module id is content that must never ship to the login page. */
5
5
  export function isPrivateContent(id) {
6
6
  const path = id.split("?")[0].replaceAll("\\", "/");
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@atelic-action/ui",
3
- "version": "2.2.1",
3
+ "version": "3.1.0",
4
4
  "description": "Shared UI for the Atelic templates: site chrome, base styles, the component library, the access gate, and the app shell they install",
5
5
  "type": "module",
6
6
  "license": "UNLICENSED",
package/src/app/config.ts CHANGED
@@ -26,7 +26,7 @@ export interface AppConfig {
26
26
  * `import.meta.env.VITE_API_BASE_URL ?? "https://api.example.com"`), so the
27
27
  * package never reads the app's environment itself.
28
28
  */
29
- apiBaseUrl: string;
29
+ apiBaseURL: string;
30
30
  /** Where the Profile link in the rail menu and the mobile menu goes. Defaults to "/profile". */
31
31
  profileHref?: string;
32
32
  }
@@ -33,7 +33,7 @@ function readSaved(name: string): boolean {
33
33
  const config = peekAppConfig();
34
34
  if (!config) return false;
35
35
  try {
36
- return localStorage.getItem(config.storagePrefix + "-" + name) === "true";
36
+ return localStorage.getItem(`${config.storagePrefix}-${name}`) === "true";
37
37
  } catch {
38
38
  return false;
39
39
  }
@@ -43,7 +43,7 @@ function writeSaved(name: string, value: boolean) {
43
43
  const config = peekAppConfig();
44
44
  if (!config) return;
45
45
  try {
46
- localStorage.setItem(config.storagePrefix + "-" + name, String(value));
46
+ localStorage.setItem(`${config.storagePrefix}-${name}`, String(value));
47
47
  } catch {
48
48
  // Storage refused; the state lives in memory for this visit.
49
49
  }
@@ -20,7 +20,7 @@ export const MONTHS = [
20
20
  // Today as a local YYYY-MM-DD string, for lexical comparison against other ISO
21
21
  // dates and for asking the api about the day the user is standing in. `now` is
22
22
  // injectable for tests.
23
- export function todayIso(now: Date = new Date()): string {
23
+ export function todayISO(now: Date = new Date()): string {
24
24
  const month = String(now.getMonth() + 1).padStart(2, "0");
25
25
  const day = String(now.getDate()).padStart(2, "0");
26
26
  return `${now.getFullYear()}-${month}-${day}`;
@@ -41,7 +41,7 @@ export function localTimeZone(): string | undefined {
41
41
  // Format an ISO date without constructing a Date. "2026-06-08" -> "Jun 8, 2026".
42
42
  // Returns the input untouched when it does not parse, so a bad value is visible
43
43
  // rather than blank.
44
- export function formatIsoDate(iso: string | null): string | null {
44
+ export function formatISODate(iso: string | null): string | null {
45
45
  if (!iso) return null;
46
46
  const [year, month, day] = iso.split("-").map(Number);
47
47
  if (!year || !month || !day || month < 1 || month > 12) return iso;
@@ -5,7 +5,7 @@
5
5
  * modules under src/features/<feature>/api.ts build on `apiFetch` and never
6
6
  * touch `fetch` themselves; swapping the backend means swapping this file and
7
7
  * the session module beside it, not the features. The base URL is the app's
8
- * `apiBaseUrl`, read on every call.
8
+ * `apiBaseURL`, read on every call.
9
9
  */
10
10
 
11
11
  import { getAppConfig } from "../config";
@@ -81,7 +81,7 @@ async function readBody<T>(response: Response): Promise<T> {
81
81
  * page that needs none. GET only by design: nothing public writes.
82
82
  */
83
83
  export async function publicFetch<T>(endpoint: string): Promise<T> {
84
- const response = await fetch(`${getAppConfig().apiBaseUrl}${endpoint}`, {
84
+ const response = await fetch(`${getAppConfig().apiBaseURL}${endpoint}`, {
85
85
  headers: { Accept: "application/json" },
86
86
  });
87
87
 
@@ -98,7 +98,7 @@ export async function publicFetch<T>(endpoint: string): Promise<T> {
98
98
  * Handles authentication headers, proactive token refresh, and 401 retry.
99
99
  */
100
100
  export async function apiFetch<T>(endpoint: string, options: RequestInit = {}): Promise<T> {
101
- const url = `${getAppConfig().apiBaseUrl}${endpoint}`;
101
+ const url = `${getAppConfig().apiBaseURL}${endpoint}`;
102
102
  const skipAuth = isAuthEndpoint(endpoint);
103
103
 
104
104
  // Proactive refresh: skip for auth endpoints (login/refresh)
@@ -145,7 +145,7 @@ export async function apiFetch<T>(endpoint: string, options: RequestInit = {}):
145
145
  * all mirror apiFetch.
146
146
  */
147
147
  export async function uploadFormData<T>(endpoint: string, formData: FormData): Promise<T> {
148
- const url = `${getAppConfig().apiBaseUrl}${endpoint}`;
148
+ const url = `${getAppConfig().apiBaseURL}${endpoint}`;
149
149
 
150
150
  if (!(await ensureValidToken())) {
151
151
  throw new Error("Session expired. Please log in again.");
@@ -6,7 +6,7 @@ import { getAppConfig } from "../config";
6
6
  * 2.0's POST-only default for resistance to login CSRF.
7
7
  */
8
8
  export function startGoogleAuth(): void {
9
- postNavigate(`${getAppConfig().apiBaseUrl}/auth/google_oauth2`);
9
+ postNavigate(`${getAppConfig().apiBaseURL}/auth/google_oauth2`);
10
10
  }
11
11
 
12
12
  /**
@@ -9,7 +9,7 @@ import type { RegisteredUser, TokenResponse } from "../types";
9
9
  import { apiFetch } from "./http";
10
10
  import { toSnakeCase } from "./transformKeys";
11
11
  import type { UnitPreferences } from "./units";
12
- import { type ApiEntityEnvelope, unwrapEntity } from "./unwrap";
12
+ import { type APIEntityEnvelope, unwrapEntity } from "./unwrap";
13
13
 
14
14
  export const session = {
15
15
  /** Exchange an OAuth code for the JWT pair. */
@@ -21,13 +21,13 @@ export const session = {
21
21
 
22
22
  /** The signed in user. */
23
23
  me: async () => {
24
- const envelope = await apiFetch<ApiEntityEnvelope<RegisteredUser>>("/v1/users/me");
24
+ const envelope = await apiFetch<APIEntityEnvelope<RegisteredUser>>("/v1/users/me");
25
25
  return unwrapEntity(envelope);
26
26
  },
27
27
 
28
28
  /** Save the person's unit preferences, a system per measure. */
29
29
  updateUnits: async (units: UnitPreferences) => {
30
- const envelope = await apiFetch<ApiEntityEnvelope<RegisteredUser>>("/v1/users/me", {
30
+ const envelope = await apiFetch<APIEntityEnvelope<RegisteredUser>>("/v1/users/me", {
31
31
  method: "PATCH",
32
32
  body: JSON.stringify({ user: { units: toSnakeCase(units) } }),
33
33
  });
@@ -35,7 +35,7 @@ export async function refreshAccessToken(): Promise<RefreshResponse> {
35
35
  }
36
36
 
37
37
  refreshPromise = (async () => {
38
- const response = await fetch(`${getAppConfig().apiBaseUrl}/v1/auth/refresh`, {
38
+ const response = await fetch(`${getAppConfig().apiBaseURL}/v1/auth/refresh`, {
39
39
  method: "POST",
40
40
  headers: {
41
41
  "Content-Type": "application/json",
@@ -7,7 +7,7 @@
7
7
  /**
8
8
  * API response envelope for collection (index) endpoints.
9
9
  */
10
- export interface ApiCollectionEnvelope<T> {
10
+ export interface APICollectionEnvelope<T> {
11
11
  data: { collection: T[] };
12
12
  meta: {
13
13
  currentPage: number;
@@ -20,7 +20,7 @@ export interface ApiCollectionEnvelope<T> {
20
20
  /**
21
21
  * API response envelope for entity (show/create/update) endpoints.
22
22
  */
23
- export interface ApiEntityEnvelope<T> {
23
+ export interface APIEntityEnvelope<T> {
24
24
  data: { entity: T };
25
25
  meta: Record<string, unknown>;
26
26
  }
@@ -43,6 +43,6 @@ export function unwrapCollection<T, M>(envelope: {
43
43
  /**
44
44
  * Extract a single entity from the API envelope.
45
45
  */
46
- export function unwrapEntity<T>(envelope: ApiEntityEnvelope<T>): T {
46
+ export function unwrapEntity<T>(envelope: APIEntityEnvelope<T>): T {
47
47
  return envelope.data.entity;
48
48
  }
@@ -24,7 +24,7 @@ export {
24
24
  Paragraph,
25
25
  Runs,
26
26
  } from "./Paragraph";
27
- export { buildPageHead, canonicalUrl, type PageMeta } from "./pageHead";
27
+ export { buildPageHead, canonicalURL, type PageMeta } from "./pageHead";
28
28
  export {
29
29
  mintToken,
30
30
  openReader,
@@ -22,7 +22,7 @@ export interface PageMeta {
22
22
  }
23
23
 
24
24
  /** Canonical URL for a path under the artifact's origin. */
25
- export function canonicalUrl(site: ArtifactConfig, path: string): string {
25
+ export function canonicalURL(site: ArtifactConfig, path: string): string {
26
26
  return path === "/" ? `${site.url}/` : `${site.url}${path}`;
27
27
  }
28
28
 
@@ -34,7 +34,7 @@ export function canonicalUrl(site: ArtifactConfig, path: string): string {
34
34
  * to one business is asking nothing of the kind.
35
35
  */
36
36
  export function buildPageHead(site: ArtifactConfig, page: PageMeta) {
37
- const canonical = canonicalUrl(site, page.path);
37
+ const canonical = canonicalURL(site, page.path);
38
38
  // og:image must be a full URL per the OG spec; site-relative paths
39
39
  // (self-hosted images under public/) resolve against the host origin.
40
40
  const ogImageRaw = page.ogImage ?? site.ogImage;
@@ -29,14 +29,14 @@ import type { ArtifactReader } from "./artifactConfig.js";
29
29
  const encoder = new TextEncoder();
30
30
  const decoder = new TextDecoder();
31
31
 
32
- function toBase64Url(bytes: Uint8Array): string {
32
+ function toBase64URL(bytes: Uint8Array): string {
33
33
  return btoa(String.fromCharCode(...bytes))
34
34
  .replaceAll("+", "-")
35
35
  .replaceAll("/", "_")
36
36
  .replaceAll("=", "");
37
37
  }
38
38
 
39
- function fromBase64Url(text: string): Uint8Array<ArrayBuffer> {
39
+ function fromBase64URL(text: string): Uint8Array<ArrayBuffer> {
40
40
  const padded = text.replaceAll("-", "+").replaceAll("_", "/");
41
41
  return Uint8Array.from(atob(padded), (char) => char.charCodeAt(0));
42
42
  }
@@ -60,7 +60,7 @@ async function keyFor(token: string): Promise<CryptoKey> {
60
60
 
61
61
  /** A fresh token for one reader's links: twelve characters, safe in a URL. */
62
62
  export function mintToken(): string {
63
- return toBase64Url(crypto.getRandomValues(new Uint8Array(9)));
63
+ return toBase64URL(crypto.getRandomValues(new Uint8Array(9)));
64
64
  }
65
65
 
66
66
  /** Seal a reader's email under their token: the entry site.config.ts lists. */
@@ -76,7 +76,7 @@ export async function sealReader(email: string, token: string): Promise<Artifact
76
76
  const packed = new Uint8Array(iv.length + sealed.length);
77
77
  packed.set(iv);
78
78
  packed.set(sealed, iv.length);
79
- return { id: await readerId(token), sealed: toBase64Url(packed) };
79
+ return { id: await readerId(token), sealed: toBase64URL(packed) };
80
80
  }
81
81
 
82
82
  /** The email a link's token opens, or undefined when it opens nobody's. */
@@ -89,7 +89,7 @@ export async function openReader(
89
89
  const reader = readers.find((entry) => entry.id === id);
90
90
  if (!reader) return undefined;
91
91
  try {
92
- const packed = fromBase64Url(reader.sealed);
92
+ const packed = fromBase64URL(reader.sealed);
93
93
  const opened = await crypto.subtle.decrypt(
94
94
  { name: "AES-GCM", iv: packed.slice(0, 12) },
95
95
  await keyFor(token),
@@ -139,8 +139,8 @@ export async function whoIsReading(visit: {
139
139
  }
140
140
 
141
141
  /** The link to send a reader: the artifact's page with their token on it. */
142
- export function readerLink(siteUrl: string, token: string, path = "/writeup"): string {
143
- const url = new URL(path, siteUrl);
142
+ export function readerLink(siteURL: string, token: string, path = "/writeup"): string {
143
+ const url = new URL(path, siteURL);
144
144
  url.searchParams.set("k", token);
145
145
  return url.toString();
146
146
  }
@@ -33,7 +33,7 @@ export interface SiteShellProps {
33
33
  * A page that closes on its own ask turns the mobile booking bar off,
34
34
  * since that ask is the conversion surface there.
35
35
  */
36
- stickyCta?: boolean;
36
+ stickyCTA?: boolean;
37
37
  /**
38
38
  * Such a page also drops the site footer: when a page ends on its own
39
39
  * sign off, the NAP footer after it steps on the close.
@@ -44,7 +44,7 @@ export interface SiteShellProps {
44
44
  /** Match the header to a dark page surface. */
45
45
  navOnDark?: boolean;
46
46
  /** Hide the header's primary CTA (e.g. a reveal, where the ask is the letter). The menu keeps it. */
47
- hideNavCta?: boolean;
47
+ hideNavCTA?: boolean;
48
48
  /**
49
49
  * The developer credit band. Defaults on (client sites carry it); the
50
50
  * practice's own site turns it off, since crediting yourself on your
@@ -67,11 +67,11 @@ export function SiteShell({
67
67
  basePath,
68
68
  nav,
69
69
  heroNav = false,
70
- stickyCta = true,
70
+ stickyCTA = true,
71
71
  footer = true,
72
72
  pageNav,
73
73
  navOnDark = false,
74
- hideNavCta = false,
74
+ hideNavCTA = false,
75
75
  credit = true,
76
76
  children,
77
77
  }: SiteShellProps) {
@@ -106,11 +106,11 @@ export function SiteShell({
106
106
  variant={variant}
107
107
  currentPath={pathname}
108
108
  stops={pageNav}
109
- hideCTA={hideNavCta}
109
+ hideCTA={hideNavCTA}
110
110
  contact={{ phone: site.phone, email: site.email, address: site.address }}
111
111
  />
112
112
  <main id="main">{children}</main>
113
- {stickyCta && <StickyCTABar primaryCTA={cta} phone={site.phone} />}
113
+ {stickyCTA && <StickyCTABar primaryCTA={cta} phone={site.phone} />}
114
114
  {footer ? (
115
115
  <Footer
116
116
  brand={brand}
@@ -38,15 +38,15 @@ export interface ReaderArgs {
38
38
  /** A token already in a sent link, when --token gave one. */
39
39
  token?: string;
40
40
  /** --qr: write the QR for this reader even when another is already named. */
41
- forceQr: boolean;
41
+ forceQR: boolean;
42
42
  /** --no-qr: never write the QR. */
43
- noQr: boolean;
43
+ noQR: boolean;
44
44
  }
45
45
 
46
46
  /** Reads the command line; undefined when it does not name a reader, which is a usage error. */
47
47
  export function parseReaderArgs(flags: string[]): ReaderArgs | undefined {
48
- const noQr = flags.includes("--no-qr");
49
- const forceQr = flags.includes("--qr");
48
+ const noQR = flags.includes("--no-qr");
49
+ const forceQR = flags.includes("--qr");
50
50
  const args = flags.filter((flag) => flag !== "--no-qr" && flag !== "--qr");
51
51
  const at = args.indexOf("--token");
52
52
  const given = at === -1 ? undefined : args[at + 1];
@@ -56,7 +56,7 @@ export function parseReaderArgs(flags: string[]): ReaderArgs | undefined {
56
56
  const path = `/${givenPath.replace(/^\/+|\/+$/g, "")}`;
57
57
 
58
58
  if (!email?.includes("@") || (at !== -1 && !given)) return undefined;
59
- return { email, path, token: given, forceQr, noQr };
59
+ return { email, path, token: given, forceQR, noQR };
60
60
  }
61
61
 
62
62
  /**
@@ -69,7 +69,7 @@ export async function main(flags: string[], cwd: string = process.cwd()): Promis
69
69
  console.error(USAGE);
70
70
  process.exit(1);
71
71
  }
72
- const { email, path, forceQr, noQr } = parsed;
72
+ const { email, path, forceQR, noQR } = parsed;
73
73
  let site: Awaited<ReturnType<typeof loadSite>>;
74
74
  try {
75
75
  site = await loadSite(cwd);
@@ -90,14 +90,14 @@ export async function main(flags: string[], cwd: string = process.cwd()): Promis
90
90
  // The sheet's QR is the reader's link. A private artifact identifies by sign
91
91
  // in and never by a token, so its QR stays on the bare host.
92
92
  const named = site.analytics?.identify.length ?? 0;
93
- const sheetIsFree = named === 0 || forceQr;
94
- if (path === "/writeup" && !noQr && site.access === "public" && !sheetIsFree) {
93
+ const sheetIsFree = named === 0 || forceQR;
94
+ if (path === "/writeup" && !noQR && site.access === "public" && !sheetIsFree) {
95
95
  console.log("");
96
96
  console.log(
97
97
  "Left public/images/qr.png alone: a reader is already named, and the sheet is theirs. Pass --qr to give the sheet to this reader.",
98
98
  );
99
99
  }
100
- if (path === "/writeup" && !noQr && site.access === "public" && sheetIsFree) {
100
+ if (path === "/writeup" && !noQR && site.access === "public" && sheetIsFree) {
101
101
  const qr = resolve(cwd, "public/images/qr.png");
102
102
  await QRCode.toFile(qr, link, { width: 330, margin: 4, errorCorrectionLevel: "M" });
103
103
  console.log("");
@@ -82,15 +82,15 @@ async function deriveKey(email, salt) {
82
82
  }
83
83
 
84
84
  /**
85
- * Encrypt `contentHtml` once and wrap the content key for each person.
85
+ * Encrypt `contentHTML` once and wrap the content key for each person.
86
86
  * `people` is [{ id, email }]. The email is the reader's secret, the thing
87
87
  * their key derives from, so it never reaches the payload; nor does the id,
88
88
  * which is often a first name. A sealed reader is an ordinal (`r1`, `r2`)
89
89
  * and their wrap, and nothing that says who they are.
90
90
  */
91
- export async function sealDocument(contentHtml, people) {
92
- if (typeof contentHtml !== "string" || contentHtml.length === 0) {
93
- throw new Error("contentHtml is empty; nothing to seal");
91
+ export async function sealDocument(contentHTML, people) {
92
+ if (typeof contentHTML !== "string" || contentHTML.length === 0) {
93
+ throw new Error("contentHTML is empty; nothing to seal");
94
94
  }
95
95
  if (!Array.isArray(people) || people.length === 0) {
96
96
  throw new Error("no people; a payload nobody can open is not worth building");
@@ -109,7 +109,7 @@ export async function sealDocument(contentHtml, people) {
109
109
  await webcrypto.subtle.encrypt(
110
110
  { name: "AES-GCM", iv: civ },
111
111
  contentKey,
112
- encoder.encode(contentHtml),
112
+ encoder.encode(contentHTML),
113
113
  ),
114
114
  );
115
115
 
@@ -160,8 +160,8 @@ export async function main(args = argv.slice(2)) {
160
160
  const config = JSON.parse(readFileSync(configFile, "utf8"));
161
161
  if (!config.content) throw new Error(`${configPath} has no "content" path`);
162
162
 
163
- const contentHtml = readFileSync(resolve(dirname(configFile), config.content), "utf8");
164
- const payload = await sealDocument(contentHtml, config.people);
163
+ const contentHTML = readFileSync(resolve(dirname(configFile), config.content), "utf8");
164
+ const payload = await sealDocument(contentHTML, config.people);
165
165
 
166
166
  const out = resolve(outPath ?? "gate-payload.json");
167
167
  writeFileSync(out, `${JSON.stringify(payload, null, "\t")}\n`);
@@ -21,8 +21,8 @@ export interface CTABannerProps {
21
21
  * works before photography exists.
22
22
  */
23
23
  image?: { src: string; alt?: string };
24
- primaryCta?: CTABannerCTA;
25
- secondaryCta?: CTABannerCTA;
24
+ primaryCTA?: CTABannerCTA;
25
+ secondaryCTA?: CTABannerCTA;
26
26
  /**
27
27
  * Wash over the image so the copy pops: "dark" (light text over a dark
28
28
  * gradient, the hero treatment) or "light" (ink text over a surface wash,
@@ -43,8 +43,8 @@ export function CTABanner({
43
43
  title,
44
44
  lead,
45
45
  image,
46
- primaryCta,
47
- secondaryCta,
46
+ primaryCTA,
47
+ secondaryCTA,
48
48
  tone = "dark",
49
49
  }: CTABannerProps) {
50
50
  const withBase = useWithBase();
@@ -67,21 +67,21 @@ export function CTABanner({
67
67
  {eyebrow && <Eyebrow onDark={onDark}>{eyebrow}</Eyebrow>}
68
68
  <h2>{title}</h2>
69
69
  {lead && <Lead>{lead}</Lead>}
70
- {(primaryCta || secondaryCta) && (
70
+ {(primaryCTA || secondaryCTA) && (
71
71
  <div className="cta-banner-actions">
72
- {primaryCta && (
73
- <Button href={primaryCta.href} newTab={primaryCta.newTab} arrow>
74
- {primaryCta.label}
72
+ {primaryCTA && (
73
+ <Button href={primaryCTA.href} newTab={primaryCTA.newTab} arrow>
74
+ {primaryCTA.label}
75
75
  </Button>
76
76
  )}
77
- {secondaryCta && (
77
+ {secondaryCTA && (
78
78
  <Button
79
79
  variant="ghost"
80
80
  onDark={onDark}
81
- href={secondaryCta.href}
82
- newTab={secondaryCta.newTab}
81
+ href={secondaryCTA.href}
82
+ newTab={secondaryCTA.newTab}
83
83
  >
84
- {secondaryCta.label}
84
+ {secondaryCTA.label}
85
85
  </Button>
86
86
  )}
87
87
  </div>
@@ -6,7 +6,7 @@ import { useWithBase } from "../routing/BasePathProvider";
6
6
  import { JumpRail } from "./JumpRail";
7
7
  import { PageHero } from "./PageHero";
8
8
 
9
- export interface FAQCta {
9
+ export interface FAQCTA {
10
10
  label: string;
11
11
  href: string;
12
12
  newTab?: boolean;
@@ -20,7 +20,7 @@ export interface FAQEntry {
20
20
  /** Plain-text answer for search matching and FAQPage structured data. */
21
21
  answerText: string;
22
22
  /** Optional link rendered at the end of the open answer. */
23
- cta?: FAQCta;
23
+ cta?: FAQCTA;
24
24
  }
25
25
 
26
26
  export interface FAQCategory {
@@ -31,7 +31,7 @@ export interface FAQCategory {
31
31
  tabLabel?: string;
32
32
  description?: string;
33
33
  /** Optional link line rendered under the category's card group. */
34
- cta?: FAQCta;
34
+ cta?: FAQCTA;
35
35
  items: FAQEntry[];
36
36
  }
37
37
 
@@ -50,7 +50,7 @@ function matches(query: string, item: FAQEntry): boolean {
50
50
  return item.question.toLowerCase().includes(q) || item.answerText.toLowerCase().includes(q);
51
51
  }
52
52
 
53
- function CTALink({ cta, className }: { cta: FAQCta; className?: string }) {
53
+ function CTALink({ cta, className }: { cta: FAQCTA; className?: string }) {
54
54
  const withBase = useWithBase();
55
55
  return (
56
56
  <a className={className} href={withBase(cta.href)} {...newTabProps(cta.href, cta.newTab)}>
@@ -30,8 +30,8 @@ export interface HeroProps {
30
30
  */
31
31
  position?: string;
32
32
  };
33
- primaryCta?: HeroCTA;
34
- secondaryCta?: HeroCTA;
33
+ primaryCTA?: HeroCTA;
34
+ secondaryCTA?: HeroCTA;
35
35
  /** Trust indicators rendered under the CTAs. */
36
36
  trust?: HeroTrustItem[];
37
37
  /** Anchor target for the scroll-down affordance, e.g. "#pain". */
@@ -44,8 +44,8 @@ export function Hero({
44
44
  title,
45
45
  subtitle,
46
46
  image,
47
- primaryCta,
48
- secondaryCta,
47
+ primaryCTA,
48
+ secondaryCTA,
49
49
  trust,
50
50
  scrollTarget,
51
51
  }: HeroProps) {
@@ -75,16 +75,16 @@ export function Hero({
75
75
  {eyebrow && <Eyebrow onDark>{eyebrow}</Eyebrow>}
76
76
  <h1>{title}</h1>
77
77
  {subtitle && <p className="hero-sub">{subtitle}</p>}
78
- {(primaryCta || secondaryCta) && (
78
+ {(primaryCTA || secondaryCTA) && (
79
79
  <div className="cta-row">
80
- {primaryCta && (
81
- <Button {...ctaProps(primaryCta, { size: "lg", arrow: true })}>
82
- {primaryCta.label}
80
+ {primaryCTA && (
81
+ <Button {...ctaProps(primaryCTA, { size: "lg", arrow: true })}>
82
+ {primaryCTA.label}
83
83
  </Button>
84
84
  )}
85
- {secondaryCta && (
86
- <Button {...ctaProps(secondaryCta, { variant: "ghost", size: "lg", onDark: true })}>
87
- {secondaryCta.label}
85
+ {secondaryCTA && (
86
+ <Button {...ctaProps(secondaryCTA, { variant: "ghost", size: "lg", onDark: true })}>
87
+ {secondaryCTA.label}
88
88
  </Button>
89
89
  )}
90
90
  </div>
@@ -45,8 +45,8 @@ export function LocationSection({ site, eyebrow = "Find us", title, lead }: Loca
45
45
  </div>
46
46
 
47
47
  <div className="loc-actions">
48
- {site.mapLinkUrl && (
49
- <Button variant="dark" href={site.mapLinkUrl} newTab arrow>
48
+ {site.mapLinkURL && (
49
+ <Button variant="dark" href={site.mapLinkURL} newTab arrow>
50
50
  Get Directions
51
51
  </Button>
52
52
  )}
@@ -6,7 +6,7 @@ import type { SiteConfig } from "./siteConfig";
6
6
  /** The keys of the site config the map reads; a whole `SiteConfig` passes as it is. */
7
7
  export type MapEmbedSite = Pick<
8
8
  SiteConfig,
9
- "name" | "address" | "mapImage" | "mapLinkUrl" | "mapEmbedUrl"
9
+ "name" | "address" | "mapImage" | "mapLinkURL" | "mapEmbedURL"
10
10
  >;
11
11
 
12
12
  export interface MapEmbedProps {
@@ -30,8 +30,8 @@ export function MapEmbed({ site }: MapEmbedProps) {
30
30
  return (
31
31
  <a
32
32
  className="map-frame map-static"
33
- href={site.mapLinkUrl ?? "#"}
34
- {...(site.mapLinkUrl ? { target: "_blank", rel: "noopener" } : {})}
33
+ href={site.mapLinkURL ?? "#"}
34
+ {...(site.mapLinkURL ? { target: "_blank", rel: "noopener" } : {})}
35
35
  aria-label={`Get directions to ${site.name}, ${address}`}
36
36
  >
37
37
  <img src={withBase(site.mapImage)} alt={`Map to ${site.name}, ${address}`} loading="lazy" />
@@ -43,7 +43,7 @@ export function MapEmbed({ site }: MapEmbedProps) {
43
43
  );
44
44
  }
45
45
 
46
- if (!site.mapEmbedUrl) return null;
46
+ if (!site.mapEmbedURL) return null;
47
47
 
48
48
  if (!loaded) {
49
49
  return (
@@ -66,7 +66,7 @@ export function MapEmbed({ site }: MapEmbedProps) {
66
66
  <div className="map-frame">
67
67
  <iframe
68
68
  title={`Map to ${site.name}, ${address}`}
69
- src={site.mapEmbedUrl}
69
+ src={site.mapEmbedURL}
70
70
  loading="lazy"
71
71
  referrerPolicy="no-referrer-when-downgrade"
72
72
  />
@@ -28,7 +28,7 @@ export interface ReviewCarouselProps {
28
28
  * the prop is inert. Prefer a count the profile can't outgrow ("100+"),
29
29
  * since the live number climbs and the caption doesn't.
30
30
  */
31
- profileUrl?: string;
31
+ profileURL?: string;
32
32
  /** Short paragraph under the caption: where the reviews come from. */
33
33
  lead?: ReactNode;
34
34
  /** Optional value/label row (three reads well). Omit to hide entirely. */
@@ -68,7 +68,7 @@ function quoteSize(quote: string): string {
68
68
  * Per-review stars are opt-in per item (`rating`), like `ReviewWall`:
69
69
  * render them only when the source actually shows them.
70
70
  *
71
- * `profileUrl` turns the caption into a link out to the live review
71
+ * `profileURL` turns the caption into a link out to the live review
72
72
  * profile, carrying the wall's dated-source idiom (quiet text, trailing
73
73
  * arrow, new tab) so a visitor can check the aggregate at the source.
74
74
  */
@@ -78,7 +78,7 @@ export function ReviewCarousel({
78
78
  outOf = "5",
79
79
  rating,
80
80
  caption,
81
- profileUrl,
81
+ profileURL,
82
82
  lead,
83
83
  stats,
84
84
  items,
@@ -112,21 +112,21 @@ export function ReviewCarousel({
112
112
  className={["section", "review-carousel", className].filter(Boolean).join(" ")}
113
113
  id={id}
114
114
  >
115
- <div className="wrap rc-grid">
116
- <div className="rc-aggregate">
115
+ <div className="wrap rvc-grid">
116
+ <div className="rvc-aggregate">
117
117
  {eyebrow && <Eyebrow>{eyebrow}</Eyebrow>}
118
- <p className="rc-score">
119
- <span className="rc-score-num">{score}</span>
120
- {outOf && <span className="rc-score-out">/ {outOf}</span>}
118
+ <p className="rvc-score">
119
+ <span className="rvc-score-num">{score}</span>
120
+ {outOf && <span className="rvc-score-out">/ {outOf}</span>}
121
121
  </p>
122
122
  {(rating != null || caption) && (
123
- <p className="rc-rating">
123
+ <p className="rvc-rating">
124
124
  {rating != null && <StarRow rating={rating} size={19} />}
125
125
  {caption &&
126
- (profileUrl ? (
126
+ (profileURL ? (
127
127
  <a
128
- className="rc-caption"
129
- href={profileUrl}
128
+ className="rvc-caption"
129
+ href={profileURL}
130
130
  target="_blank"
131
131
  rel="noopener"
132
132
  aria-label={`${caption}, opens the review profile in a new tab`}
@@ -135,17 +135,17 @@ export function ReviewCarousel({
135
135
  <ArrowUpRight size={14} strokeWidth={2} aria-hidden="true" />
136
136
  </a>
137
137
  ) : (
138
- <span className="rc-caption">{caption}</span>
138
+ <span className="rvc-caption">{caption}</span>
139
139
  ))}
140
140
  </p>
141
141
  )}
142
- {lead && <p className="rc-lead">{lead}</p>}
142
+ {lead && <p className="rvc-lead">{lead}</p>}
143
143
  {stats && stats.length > 0 && (
144
- <dl className="rc-stats">
144
+ <dl className="rvc-stats">
145
145
  {stats.map((stat) => (
146
- <div className="rc-stat" key={stat.label}>
147
- <dt className="rc-stat-value">{stat.value}</dt>
148
- <dd className="rc-stat-label">{stat.label}</dd>
146
+ <div className="rvc-stat" key={stat.label}>
147
+ <dt className="rvc-stat-value">{stat.value}</dt>
148
+ <dd className="rvc-stat-label">{stat.label}</dd>
149
149
  </div>
150
150
  ))}
151
151
  </dl>
@@ -155,15 +155,15 @@ export function ReviewCarousel({
155
155
  {/* A named <section> is an ARIA region, the carousel pattern's
156
156
  wrapper — spelled semantically rather than role="group". */}
157
157
  <section
158
- className="rc-viewport"
158
+ className="rvc-viewport"
159
159
  aria-roledescription="carousel"
160
160
  aria-label={ariaLabel}
161
161
  onKeyDown={onKeyDown}
162
162
  >
163
- <div className="rc-stage" aria-live="polite">
163
+ <div className="rvc-stage" aria-live="polite">
164
164
  {items.map((item, i) => (
165
165
  <figure
166
- className={["rc-slide", i === index && "is-active"].filter(Boolean).join(" ")}
166
+ className={["rvc-slide", i === index && "is-active"].filter(Boolean).join(" ")}
167
167
  key={item.by + item.quote.slice(0, 24)}
168
168
  aria-hidden={i !== index}
169
169
  aria-roledescription="slide"
@@ -171,24 +171,24 @@ export function ReviewCarousel({
171
171
  >
172
172
  {item.rating != null && <StarRow rating={item.rating} size={17} />}
173
173
  <blockquote
174
- className={["rc-quote", quoteSize(item.quote)].filter(Boolean).join(" ")}
174
+ className={["rvc-quote", quoteSize(item.quote)].filter(Boolean).join(" ")}
175
175
  >
176
176
  “{item.quote}”
177
177
  </blockquote>
178
- <figcaption className="rc-who">
179
- <span className="rc-name">{item.by}</span>
180
- {item.detail && <span className="rc-detail">{item.detail}</span>}
178
+ <figcaption className="rvc-who">
179
+ <span className="rvc-name">{item.by}</span>
180
+ {item.detail && <span className="rvc-detail">{item.detail}</span>}
181
181
  </figcaption>
182
182
  </figure>
183
183
  ))}
184
184
  </div>
185
185
 
186
- <div className="rc-controls">
187
- <div className="rc-dots">
186
+ <div className="rvc-controls">
187
+ <div className="rvc-dots">
188
188
  {items.map((_, i) => (
189
189
  <button
190
190
  type="button"
191
- className={["rc-dot", i === index && "is-active"].filter(Boolean).join(" ")}
191
+ className={["rvc-dot", i === index && "is-active"].filter(Boolean).join(" ")}
192
192
  // biome-ignore lint/suspicious/noArrayIndexKey: a dot is a position, not an identity; review text can repeat
193
193
  key={`dot-${i}`}
194
194
  aria-label={`Go to review ${i + 1}`}
@@ -197,10 +197,10 @@ export function ReviewCarousel({
197
197
  />
198
198
  ))}
199
199
  </div>
200
- <div className="rc-arrows">
200
+ <div className="rvc-arrows">
201
201
  <button
202
202
  type="button"
203
- className="rc-arrow"
203
+ className="rvc-arrow"
204
204
  aria-label="Previous review"
205
205
  onClick={() => go(index - 1)}
206
206
  >
@@ -208,7 +208,7 @@ export function ReviewCarousel({
208
208
  </button>
209
209
  <button
210
210
  type="button"
211
- className="rc-arrow"
211
+ className="rvc-arrow"
212
212
  aria-label="Next review"
213
213
  onClick={() => go(index + 1)}
214
214
  >
@@ -22,7 +22,7 @@ export interface ReviewItem {
22
22
  */
23
23
  date?: string;
24
24
  /** Permalink to the review at its source; turns the date into a link. */
25
- sourceUrl?: string;
25
+ sourceURL?: string;
26
26
  /** Short labels (service, program, location) that drive the filter. */
27
27
  tags?: string[];
28
28
  }
@@ -73,10 +73,10 @@ function ReviewCard({ item, clampAt }: { item: ReviewItem; clampAt: number }) {
73
73
  <div className="rw-top">
74
74
  {item.rating != null && <StarRow rating={item.rating} />}
75
75
  {item.date &&
76
- (item.sourceUrl ? (
76
+ (item.sourceURL ? (
77
77
  <a
78
78
  className="rw-date"
79
- href={item.sourceUrl}
79
+ href={item.sourceURL}
80
80
  target="_blank"
81
81
  rel="noopener"
82
82
  aria-label={`Read the ${item.date} review from ${item.by} at its source`}
@@ -20,7 +20,7 @@ export interface SocialStripProps {
20
20
  /** Display handle, e.g. "@platformstrength". */
21
21
  handle: string;
22
22
  /** The profile the strip links out to. */
23
- profileUrl: string;
23
+ profileURL: string;
24
24
  photos: SocialPhoto[];
25
25
  /** Follow button label; defaults to "Follow {handle}". */
26
26
  followLabel?: string;
@@ -39,7 +39,7 @@ export function SocialStrip({
39
39
  title,
40
40
  lead,
41
41
  handle,
42
- profileUrl,
42
+ profileURL,
43
43
  photos,
44
44
  followLabel,
45
45
  surface = "default",
@@ -57,7 +57,7 @@ export function SocialStrip({
57
57
  <a
58
58
  key={photo.src}
59
59
  className="social-tile"
60
- href={profileUrl}
60
+ href={profileURL}
61
61
  target="_blank"
62
62
  rel="noopener"
63
63
  aria-label={photo.alt ? `${photo.alt} (opens ${handle})` : `Opens ${handle}`}
@@ -72,7 +72,7 @@ export function SocialStrip({
72
72
  )}
73
73
  </div>
74
74
  <div className="social-follow">
75
- <Button href={profileUrl} newTab variant="dark">
75
+ <Button href={profileURL} newTab variant="dark">
76
76
  <AtSign size={17} aria-hidden />
77
77
  {followLabel ?? `Follow ${handle}`}
78
78
  </Button>
@@ -32,7 +32,7 @@ export {
32
32
  FAQBrowser,
33
33
  type FAQBrowserProps,
34
34
  type FAQCategory,
35
- type FAQCta,
35
+ type FAQCTA,
36
36
  type FAQEntry,
37
37
  } from "./FAQBrowser";
38
38
  export { FeatureDetail, type FeatureDetailProps } from "./FeatureDetail";
@@ -121,8 +121,8 @@ export interface SiteConfig {
121
121
  email: string;
122
122
  address: Address;
123
123
  geo?: { latitude: number; longitude: number };
124
- mapEmbedUrl?: string;
125
- mapLinkUrl?: string;
124
+ mapEmbedURL?: string;
125
+ mapLinkURL?: string;
126
126
  /**
127
127
  * Path to a static map screenshot (e.g. "/images/map.jpg"). When set, the
128
128
  * map renders as a lightweight image linking to directions instead of the
@@ -2307,56 +2307,56 @@
2307
2307
  REVIEW CAROUSEL — aggregate rating beside one review card at a time
2308
2308
  ================================================================= */
2309
2309
 
2310
- .atelic-ui .rc-grid {
2310
+ .atelic-ui .rvc-grid {
2311
2311
  display: grid;
2312
2312
  grid-template-columns: 1fr;
2313
2313
  gap: clamp(32px, 4vw, 48px);
2314
2314
  align-items: center;
2315
2315
  }
2316
2316
  @media (min-width: 940px) {
2317
- .atelic-ui .rc-grid {
2317
+ .atelic-ui .rvc-grid {
2318
2318
  grid-template-columns: minmax(0, 0.85fr) minmax(0, 1.15fr);
2319
2319
  gap: clamp(40px, 5vw, 72px);
2320
2320
  }
2321
2321
  }
2322
2322
 
2323
2323
  /* ---- Left column: the aggregate ---- */
2324
- .atelic-ui .rc-aggregate {
2324
+ .atelic-ui .rvc-aggregate {
2325
2325
  min-width: 0;
2326
2326
  }
2327
- .atelic-ui .rc-score {
2327
+ .atelic-ui .rvc-score {
2328
2328
  display: flex;
2329
2329
  align-items: baseline;
2330
2330
  gap: 10px;
2331
2331
  margin: 6px 0 0;
2332
2332
  }
2333
- .atelic-ui .rc-score-num {
2333
+ .atelic-ui .rvc-score-num {
2334
2334
  font-size: clamp(4.2rem, 8vw, 6.6rem);
2335
2335
  font-weight: 800;
2336
2336
  line-height: 0.95;
2337
2337
  letter-spacing: -0.045em;
2338
2338
  color: var(--ink);
2339
2339
  }
2340
- .atelic-ui .rc-score-out {
2340
+ .atelic-ui .rvc-score-out {
2341
2341
  font-size: clamp(1.2rem, 2vw, 1.7rem);
2342
2342
  font-weight: 500;
2343
2343
  color: var(--neutral-300);
2344
2344
  }
2345
- .atelic-ui .rc-rating {
2345
+ .atelic-ui .rvc-rating {
2346
2346
  display: flex;
2347
2347
  align-items: center;
2348
2348
  flex-wrap: wrap;
2349
2349
  gap: 10px;
2350
2350
  margin: 16px 0 0;
2351
2351
  }
2352
- .atelic-ui .rc-caption {
2352
+ .atelic-ui .rvc-caption {
2353
2353
  font-size: 0.95rem;
2354
2354
  color: var(--ink-soft);
2355
2355
  }
2356
2356
  /* The caption as a link out to the live profile: the same quiet idiom as
2357
2357
  the wall's dated source link (trailing arrow, new tab), plus a hairline
2358
2358
  underline so it reads as a link inside an otherwise static block. */
2359
- .atelic-ui a.rc-caption {
2359
+ .atelic-ui a.rvc-caption {
2360
2360
  display: inline-flex;
2361
2361
  align-items: center;
2362
2362
  gap: 3px;
@@ -2367,16 +2367,16 @@
2367
2367
  color 0.2s ease,
2368
2368
  text-decoration-color 0.2s ease;
2369
2369
  }
2370
- .atelic-ui a.rc-caption:hover {
2370
+ .atelic-ui a.rvc-caption:hover {
2371
2371
  color: var(--primary);
2372
2372
  text-decoration-color: currentColor;
2373
2373
  }
2374
- .atelic-ui a.rc-caption:focus-visible {
2374
+ .atelic-ui a.rvc-caption:focus-visible {
2375
2375
  outline: 2px solid var(--primary);
2376
2376
  outline-offset: 3px;
2377
2377
  border-radius: var(--radius-sm);
2378
2378
  }
2379
- .atelic-ui .rc-lead {
2379
+ .atelic-ui .rvc-lead {
2380
2380
  margin: 20px 0 0;
2381
2381
  max-width: 46ch;
2382
2382
  font-size: 1.02rem;
@@ -2385,36 +2385,36 @@
2385
2385
  }
2386
2386
 
2387
2387
  /* ---- Optional mini stat row ---- */
2388
- .atelic-ui .rc-stats {
2388
+ .atelic-ui .rvc-stats {
2389
2389
  display: flex;
2390
2390
  flex-wrap: wrap;
2391
2391
  gap: 0;
2392
2392
  margin: clamp(24px, 3vw, 34px) 0 0;
2393
2393
  }
2394
- .atelic-ui .rc-stat {
2394
+ .atelic-ui .rvc-stat {
2395
2395
  padding: 2px 22px 2px 0;
2396
2396
  margin-right: 22px;
2397
2397
  border-right: 1px solid var(--hairline-strong);
2398
2398
  }
2399
- .atelic-ui .rc-stat:last-child {
2399
+ .atelic-ui .rvc-stat:last-child {
2400
2400
  border-right: none;
2401
2401
  margin-right: 0;
2402
2402
  padding-right: 0;
2403
2403
  }
2404
- .atelic-ui .rc-stat-value {
2404
+ .atelic-ui .rvc-stat-value {
2405
2405
  font-size: 1.35rem;
2406
2406
  font-weight: 800;
2407
2407
  letter-spacing: -0.02em;
2408
2408
  color: var(--ink);
2409
2409
  }
2410
- .atelic-ui .rc-stat-label {
2410
+ .atelic-ui .rvc-stat-label {
2411
2411
  margin: 2px 0 0;
2412
2412
  font-size: 0.8rem;
2413
2413
  color: var(--neutral-500);
2414
2414
  }
2415
2415
 
2416
2416
  /* ---- Right column: the card ---- */
2417
- .atelic-ui .rc-viewport {
2417
+ .atelic-ui .rvc-viewport {
2418
2418
  min-width: 0;
2419
2419
  padding: clamp(24px, 3vw, 40px);
2420
2420
  background: var(--card);
@@ -2427,10 +2427,10 @@
2427
2427
  HTML, the card is as tall as the longest one, and stepping through
2428
2428
  never shifts the page. Inactive slides are hidden (and so are dropped
2429
2429
  from the tab order and the accessibility tree) rather than unmounted. */
2430
- .atelic-ui .rc-stage {
2430
+ .atelic-ui .rvc-stage {
2431
2431
  display: grid;
2432
2432
  }
2433
- .atelic-ui .rc-slide {
2433
+ .atelic-ui .rvc-slide {
2434
2434
  grid-area: 1 / 1;
2435
2435
  display: flex;
2436
2436
  flex-direction: column;
@@ -2444,20 +2444,20 @@
2444
2444
  visibility: hidden;
2445
2445
  transition: opacity 0.35s ease;
2446
2446
  }
2447
- .atelic-ui .rc-slide.is-active {
2447
+ .atelic-ui .rvc-slide.is-active {
2448
2448
  opacity: 1;
2449
2449
  visibility: visible;
2450
2450
  }
2451
2451
  @media (prefers-reduced-motion: reduce) {
2452
- .atelic-ui .rc-slide {
2452
+ .atelic-ui .rvc-slide {
2453
2453
  transition: none;
2454
2454
  }
2455
2455
  }
2456
- .atelic-ui .rc-slide .star-row {
2456
+ .atelic-ui .rvc-slide .star-row {
2457
2457
  margin-bottom: 18px;
2458
2458
  }
2459
2459
 
2460
- .atelic-ui .rc-quote {
2460
+ .atelic-ui .rvc-quote {
2461
2461
  margin: 0;
2462
2462
  font-size: clamp(1.15rem, 1.5vw, 1.42rem);
2463
2463
  font-weight: 500;
@@ -2467,54 +2467,54 @@
2467
2467
  }
2468
2468
  /* Longer quotes set smaller, so one paragraph-length review doesn't drive
2469
2469
  the shared card height past what the page can hold (see quoteSize). */
2470
- .atelic-ui .rc-quote.is-medium {
2470
+ .atelic-ui .rvc-quote.is-medium {
2471
2471
  font-size: clamp(1.05rem, 1.25vw, 1.2rem);
2472
2472
  line-height: 1.55;
2473
2473
  }
2474
- .atelic-ui .rc-quote.is-compact {
2474
+ .atelic-ui .rvc-quote.is-compact {
2475
2475
  font-size: clamp(0.98rem, 1.1vw, 1.06rem);
2476
2476
  line-height: 1.6;
2477
2477
  letter-spacing: 0;
2478
2478
  }
2479
2479
 
2480
- .atelic-ui .rc-who {
2480
+ .atelic-ui .rvc-who {
2481
2481
  display: flex;
2482
2482
  flex-direction: column;
2483
2483
  gap: 3px;
2484
2484
  width: 100%;
2485
2485
  padding-top: clamp(20px, 2.4vw, 28px);
2486
2486
  }
2487
- .atelic-ui .rc-who::before {
2487
+ .atelic-ui .rvc-who::before {
2488
2488
  content: "";
2489
2489
  display: block;
2490
2490
  height: 1px;
2491
2491
  background: var(--hairline-strong);
2492
2492
  margin-bottom: clamp(18px, 2.2vw, 24px);
2493
2493
  }
2494
- .atelic-ui .rc-name {
2494
+ .atelic-ui .rvc-name {
2495
2495
  font-weight: 700;
2496
2496
  font-size: 1rem;
2497
2497
  color: var(--ink);
2498
2498
  }
2499
- .atelic-ui .rc-detail {
2499
+ .atelic-ui .rvc-detail {
2500
2500
  font-size: 0.86rem;
2501
2501
  color: var(--neutral-500);
2502
2502
  }
2503
2503
 
2504
2504
  /* ---- Pagination and arrows ---- */
2505
- .atelic-ui .rc-controls {
2505
+ .atelic-ui .rvc-controls {
2506
2506
  display: flex;
2507
2507
  align-items: center;
2508
2508
  justify-content: space-between;
2509
2509
  gap: 16px;
2510
2510
  margin-top: clamp(20px, 2.4vw, 28px);
2511
2511
  }
2512
- .atelic-ui .rc-dots {
2512
+ .atelic-ui .rvc-dots {
2513
2513
  display: flex;
2514
2514
  align-items: center;
2515
2515
  gap: 8px;
2516
2516
  }
2517
- .atelic-ui .rc-dot {
2517
+ .atelic-ui .rvc-dot {
2518
2518
  width: 8px;
2519
2519
  height: 8px;
2520
2520
  padding: 0;
@@ -2524,19 +2524,19 @@
2524
2524
  width 0.25s ease,
2525
2525
  background 0.2s ease;
2526
2526
  }
2527
- .atelic-ui .rc-dot:hover {
2527
+ .atelic-ui .rvc-dot:hover {
2528
2528
  background: var(--neutral-500);
2529
2529
  }
2530
- .atelic-ui .rc-dot.is-active {
2530
+ .atelic-ui .rvc-dot.is-active {
2531
2531
  width: 26px;
2532
2532
  background: var(--ink);
2533
2533
  }
2534
- .atelic-ui .rc-arrows {
2534
+ .atelic-ui .rvc-arrows {
2535
2535
  display: flex;
2536
2536
  align-items: center;
2537
2537
  gap: 8px;
2538
2538
  }
2539
- .atelic-ui .rc-arrow {
2539
+ .atelic-ui .rvc-arrow {
2540
2540
  width: 40px;
2541
2541
  height: 40px;
2542
2542
  display: grid;
@@ -2550,12 +2550,12 @@
2550
2550
  border-color 0.2s ease,
2551
2551
  color 0.2s ease;
2552
2552
  }
2553
- .atelic-ui .rc-arrow:hover {
2553
+ .atelic-ui .rvc-arrow:hover {
2554
2554
  background: var(--surface-alt);
2555
2555
  border-color: var(--neutral-300);
2556
2556
  }
2557
- .atelic-ui .rc-arrow:focus-visible,
2558
- .atelic-ui .rc-dot:focus-visible {
2557
+ .atelic-ui .rvc-arrow:focus-visible,
2558
+ .atelic-ui .rvc-dot:focus-visible {
2559
2559
  outline: 2px solid var(--primary);
2560
2560
  outline-offset: 3px;
2561
2561
  }
@@ -11,17 +11,20 @@
11
11
  * up anywhere else.
12
12
  *
13
13
  * Public content is the short list that is safe to ship to every page: the
14
- * 404's copy, the sealed proposal (ciphertext by design, GATE.md), and the
15
- * heads in `*.meta.ts`, which carry a title and nothing a snapshot produced.
16
- * Every other module under src/content/ is treated as private, so a new
17
- * artifact starts behind the wall.
14
+ * 404's copy, the sealed proposal (ciphertext by design, GATE.md), the note
15
+ * (built from public signals only, the practice's FOOTPRINT.md, Privacy,
16
+ * and meant to be read at /notes with no sign in, which a host opens through
17
+ * the gate's `open` option), and the heads in `*.meta.ts`, which carry a title and
18
+ * nothing a snapshot produced. Every other module under src/content/ is
19
+ * treated as private, so a new artifact starts behind the wall.
18
20
  */
19
21
  import type { Plugin } from "vite";
20
22
 
21
23
  /** Where the client build writes the chunks the wall holds back. */
22
24
  export const DOC_DIR = "assets/doc";
23
25
 
24
- const PUBLIC_CONTENT = /\/src\/content\/(not-found\.tsx|proposal[^/]*|[^/]+\.meta\.ts)$/;
26
+ const PUBLIC_CONTENT =
27
+ /\/src\/content\/(not-found\.tsx|proposal[^/]*|notes\.(?:tsx|json)|[^/]+\.meta\.ts)$/;
25
28
 
26
29
  /** Whether a module id is content that must never ship to the login page. */
27
30
  export function isPrivateContent(id: string): boolean {