feat(landing): add HubSpot tracking script for hosted marketing site (#5565)

* feat(landing): add HubSpot tracking script for hosted marketing site

- Loads the HubSpot loader in the landing route group only, gated by isHosted
- Not loaded for self-hosted/OSS deployments
- Adds the loader's companion scripts (analytics, form-tracking, banner) and their beacon hosts to CSP, verified against the actual scripts' network calls

* fix(landing): scope HubSpot CSP hosts to the landing route group only

Greptile flagged that the HubSpot script/connect hosts were added to the
shared CSP arrays used by every route, including /workspace, /login, and
/signup — even though the HubSpot loader only ever renders inside the
(landing) route group.

- Move the HubSpot hosts out of STATIC_SCRIPT_SRC/STATIC_CONNECT_SRC
- Add generateLandingRuntimeCSP(), which extends the shared runtime policy
  with the HubSpot hosts, mirroring the existing getChatEmbedCSPPolicy()
  pattern for route-scoped CSP variants
- Wire it into proxy.ts's catch-all branch, which is what actually serves
  the marketing/landing site; /workspace, /login, /signup keep the
  unmodified shared policy

* fix(landing): track HubSpot pageviews on client-side landing navigations

Cursor Bugbot flagged that the HubSpot loader only auto-fires a pageview
on the initial load. Since LandingLayout persists across client-side
navigations between landing routes, subsequent Link navigations never
told HubSpot about the route change, undercounting pageviews.

Add HubspotPageViewTracker, a small client component using the standard
Next.js App Router pattern (usePathname/useSearchParams in a Suspense
boundary) to push a manual pageview through HubSpot's _hsq queue on every
navigation after the first.

* simplify(landing): drop unnecessary Suspense from HubSpot pageview tracker

usePathname() alone doesn't require a Suspense boundary to preserve static
rendering — only useSearchParams() does. The tracker only needs the path,
not the query string, so drop useSearchParams and the Suspense wrapper
entirely. Simpler, and no risk to the landing site's static rendering/LCP.

* fix(landing): exclude non-landing routes from the landing CSP fallback

Greptile's second pass caught that proxy.ts's catch-all branch (which
serves generateLandingRuntimeCSP()) also handles several non-landing pages
that fall through the earlier explicit branches: /verify, /sso,
/reset-password (auth sub-pages), /resume/[workflowId] (interfaces),
/f/[token] (file shares), /playground, and the authenticated/callbackUrl
invite fallthrough. None of these render the HubSpot loader, so they
shouldn't get its CSP allowance either.

Add an explicit non-landing path prefix list and only fall back to
generateRuntimeCSP() (the tight policy) for those, keeping
generateLandingRuntimeCSP() for everything else in the catch-all.

* fix(landing): add /unsubscribe to non-landing paths, fix tracker remount bug

- Greptile correctly flagged /unsubscribe as another top-level page outside
  (landing) that reaches the CSP fallback branch; add it to the exclusion list
- Cursor caught that the per-mount useRef in HubspotPageViewTracker resets
  whenever LandingLayout remounts (e.g. leaving the landing site and coming
  back), but next/script dedupes the loader by id and won't re-fire the
  auto-tracked pageview on remount — so that return visit was silently
  dropped. Move the flag to module scope so it reflects the actual
  once-per-browser-session lifetime of the loader script, not per-mount

* fix(landing): track query-only landing navigations in HubSpot pageviews

Cursor caught that the tracker only depended on usePathname(), so
client-side navigations that change only the query string (blog/library
pagination, careers filters) never fired a pageview at all, and setPath
dropped the search string even when the path did change.

Add useSearchParams() back (the officially documented Next.js pattern for
tracking all route changes) and depend on the full path+query string.
Wrap the tracker in a local Suspense boundary, as required to keep the
route statically rendered — the fallback is null and the component
renders nothing, so this has no LCP/visual cost.

* test(proxy): add regression coverage for the non-landing path classifier

Exports isNonLandingPath and covers the exact prefix-boundary cases
(e.g. /f vs /ffoo, /resume vs /resumes) that the CSP routing fix depends
on, so this logic is verified by CI rather than my own ad-hoc checks.

* fix(landing): exclude /landing-preview from the landing CSP fallback

Greptile caught that /landing-preview calls notFound() in production
(see app/landing-preview/page.tsx) and its subroutes (marks-lab,
readme-tour-capture) don't render the (landing) layout either, so none of
them ever load the HubSpot tracker — but the CSP fallback was still
classifying the whole prefix as landing.

* chore(landing): remove the /landing-preview test scaffold

It was a temporary route for visual iteration (404s in production) — not
needed anymore, and Greptile had just flagged it as another route that
falsely inherited the landing CSP allowance. Removing it outright is
simpler than maintaining an exclusion for it.

Also removes SandboxWorkspacePermissionsProvider, which existed solely to
support the deleted readme-tour-capture page and has no other callers.

* refactor(landing): fix invalid const assertion, trim comments

- Fixed HUBSPOT_SCRIPT_SRC/HUBSPOT_CONNECT_SRC: 'as const' cannot wrap a
  ternary expression directly (TS1355) — caught by a full project typecheck
  I ran specifically to verify this PR, not by lint/tests alone. Rewrote
  using the same conditional-spread-inside-array-literal pattern already
  used by every other array in this file (e.g. STATIC_FRAME_SRC)
- Trimmed comments across all four touched files down to only the
  non-obvious 'why' (module-scope tracking flag, HubSpot CSP scoping
  rationale), matching this codebase's terse comment style elsewhere

* revert(landing): drop landing-scoped CSP, put HubSpot in the shared policy

Cursor caught a fundamental problem with the landing-scoped CSP: the
Content-Security-Policy header is fixed to the document's initial HTTP
response and is NOT re-applied on Next.js client-side (soft) navigation.

Both the landing navbar's ChipLink to /login and AuthShell's Link back to
/ are soft navigations (confirmed directly in the source, not assumed).
That means:
- /login -> / (soft nav): the browser keeps /login's CSP, which never
  allowed HubSpot hosts, so the loader gets silently blocked on landing.
- / -> /login (soft nav): the browser keeps the landing CSP, which is
  MORE permissive than /login's, undoing the tightening entirely.

A per-route CSP is fundamentally incompatible with this app's
client-side-routed navigation. Greptile's original 'CSP too broad on
/workspace' concern was valid in isolation, but the fix built across the
last several rounds doesn't actually work — it's neither reliably
tighter nor reliably functional, and each round's patch (exclusion list
entries, /landing-preview handling) was really just papering over that
core issue.

Revert to a single shared CSP for the whole app, matching exactly how
GTM/GA/ahrefs are already handled in this same file: HubSpot hosts land
in STATIC_SCRIPT_SRC/STATIC_CONNECT_SRC under the existing isHosted
gate. /workspace's CSP header technically allows origins it never
requests (same accepted tradeoff as GTM/GA), but the tracking script only
ever renders in the (landing) layout — matching the CSP scope Greptile
originally objected to. This is now provably correct because it can't
desync: proxy.ts, csp.ts, and proxy.test.ts are byte-for-byte identical
to origin/staging except for the HubSpot host list itself.

* fix(csp): drop overbroad *.hubspot.com connect-src wildcard

Greptile correctly flagged that *.hubspot.com is far broader than
anything the tracker actually needs — it covers HubSpot's entire product
surface (app, api, marketing), not just the tracking endpoints.

Re-checked my own network trace from earlier: the pageview beacon itself
is an image pixel (new Image() to track.hubspot.com/__pto.gif), which is
governed by img-src (already wide open to any https: origin), not
connect-src. The *.hubspot.com entry was an unverified guess for the
forms-API/banner fetch calls I couldn't pin down through minification —
removing it since I can't confirm what it was actually protecting,
keeping only the verified *.hscollectedforms.net entry.
This commit is contained in:
Waleed
2026-07-10 12:24:57 -07:00
committed by GitHub
parent 08320d5ab0
commit 4952ddb73f
8 changed files with 68 additions and 1702 deletions
@@ -0,0 +1,38 @@
'use client'
import { useEffect } from 'react'
import { usePathname, useSearchParams } from 'next/navigation'
declare global {
interface Window {
_hsq?: unknown[][]
}
}
// next/script dedupes by id and never reloads on remount, so this must be
// module-scope (not a ref) to survive LandingLayout unmounting/remounting.
let hasTrackedInitialPageView = false
/**
* The HubSpot loader only auto-tracks the first page load; LandingLayout
* persists across client-side navigations, so HubSpot never sees the rest.
* Pushes a manual pageview through `_hsq` on every navigation after the first.
*/
export function HubspotPageViewTracker() {
const pathname = usePathname()
const searchParams = useSearchParams()
const query = searchParams.toString()
useEffect(() => {
if (!hasTrackedInitialPageView) {
hasTrackedInitialPageView = true
return
}
window._hsq = window._hsq || []
window._hsq.push(['setPath', query ? `${pathname}?${query}` : pathname])
window._hsq.push(['trackPageView'])
}, [pathname, query])
return null
}
+20 -1
View File
@@ -1,7 +1,13 @@
import type { ReactNode } from 'react'
import { Suspense } from 'react'
import type { Metadata } from 'next'
import Script from 'next/script'
import { isHosted } from '@/lib/core/config/env-flags'
import { SITE_URL } from '@/lib/core/utils/urls'
import { LandingShell } from '@/app/(landing)/components'
import { HubspotPageViewTracker } from '@/app/(landing)/hubspot-page-view-tracker'
const HUBSPOT_SCRIPT_SRC = 'https://js-na2.hs-scripts.com/246720681.js' as const
/**
* Route-group layout for the entire landing family - the home page, platform and
@@ -24,5 +30,18 @@ export const metadata: Metadata = {
}
export default function LandingLayout({ children }: { children: ReactNode }) {
return <LandingShell>{children}</LandingShell>
return (
<LandingShell>
{children}
{/* HubSpot tracking — hosted only */}
{isHosted && (
<>
<Script id='hs-script-loader' src={HUBSPOT_SCRIPT_SRC} strategy='afterInteractive' />
<Suspense fallback={null}>
<HubspotPageViewTracker />
</Suspense>
</>
)}
</LandingShell>
)
}
@@ -1,966 +0,0 @@
'use client'
import { useEffect, useMemo, useRef, useState } from 'react'
import {
normalizeReach,
type Pt,
sampleClosed,
toPath,
} from '@/app/(landing)/components/mothership/components/goo-marks/use-goo-hover'
import {
type Edge,
edgesToPaths,
isoProject,
rotate2,
} from '@/app/(landing)/components/mothership/components/iso-marks/use-goo-mark'
/**
* Internal tuning lab for the Sim brand marks. Renders each mark with live
* controls — size, plus a full before-hover (rest) and after-hover (hover) value
* for every geometry parameter AND for stroke width and goo fusion — with a
* scrub/play for the hover transition and a JSON readout to copy back into the
* production component constants.
*
* Forced light (`light` wrapper): the marks use the dark brand gradient, so they
* only read on a light surface; this keeps the lab correct regardless of the
* app's active theme.
*
* Not linked from nav — internal route at /landing-preview/marks-lab.
*/
const lerp = (a: number, b: number, t: number) => a + (b - a) * t
interface ParamDef {
key: string
label: string
min: number
max: number
step: number
rest: number
hover: number
/** Structural params don't animate — one value drives both rest and hover. */
structural?: boolean
}
interface LabMark {
id: string
label: string
defaultStroke: number
defaultGoo: number
defaultSize: number
params: ParamDef[]
build: (
p: Record<string, number>,
amt: number,
pairs: Record<string, { rest: number; hover: number }>
) => string
}
type P3 = [number, number, number]
/** Rotate a model-space point about the vertical Z axis (`az`), then about the
* in-plane X axis (`ax`) for an isometric tumble. */
function rot3([x, y, z]: P3, az: number, ax: number): P3 {
const cz = Math.cos(az)
const sz = Math.sin(az)
const x1 = x * cz - y * sz
const y1 = x * sz + y * cz
const cx = Math.cos(ax)
const sx = Math.sin(ax)
return [x1, y1 * cx - z * sx, y1 * sx + z * cx]
}
/**
* Per-element transition progress. `wave` (0..1) staggers each element's hover
* across time: 0 = every element animates together, 1 = fully sequential (each
* element waits for the ones before it). This is what makes the pieces "spin
* individually at different times" as the hover scrubs 0 → 1.
*/
function elementProgress(amt: number, i: number, n: number, wave: number): number {
if (wave <= 0 || n <= 1) return amt
const span = Math.max(0.0001, 1 - wave)
const start = (i / (n - 1)) * wave
return Math.max(0, Math.min(1, (amt - start) / span))
}
function cubeEdges(s: number, ky: number, rot: number, zScale: number, zc: number): Edge[] {
const corner = (sx: number, sy: number, sz: number): Pt => {
const [rx, ry] = rotate2(sx * s, sy * s, rot)
return isoProject(rx, ry, sz * s * zScale + zc, ky)
}
const c = [
corner(-1, -1, -1),
corner(1, -1, -1),
corner(1, 1, -1),
corner(-1, 1, -1),
corner(-1, -1, 1),
corner(1, -1, 1),
corner(1, 1, 1),
corner(-1, 1, 1),
]
const ed: [number, number][] = [
[0, 1],
[1, 2],
[2, 3],
[3, 0],
[4, 5],
[5, 6],
[6, 7],
[7, 4],
[0, 4],
[1, 5],
[2, 6],
[3, 7],
]
return ed.map(([a, b]) => [c[a], c[b]] as Edge)
}
const MARKS: LabMark[] = [
{
id: 'stacked',
label: 'Stacked planes — Integrate',
defaultStroke: 1.5,
defaultGoo: 0.8,
defaultSize: 160,
params: [
{
key: 'planes',
label: 'Planes',
min: 1,
max: 5,
step: 1,
rest: 3,
hover: 3,
structural: true,
},
{
key: 'divisions',
label: 'Divisions',
min: 1,
max: 6,
step: 1,
rest: 2,
hover: 2,
structural: true,
},
{ key: 'gap', label: 'Gap', min: 0, max: 40, step: 0.5, rest: 6, hover: 20 },
{ key: 'tilt', label: 'Tilt', min: 0, max: 1, step: 0.01, rest: 0.36, hover: 0.25 },
{ key: 'spin', label: 'Spin', min: -3.14, max: 3.14, step: 0.01, rest: 0, hover: 1.2 },
],
build: (p) => {
const half = 40
const planes = Math.round(p.planes)
const div = Math.round(p.divisions)
const totalH = (planes - 1) * p.gap
const proj = (u: number, v: number, z: number): Pt => {
const [ru, rv] = rotate2(u, v, p.spin)
const pp = isoProject(ru * half, rv * half, 0, p.tilt)
return [pp[0], pp[1] + (z - totalH / 2)]
}
const E: Edge[] = []
for (let pl = 0; pl < planes; pl++) {
const z = pl * p.gap
for (let i = 0; i <= div; i++) {
const v = -1 + (2 * i) / div
E.push([proj(-1, v, z), proj(1, v, z)])
}
for (let i = 0; i <= div; i++) {
const u = -1 + (2 * i) / div
E.push([proj(u, -1, z), proj(u, 1, z)])
}
}
return edgesToPaths(E)
},
},
{
id: 'fourbox',
label: 'Four-box twist — Ingest context',
defaultStroke: 1.5,
defaultGoo: 0.8,
defaultSize: 160,
params: [
{
key: 'boxes',
label: 'Boxes',
min: 2,
max: 6,
step: 1,
rest: 4,
hover: 4,
structural: true,
},
{ key: 'gap', label: 'Gap', min: 0, max: 20, step: 0.5, rest: 2.5, hover: 9 },
{ key: 'twist', label: 'Twist (deg)', min: 0, max: 90, step: 1, rest: 14, hover: 34 },
{ key: 'spin', label: 'Spin', min: -3.14, max: 3.14, step: 0.01, rest: 0, hover: 1.2 },
{ key: 'tilt', label: 'Tilt', min: 0, max: 1, step: 0.01, rest: 0.55, hover: 0.5 },
],
build: (p) => {
const boxes = Math.round(p.boxes)
const twRad = (p.twist * Math.PI) / 180
const totalH = (boxes - 1) * p.gap
const E: Edge[] = []
for (let i = 0; i < boxes; i++) {
const zc = i * p.gap - totalH / 2
const rot = p.spin * i * 0.5 + i * twRad
E.push(...cubeEdges(1.0, p.tilt, rot, 0.4, zc))
}
return edgesToPaths(E)
},
},
{
id: 'nestedcube',
label: 'Nested cube — Monitor',
defaultStroke: 1.5,
defaultGoo: 0.8,
defaultSize: 160,
params: [
{
key: 'tilt',
label: 'Tilt',
min: 0,
max: 1,
step: 0.01,
rest: 0.5,
hover: 0.5,
structural: true,
},
{ key: 'inner', label: 'Inner scale', min: 0.1, max: 1, step: 0.01, rest: 0.42, hover: 0.9 },
{ key: 'spin', label: 'Spin', min: -3.14, max: 3.14, step: 0.01, rest: 0, hover: 1.2 },
],
build: (p) => {
const E: Edge[] = [
...cubeEdges(1.0, p.tilt, 0, 1, 0),
...cubeEdges(p.inner, p.tilt, p.spin, 1, 0),
]
return edgesToPaths(E)
},
},
{
id: 'lissajous',
label: 'Lissajous — Build',
defaultStroke: 3,
defaultGoo: 1.5,
defaultSize: 160,
params: [
{ key: 'a', label: 'Freq A', min: 1, max: 7, step: 1, rest: 3, hover: 3, structural: true },
{ key: 'b', label: 'Freq B', min: 1, max: 7, step: 1, rest: 2, hover: 2, structural: true },
{
key: 'amp',
label: 'Amplitude',
min: 10,
max: 48,
step: 1,
rest: 40,
hover: 40,
structural: true,
},
{ key: 'phase', label: 'Phase', min: 0, max: 6.28, step: 0.01, rest: 1.5708, hover: 1.9708 },
],
build: (p) => {
const fn = (t: number): Pt => [
50 + p.amp * Math.sin(p.a * t + p.phase),
50 + p.amp * Math.sin(p.b * t),
]
return toPath(normalizeReach(sampleClosed(fn)))
},
},
{
id: 'gridfloors',
label: 'Grid floors',
defaultStroke: 1.5,
defaultGoo: 0.8,
defaultSize: 160,
params: [
{ key: 'grid', label: 'Grid', min: 1, max: 5, step: 1, rest: 3, hover: 3, structural: true },
{
key: 'floors',
label: 'Floors',
min: 1,
max: 5,
step: 1,
rest: 3,
hover: 3,
structural: true,
},
{ key: 'gap', label: 'Gap', min: 0, max: 40, step: 0.5, rest: 14, hover: 26 },
{ key: 'tilt', label: 'Tilt', min: 0, max: 1, step: 0.01, rest: 0.5, hover: 0.42 },
{ key: 'spin', label: 'Spin', min: -3.14, max: 3.14, step: 0.01, rest: 0, hover: 1.2 },
{
key: 'twist',
label: 'Twist / floor',
min: -1.2,
max: 1.2,
step: 0.01,
rest: 0,
hover: 0.5,
},
{ key: 'tumble', label: 'Iso tumble', min: -1.57, max: 1.57, step: 0.01, rest: 0, hover: 0 },
{
key: 'wave',
label: 'Wave (stagger)',
min: 0,
max: 1,
step: 0.01,
rest: 0,
hover: 0.6,
structural: true,
},
],
build: (p, amt, pairs) => {
const half = 40
const div = Math.round(p.grid)
const floors = Math.round(p.floors)
const totalH = (floors - 1) * p.gap
const proj = (x: number, y: number, z: number): Pt => isoProject(x, y, z, p.tilt)
const E: Edge[] = []
const nodes: Pt[][] = []
for (let fl = 0; fl < floors; fl++) {
const ai = elementProgress(amt, fl, floors, p.wave)
const az = lerp(pairs.spin.rest, pairs.spin.hover, ai) + fl * p.twist
const ax = lerp(pairs.tumble.rest, pairs.tumble.hover, ai)
const zc = fl * p.gap - totalH / 2
const tp = (x: number, y: number): Pt => {
const r = rot3([x, y, 0], az, ax)
return proj(r[0], r[1], r[2] + zc)
}
for (let i = 0; i <= div; i++) {
const v = -1 + (2 * i) / div
E.push([tp(-half, v * half), tp(half, v * half)])
}
for (let i = 0; i <= div; i++) {
const u = -1 + (2 * i) / div
E.push([tp(u * half, -half), tp(u * half, half)])
}
const row: Pt[] = []
for (let i = 0; i <= div; i++) {
for (let j = 0; j <= div; j++) {
const u = -1 + (2 * i) / div
const v = -1 + (2 * j) / div
row.push(tp(u * half, v * half))
}
}
nodes.push(row)
}
for (let fl = 0; fl < floors - 1; fl++) {
for (let k = 0; k < nodes[fl].length; k++) {
E.push([nodes[fl][k], nodes[fl + 1][k]])
}
}
return edgesToPaths(E)
},
},
{
id: 'tunnel',
label: 'Square tunnel',
defaultStroke: 1.5,
defaultGoo: 0.8,
defaultSize: 160,
params: [
{
key: 'count',
label: 'Count',
min: 2,
max: 20,
step: 1,
rest: 12,
hover: 12,
structural: true,
},
{
key: 'sq',
label: 'Square',
min: 6,
max: 30,
step: 0.5,
rest: 18,
hover: 18,
structural: true,
},
{ key: 'step', label: 'Step', min: 2, max: 16, step: 0.5, rest: 6, hover: 10 },
{ key: 'tilt', label: 'Tilt', min: 0, max: 1, step: 0.01, rest: 0.5, hover: 0.42 },
{ key: 'spin', label: 'Spin', min: -3.14, max: 3.14, step: 0.01, rest: 0, hover: 0.6 },
{
key: 'twist',
label: 'Twist / square',
min: -1.2,
max: 1.2,
step: 0.01,
rest: 0,
hover: 0.4,
},
{ key: 'tumble', label: 'Iso tumble', min: -1.57, max: 1.57, step: 0.01, rest: 0, hover: 0 },
{
key: 'wave',
label: 'Wave (stagger)',
min: 0,
max: 1,
step: 0.01,
rest: 0,
hover: 0.6,
structural: true,
},
],
build: (p, amt, pairs) => {
const count = Math.round(p.count)
const s = p.sq
const totalU = (count - 1) * p.step
const proj = (x: number, y: number, z: number): Pt => isoProject(x, y, z, p.tilt)
const E: Edge[] = []
for (let k = 0; k < count; k++) {
const ai = elementProgress(amt, k, count, p.wave)
const az = lerp(pairs.spin.rest, pairs.spin.hover, ai) + k * p.twist
const ax = lerp(pairs.tumble.rest, pairs.tumble.hover, ai)
const u = k * p.step - totalU / 2
const tp = (y: number, z: number): Pt => {
const r = rot3([0, y, z], az, ax)
return proj(r[0] + u, r[1], r[2])
}
E.push(
[tp(-s, -s), tp(s, -s)],
[tp(s, -s), tp(s, s)],
[tp(s, s), tp(-s, s)],
[tp(-s, s), tp(-s, -s)]
)
}
return edgesToPaths(E)
},
},
{
id: 'tilegrid',
label: 'Tile grid',
defaultStroke: 1.5,
defaultGoo: 0.8,
defaultSize: 160,
params: [
{ key: 'grid', label: 'Grid', min: 2, max: 6, step: 1, rest: 3, hover: 3, structural: true },
{ key: 'inset', label: 'Tile gap', min: 0, max: 0.4, step: 0.01, rest: 0.12, hover: 0.22 },
{
key: 'tilt',
label: 'Tilt',
min: 0,
max: 1,
step: 0.01,
rest: 0.5,
hover: 0.5,
structural: true,
},
{ key: 'spin', label: 'Spin', min: -3.14, max: 3.14, step: 0.01, rest: 0, hover: 0 },
{ key: 'twist', label: 'Twist / tile', min: -1.5, max: 1.5, step: 0.01, rest: 0, hover: 0.6 },
{
key: 'tumble',
label: 'Iso tumble',
min: -1.57,
max: 1.57,
step: 0.01,
rest: 0,
hover: 0.8,
},
{
key: 'wave',
label: 'Wave (stagger)',
min: 0,
max: 1,
step: 0.01,
rest: 0,
hover: 0.7,
structural: true,
},
],
build: (p, amt, pairs) => {
const half = 40
const div = Math.round(p.grid)
const m = p.inset
const proj = (x: number, y: number, z: number): Pt => isoProject(x, y, z, p.tilt)
const E: Edge[] = []
const n = div * div
const hs = (1 - 2 * m) * (half / div)
let idx = 0
for (let i = 0; i < div; i++) {
for (let j = 0; j < div; j++) {
const ai = elementProgress(amt, idx, n, p.wave)
idx++
const az = lerp(pairs.spin.rest, pairs.spin.hover, ai) + (i + j) * p.twist * 0.5
const ax = lerp(pairs.tumble.rest, pairs.tumble.hover, ai)
const cu = (-1 + (2 * i + 1) / div) * half
const cv = (-1 + (2 * j + 1) / div) * half
const tp = (lx: number, ly: number): Pt => {
const r = rot3([lx, ly, 0], az, ax)
return proj(r[0] + cu, r[1] + cv, r[2])
}
E.push(
[tp(-hs, -hs), tp(hs, -hs)],
[tp(hs, -hs), tp(hs, hs)],
[tp(hs, hs), tp(-hs, hs)],
[tp(-hs, hs), tp(-hs, -hs)]
)
}
}
return edgesToPaths(E)
},
},
]
interface Pair {
rest: number
hover: number
}
interface GradientConfig {
from: string
to: string
cx: Pair
cy: Pair
r: Pair
}
interface MarkConfig {
params: Record<string, Pair>
stroke: Pair
goo: Pair
gradient: GradientConfig
size: number
}
function makeConfig(m: LabMark): MarkConfig {
const params: Record<string, Pair> = {}
for (const p of m.params) params[p.key] = { rest: p.rest, hover: p.hover }
return {
params,
stroke: { rest: m.defaultStroke, hover: m.defaultStroke },
goo: { rest: m.defaultGoo, hover: m.defaultGoo },
gradient: {
from: '#2C2C2C',
to: '#5F5F5F',
cx: { rest: 50, hover: 50 },
cy: { rest: 50, hover: 50 },
r: { rest: 44, hover: 44 },
},
size: m.defaultSize,
}
}
/** A config that always has an entry (and every param key) for `mark`. Guards
* against stale lab state after a mark or param is added during development. */
function ensureConfig(base: MarkConfig | undefined, mark: LabMark): MarkConfig {
const safe = base ?? makeConfig(mark)
let params = safe.params
let cloned = false
for (const def of mark.params) {
if (!params[def.key]) {
if (!cloned) {
params = { ...params }
cloned = true
}
params[def.key] = { rest: def.rest, hover: def.hover }
}
}
return cloned ? { ...safe, params } : safe
}
function initConfigs(): Record<string, MarkConfig> {
const out: Record<string, MarkConfig> = {}
for (const m of MARKS) out[m.id] = makeConfig(m)
return out
}
const panel = 'rounded-lg border border-[var(--border)] bg-[var(--surface-2)] p-4'
const sectionLabel = 'text-[12px] text-[var(--text-muted)]'
function Slider({
label,
value,
min,
max,
step,
onChange,
}: {
label: string
value: number
min: number
max: number
step: number
onChange: (v: number) => void
}) {
return (
<label className='flex items-center gap-3'>
<span className='w-[104px] flex-shrink-0 text-[12px] text-[var(--text-body)]'>{label}</span>
<input
type='range'
min={min}
max={max}
step={step}
value={value}
onChange={(e) => onChange(Number(e.target.value))}
className='h-1 flex-1 cursor-pointer accent-[var(--text-primary)]'
/>
<span className='w-[48px] flex-shrink-0 text-right font-mono text-[11px] text-[var(--text-muted)]'>
{value.toFixed(step < 1 ? 2 : 0)}
</span>
</label>
)
}
function PairRow({
label,
def,
pair,
onChange,
}: {
label: string
def: { min: number; max: number; step: number }
pair: Pair
onChange: (side: 'rest' | 'hover', v: number) => void
}) {
return (
<div className='flex flex-col gap-1.5'>
<Slider
label={`${label} ·before`}
value={pair.rest}
min={def.min}
max={def.max}
step={def.step}
onChange={(v) => onChange('rest', v)}
/>
<Slider
label={`${label} ·after`}
value={pair.hover}
min={def.min}
max={def.max}
step={def.step}
onChange={(v) => onChange('hover', v)}
/>
</div>
)
}
function ColorRow({
label,
value,
onChange,
}: {
label: string
value: string
onChange: (v: string) => void
}) {
return (
<label className='flex items-center gap-3'>
<span className='w-[104px] flex-shrink-0 text-[12px] text-[var(--text-body)]'>{label}</span>
<input
type='color'
value={value}
onChange={(e) => onChange(e.target.value)}
className='h-7 w-10 flex-shrink-0 cursor-pointer rounded border border-[var(--border)] bg-transparent'
/>
<span className='font-mono text-[11px] text-[var(--text-muted)]'>{value}</span>
</label>
)
}
export function MarksLab() {
const [configs, setConfigs] = useState<Record<string, MarkConfig>>(initConfigs)
const [markId, setMarkId] = useState(MARKS[0].id)
const [amt, setAmt] = useState(0)
const [playing, setPlaying] = useState(false)
const rafRef = useRef<number | null>(null)
const mark = MARKS.find((m) => m.id === markId) as LabMark
const cfg = ensureConfig(configs[markId], mark)
// Persist a complete config for the active mark so edits always have a target
// — covers marks/params added during development without a full reload.
useEffect(() => {
setConfigs((prev) => {
const ensured = ensureConfig(prev[markId], mark)
return prev[markId] === ensured ? prev : { ...prev, [markId]: ensured }
})
}, [markId, mark])
useEffect(() => {
if (!playing) return
const start = performance.now()
const loop = () => {
const t = (performance.now() - start) / 1000
setAmt((1 - Math.cos(t * 1.4)) / 2)
rafRef.current = requestAnimationFrame(loop)
}
rafRef.current = requestAnimationFrame(loop)
return () => {
if (rafRef.current != null) cancelAnimationFrame(rafRef.current)
}
}, [playing])
const resolved = useMemo(() => {
const p: Record<string, number> = {}
for (const def of mark.params) {
const c = cfg.params[def.key]
p[def.key] = lerp(c.rest, c.hover, amt)
}
return p
}, [mark, cfg, amt])
const strokeNow = lerp(cfg.stroke.rest, cfg.stroke.hover, amt)
const gooNow = lerp(cfg.goo.rest, cfg.goo.hover, amt)
const gradCx = lerp(cfg.gradient.cx.rest, cfg.gradient.cx.hover, amt)
const gradCy = lerp(cfg.gradient.cy.rest, cfg.gradient.cy.hover, amt)
const gradR = lerp(cfg.gradient.r.rest, cfg.gradient.r.hover, amt)
const d = mark.build(resolved, amt, cfg.params)
const setPair = (
group: 'params' | 'stroke' | 'goo',
key: string,
side: 'rest' | 'hover',
v: number
) =>
setConfigs((prev) => {
const next = structuredClone(prev)
const target = group === 'params' ? next[markId].params[key] : next[markId][group]
target[side] = v
return next
})
const setSize = (v: number) =>
setConfigs((prev) => {
const next = structuredClone(prev)
next[markId].size = v
return next
})
const setGradColor = (which: 'from' | 'to', v: string) =>
setConfigs((prev) => {
const next = structuredClone(prev)
next[markId].gradient[which] = v
return next
})
const setGradPair = (key: 'cx' | 'cy' | 'r', side: 'rest' | 'hover', v: number) =>
setConfigs((prev) => {
const next = structuredClone(prev)
next[markId].gradient[key][side] = v
return next
})
const readout = useMemo(() => {
const restGeo: Record<string, number> = {}
const hoverGeo: Record<string, number> = {}
for (const def of mark.params) {
const c = cfg.params[def.key]
restGeo[def.key] = c.rest
hoverGeo[def.key] = c.hover
}
const g = cfg.gradient
return JSON.stringify(
{
size: cfg.size,
gradient: { from: g.from, to: g.to },
REST: {
...restGeo,
stroke: cfg.stroke.rest,
gooFusion: cfg.goo.rest,
gradCx: g.cx.rest,
gradCy: g.cy.rest,
gradR: g.r.rest,
},
HOVER: {
...hoverGeo,
stroke: cfg.stroke.hover,
gooFusion: cfg.goo.hover,
gradCx: g.cx.hover,
gradCy: g.cy.hover,
gradR: g.r.hover,
},
},
null,
2
)
}, [mark, cfg])
return (
<div className='light min-h-screen bg-[var(--bg)] px-8 py-10 text-[var(--text-primary)]'>
<div className='mx-auto flex max-w-[1100px] flex-col gap-6'>
<div>
<h1 className='font-medium text-[20px]'>Brand mark lab</h1>
<p className='mt-1 text-[13px] text-[var(--text-muted)]'>
Tune each mark's before-hover and after-hover state — geometry, stroke, and goo fusion.
Scrub or play the transition, then copy the readout into the component constants.
</p>
</div>
<div className='flex flex-wrap gap-2'>
{MARKS.map((m) => (
<button
key={m.id}
type='button'
onClick={() => {
setMarkId(m.id)
setAmt(0)
setPlaying(false)
}}
className={`rounded-md px-3 py-1.5 text-[13px] transition-colors ${
m.id === markId
? 'bg-[var(--text-primary)] text-[var(--bg)]'
: 'border border-[var(--border)] bg-[var(--surface-2)] text-[var(--text-body)] hover:bg-[var(--surface-hover)]'
}`}
>
{m.label}
</button>
))}
</div>
<div className='grid grid-cols-[1fr_380px] gap-6'>
<div className='flex flex-col gap-4'>
<div className='flex min-h-[360px] items-center justify-center rounded-lg border border-[var(--border-1)] bg-[#ffffff]'>
<svg
viewBox='0 0 100 100'
width={cfg.size}
height={cfg.size}
fill='none'
style={{ display: 'block' }}
>
<defs>
<radialGradient
id='lab-grad'
gradientUnits='userSpaceOnUse'
cx={gradCx}
cy={gradCy}
r={gradR}
>
<stop stopColor={cfg.gradient.from} />
<stop offset='1' stopColor={cfg.gradient.to} />
</radialGradient>
<filter id='lab-goo' x='-25%' y='-25%' width='150%' height='150%'>
<feGaussianBlur in='SourceGraphic' stdDeviation={gooNow} result='b' />
<feColorMatrix
in='b'
type='matrix'
values='1 0 0 0 0 0 1 0 0 0 0 0 1 0 0 0 0 0 20 -9'
/>
</filter>
</defs>
<g
filter='url(#lab-goo)'
stroke='url(#lab-grad)'
strokeWidth={strokeNow}
strokeLinecap='round'
strokeLinejoin='round'
fill='none'
>
<path d={d} />
</g>
</svg>
</div>
<div className={`${panel} flex flex-col gap-3`}>
<div className='flex items-center gap-3'>
<button
type='button'
onClick={() => setPlaying((v) => !v)}
className='rounded-md bg-[var(--text-primary)] px-3 py-1.5 text-[13px] text-[var(--bg)]'
>
{playing ? 'Pause' : 'Play hover'}
</button>
<Slider
label='Hover amt'
value={amt}
min={0}
max={1}
step={0.01}
onChange={(v) => {
setPlaying(false)
setAmt(v)
}}
/>
</div>
<Slider
label='Size'
value={cfg.size}
min={60}
max={300}
step={2}
onChange={setSize}
/>
</div>
</div>
<div className='flex flex-col gap-4'>
<div className={`${panel} flex flex-col gap-3`}>
<div className={sectionLabel}>Geometry (before → after hover)</div>
{mark.params.map((def) => (
<PairRow
key={def.key}
label={def.label}
def={def}
pair={cfg.params[def.key]}
onChange={(side, v) => setPair('params', def.key, side, v)}
/>
))}
</div>
<div className={`${panel} flex flex-col gap-3`}>
<div className={sectionLabel}>Style (before → after hover)</div>
<PairRow
label='Stroke'
def={{ min: 0.5, max: 6, step: 0.1 }}
pair={cfg.stroke}
onChange={(side, v) => setPair('stroke', 'stroke', side, v)}
/>
<PairRow
label='Goo fusion'
def={{ min: 0, max: 3, step: 0.1 }}
pair={cfg.goo}
onChange={(side, v) => setPair('goo', 'goo', side, v)}
/>
</div>
<div className={`${panel} flex flex-col gap-3`}>
<div className={sectionLabel}>
Gradient — radial stops + position (moves on hover)
</div>
<ColorRow
label='From'
value={cfg.gradient.from}
onChange={(v) => setGradColor('from', v)}
/>
<ColorRow
label='To'
value={cfg.gradient.to}
onChange={(v) => setGradColor('to', v)}
/>
<PairRow
label='Center X'
def={{ min: 0, max: 100, step: 1 }}
pair={cfg.gradient.cx}
onChange={(side, v) => setGradPair('cx', side, v)}
/>
<PairRow
label='Center Y'
def={{ min: 0, max: 100, step: 1 }}
pair={cfg.gradient.cy}
onChange={(side, v) => setGradPair('cy', side, v)}
/>
<PairRow
label='Radius'
def={{ min: 6, max: 90, step: 1 }}
pair={cfg.gradient.r}
onChange={(side, v) => setGradPair('r', side, v)}
/>
</div>
<div className={`${panel} flex flex-col gap-2`}>
<div className={sectionLabel}>Readout</div>
<pre className='max-h-[240px] overflow-auto whitespace-pre-wrap rounded bg-[var(--surface-1)] p-3 font-mono text-[11px] text-[var(--text-body)]'>
{readout}
</pre>
<button
type='button'
onClick={() => navigator.clipboard?.writeText(readout)}
className='self-start rounded-md border border-[var(--border)] bg-[var(--surface-2)] px-3 py-1.5 text-[12px] text-[var(--text-body)]'
>
Copy JSON
</button>
</div>
</div>
</div>
</div>
</div>
)
}
@@ -1,10 +0,0 @@
import { MarksLab } from '@/app/landing-preview/marks-lab/marks-lab'
/**
* Internal brand-mark tuning lab. Not linked from nav — reachable at
* /landing-preview/marks-lab for dialing in mark parameters before porting the
* values into the production component constants.
*/
export default function MarksLabPage() {
return <MarksLab />
}
-22
View File
@@ -1,22 +0,0 @@
import { notFound } from 'next/navigation'
import { LandingShell } from '@/app/(landing)/components'
import Landing from '@/app/(landing)/landing'
/**
* TEMPORARY preview route — renders the new `(landing)` page at a path that
* bypasses the self-hosted `/` -> `/login` redirect (proxy only redirects `/`).
* Wrapped in {@link LandingShell} so the preview carries the exact prod chrome
* (light tokens, navbar with GitHub stars, footer, JSON-LD).
* Local/preview-only scaffold for visual iteration — 404s in production.
*/
export const dynamic = 'force-dynamic'
export default function LandingPreviewPage() {
if (process.env.NODE_ENV === 'production') notFound()
return (
<LandingShell>
<Landing />
</LandingShell>
)
}
@@ -1,670 +0,0 @@
'use client'
import { Suspense, use, useEffect, useRef, useState } from 'react'
import { ToastProvider } from '@sim/emcn'
import { type QueryClient, useQueryClient } from '@tanstack/react-query'
import { notFound } from 'next/navigation'
import { useTheme } from 'next-themes'
import { WorkspaceChrome } from '@/app/workspace/[workspaceId]/components/workspace-chrome'
import { Files } from '@/app/workspace/[workspaceId]/files/files'
import { Home } from '@/app/workspace/[workspaceId]/home/home'
import { Integrations } from '@/app/workspace/[workspaceId]/integrations/integrations'
import { Knowledge } from '@/app/workspace/[workspaceId]/knowledge/knowledge'
import Logs from '@/app/workspace/[workspaceId]/logs/logs'
import { GlobalCommandsProvider } from '@/app/workspace/[workspaceId]/providers/global-commands-provider'
import { SandboxWorkspacePermissionsProvider } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider'
import { Tables } from '@/app/workspace/[workspaceId]/tables/tables'
import Workflow from '@/app/workspace/[workspaceId]/w/[workflowId]/workflow'
import { SocketProvider } from '@/app/workspace/providers/socket-provider'
import { deploymentKeys } from '@/hooks/queries/deployments'
import { connectorKeys } from '@/hooks/queries/kb/connectors'
import { knowledgeKeys } from '@/hooks/queries/kb/knowledge'
import { type LogFilters, logKeys } from '@/hooks/queries/logs'
import { mothershipChatKeys } from '@/hooks/queries/mothership-chats'
import { sessionKeys } from '@/hooks/queries/session'
import { workspaceCredentialKeys } from '@/hooks/queries/utils/credential-keys'
import { folderKeys } from '@/hooks/queries/utils/folder-keys'
import { tableKeys } from '@/hooks/queries/utils/table-keys'
import { workflowKeys } from '@/hooks/queries/utils/workflow-keys'
import { workspaceKeys } from '@/hooks/queries/workspace'
import { workspaceFileFolderKeys } from '@/hooks/queries/workspace-file-folders'
import { workspaceFilesKeys } from '@/hooks/queries/workspace-files'
import { SIDEBAR_WIDTH } from '@/stores/constants'
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
import { useWorkflowStore } from '@/stores/workflows/workflow/store'
import type { WorkflowState } from '@/stores/workflows/workflow/types'
/**
* TEMPORARY README tour-capture route — the REAL Sim workspace (genuine
* WorkspaceChrome sidebar) with the main view switched IN PLACE as the capture
* cursor clicks sidebar nav items, so one continuous screencast shows a user
* navigating chat → integrations → knowledge → tables → files → workflow → logs.
* No auth/network (everything seeded), light mode. 404s in production
* (see the `NODE_ENV` guard below) — only reachable in dev/preview builds
* for the capture script.
*/
export const dynamic = 'force-dynamic'
const FRAME_BG = '#F8F8F8'
const CARD_BG = '#FFFFFF'
const CARD_W = 1180
const CARD_H = 720
const WS_ID = 'demo'
const WS_NAME = 'Brightwave'
const USER = { id: 'demo-user', name: 'Sam Rivera', email: 'sam@brightwave.com', image: null }
const CHAT_ID = 'demo-chat-1'
const WF_ID = 'wf1'
const KB_ID = 'kb-pricing'
type View = 'chat' | 'integrations' | 'knowledge' | 'tables' | 'files' | 'logs' | 'workflow'
const PDF = 'application/pdf'
const DOC_TOKENS = [12400, 23100, 4300, 15800, 9200]
const DEMO_WORKFLOW: WorkflowState = {
currentWorkflowId: WF_ID,
blocks: {
start: {
id: 'start',
type: 'start_trigger',
name: 'Start',
position: { x: 170, y: 60 },
subBlocks: { inputFormat: { id: 'inputFormat', type: 'input-format', value: null } },
outputs: {},
enabled: true,
horizontalHandles: false,
height: 0,
},
agent: {
id: 'agent',
type: 'agent',
name: 'Enrich lead',
position: { x: 140, y: 260 },
subBlocks: {
model: { id: 'model', type: 'dropdown', value: 'claude-opus-4-1' },
systemPrompt: {
id: 'systemPrompt',
type: 'long-input',
value: 'Enrich the lead with firmographics and a fit score.',
},
},
outputs: {},
enabled: true,
horizontalHandles: false,
height: 0,
},
slack: {
id: 'slack',
type: 'slack',
name: 'Post to #sales',
position: { x: 160, y: 540 },
subBlocks: {
operation: { id: 'operation', type: 'dropdown', value: 'send' },
channel: { id: 'channel', type: 'short-input', value: '#sales' },
},
outputs: {},
enabled: true,
horizontalHandles: false,
height: 0,
},
},
edges: [
{
id: 'e1',
source: 'start',
target: 'agent',
sourceHandle: 'source',
targetHandle: 'target',
type: 'workflowEdge',
data: {},
},
{
id: 'e2',
source: 'agent',
target: 'slack',
sourceHandle: 'source',
targetHandle: 'target',
type: 'workflowEdge',
data: {},
},
],
loops: {},
parallels: {},
lastSaved: Date.now(),
}
function makeKb(
id: string,
name: string,
description: string,
docCount: number,
tokenCount: number,
connectorTypes: string[],
iso: string
) {
return {
id,
userId: USER.id,
name,
description,
tokenCount,
embeddingModel: 'text-embedding-3-small',
embeddingDimension: 1536,
chunkingConfig: { maxSize: 1024, minSize: 100, overlap: 200 },
createdAt: iso,
updatedAt: iso,
deletedAt: null,
workspaceId: WS_ID,
docCount,
connectorTypes,
}
}
function seed(qc: QueryClient) {
if (typeof document !== 'undefined')
document.documentElement.style.setProperty('--sidebar-width', `${SIDEBAR_WIDTH.DEFAULT}px`)
qc.setQueryDefaults(sessionKeys.detail(), { staleTime: Number.POSITIVE_INFINITY })
qc.setQueryData(sessionKeys.detail(), { user: USER, session: { activeOrganizationId: null } })
qc.setQueryData(workspaceKeys.list('active'), {
workspaces: [
{
id: WS_ID,
name: WS_NAME,
color: '#525252',
ownerId: USER.id,
organizationId: null,
workspaceMode: 'personal',
permissions: 'admin',
logoUrl: '/landing/rivian-logo.svg',
},
],
lastActiveWorkspaceId: WS_ID,
creationPolicy: null,
})
qc.setQueryData(workspaceKeys.members(WS_ID), [
{ userId: USER.id, name: USER.name, email: USER.email, image: null, role: 'admin' },
])
const now = new Date()
const iso = (d: number) => new Date(now.getTime() - d * 86_400_000).toISOString()
const day = 86_400_000
qc.setQueryData(workflowKeys.list(WS_ID, 'active'), [
{
id: 'wf1',
name: 'Lead enrichment',
description: undefined,
workspaceId: WS_ID,
folderId: null,
sortOrder: 0,
createdAt: now,
lastModified: now,
archivedAt: null,
locked: false,
},
{
id: 'wf2',
name: 'Inbound lead routing',
description: undefined,
workspaceId: WS_ID,
folderId: null,
sortOrder: 1,
createdAt: now,
lastModified: now,
archivedAt: null,
locked: false,
},
{
id: 'wf3',
name: 'Weekly pipeline report',
description: undefined,
workspaceId: WS_ID,
folderId: null,
sortOrder: 2,
createdAt: now,
lastModified: now,
archivedAt: null,
locked: false,
},
])
qc.setQueryData(folderKeys.list(WS_ID, 'active'), [])
qc.setQueryData(workspaceCredentialKeys.list(WS_ID), [])
qc.setQueryData(mothershipChatKeys.list(WS_ID), [
{
id: CHAT_ID,
name: 'Enrich new signups',
updatedAt: now,
isActive: false,
isUnread: false,
isPinned: false,
},
{
id: 'c2',
name: 'Post deal alerts to #sales',
updatedAt: now,
isActive: false,
isUnread: false,
isPinned: false,
},
])
qc.setQueryData(deploymentKeys.info(WF_ID), {
isDeployed: false,
needsRedeployment: false,
deployedAt: null,
})
qc.setQueryData(deploymentKeys.deployedState(WF_ID), DEMO_WORKFLOW)
useWorkflowStore.getState().setCurrentWorkflowId(WF_ID)
useWorkflowStore.getState().replaceWorkflowState(DEMO_WORKFLOW)
useWorkflowRegistry.setState({
activeWorkflowId: WF_ID,
hydration: {
phase: 'ready',
workspaceId: WS_ID,
workflowId: WF_ID,
requestId: null,
error: null,
},
})
// Tables
qc.setQueryData(tableKeys.list(WS_ID, 'active'), [
{
id: 'leads',
name: 'Leads',
description: 'Sales leads with enrichment',
schema: {
columns: [
{ id: 'c_name', name: 'Name', type: 'string' },
{ id: 'c_email', name: 'Email', type: 'email' },
{ id: 'c_company', name: 'Company', type: 'string' },
{ id: 'c_score', name: 'Fit score', type: 'number' },
{ id: 'c_status', name: 'Status', type: 'string' },
],
workflowGroups: [],
},
metadata: null,
rowCount: 128,
maxRows: 5000,
workspaceId: WS_ID,
createdBy: USER.id,
archivedAt: null,
createdAt: new Date(now.getTime() - 9 * day),
updatedAt: now,
},
{
id: 'enriched',
name: 'Enriched signups',
description: null,
schema: {
columns: [
{ id: 'c_email', name: 'Email', type: 'email' },
{ id: 'c_domain', name: 'Domain', type: 'string' },
{ id: 'c_rev', name: 'Est. revenue', type: 'string' },
],
workflowGroups: [],
},
metadata: null,
rowCount: 342,
maxRows: 5000,
workspaceId: WS_ID,
createdBy: USER.id,
archivedAt: null,
createdAt: new Date(now.getTime() - 7 * day),
updatedAt: new Date(now.getTime() - 2 * day),
},
{
id: 'accounts',
name: 'Target accounts',
description: null,
schema: {
columns: [
{ id: 'c_acct', name: 'Account', type: 'string' },
{ id: 'c_tier', name: 'Tier', type: 'string' },
{ id: 'c_owner', name: 'Owner', type: 'string' },
],
workflowGroups: [],
},
metadata: null,
rowCount: 64,
maxRows: 5000,
workspaceId: WS_ID,
createdBy: USER.id,
archivedAt: null,
createdAt: new Date(now.getTime() - 20 * day),
updatedAt: new Date(now.getTime() - 5 * day),
},
])
// Files
const mkFile = (id: string, name: string, type: string, size: number, days: number) => ({
id,
workspaceId: WS_ID,
name,
key: `workspace/${WS_ID}/${id}`,
path: `/serve/workspace/${WS_ID}/${id}`,
size,
type,
uploadedBy: USER.id,
folderId: null,
folderPath: null,
uploadedAt: new Date(now.getTime() - days * day),
updatedAt: new Date(now.getTime() - days * day),
storageContext: 'workspace',
share: null,
})
qc.setQueryData(workspaceFilesKeys.list(WS_ID, 'active'), [
mkFile('file1', 'Pricing & Sales Playbook.pdf', PDF, 2_400_000, 3),
mkFile('file2', 'Competitor Battlecards.pdf', PDF, 1_800_000, 6),
mkFile(
'file3',
'Q4 Pipeline.xlsx',
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
1_200_000,
9
),
mkFile(
'file4',
'ICP & Account Research.docx',
'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
640_000,
14
),
])
qc.setQueryData(workspaceFileFolderKeys.list(WS_ID, 'active'), [])
// Logs
const logFilters: LogFilters = {
timeRange: 'All time',
startDate: undefined,
endDate: undefined,
level: 'all',
workflowIds: [],
folderIds: [],
triggers: [],
searchQuery: '',
limit: 50,
sortBy: 'date',
sortOrder: 'desc',
}
const mkLog = (
id: string,
status: string,
level: string,
duration: string,
trigger: string,
mins: number,
wfName: string,
wfId: string
) => ({
id,
workflowId: wfId,
executionId: `exec-${id}`,
deploymentVersionId: null,
deploymentVersion: null,
deploymentVersionName: null,
level,
status,
duration,
trigger,
createdAt: new Date(now.getTime() - mins * 60_000).toISOString(),
workflow: { id: wfId, name: wfName, description: undefined },
jobTitle: null,
cost: { total: 0.0231 },
pauseSummary: { status: null, total: 0, resumed: 0 },
hasPendingPause: false,
})
qc.setQueryData(logKeys.list(WS_ID, logFilters), {
pages: [
{
logs: [
mkLog('l1', 'success', 'info', 'PT2.3S', 'webhook', 4, 'Lead enrichment', 'wf1'),
mkLog('l2', 'success', 'info', 'PT1.9S', 'webhook', 26, 'Lead enrichment', 'wf1'),
mkLog('l3', 'success', 'info', 'PT3.1S', 'schedule', 92, 'Weekly pipeline report', 'wf3'),
mkLog('l4', 'error', 'error', 'PT0.8S', 'webhook', 140, 'Inbound lead routing', 'wf2'),
mkLog('l5', 'success', 'info', 'PT2.6S', 'manual', 180, 'Lead enrichment', 'wf1'),
],
nextCursor: null,
},
],
pageParams: [null],
})
// Knowledge base
qc.setQueryDefaults(knowledgeKeys.all, {
staleTime: Number.POSITIVE_INFINITY,
gcTime: Number.POSITIVE_INFINITY,
})
const pricingTokens = DOC_TOKENS.reduce((a, b) => a + b, 0)
qc.setQueryData(knowledgeKeys.list(WS_ID, 'active'), [
makeKb(
KB_ID,
'Pricing & Sales Playbooks',
'Pricing, packaging, and deal-desk references',
7,
pricingTokens,
[],
iso(6)
),
makeKb(
'kb-battlecards',
'Competitor Battlecards',
'Win/loss intel and objection handling',
5,
188000,
['google_drive'],
iso(12)
),
makeKb(
'kb-icp',
'ICP & Account Research',
'Ideal customer profiles and territory notes',
9,
142000,
[],
iso(20)
),
])
qc.setQueryData(knowledgeKeys.tagDefinitions(KB_ID), [])
qc.setQueryData(connectorKeys.list(KB_ID), [])
qc.setQueryData(mothershipChatKeys.detail(CHAT_ID), {
id: CHAT_ID,
title: 'Enrich new signups',
messages: [
{
id: 'm1',
role: 'user',
content: 'When a new lead signs up, enrich it with company data and post it to #sales.',
timestamp: now.toISOString(),
},
{
id: 'm2',
role: 'assistant',
content:
"On it. I'll build a workflow that enriches each new signup with firmographics, scores it, and posts a summary to your #sales channel in Slack.",
timestamp: new Date(now.getTime() + 1200).toISOString(),
},
],
activeStreamId: null,
resources: [{ type: 'workflow', id: WF_ID, title: 'Lead enrichment' }],
})
}
interface CapturePageProps {
searchParams: Promise<{
w?: string
h?: string
view?: string
cardW?: string
cardH?: string
bare?: string
}>
}
export default function ReadmeTourCapturePage({ searchParams }: CapturePageProps) {
if (process.env.NODE_ENV === 'production') notFound()
const params = use(searchParams)
const camW = Number(params.w ?? 1280)
const camH = Number(params.h ?? 800)
const cardW = Number(params.cardW ?? CARD_W)
const cardH = Number(params.cardH ?? CARD_H)
const bare = params.bare === '1'
const queryClient = useQueryClient()
const { setTheme } = useTheme()
const [view, setView] = useState<View>((params.view as View) || 'chat')
const cameraRef = useRef<HTMLDivElement>(null)
const cardRef = useRef<HTMLDivElement>(null)
const seededRef = useRef(false)
// Seed the query cache once, synchronously, before first paint - a useEffect
// would flash an empty workspace first. A useState lazy initializer would
// do the same but re-runs the store mutation on every Strict Mode
// double-invoke; this ref guard makes the seed idempotent instead.
if (!seededRef.current) {
seededRef.current = true
seed(queryClient)
}
useEffect(() => {
setTheme('light')
const prevBody = document.body.style.background
document.body.style.background = FRAME_BG
document.documentElement.style.background = FRAME_BG
// Intercept sidebar nav clicks → switch the in-place view (no real routing).
const hrefToView = (href: string): View | null => {
if (/\/integrations(\b|\/|\?)/.test(href)) return 'integrations'
if (/\/tables(\b|\/|\?)/.test(href)) return 'tables'
if (/\/files(\b|\/|\?)/.test(href)) return 'files'
if (/\/knowledge(\b|\/|\?)/.test(href)) return 'knowledge'
if (/\/logs(\b|\/|\?)/.test(href)) return 'logs'
if (/\/w\//.test(href)) return 'workflow'
if (/\/workspace\/[^/]+\/?($|\?)/.test(href) || /\/home(\b|\/|\?)/.test(href)) return 'chat'
return null
}
const onClick = (e: MouseEvent) => {
const a = (e.target as HTMLElement)?.closest?.('a[href]') as HTMLAnchorElement | null
if (!a) return
const v = hrefToView(a.getAttribute('href') || '')
if (v) {
e.preventDefault()
e.stopPropagation()
setView(v)
}
}
document.addEventListener('click', onClick, true)
// double-cast-allowed: dev-only capture harness exposes imperative hooks on window for Playwright
const w = window as unknown as {
__setCamera?: (s: number, tx: number, ty: number) => void
__cardSize?: () => { w: number; h: number }
__deploy?: () => void
__setView?: (v: View) => void
}
w.__setCamera = (s, tx, ty) => {
const cam = cameraRef.current
if (cam) cam.style.transform = `translate(${tx}px, ${ty}px) scale(${s})`
}
w.__cardSize = () => ({
w: cardRef.current?.offsetWidth ?? CARD_W,
h: cardRef.current?.offsetHeight ?? CARD_H,
})
w.__setView = (v) => setView(v)
w.__deploy = () =>
queryClient.setQueryData(deploymentKeys.info(WF_ID), {
isDeployed: true,
needsRedeployment: false,
deployedAt: new Date().toISOString(),
})
return () => {
document.body.style.background = prevBody
document.removeEventListener('click', onClick, true)
w.__setCamera = undefined
w.__cardSize = undefined
w.__deploy = undefined
w.__setView = undefined
}
}, [setTheme, queryClient])
return (
<div
className='light'
data-capture-stage
style={{
width: `${camW}px`,
height: `${camH}px`,
background: FRAME_BG,
position: 'relative',
overflow: 'hidden',
}}
>
<div
ref={cameraRef}
style={{
position: 'absolute',
left: 0,
top: 0,
transformOrigin: '0 0',
transform: 'translate(0px,0px) scale(1)',
}}
>
<div
ref={cardRef}
data-app-card
style={{
width: `${cardW}px`,
height: `${cardH}px`,
background: CARD_BG,
border: bare ? 'none' : '1px solid #E9E9E9',
boxShadow: bare ? 'none' : '0px 1px 2px rgba(0,0,0,0.12)',
borderRadius: bare ? 0 : '24px',
overflow: 'hidden',
position: 'relative',
display: 'flex',
flexDirection: 'column',
}}
>
<SocketProvider>
<ToastProvider>
<GlobalCommandsProvider>
<SandboxWorkspacePermissionsProvider>
<WorkspaceChrome>
{view === 'workflow' ? (
<Workflow workspaceId={WS_ID} workflowId={WF_ID} embedded />
) : view === 'integrations' ? (
<Suspense fallback={null}>
<Integrations />
</Suspense>
) : view === 'knowledge' ? (
<Suspense fallback={null}>
<Knowledge />
</Suspense>
) : view === 'tables' ? (
<Suspense fallback={null}>
<Tables />
</Suspense>
) : view === 'files' ? (
<Suspense fallback={null}>
<Files />
</Suspense>
) : view === 'logs' ? (
<Suspense fallback={null}>
<Logs />
</Suspense>
) : (
<Suspense fallback={null}>
<Home chatId={CHAT_ID} userName={USER.name} userId={USER.id} />
</Suspense>
)}
</WorkspaceChrome>
</SandboxWorkspacePermissionsProvider>
</GlobalCommandsProvider>
</ToastProvider>
</SocketProvider>
</div>
</div>
</div>
)
}
@@ -245,36 +245,3 @@ export function useUserPermissionsContext(): WorkspaceUserPermissions & {
const { userPermissions } = useWorkspacePermissionsContext()
return userPermissions
}
/**
* Lightweight permissions provider for sandbox/capture contexts (the
* landing-preview capture harness). Grants full edit access without any API
* calls or workspace dependencies.
*/
export function SandboxWorkspacePermissionsProvider({ children }: { children: React.ReactNode }) {
const sandboxPermissions = useMemo(
(): WorkspacePermissionsContextType => ({
workspacePermissions: null,
permissionsLoading: false,
permissionsError: null,
updatePermissions: () => {},
refetchPermissions: async () => {},
userPermissions: {
canRead: true,
canEdit: true,
canAdmin: false,
userPermissions: 'write',
isLoading: false,
error: null,
isOfflineMode: false,
},
}),
[]
)
return (
<WorkspacePermissionsContext.Provider value={sandboxPermissions}>
{children}
</WorkspacePermissionsContext.Provider>
)
}
+10
View File
@@ -60,6 +60,12 @@ const STATIC_SCRIPT_SRC = [
'https://www.googletagmanager.com',
'https://www.google-analytics.com',
'https://analytics.ahrefs.com',
// HubSpot tracking (landing pages) — loader plus the
// analytics/form-tracking/banner scripts it injects as <script> tags
'https://*.hs-scripts.com',
'https://*.hs-analytics.net',
'https://*.hscollectedforms.net',
'https://*.hs-banner.com',
]
: []),
] as const
@@ -96,6 +102,10 @@ const STATIC_CONNECT_SRC = [
'https://www.google.com',
'https://analytics.ahrefs.com',
'https://*.g.doubleclick.net',
// HubSpot tracking — form-tracking API (hscollectedforms.js).
// The visitor beacon itself is an image pixel (img-src, already
// permitted below), not a connect-src request.
'https://*.hscollectedforms.net',
]
: []),
] as const