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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .claude/skills/frontend-review/references/checklist.md
Original file line number Diff line number Diff line change
Expand Up @@ -155,6 +155,11 @@ Forward-looking: Tally (live in `WaitlistForm`), Cal.eu (live in `ThankYou`), an

**E5**: Embeds that set cookies (Cal.eu, Stripe) must be reflected in the privacy policy. A new tracking or cookie-setting embed is a legal-page update trigger (ties to L1).

**E6**: A cross-origin embed cannot be themed or driven from the parent page. Flag as ineffective any attempt to style inside a vendor iframe (injected `<style>`, `!important` targeting embed internals) or to drive one (writing to `contentDocument`, synthesizing clicks, sending `postMessage` commands a vendor does not document as inbound). If an embed must match the site theme, the answer is to own the markup, not to work around the boundary. See `docs/dev-guide.md`. Do not "fix" a vendor iframe with `filter: invert()`.

**E7**: The waitlist form posts directly to `api.tally.so`. `TALLY_FIELDS` / `TALLY_ROLE_OPTIONS` in `src/config.ts` are Tally's internal block UUIDs and are the contract with Tally's columns. Flag any change to the form's fields that does not re-read them, and flag a dropdown answer sent as a label rather than an array containing the option UUID.

**E8**: A submission must never fail silently. Any change to the submit path keeps a non-2xx or thrown request visible: preserve what the visitor typed, show the error, and keep the `mailto:` fallback. A lost signup is the one failure this page cannot absorb.
---

## 10. Performance (P)
Expand Down
3 changes: 3 additions & 0 deletions .dockerignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
node_modules
dist
.git
12 changes: 12 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,18 @@ bun run dev
| `bun run lint` | ESLint |
| `bun run preview` | Preview the production build locally |

### Docker (optional)

Runs the same dev server in a container, so you need nothing but Docker installed. Serves on `localhost:5173` with hot reload.

```bash
docker compose up # start (add -d to detach)
docker compose down # stop
docker compose exec web bun run typecheck
```

`compose.yaml` uses the stock `oven/bun:1` image, no Dockerfile. Dependencies live in a named volume rather than the bind mount, so the host's `node_modules` and the container's stay independent: run `bun install` on the host too if you want editor type resolution. The `DOCKER=1` env var switches Vite's watcher to polling, because bind mounts do not propagate inotify events.

## CI

Every PR and push to `main` runs typecheck, lint, and build via GitHub Actions (`.github/workflows/ci.yml`). The check must pass before merging.
Expand Down
16 changes: 16 additions & 0 deletions compose.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
# ponytail: stock bun image, no Dockerfile. Add one when the build needs system deps.
services:
web:
image: oven/bun:1
working_dir: /app
environment:
DOCKER: "1"
command: sh -c "bun install --frozen-lockfile && bun run dev --host 0.0.0.0"
ports:
- "5173:5173"
volumes:
- .:/app
- node_modules:/app/node_modules

volumes:
node_modules:
2 changes: 1 addition & 1 deletion docs/build-plan.md
Original file line number Diff line number Diff line change
Expand Up @@ -218,7 +218,7 @@ Single route: `/`. Sections in render order:
5. `<RuleRegistry />`: the moat section with registry preview card
6. `<BuiltFor />`: 3 industry cards: fintech, medtech, HR (fintech first)
7. `<Pricing />`: Free / Startup / Enterprise tiers, self-host note
8. `<WaitlistForm id="waitlist" />`: Tally form embed
8. `<WaitlistForm id="waitlist" />`: native form, POSTed directly to Tally (the iframe embed was dropped: a cross-origin embed cannot follow the site theme)
9. `<Footer />`: company info, legal note, Driftware Dynamics Ltd

The deadline badge JS logic:
Expand Down
14 changes: 14 additions & 0 deletions docs/design-system.html
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,9 @@
--shadow-card:none;
}

.form-input-invalid { border-color: var(--danger-border); }
.form-error { font-size: 12px; line-height: 1.6; color: var(--danger-text); margin-top: 6px; }

/* ═══ FONT SWITCHING ═══ */
[data-font="A"] .f-display { font-family: var(--f-display-A); }
[data-font="A"] .f-body { font-family: var(--f-body-AB); }
Expand Down Expand Up @@ -846,6 +849,17 @@
<div>
<div class="group-label">Hash Block</div>
<div class="hash-block">sha256:a3f8c2d91e74b60527f9e4c1d8a2b35f6e7c9d04a1b8e250c37f8d96e4b2a10f3e5d7c9b1a8f2e4d6c0b8a7e2d1c5b3</div>

<div class="group-label" style="margin-top:28px">Waitlist Form, field states</div>
<p class="note">The waitlist form is rendered natively and POSTed straight to Tally, so it themes with the rest of the site. It reuses <strong class="hl">.form-input</strong>, <strong class="hl">.form-textarea</strong> and <strong class="hl">.btn-primary</strong>; the only form-specific additions are the invalid state and the error message.</p>
<p class="note">An invalid field takes <strong class="hl">--danger-border</strong> and its focus ring flips to <strong class="hl">--danger-text</strong>, outranking the global <code style="font-family:var(--f-mono);font-size:12px">:focus-visible</code> accent ring so a focused invalid field still reads as invalid.</p>
<div style="max-width:420px">
<label class="form-label" style="display:block">Work email *</label>
<input class="form-input" placeholder="you@company.com" style="margin-bottom:16px">
<label class="form-label" style="display:block">Work email * (invalid)</label>
<input class="form-input form-input-invalid" value="not-an-email">
<p class="form-error">Enter a valid work email address.</p>
</div>
</div>
</div>

Expand Down
29 changes: 29 additions & 0 deletions docs/dev-guide.md
Original file line number Diff line number Diff line change
Expand Up @@ -153,6 +153,35 @@ The site is static and client-only, so every integration runs in the browser: Ta
- When a Content-Security-Policy is added to `vercel.json`, `frame-src` / `connect-src` must allow the embed origins (tally.so, cal.eu, stripe.com).
- Embeds that set cookies (Cal.eu, Stripe) must be reflected in the privacy policy. Adding such an embed is a legal-page update trigger.

### A vendor iframe cannot be themed: render the form yourself

The waitlist form is **not** an embed. It is our own markup in `src/sections/WaitlistForm.tsx`, POSTed straight to Tally. That is a deliberate decision, and this section records why, so nobody re-embeds the iframe to "simplify" things.

A cross-origin iframe cannot be styled or driven from the parent page. Every route was tested against the live Tally embed, and all of them are closed:

| Route | Result |
|-------|--------|
| CSS into the frame (`!important`, injected `<style>`, `::part()`) | Blocked by same-origin policy |
| Drive it via `postMessage` | Tally's protocol is outbound-only: `FormLoaded`, `FormPageView`, `FormSubmitted`, `FormRedirect`, viewport height. No inbound command exists |
| Synthesize clicks or typing into it | Cross-origin: cannot focus, dispatch events, or reach any element inside |
| A theme or color URL parameter | None. The palette lives server-side in the form's `settings.styles` |
| Tally's own custom CSS | A paid feature; this workspace is on `FREE` |
| Propagate `color-scheme` so CSS inside could follow our toggle | Does not propagate. With the OS in light mode and `color-scheme: dark` on the iframe, `prefers-color-scheme` inside the frame still reports light. Tally's `embed.js` also force-sets `iframe.style.colorScheme = 'light'` whenever `transparentBackground=1` |

The general rule: **if a third-party embed has to match your theme, you cannot use the embed.** Own the markup and talk to the vendor's endpoint directly.

Do not reach for a `filter: invert()` hack. It inverts the accent color and semi-transparent input fills along with the text, and it breaks focus rings.

### Posting to Tally directly

`TALLY_SUBMIT_URL` in `src/config.ts` posts to `api.tally.so/forms/<id>/respond`. It takes no API key and its CORS headers reflect our origin, so the browser permits the cross-origin POST.

- `TALLY_FIELDS` and `TALLY_ROLE_OPTIONS` hold Tally's internal block UUIDs. **They are the contract between our fields and Tally's columns.** If a field is added, removed, or reordered in the Tally dashboard, re-read them or answers land in the wrong column. `config.ts` documents exactly how.
- The endpoint is undocumented, so treat a non-2xx as expected rather than exceptional. The form keeps everything the visitor typed, shows the error, and offers a `mailto:` fallback. Never let a submission fail silently: a lost signup is the one outcome this page cannot afford.
- Dropdown answers are sent as an **array of the selected option's UUID**, not its label.
- Success navigates to `/thank-you` with the router, so the visitor stays in the SPA. Tally's own `redirectOnCompletion` is an absolute production URL and is now bypassed entirely.
- Spam is handled with an off-screen honeypot rather than a captcha, since the embed's own heuristics are gone. Escalate only if spam actually arrives.

`CAL_BOOKING_URL` in `src/config.ts` points to `https://www.cal.eu/traced-ai/discovery`. The `/thank-you` booking CTA is an **outbound `<a>` link** that opens cal.eu in a new tab, not an on-page embed. This sets no cookies on our domain and does not trigger a privacy-policy update. Switching to an inline `@calcom/embed` later (lazy-loaded on `/thank-you` only) would set cookies and require a privacy-policy cookie disclosure update before shipping.

## Editing `src/copy.ts`
Expand Down
6 changes: 5 additions & 1 deletion docs/site-copy.md
Original file line number Diff line number Diff line change
Expand Up @@ -90,7 +90,11 @@ Section label, headline, subheadline, and fine print: `waitlist.*`.

Voice: the headline stays company "we" ("We're building for the companies..."), but the reply promise in the subheadline is deliberately founder "I" ("You'll hear back from me personally") because the response is a personal act by the solo founder. Revert to "we" when the team grows. [cut: "We'll respond personally." β€” company-voice version of the reply promise, swapped to founder voice while solo.]

**Tally embed (form fields are in the Tally config, not copy.ts):**
**Implementation note:** the form is rendered natively (`src/sections/WaitlistForm.tsx`) and POSTed straight to Tally; it is no longer an iframe embed. It therefore themes with the rest of the site in both light and dark. All field labels, placeholders, the submit label and every validation message are ours and live in `waitlist.form.*` in copy.ts. The role options are the exception: their labels live in `TALLY_ROLE_OPTIONS` in `src/config.ts`, because each label is paired with the Tally option UUID it submits.

[cut: the Tally iframe embed, plus a `.tally-surface` light card that hosted it so it stayed readable in dark mode. The card was readable but never themed: a light form on a black page. Replaced once it was established that a cross-origin embed cannot follow the site theme by any route. See `docs/dev-guide.md`.]

**Fields (mirrored from the Tally form definition; the UUID mapping is in `src/config.ts`):**
1. Business email (required) β€” placeholder `you@company.com`
2. Company name (required) β€” placeholder `Acme Financial GmbH`
3. Your role (required) β€” CTO / Head of Engineering / Head of Compliance / Founder / Legal Counsel / Other
Expand Down
28 changes: 28 additions & 0 deletions src/config.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,32 @@
export const TALLY_FORM_ID = 'xXvOJk';

/**
* The waitlist form is rendered natively (see `src/sections/WaitlistForm.tsx`) and
* POSTed straight to Tally, rather than embedded as an iframe. Tally's embed is
* cross-origin and bakes its own palette in, so it could never follow the site theme.
*
* These UUIDs are Tally's internal block identifiers, read from the form definition.
* They are the contract between our fields and Tally's columns: if a field is added,
* removed, or reordered in the Tally dashboard, the UUIDs must be re-read here or
* submissions will land in the wrong column. Re-read them from
* `https://tally.so/embed/<id>` (the `__NEXT_DATA__` blob, `props.pageProps.blocks`,
* using each block's `groupUuid`).
*/
export const TALLY_SUBMIT_URL = `https://api.tally.so/forms/${TALLY_FORM_ID}/respond`;

export const TALLY_FIELDS = {
email: '650acce8-c72a-4299-8a91-91c534fc31fd',
company: '7ab8e551-0b18-4f48-966b-fe8b3ffb1cd3',
role: '1fb220fa-0f27-40f6-a322-4f916378fc6b',
useCase: '0b8a4e51-cec2-4191-89bb-1b4cb1ae2b6f',
} as const;

/** Role is a dropdown: Tally expects an array of the selected option's UUID. */
export const TALLY_ROLE_OPTIONS = [
{ id: 'b8749454-7dfd-4da2-8a46-34bde19067d8', label: 'Founder or C-suite' },
{ id: 'b83d3d9c-6ce7-4c49-9497-863b55e44aa1', label: 'Engineering or Technical lead' },
{ id: '9ccafe05-3112-4332-97db-40c769c03aa6', label: 'Compliance, Risk, or Legal' },
] as const;
export const CAL_BOOKING_URL = 'https://www.cal.eu/traced-ai/discovery';
export const ANNEX_III_APPLICATION_DATE = '2027-12-02';
export const DOMAIN = 'https://www.traced-ai.com';
Expand Down
21 changes: 21 additions & 0 deletions src/copy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -295,6 +295,27 @@ export const waitlist = {
'Two minutes. Tell us what you’re building and what you can’t yet explain. You’ll hear back from me personally.',
finePrint:
"Once you’ve joined, you can book a 30-minute call. Optional, not required.",
form: {
email: { label: 'Work email', placeholder: 'you@company.com' },
company: { label: 'Company', placeholder: 'Company name or link' },
role: { label: 'Role', placeholder: 'Select your role' },
useCase: {
label: 'What does your AI decide in production, and how would you evidence it to an auditor today?',
placeholder:
'e.g. We score loan applications with an LLM. We can explain the logic, but we have no structured, tamper-evident record per decision. If a regulator asked us to reconstruct a specific case from 3 months ago, we’d be pulling from CloudWatch for a week.',
},
submit: 'Join the waitlist β†’',
submitting: 'Joining…',
required: 'Required',
errors: {
email: 'Enter a valid work email address.',
company: 'Tell us which company you’re with.',
role: 'Select the option closest to your role.',
useCase: 'A sentence or two is plenty.',
submit:
'Something went wrong on our side and your details were not saved. Try again, or email us directly.',
},
},
};

export const thankYou = {
Expand Down
41 changes: 41 additions & 0 deletions src/index.css
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@
--sp-1: 4px; --sp-2: 8px; --sp-3: 12px; --sp-4: 16px; --sp-5: 24px;
--sp-6: 32px; --sp-7: 48px; --sp-8: 64px;
--r-sm: 4px; --r-md: 8px; --r-lg: 12px;
color-scheme: light;
}

/* ─── DARK THEME ─── */
Expand Down Expand Up @@ -81,6 +82,7 @@
--deadline-bg: rgba(74, 222, 128, 0.08);
--deadline-border: rgba(74, 222, 128, 0.22);
--shadow-card: none;
color-scheme: dark;
}

/* ─── TAILWIND TOKEN MAPPING ─── */
Expand Down Expand Up @@ -604,6 +606,34 @@ body {
.form-input::placeholder { color: var(--tx-2); }
.form-textarea { min-height: 88px; resize: vertical; line-height: 1.6; }

/* A native <select> keeps the UA arrow, which follows :root color-scheme. */
select.form-input { cursor: pointer; }

.form-input-invalid { border-color: var(--danger-border); }
.form-input-invalid:focus {
border-color: var(--danger-border);
box-shadow: 0 0 0 3px var(--danger-bg);
}
/* Outranks the global :focus-visible accent ring, so an invalid field reads as
invalid while focused instead of flipping back to the teal accent. */
.form-input-invalid:focus-visible { outline-color: var(--danger-text); }
.form-error {
font-size: 12px;
line-height: 1.6;
color: var(--danger-text);
margin-top: 6px;
}

/* Spam trap: off-screen rather than display:none, which some bots skip. */
.honeypot {
position: absolute;
left: -9999px;
width: 1px;
height: 1px;
opacity: 0;
pointer-events: none;
}

/* ─── MUTED TEXT ─── */
.footnote { font-size: 10px; color: var(--tx-2); line-height: 1.7; }
.legal-sub-label { font-size: 13px; color: var(--tx-2); font-weight: 500; margin-top: 20px; margin-bottom: 8px; }
Expand Down Expand Up @@ -691,6 +721,17 @@ body {

/* ─── ACCESSIBILITY ─── */

/* Visible to screen readers only: carries the "Required" word behind the * glyph. */
.sr-only {
position: absolute;
width: 1px; height: 1px;
padding: 0; margin: -1px;
overflow: hidden;
clip-path: inset(50%);
white-space: nowrap;
border: 0;
}

/* All hash-targeted elements need clearance from the 56px sticky navbar. */
[id] { scroll-margin-top: 72px; }

Expand Down
2 changes: 1 addition & 1 deletion src/pages/Privacy.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -84,7 +84,7 @@ export default function Privacy() {

<LegalSection id="5" title="Cookies and analytics">
<p><strong>Vercel Web Analytics</strong> is cookieless. It sets no cookies, collects no personally identifiable information, and identifies sessions by a request hash that is discarded after 24 hours. No consent banner is required for it.</p>
<p>The <strong>Tally</strong> waitlist form is a third-party embed. Tally does not use cookie tracking on forms; it operates under its own privacy notice: <a href="https://tally.so/privacy" target="_blank" rel="noopener noreferrer">Tally privacy policy</a>.</p>
<p>The <strong>Tally</strong> waitlist form is not an embed: the form is served by this site and your answers are sent directly to Tally when you submit it. Nothing is loaded from Tally while you browse, and no Tally cookie or tracking script runs on this site. Tally processes the submission under its own privacy notice: <a href="https://tally.so/privacy" target="_blank" rel="noopener noreferrer">Tally privacy policy</a>.</p>
<p>The <strong>Cal.eu</strong> booking widget is a third-party embed. It operates under its own privacy notice and may set cookies within its embed context: <a href="https://cal.com/privacy" target="_blank" rel="noopener noreferrer">Cal.eu privacy policy</a>.</p>
<p>When user authentication is added to the product, any session cookies introduced will be listed here with their purpose and lifespan.</p>
</LegalSection>
Expand Down
Loading