Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
60748bf
feat(web): add the Tricorder product page
claude Sep 16, 2026
be0aa2a
feat(web): rebuild the Tricorder "One Intelligence Layer" scene
claude Sep 16, 2026
56f6a28
feat(web): rebuild the Tricorder "isolation" section as an argument
claude Sep 16, 2026
65ceb6a
fix(web): restore the Tricorder page's copy fidelity to the source doc
claude Sep 16, 2026
58870e9
feat(web): rebuild the Tricorder isolation section on the doc's diagram
claude Sep 16, 2026
2aa0344
Merge pull request #268 from digibranders/claude/cleanstart-new-page-…
jadhavgaurav Sep 17, 2026
028fec5
feat(web): polish the Tricorder isolation diagram
jadhavgaurav Sep 17, 2026
d7fd447
fix(web): remove the Tricorder by CleanStart eyebrow from the hero
jadhavgaurav Sep 17, 2026
003bca1
fix(web): drop the three panel connector lines on the Tricorder diagram
jadhavgaurav Sep 17, 2026
79655c2
Revert "fix(web): drop the three panel connector lines on the Tricord…
jadhavgaurav Sep 17, 2026
c081800
fix(web): drop the group header icons on the Tricorder diagram
jadhavgaurav Sep 17, 2026
1dffb81
fix(web): drop the verdict ledger from the Tricorder CTA card
jadhavgaurav Sep 17, 2026
171781a
fix(web): vertically center the Tricorder CTA card content
jadhavgaurav Sep 17, 2026
ca6747d
fix(web): make gated resource downloads actually gate
jadhavgaurav Sep 17, 2026
26e4ac7
feat(cms): thank visitors after a gated download, and restore the Web…
jadhavgaurav Sep 17, 2026
9ca11e4
fix(web): redirect bare busybox blog URL to /blogs/ post
jadhavgaurav Sep 17, 2026
18df786
feat(web): redesign the Tricorder page and hold it from indexing
jadhavgaurav Sep 17, 2026
5d5aa07
fix(cms): subscribe to HubSpot marketing only when the visitor opted in
jadhavgaurav Sep 17, 2026
c4c468b
docs(web): correct the stale note that software-applications is noindex
jadhavgaurav Sep 17, 2026
3e361ec
feat(cms): show a synced count instead of "Sync attempts" on leads
jadhavgaurav Sep 17, 2026
d87b224
chore(web): remove the unused FormRenderer
jadhavgaurav Sep 17, 2026
ce57706
feat(web): track newsletter signups in GA4
jadhavgaurav Sep 17, 2026
7209a0e
feat(web): rebuild the vulnerability remediation page
jadhavgaurav Sep 18, 2026
14ccfeb
fix(web): make the newsletter consent readable on white CTA cards
jadhavgaurav Sep 18, 2026
36d3317
copy(web): update attack surface reduction page copy
jadhavgaurav Sep 18, 2026
e4bac2c
chore(web): remove Tricorder from the products nav
jadhavgaurav Sep 18, 2026
272c454
chore(cms): add read-only GSC URL audit scripts
jadhavgaurav Sep 18, 2026
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
2 changes: 1 addition & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -142,7 +142,7 @@ pnpm --filter @cleanstart/web build
- Every `<Image>` needs `sizes` matching rendered widths per breakpoint. SVGs: `preserveAspectRatio="xMidYMid meet"` (never `"none"`).
- Buttons: `--btn-fs-*`/`--btn-h-*`/`--btn-px-*`, never `clamp()`. Primary CTAs ≥ 44px tall.
- Section vertical padding: `--spacing-section-*` tokens via `<Section padding>` or `py-section-*` utilities. Do not invent `pt-Xpx sm:pt-Ypx lg:pt-Zpx` shapes.
- Form inputs ≥ 16px font-size (iOS Safari zoom rule). `FormRenderer.tsx`'s `fieldInputStyle` already sets this; never override with `text-sm` (14 px).
- Form inputs ≥ 16px font-size (iOS Safari zoom rule). The shared field surface (`src/components/forms/field-surface.ts`, used by `TextField` and `PhoneField`) sets this through `--fs-input`; never override with `text-sm` (14 px).
- No `<br />` in prose — trust `max-width` + `text-wrap: balance` (applied globally on `h1`–`h4` via `@layer base`).
- Footer CTA-card geometry is owned by `Footer.tsx`. Per-page CTAs paint inside the slot via the `cta` prop.

Expand Down
2 changes: 1 addition & 1 deletion apps/cms/emails/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ <h1>CleanStart email templates</h1>
<tr><td><a href="./demo-confirmation.html">demo-confirmation</a></td><td>book-a-demo</td><td><span class="tag visitor">visitor</span></td><td>Your CleanStart demo request</td></tr>
<tr><td><a href="./contact-confirmation.html">contact-confirmation</a></td><td>contact</td><td><span class="tag visitor">visitor</span></td><td>We've received your message</td></tr>
<tr><td><a href="./newsletter-welcome.html">newsletter-welcome</a></td><td>newsletter</td><td><span class="tag visitor">visitor</span></td><td>You're subscribed to CleanStart</td></tr>
<tr><td><a href="./resource-download.html">resource-download</a></td><td>resource-capture (gated resources)</td><td><span class="tag visitor">visitor</span></td><td>Your download: The 2026 Container Hardening Playbook</td></tr>
<tr><td><a href="./resource-download.html">resource-download</a></td><td>content-gated (gated resource downloads)</td><td><span class="tag visitor">visitor</span></td><td>Thanks for downloading The 2026 Container Hardening Playbook</td></tr>
<tr><td><a href="./careers-applicant-confirmation.html">careers-applicant-confirmation</a></td><td>job application</td><td><span class="tag visitor">visitor</span></td><td>We've received your application for Senior Platform Engineer</td></tr>
<tr><td><a href="./careers-hr-notification.html">careers-hr-notification</a></td><td>job application</td><td><span class="tag internal">internal</span></td><td>New application: Senior Platform Engineer, Priya Nair</td></tr>
<tr><td><a href="./partner-confirmation.html">partner-confirmation</a></td><td>become-a-partner</td><td><span class="tag visitor">visitor</span></td><td>Thanks for your interest in partnering with CleanStart</td></tr>
Expand Down
13 changes: 7 additions & 6 deletions apps/cms/emails/resource-download.html
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
<!-- Generated by scripts/render-email-templates.ts. Do not edit.
Source: endpoints/submit-lead.ts
Form: resource-capture (gated resources)
Subject: Your download: The 2026 Container Hardening Playbook -->
Form: content-gated (gated resource downloads)
Subject: Thanks for downloading The 2026 Container Hardening Playbook -->
<!doctype html>
<html lang="en" xmlns:v="urn:schemas-microsoft-com:vml" xmlns:o="urn:schemas-microsoft-com:office:office">
<head>
Expand Down Expand Up @@ -39,7 +39,7 @@
<body style="margin:0;padding:0;width:100%;background-color:#f1f5f9;-webkit-text-size-adjust:100%;-ms-text-size-adjust:100%;">
<!-- Preheader: shown in the inbox list, never in the open email. The trailing
entities stop clients padding the preview with body text. -->
<div style="display:none;max-height:0;overflow:hidden;mso-hide:all;font-size:1px;line-height:1px;color:#f1f5f9;opacity:0;">The 2026 Container Hardening Playbook is ready to download.&#847;&zwnj;&nbsp;&#847;&zwnj;&nbsp;&#847;&zwnj;&nbsp;&#847;&zwnj;&nbsp;&#847;&zwnj;&nbsp;</div>
<div style="display:none;max-height:0;overflow:hidden;mso-hide:all;font-size:1px;line-height:1px;color:#f1f5f9;opacity:0;">Your copy of The 2026 Container Hardening Playbook, and a link to download it again.&#847;&zwnj;&nbsp;&#847;&zwnj;&nbsp;&#847;&zwnj;&nbsp;&#847;&zwnj;&nbsp;&#847;&zwnj;&nbsp;</div>

<table role="presentation" cellpadding="0" cellspacing="0" border="0" width="100%" style="background-color:#f1f5f9;">
<tr><td align="center" style="padding:32px 12px;">
Expand All @@ -56,12 +56,13 @@
</td></tr>

<tr><td class="cs-pad" style="padding:0 32px 28px;">
<p style="margin:0 0 10px;font-family:Arial,Helvetica,sans-serif;font-size:12px;font-weight:700;letter-spacing:0.08em;text-transform:uppercase;color:#3960f9;">Your download</p>
<p style="margin:0 0 10px;font-family:Arial,Helvetica,sans-serif;font-size:12px;font-weight:700;letter-spacing:0.08em;text-transform:uppercase;color:#3960f9;">Thank you</p>
<h1 class="cs-heading" style="margin:0 0 18px;font-family:Arial,Helvetica,sans-serif;font-size:22px;line-height:1.35;font-weight:700;color:#0f172a;">Thanks, Priya</h1>
<p class="cs-text" style="margin:0 0 16px;font-family:Arial,Helvetica,sans-serif;font-size:16px;line-height:1.6;color:#334155;">Here is your copy of The 2026 Container Hardening Playbook.</p>
<p class="cs-text" style="margin:0 0 16px;font-family:Arial,Helvetica,sans-serif;font-size:16px;line-height:1.6;color:#334155;">Thank you for downloading The 2026 Container Hardening Playbook.</p>
<p class="cs-text" style="margin:0 0 16px;font-family:Arial,Helvetica,sans-serif;font-size:16px;line-height:1.6;color:#334155;">Your download should have started in your browser. If it did not, or you want the file again later, use the button below.</p>
<table role="presentation" cellpadding="0" cellspacing="0" border="0" style="margin:4px 0 24px;border-collapse:separate;">
<tr><td class="cs-btn" align="center" bgcolor="#3960f9" style="padding:14px 28px;border-radius:8px;mso-padding-alt:14px 28px;">
<a href="https://cms.cleanstart.com/api/resources/example/download?token=sample" target="_blank" rel="noopener" style="font-family:Arial,Helvetica,sans-serif;font-size:16px;font-weight:bold;line-height:1;color:#ffffff;text-decoration:none;display:block;">Download it now</a>
<a href="https://cms.cleanstart.com/api/resources/example/download?token=sample" target="_blank" rel="noopener" style="font-family:Arial,Helvetica,sans-serif;font-size:16px;font-weight:bold;line-height:1;color:#ffffff;text-decoration:none;display:block;">Download your copy</a>
</td></tr>
</table>
<table role="presentation" cellpadding="0" cellspacing="0" border="0" width="100%" style="margin:0 0 20px;">
Expand Down
1 change: 1 addition & 0 deletions apps/cms/scripts/data/gsc-audit-urls.json

Large diffs are not rendered by default.

169 changes: 169 additions & 0 deletions apps/cms/scripts/gate-webflow-gated-resources.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,169 @@
#!/usr/bin/env -S node --no-warnings --experimental-strip-types
/**
* One-shot: restore the download gate on the two resources Webflow gated.
*
* Webflow gated by resource type in its page template, not with a CMS field:
* the ebook and report templates put the PDF link only inside the form's
* success block, so it appeared after a visitor submitted. Whitepapers,
* datasheets and architecture insights rendered an open download link. The
* Webflow CMS export therefore had nothing to read, and the import transform
* wrote `gated: false, accessLevel: 'public'` for every resource, which is how
* both gates were lost in the migration.
*
* Evidence (Wayback snapshots of the live Webflow pages):
* - containing-vulnerabilities-in-your-containers (ebook, 2025-11-09)
* - securing-the-software-supply-chain-in-2026 (report, 2026-01-19)
* On both, every PDF link was either conditionally hidden or inside
* `w-form-done`; neither exposed an open link. All other 25 archived resource
* pages did.
*
* Sets `gated`, points `gateForm` at the `content-gated` form (resolved by slug,
* the only form the Resources gate picker allows), and sets `accessLevel` to
* `lead-gated`, which the field's own help text says gating implies but which no
* hook derives.
*
* Writes via `payload.update` without `draft`, so each resource stays published
* and its afterChange hooks run (including the web revalidation).
*
* Idempotent: a resource already gated behind the right form is skipped.
*
* Flags:
* --dry-run Report what would change without writing.
*
* In the prod container (env is already in the process, there is no .env):
* /app/node_modules/.bin/tsx scripts/gate-webflow-gated-resources.ts --dry-run
* /app/node_modules/.bin/tsx scripts/gate-webflow-gated-resources.ts
*/
import { getPayload } from 'payload';

import payloadConfig from '../src/payload.config.ts';

const GATE_FORM_SLUG = 'content-gated';

const WEBFLOW_GATED_SLUGS = [
'containing-vulnerabilities-in-your-containers',
'securing-the-software-supply-chain-in-2026',
] as const;

const args = new Set(process.argv.slice(2));
const DRY_RUN = args.has('--dry-run');

const log = (msg: string): void => {
// eslint-disable-next-line no-console -- script output
console.log(msg);
};

const idOf = (value: unknown): number | string | null => {
if (value == null) return null;
if (typeof value === 'object' && 'id' in value) {
return (value as { id: number | string }).id;
}
return value as number | string;
};

const run = async (): Promise<void> => {
const payload = await getPayload({ config: payloadConfig });

log(`\nMode: ${DRY_RUN ? 'DRY RUN (no writes)' : 'WRITE via payload.update'}\n`);

const forms = await payload.find({
collection: 'forms',
where: { slug: { equals: GATE_FORM_SLUG } },
limit: 1,
depth: 0,
overrideAccess: true,
});
const gateForm = forms.docs[0] as { id: number | string; _status?: string } | undefined;
if (!gateForm) {
throw new Error(`Gate form "${GATE_FORM_SLUG}" not found; refusing to gate anything.`);
}
if (gateForm._status !== 'published') {
throw new Error(
`Gate form "${GATE_FORM_SLUG}" is ${String(gateForm._status)}, not published; the modal would not render.`,
);
}
log(`Gate form: ${GATE_FORM_SLUG} (id ${String(gateForm.id)})\n`);

let gated = 0;
let skipped = 0;
let missing = 0;
let errors = 0;

for (const slug of WEBFLOW_GATED_SLUGS) {
const found = await payload.find({
collection: 'resources',
where: { slug: { equals: slug } },
limit: 1,
depth: 0,
overrideAccess: true,
});
const doc = found.docs[0] as
| {
id: number | string;
gated?: boolean | null;
gateForm?: unknown;
accessLevel?: string | null;
asset?: unknown;
_status?: string;
}
| undefined;

if (!doc) {
missing += 1;
log(` MISSING ${slug}`);
continue;
}
if (idOf(doc.asset) == null) {
missing += 1;
log(` NO FILE ${slug} (nothing to gate)`);
continue;
}

const alreadyGated =
doc.gated === true &&
String(idOf(doc.gateForm)) === String(gateForm.id) &&
doc.accessLevel === 'lead-gated';
if (alreadyGated) {
skipped += 1;
log(` skip ${slug} (already gated)`);
continue;
}

const before = `gated=${String(doc.gated)} gateForm=${String(idOf(doc.gateForm))} accessLevel=${String(doc.accessLevel)}`;
if (DRY_RUN) {
gated += 1;
log(` would gate ${slug} [${before}] status=${String(doc._status)}`);
continue;
}

try {
await payload.update({
collection: 'resources',
id: doc.id,
data: { gated: true, gateForm: gateForm.id, accessLevel: 'lead-gated' } as Record<
string,
unknown
>,
overrideAccess: true,
});
gated += 1;
log(` gated ${slug} [was ${before}]`);
} catch (err) {
errors += 1;
const message = err instanceof Error ? err.message : String(err);
// eslint-disable-next-line no-console -- script output
console.error(` ! resources ${slug}: ${message}`);
}
}

log(`\nDone. gated=${gated} skipped=${skipped} missing=${missing} errors=${errors}`);
if (errors > 0) process.exitCode = 1;
};

run()
.then(() => process.exit(process.exitCode ?? 0))
.catch((err: unknown) => {
// eslint-disable-next-line no-console -- script output
console.error(err);
process.exit(1);
});
93 changes: 93 additions & 0 deletions apps/cms/scripts/gsc-single-inspect.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
#!/usr/bin/env -S node --no-warnings --experimental-strip-types
/**
* Read-only single-URL GSC inspection, for the one-off case of a URL added
* to an audit list after the main batch (gsc-url-audit.ts) already ran.
* Run inside the prod cms container:
* docker exec -w /app/apps/cms cleanstart-cms-1 pnpm exec tsx scripts/gsc-single-inspect.ts <url>
*/
import { getPayload } from 'payload';

import payloadConfig from '../src/payload.config.ts';
import { resolveGscCredentials } from '../src/payload/lib/integrations/credentials.ts';

const run = async (): Promise<void> => {
const url = process.argv[2];
if (!url) throw new Error('Usage: gsc-single-inspect.ts <url>');

const payload = await getPayload({ config: payloadConfig });
const rows = await payload.find({
collection: 'integrations',
where: { kind: { equals: 'gscSearchAnalyticsApi' } },
limit: 1,
overrideAccess: true,
});
const row = rows.docs[0];
if (!row) throw new Error('No gscSearchAnalyticsApi integration row found');
const creds = resolveGscCredentials(row as unknown as { gscConfig?: { siteUrl?: string } });
if (!creds) throw new Error('Could not resolve GSC credentials');

const { google } = await import('googleapis');
const auth = new google.auth.JWT({
email: creds.serviceAccountJson.client_email as string,
key: creds.serviceAccountJson.private_key as string,
scopes: ['https://www.googleapis.com/auth/webmasters.readonly'],
});
const client = google.searchconsole({ version: 'v1', auth });

const res = await client.urlInspection.index.inspect({
requestBody: { siteUrl: creds.siteUrl, inspectionUrl: url },
});
const ir = res.data.inspectionResult?.indexStatusResult;

const fmtDate = (d: Date): string => d.toISOString().slice(0, 10);
const now = new Date();
const endDate = fmtDate(now);
const startDate = fmtDate(new Date(now.getTime() - 91 * 86_400_000));
const prevEndDate = fmtDate(new Date(now.getTime() - 92 * 86_400_000));
const prevStartDate = fmtDate(new Date(now.getTime() - 182 * 86_400_000));

const queryOne = async (sd: string, ed: string) => {
const r = await client.searchanalytics.query({
siteUrl: creds.siteUrl,
requestBody: {
startDate: sd,
endDate: ed,
dimensions: ['page'],
dimensionFilterGroups: [{ filters: [{ dimension: 'page', operator: 'equals', expression: url }] }],
rowLimit: 1,
},
});
const row0 = r.data.rows?.[0];
return {
clicks: row0?.clicks ?? 0,
impressions: row0?.impressions ?? 0,
ctr: row0?.ctr ?? 0,
position: row0?.position ?? 0,
};
};

const current = await queryOne(startDate, endDate);
const previous = await queryOne(prevStartDate, prevEndDate);

const result = {
url,
indexVerdict: ir?.verdict ?? null,
coverageState: ir?.coverageState ?? null,
robotsTxtState: ir?.robotsTxtState ?? null,
lastCrawlTime: ir?.lastCrawlTime ?? null,
googleCanonical: ir?.googleCanonical ?? null,
userCanonical: ir?.userCanonical ?? null,
current,
previous,
};

const fs = await import('node:fs');
fs.writeFileSync('/tmp/gsc-single-output.json', JSON.stringify(result, null, 2));
payload.logger.info(JSON.stringify(result));
process.exit(0);
};

run().catch((err: unknown) => {
console.error(err);
process.exit(1);
});
Loading
Loading