mirror of
https://github.com/simstudioai/sim.git
synced 2026-09-24 15:45:35 +08:00
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:
@@ -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
|
||||
}
|
||||
@@ -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 />
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user