@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 +6 -6
- package/dist/artifact/readers.d.ts +1 -1
- package/dist/artifact/readers.js +7 -7
- package/dist/vite/private-chunks.d.ts +6 -4
- package/dist/vite/private-chunks.js +1 -1
- package/package.json +1 -1
- package/src/app/config.ts +1 -1
- package/src/app/context/SidebarContext.tsx +2 -2
- package/src/app/lib/dates.ts +2 -2
- package/src/app/lib/http.ts +4 -4
- package/src/app/lib/oauth.ts +1 -1
- package/src/app/lib/session.ts +3 -3
- package/src/app/lib/tokenRefresh.ts +1 -1
- package/src/app/lib/unwrap.ts +3 -3
- package/src/artifact/index.ts +1 -1
- package/src/artifact/pageHead.ts +2 -2
- package/src/artifact/readers.ts +7 -7
- package/src/chrome/SiteShell.tsx +6 -6
- package/src/scripts/reader-link.ts +9 -9
- package/src/scripts/seal-document.mjs +7 -7
- package/src/sections/CTABanner.tsx +12 -12
- package/src/sections/FAQBrowser.tsx +4 -4
- package/src/sections/Hero.tsx +11 -11
- package/src/sections/LocationSection.tsx +2 -2
- package/src/sections/MapEmbed.tsx +5 -5
- package/src/sections/ReviewCarousel.tsx +31 -31
- package/src/sections/ReviewWall.tsx +3 -3
- package/src/sections/SocialStrip.tsx +4 -4
- package/src/sections/index.ts +1 -1
- package/src/sections/siteConfig.ts +2 -2
- package/src/styles/sections.css +40 -40
- package/src/vite/private-chunks.ts +8 -5
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`, `
|
|
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 `
|
|
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."
|
|
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`, `
|
|
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 `
|
|
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 `
|
|
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(
|
|
33
|
+
export declare function readerLink(siteURL: string, token: string, path?: string): string;
|
package/dist/artifact/readers.js
CHANGED
|
@@ -25,13 +25,13 @@
|
|
|
25
25
|
*/
|
|
26
26
|
const encoder = new TextEncoder();
|
|
27
27
|
const decoder = new TextDecoder();
|
|
28
|
-
function
|
|
28
|
+
function toBase64URL(bytes) {
|
|
29
29
|
return btoa(String.fromCharCode(...bytes))
|
|
30
30
|
.replaceAll("+", "-")
|
|
31
31
|
.replaceAll("/", "_")
|
|
32
32
|
.replaceAll("=", "");
|
|
33
33
|
}
|
|
34
|
-
function
|
|
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
|
|
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:
|
|
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 =
|
|
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(
|
|
114
|
-
const url = new URL(path,
|
|
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),
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
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": "
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
}
|
package/src/app/lib/dates.ts
CHANGED
|
@@ -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
|
|
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
|
|
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;
|
package/src/app/lib/http.ts
CHANGED
|
@@ -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
|
-
* `
|
|
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().
|
|
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().
|
|
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().
|
|
148
|
+
const url = `${getAppConfig().apiBaseURL}${endpoint}`;
|
|
149
149
|
|
|
150
150
|
if (!(await ensureValidToken())) {
|
|
151
151
|
throw new Error("Session expired. Please log in again.");
|
package/src/app/lib/oauth.ts
CHANGED
|
@@ -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().
|
|
9
|
+
postNavigate(`${getAppConfig().apiBaseURL}/auth/google_oauth2`);
|
|
10
10
|
}
|
|
11
11
|
|
|
12
12
|
/**
|
package/src/app/lib/session.ts
CHANGED
|
@@ -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
|
|
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<
|
|
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<
|
|
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().
|
|
38
|
+
const response = await fetch(`${getAppConfig().apiBaseURL}/v1/auth/refresh`, {
|
|
39
39
|
method: "POST",
|
|
40
40
|
headers: {
|
|
41
41
|
"Content-Type": "application/json",
|
package/src/app/lib/unwrap.ts
CHANGED
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
/**
|
|
8
8
|
* API response envelope for collection (index) endpoints.
|
|
9
9
|
*/
|
|
10
|
-
export interface
|
|
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
|
|
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:
|
|
46
|
+
export function unwrapEntity<T>(envelope: APIEntityEnvelope<T>): T {
|
|
47
47
|
return envelope.data.entity;
|
|
48
48
|
}
|
package/src/artifact/index.ts
CHANGED
package/src/artifact/pageHead.ts
CHANGED
|
@@ -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
|
|
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 =
|
|
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;
|
package/src/artifact/readers.ts
CHANGED
|
@@ -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
|
|
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
|
|
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
|
|
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:
|
|
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 =
|
|
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(
|
|
143
|
-
const url = new URL(path,
|
|
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
|
}
|
package/src/chrome/SiteShell.tsx
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
70
|
+
stickyCTA = true,
|
|
71
71
|
footer = true,
|
|
72
72
|
pageNav,
|
|
73
73
|
navOnDark = false,
|
|
74
|
-
|
|
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={
|
|
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
|
-
{
|
|
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
|
-
|
|
41
|
+
forceQR: boolean;
|
|
42
42
|
/** --no-qr: never write the QR. */
|
|
43
|
-
|
|
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
|
|
49
|
-
const
|
|
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,
|
|
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,
|
|
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 ||
|
|
94
|
-
if (path === "/writeup" && !
|
|
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" && !
|
|
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 `
|
|
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(
|
|
92
|
-
if (typeof
|
|
93
|
-
throw new Error("
|
|
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(
|
|
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
|
|
164
|
-
const payload = await sealDocument(
|
|
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
|
-
|
|
25
|
-
|
|
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
|
-
|
|
47
|
-
|
|
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
|
-
{(
|
|
70
|
+
{(primaryCTA || secondaryCTA) && (
|
|
71
71
|
<div className="cta-banner-actions">
|
|
72
|
-
{
|
|
73
|
-
<Button href={
|
|
74
|
-
{
|
|
72
|
+
{primaryCTA && (
|
|
73
|
+
<Button href={primaryCTA.href} newTab={primaryCTA.newTab} arrow>
|
|
74
|
+
{primaryCTA.label}
|
|
75
75
|
</Button>
|
|
76
76
|
)}
|
|
77
|
-
{
|
|
77
|
+
{secondaryCTA && (
|
|
78
78
|
<Button
|
|
79
79
|
variant="ghost"
|
|
80
80
|
onDark={onDark}
|
|
81
|
-
href={
|
|
82
|
-
newTab={
|
|
81
|
+
href={secondaryCTA.href}
|
|
82
|
+
newTab={secondaryCTA.newTab}
|
|
83
83
|
>
|
|
84
|
-
{
|
|
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
|
|
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?:
|
|
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?:
|
|
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:
|
|
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)}>
|
package/src/sections/Hero.tsx
CHANGED
|
@@ -30,8 +30,8 @@ export interface HeroProps {
|
|
|
30
30
|
*/
|
|
31
31
|
position?: string;
|
|
32
32
|
};
|
|
33
|
-
|
|
34
|
-
|
|
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
|
-
|
|
48
|
-
|
|
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
|
-
{(
|
|
78
|
+
{(primaryCTA || secondaryCTA) && (
|
|
79
79
|
<div className="cta-row">
|
|
80
|
-
{
|
|
81
|
-
<Button {...ctaProps(
|
|
82
|
-
{
|
|
80
|
+
{primaryCTA && (
|
|
81
|
+
<Button {...ctaProps(primaryCTA, { size: "lg", arrow: true })}>
|
|
82
|
+
{primaryCTA.label}
|
|
83
83
|
</Button>
|
|
84
84
|
)}
|
|
85
|
-
{
|
|
86
|
-
<Button {...ctaProps(
|
|
87
|
-
{
|
|
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.
|
|
49
|
-
<Button variant="dark" href={site.
|
|
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" | "
|
|
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.
|
|
34
|
-
{...(site.
|
|
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.
|
|
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.
|
|
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
|
-
|
|
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
|
-
* `
|
|
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
|
-
|
|
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
|
|
116
|
-
<div className="
|
|
115
|
+
<div className="wrap rvc-grid">
|
|
116
|
+
<div className="rvc-aggregate">
|
|
117
117
|
{eyebrow && <Eyebrow>{eyebrow}</Eyebrow>}
|
|
118
|
-
<p className="
|
|
119
|
-
<span className="
|
|
120
|
-
{outOf && <span className="
|
|
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="
|
|
123
|
+
<p className="rvc-rating">
|
|
124
124
|
{rating != null && <StarRow rating={rating} size={19} />}
|
|
125
125
|
{caption &&
|
|
126
|
-
(
|
|
126
|
+
(profileURL ? (
|
|
127
127
|
<a
|
|
128
|
-
className="
|
|
129
|
-
href={
|
|
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="
|
|
138
|
+
<span className="rvc-caption">{caption}</span>
|
|
139
139
|
))}
|
|
140
140
|
</p>
|
|
141
141
|
)}
|
|
142
|
-
{lead && <p className="
|
|
142
|
+
{lead && <p className="rvc-lead">{lead}</p>}
|
|
143
143
|
{stats && stats.length > 0 && (
|
|
144
|
-
<dl className="
|
|
144
|
+
<dl className="rvc-stats">
|
|
145
145
|
{stats.map((stat) => (
|
|
146
|
-
<div className="
|
|
147
|
-
<dt className="
|
|
148
|
-
<dd className="
|
|
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="
|
|
158
|
+
className="rvc-viewport"
|
|
159
159
|
aria-roledescription="carousel"
|
|
160
160
|
aria-label={ariaLabel}
|
|
161
161
|
onKeyDown={onKeyDown}
|
|
162
162
|
>
|
|
163
|
-
<div className="
|
|
163
|
+
<div className="rvc-stage" aria-live="polite">
|
|
164
164
|
{items.map((item, i) => (
|
|
165
165
|
<figure
|
|
166
|
-
className={["
|
|
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={["
|
|
174
|
+
className={["rvc-quote", quoteSize(item.quote)].filter(Boolean).join(" ")}
|
|
175
175
|
>
|
|
176
176
|
“{item.quote}”
|
|
177
177
|
</blockquote>
|
|
178
|
-
<figcaption className="
|
|
179
|
-
<span className="
|
|
180
|
-
{item.detail && <span className="
|
|
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="
|
|
187
|
-
<div className="
|
|
186
|
+
<div className="rvc-controls">
|
|
187
|
+
<div className="rvc-dots">
|
|
188
188
|
{items.map((_, i) => (
|
|
189
189
|
<button
|
|
190
190
|
type="button"
|
|
191
|
-
className={["
|
|
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="
|
|
200
|
+
<div className="rvc-arrows">
|
|
201
201
|
<button
|
|
202
202
|
type="button"
|
|
203
|
-
className="
|
|
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="
|
|
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
|
-
|
|
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.
|
|
76
|
+
(item.sourceURL ? (
|
|
77
77
|
<a
|
|
78
78
|
className="rw-date"
|
|
79
|
-
href={item.
|
|
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
|
-
|
|
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
|
-
|
|
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={
|
|
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={
|
|
75
|
+
<Button href={profileURL} newTab variant="dark">
|
|
76
76
|
<AtSign size={17} aria-hidden />
|
|
77
77
|
{followLabel ?? `Follow ${handle}`}
|
|
78
78
|
</Button>
|
package/src/sections/index.ts
CHANGED
|
@@ -121,8 +121,8 @@ export interface SiteConfig {
|
|
|
121
121
|
email: string;
|
|
122
122
|
address: Address;
|
|
123
123
|
geo?: { latitude: number; longitude: number };
|
|
124
|
-
|
|
125
|
-
|
|
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
|
package/src/styles/sections.css
CHANGED
|
@@ -2307,56 +2307,56 @@
|
|
|
2307
2307
|
REVIEW CAROUSEL — aggregate rating beside one review card at a time
|
|
2308
2308
|
================================================================= */
|
|
2309
2309
|
|
|
2310
|
-
.atelic-ui .
|
|
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 .
|
|
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 .
|
|
2324
|
+
.atelic-ui .rvc-aggregate {
|
|
2325
2325
|
min-width: 0;
|
|
2326
2326
|
}
|
|
2327
|
-
.atelic-ui .
|
|
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 .
|
|
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 .
|
|
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 .
|
|
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 .
|
|
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.
|
|
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.
|
|
2370
|
+
.atelic-ui a.rvc-caption:hover {
|
|
2371
2371
|
color: var(--primary);
|
|
2372
2372
|
text-decoration-color: currentColor;
|
|
2373
2373
|
}
|
|
2374
|
-
.atelic-ui a.
|
|
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 .
|
|
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 .
|
|
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 .
|
|
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 .
|
|
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 .
|
|
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 .
|
|
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 .
|
|
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 .
|
|
2430
|
+
.atelic-ui .rvc-stage {
|
|
2431
2431
|
display: grid;
|
|
2432
2432
|
}
|
|
2433
|
-
.atelic-ui .
|
|
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 .
|
|
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 .
|
|
2452
|
+
.atelic-ui .rvc-slide {
|
|
2453
2453
|
transition: none;
|
|
2454
2454
|
}
|
|
2455
2455
|
}
|
|
2456
|
-
.atelic-ui .
|
|
2456
|
+
.atelic-ui .rvc-slide .star-row {
|
|
2457
2457
|
margin-bottom: 18px;
|
|
2458
2458
|
}
|
|
2459
2459
|
|
|
2460
|
-
.atelic-ui .
|
|
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 .
|
|
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 .
|
|
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 .
|
|
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 .
|
|
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 .
|
|
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 .
|
|
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 .
|
|
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 .
|
|
2512
|
+
.atelic-ui .rvc-dots {
|
|
2513
2513
|
display: flex;
|
|
2514
2514
|
align-items: center;
|
|
2515
2515
|
gap: 8px;
|
|
2516
2516
|
}
|
|
2517
|
-
.atelic-ui .
|
|
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 .
|
|
2527
|
+
.atelic-ui .rvc-dot:hover {
|
|
2528
2528
|
background: var(--neutral-500);
|
|
2529
2529
|
}
|
|
2530
|
-
.atelic-ui .
|
|
2530
|
+
.atelic-ui .rvc-dot.is-active {
|
|
2531
2531
|
width: 26px;
|
|
2532
2532
|
background: var(--ink);
|
|
2533
2533
|
}
|
|
2534
|
-
.atelic-ui .
|
|
2534
|
+
.atelic-ui .rvc-arrows {
|
|
2535
2535
|
display: flex;
|
|
2536
2536
|
align-items: center;
|
|
2537
2537
|
gap: 8px;
|
|
2538
2538
|
}
|
|
2539
|
-
.atelic-ui .
|
|
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 .
|
|
2553
|
+
.atelic-ui .rvc-arrow:hover {
|
|
2554
2554
|
background: var(--surface-alt);
|
|
2555
2555
|
border-color: var(--neutral-300);
|
|
2556
2556
|
}
|
|
2557
|
-
.atelic-ui .
|
|
2558
|
-
.atelic-ui .
|
|
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),
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
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 =
|
|
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 {
|