diff --git a/apps/web/biome.json b/apps/web/biome.json index 6d81bbe1..e5808d2a 100644 --- a/apps/web/biome.json +++ b/apps/web/biome.json @@ -21,7 +21,13 @@ "enabled": true, "indentStyle": "tab" }, - "assist": { "actions": { "source": { "organizeImports": "on" } } }, + "assist": { + "actions": { + "source": { + "organizeImports": "on" + } + } + }, "linter": { "enabled": true, "rules": { @@ -43,7 +49,8 @@ { "includes": [ "**/components/dither-kit/**/*.tsx", - "**/routes/dither-kit.tsx" + "**/routes/dither-kit.tsx", + "**/components/layout/landing/retro-computer.tsx" ], "linter": { "rules": { @@ -54,7 +61,9 @@ } }, { - "includes": ["**/components/icons/**/*.tsx"], + "includes": [ + "**/components/icons/**/*.tsx" + ], "linter": { "rules": { "correctness": { @@ -64,7 +73,9 @@ } }, { - "includes": ["**/packages/ui/src/icons/**/*.tsx"], + "includes": [ + "**/packages/ui/src/icons/**/*.tsx" + ], "linter": { "rules": { "correctness": { diff --git a/apps/web/public/wallpapers/win98.jpg b/apps/web/public/wallpapers/win98.jpg new file mode 100644 index 00000000..65af7cbc Binary files /dev/null and b/apps/web/public/wallpapers/win98.jpg differ diff --git a/apps/web/src/components/layout/landing/cloud-eye.tsx b/apps/web/src/components/layout/landing/cloud-eye.tsx new file mode 100644 index 00000000..031b1c71 --- /dev/null +++ b/apps/web/src/components/layout/landing/cloud-eye.tsx @@ -0,0 +1,390 @@ +"use client" + +import { Mesh, Program, Renderer, Texture, Triangle } from "ogl" +import { useEffect, useRef } from "react" +import { + TRIPWIRE_EYE_OUTER_PATH, + TRIPWIRE_EYE_OUTER_VIEWBOX, + TRIPWIRE_EYE_PUPIL_PATH, + TRIPWIRE_EYE_PUPIL_RECT_IN_OUTER, + TRIPWIRE_EYE_PUPIL_VIEWBOX, + TRIPWIRE_EYE_SOCKET_PATH, + TRIPWIRE_EYE_SOCKET_RECT_IN_OUTER, + TRIPWIRE_EYE_SOCKET_VIEWBOX, +} from "@tripwire/ui/icons/tripwire-eye" + +/** + * The tripwire eye as a cumulus cloud drifting in the Bliss sky, following + * the cursor. The eye silhouette is rasterized into a blurred density field + * and re-shaped every frame by domain-warped fbm noise, so the edges billow + * and wisp like the real thing. + * + * How the cloud look is built (each maps to a real cloud property): + * - shape: cumulus = rounded billows. The blurred mask is sampled through a + * noise warp, so the silhouette bulges and cauliflowers instead of showing + * a hard logo stamp. + * - edges: alpha is a smoothstep over density — soft but defined boundaries, + * with low-density wisps eroding off the fringe. + * - lighting: clouds are lit by scattering. Density is compared against a + * sample nudged toward the sun (upper-left in Bliss): thick-above points + * fall into self-shadow toward a gray-blue base, thin sunward edges pick up + * a silver lining. + * - motion: the noise field advects slowly with time (internal churn) and a + * wind vector fed by cursor velocity skews the warp, so the cloud drags and + * stretches while it travels, then relaxes when it settles. + * + * The pupil is its own density blob that leans toward the live cursor inside + * the socket, so the cloud still *looks at* your pointer. + */ + +const EYE_ASPECT = TRIPWIRE_EYE_OUTER_VIEWBOX[1] / TRIPWIRE_EYE_OUTER_VIEWBOX[0] +// The eye is drawn at this fraction of its texture so warped samples stay +// inside the bitmap (padding for the billow amplitude). +const PAD_SCALE = 0.62 + +const vertex = /* glsl */ ` +attribute vec2 uv; +attribute vec2 position; +varying vec2 vUv; +void main() { + vUv = uv; + gl_Position = vec4(position, 0.0, 1.0); +} +` + +const fragment = /* glsl */ ` +precision highp float; + +uniform float uTime; +uniform vec2 uResolution; // device px +uniform vec2 uMouse; // device px, smoothed, y-up +uniform vec2 uWind; // smoothed cursor velocity, roughly -1..1 +uniform vec2 uPupil; // pupil lean inside the socket, uv units +uniform float uEyeW; // eye quad width in device px +uniform float uHide; // 0 visible → 1 sunk away +uniform sampler2D uBody; // blurred eye body (outer minus socket) +uniform sampler2D uPupilTex;// blurred pupil, same texture space + +varying vec2 vUv; + +// -- value noise + fbm ------------------------------------------------ +float hash(vec2 p) { + p = fract(p * vec2(123.34, 345.45)); + p += dot(p, p + 34.345); + return fract(p.x * p.y); +} + +float vnoise(vec2 p) { + vec2 i = floor(p); + vec2 f = fract(p); + f = f * f * (3.0 - 2.0 * f); + float a = hash(i); + float b = hash(i + vec2(1.0, 0.0)); + float c = hash(i + vec2(0.0, 1.0)); + float d = hash(i + vec2(1.0, 1.0)); + return mix(mix(a, b, f.x), mix(c, d, f.x), f.y); +} + +float fbm(vec2 p) { + float v = 0.0; + float amp = 0.5; + mat2 rot = mat2(0.8, -0.6, 0.6, 0.8); // rotate octaves to hide the grid + for (int i = 0; i < 5; i++) { + v += amp * vnoise(p); + p = rot * p * 2.0; + amp *= 0.5; + } + return v; +} + +// -- the cloud's density field ---------------------------------------- +float density(vec2 uv) { + // internal churn: two warp fields scrolling at different rates gives the + // slow tumbling parallax of a real cumulus + vec2 q = vec2( + fbm(uv * 2.2 + vec2(0.0, uTime * 0.10)), + fbm(uv * 2.2 + vec2(5.2, uTime * 0.13)) + ); + vec2 warp = (q - 0.5) * 0.30; + // fine crinkle on top of the big lobes + warp += (vec2( + fbm(uv * 7.0 + vec2(uTime * 0.20, 3.1)), + fbm(uv * 7.0 + vec2(8.4, uTime * 0.17)) + ) - 0.5) * 0.045; + + // wind drag: samples shift against travel, more on the trailing half, so + // the cloud stretches behind its own motion + warp -= uWind * (0.35 + 0.55 * q.y) * 0.18; + + vec2 p = uv + warp; + float body = texture2D(uBody, p).r; + // the pupil leans toward the live cursor but billows with the same warp + float pupil = texture2D(uPupilTex, p - uPupil).r; + float d = (body + pupil) * 1.35; + + // cauliflower lobes: ridged fbm modulates thickness so the surface reads + // as clustered billows rather than one smooth blob + float ridged = 1.0 - abs(2.0 * fbm(uv * 5.0 + vec2(uTime * 0.05, -uTime * 0.04)) - 1.0); + d *= 0.78 + 0.32 * ridged; + + // fringe erosion: a low-frequency field carves detached wisps at the edge + float erode = fbm(uv * 2.0 - vec2(uTime * 0.03, 0.0)); + d -= smoothstep(0.55, 1.0, erode) * 0.12; + + return d; +} + +void main() { + // place the eye quad at the (smoothed) cursor + vec2 halfSize = vec2(uEyeW, uEyeW * ${EYE_ASPECT.toFixed(4)}) * 0.5; + vec2 uv = (gl_FragCoord.xy - uMouse) / (halfSize * 2.0) + 0.5; + if (uv.x < 0.0 || uv.x > 1.0 || uv.y < 0.0 || uv.y > 1.0) discard; + // texture y runs down, screen y runs up + uv.y = 1.0 - uv.y; + + float d = density(uv); + float alpha = smoothstep(0.35, 0.54, d); + if (alpha < 0.003) discard; + + // scattering: if the cloud thickens toward the sun the point is buried + // (self-shadow); if it thins, we're on the sunlit surface + vec2 sunDir = normalize(vec2(-0.45, -0.70)) * 0.05; // upper-left, tex space + float dSun = density(uv + sunDir); + // mostly sunlit white; self-shadow only where density really piles up + float lit = clamp(0.74 + (d - dSun) * 1.8, 0.0, 1.0); + // undersides sit in their own shadow (texture y runs down) + lit *= 1.0 - 0.24 * smoothstep(0.45, 0.95, uv.y); + + vec3 baseGray = vec3(0.78, 0.81, 0.86); // sky-ambient shade, not charcoal + vec3 sunWhite = vec3(1.0); + vec3 col = mix(baseGray, sunWhite, lit); + + // cottony surface mottle — tiny brightness grain, the cauliflower texture + col *= 0.95 + 0.09 * fbm(uv * 8.0 + vec2(uTime * 0.06, -uTime * 0.04)); + + // silver lining: thin sun-facing fringe transmits light + float rim = smoothstep(0.32, 0.44, d) * (1.0 - smoothstep(0.44, 0.68, d)); + col += rim * 0.10; + + gl_FragColor = vec4(min(col, vec3(1.0)), alpha * 0.97 * (1.0 - uHide)); +} +` + +/** Rasterize SVG path layers into a padded, blurred density texture. */ +function rasterizeMask( + layers: { + path: string + viewBox: readonly [number, number] + rect: readonly [number, number, number, number] + mode: "add" | "subtract" + }[], + blurPx: number +) { + const [vbW, vbH] = TRIPWIRE_EYE_OUTER_VIEWBOX + const w = 512 + const h = Math.round((w * vbH) / vbW) + const canvas = document.createElement("canvas") + canvas.width = w + canvas.height = h + const ctx = canvas.getContext("2d") + if (!ctx) return canvas + // draw at PAD_SCALE around the centre so billows never sample off-texture + const sx = (w / vbW) * PAD_SCALE + const sy = (h / vbH) * PAD_SCALE + const ox = (w * (1 - PAD_SCALE)) / 2 + const oy = (h * (1 - PAD_SCALE)) / 2 + ctx.filter = `blur(${blurPx}px)` + for (const layer of layers) { + ctx.save() + ctx.globalCompositeOperation = + layer.mode === "add" ? "source-over" : "destination-out" + ctx.fillStyle = "white" + const [rx, ry, rw, rh] = layer.rect + const [pw, ph] = layer.viewBox + ctx.translate(ox + rx * sx, oy + ry * sy) + ctx.scale((rw / pw) * sx, (rh / ph) * sy) + ctx.fill(new Path2D(layer.path)) + ctx.restore() + } + // the shader reads .r — flatten alpha into luminance on black + const flat = document.createElement("canvas") + flat.width = w + flat.height = h + const fctx = flat.getContext("2d") + if (!fctx) return canvas + fctx.fillStyle = "black" + fctx.fillRect(0, 0, w, h) + fctx.drawImage(canvas, 0, 0) + return flat +} + +export function CloudEye({ + size = 300, + hidden = false, +}: { + size?: number + /** Sinks the cloud down and fades it out (e.g. while the demo owns the + * visitor's cursor) — eased in the render loop so it drifts, not pops. */ + hidden?: boolean +}) { + const containerRef = useRef(null) + const hiddenRef = useRef(hidden) + useEffect(() => { + hiddenRef.current = hidden + }, [hidden]) + + useEffect(() => { + const container = containerRef.current + if (!container) return + + const renderer = new Renderer({ alpha: true, premultipliedAlpha: false }) + const gl = renderer.gl + gl.clearColor(0, 0, 0, 0) + + const bodyCanvas = rasterizeMask( + [ + { + path: TRIPWIRE_EYE_OUTER_PATH, + viewBox: TRIPWIRE_EYE_OUTER_VIEWBOX, + rect: [ + 0, + 0, + TRIPWIRE_EYE_OUTER_VIEWBOX[0], + TRIPWIRE_EYE_OUTER_VIEWBOX[1], + ], + mode: "add", + }, + { + path: TRIPWIRE_EYE_SOCKET_PATH, + viewBox: TRIPWIRE_EYE_SOCKET_VIEWBOX, + rect: TRIPWIRE_EYE_SOCKET_RECT_IN_OUTER, + mode: "subtract", + }, + ], + 13 + ) + const pupilCanvas = rasterizeMask( + [ + { + path: TRIPWIRE_EYE_PUPIL_PATH, + viewBox: TRIPWIRE_EYE_PUPIL_VIEWBOX, + rect: TRIPWIRE_EYE_PUPIL_RECT_IN_OUTER, + mode: "add", + }, + ], + 7 + ) + const texOpts = { generateMipmaps: false } as const + const bodyTex = new Texture(gl, { image: bodyCanvas, ...texOpts }) + const pupilTex = new Texture(gl, { image: pupilCanvas, ...texOpts }) + + const dpr = Math.min(window.devicePixelRatio || 1, 2) + const program = new Program(gl, { + vertex, + fragment, + transparent: true, + uniforms: { + uTime: { value: 0 }, + uResolution: { value: new Float32Array([1, 1]) }, + uMouse: { value: new Float32Array([0, 0]) }, + uWind: { value: new Float32Array([0, 0]) }, + uPupil: { value: new Float32Array([0, 0]) }, + uEyeW: { value: size * dpr }, + uHide: { value: 0 }, + uBody: { value: bodyTex }, + uPupilTex: { value: pupilTex }, + }, + }) + const mesh = new Mesh(gl, { geometry: new Triangle(gl), program }) + container.appendChild(gl.canvas) + gl.canvas.style.width = "100%" + gl.canvas.style.height = "100%" + gl.canvas.style.display = "block" + + const resize = () => { + renderer.dpr = dpr + renderer.setSize(container.offsetWidth, container.offsetHeight) + const res = program.uniforms.uResolution.value as Float32Array + res[0] = gl.canvas.width + res[1] = gl.canvas.height + } + const ro = new ResizeObserver(resize) + ro.observe(container) + resize() + + // raw + smoothed cursor, in device px with y up (gl_FragCoord space) + const raw = { + x: container.offsetWidth * 0.5 * dpr, + y: container.offsetHeight * 0.62 * dpr, + } + const smooth = { x: raw.x, y: raw.y } + let hide = 0 + const wind = { x: 0, y: 0 } + const pupil = { x: 0, y: 0 } + + const onMove = (e: MouseEvent) => { + const rect = container.getBoundingClientRect() + raw.x = (e.clientX - rect.left) * dpr + raw.y = (rect.height - (e.clientY - rect.top)) * dpr + } + window.addEventListener("mousemove", onMove) + + let raf = 0 + const update = (t: number) => { + raf = requestAnimationFrame(update) + const time = t * 0.001 + + // idle drift so the cloud never sits frozen in the sky + const driftX = Math.sin(time * 0.4) * 10 * dpr + const driftY = Math.cos(time * 0.27) * 7 * dpr + + const prevX = smooth.x + const prevY = smooth.y + smooth.x += (raw.x + driftX - smooth.x) * 0.055 + smooth.y += (raw.y + driftY - smooth.y) * 0.055 + + // wind = smoothed velocity; eased again so gusts decay naturally + const vx = (smooth.x - prevX) / dpr + const vy = (smooth.y - prevY) / dpr + wind.x += (Math.max(-1, Math.min(1, vx * 0.12)) - wind.x) * 0.06 + wind.y += (Math.max(-1, Math.min(1, vy * 0.12)) - wind.y) * 0.06 + + // the pupil leans toward where the live cursor actually is + const lookX = (raw.x - smooth.x) / (size * dpr) + const lookY = (raw.y - smooth.y) / (size * dpr) + const lookLen = Math.hypot(lookX, lookY) || 1 + const capped = Math.min(lookLen, 0.35) + pupil.x += ((lookX / lookLen) * capped * 0.13 - pupil.x) * 0.08 + // texture y is flipped relative to screen y + pupil.y += (-(lookY / lookLen) * capped * 0.13 - pupil.y) * 0.08 + + hide += ((hiddenRef.current ? 1 : 0) - hide) * 0.07 + + program.uniforms.uTime.value = time + program.uniforms.uHide.value = hide + const mu = program.uniforms.uMouse.value as Float32Array + mu[0] = smooth.x + // sinking: the anchor drifts down (gl y is up) as the cloud fades + mu[1] = smooth.y - hide * 320 * dpr + const wu = program.uniforms.uWind.value as Float32Array + wu[0] = wind.x + wu[1] = wind.y + const pu = program.uniforms.uPupil.value as Float32Array + pu[0] = pupil.x + pu[1] = pupil.y + + renderer.render({ scene: mesh }) + } + raf = requestAnimationFrame(update) + + return () => { + cancelAnimationFrame(raf) + window.removeEventListener("mousemove", onMove) + ro.disconnect() + gl.canvas.remove() + gl.getExtension("WEBGL_lose_context")?.loseContext() + } + }, [size]) + + return
+} diff --git a/apps/web/src/components/layout/landing/demo-screen.tsx b/apps/web/src/components/layout/landing/demo-screen.tsx new file mode 100644 index 00000000..c886c860 --- /dev/null +++ b/apps/web/src/components/layout/landing/demo-screen.tsx @@ -0,0 +1,408 @@ +"use client" + +import { AnimatePresence, animate, motion, useMotionValue } from "motion/react" +import { useEffect, useRef, useState } from "react" +import { AnalyticsScene } from "./demo/analytics-scene" +import { AutomodScene } from "./demo/automod-scene" +import { + type DemoPage, + type DemoRoute, + type Metric, + TopNav, +} from "./demo/chrome" +import { QUEUE_ITEMS, type QueueItem, RULES, type Rule } from "./demo/data" +import { IntegrationsScene } from "./demo/integrations-scene" +import { QueueScene } from "./demo/queue-scene" +import { ModerationDetail, PanelGrid, RuleDetail } from "./demo/side-panel" +import { type DemoTheme, themeVars } from "./demo/theme" + +/** + * The picture on the retro computer's CRT: a working miniature of modkit. + * Four real pages (queue, automod, analytics, integrations) with the app's + * actual interactions — sort toggles, filter chips, the sliding detail + * panel, chart-commit clicks, the metrics sheet, pagination, and a theme + * toggle scoped to the glass. + * + * A tour cursor walks the nav on its own; the visitor's mouse takes over on + * hover and control lingers until 5s of stillness. Rendered at 2× and scaled + * to 0.5 so type stays crisp on the tube. + */ + +/* ---------------------------------------------------------------- tour */ + +const TOUR_PAGES: DemoPage[] = ["queue", "automod", "analytics", "integrations"] + +// Cursor waypoints in the 2× render's pixel space. +const NAV_X: Record = { + queue: 120, + automod: 208, + analytics: 316, + integrations: 430, +} +const NAV_Y = 24 +const REST: Record = { + queue: { x: 430, y: 330 }, + automod: { x: 250, y: 310 }, + analytics: { x: 470, y: 270 }, + integrations: { x: 380, y: 320 }, +} + +const SCENE_MS = 4600 +const CLICK_MS = 900 + +function routeFor(page: DemoPage): DemoRoute { + return page === "analytics" + ? { page, source: "moderation", metric: "pending" } + : ({ page } as DemoRoute) +} + +function useTour(reduceMotion: boolean, paused: boolean) { + const [route, setRoute] = useState({ page: "queue" }) + const [phase, setPhase] = useState<"resting" | "clicking">("resting") + + const goTo = (r: DemoRoute) => { + setRoute(r) + setPhase("resting") + } + + useEffect(() => { + if (paused) return + const advance = () => + setRoute((r) => { + const i = TOUR_PAGES.indexOf(r.page) + return routeFor(TOUR_PAGES[(i + 1) % TOUR_PAGES.length]) + }) + if (reduceMotion) { + const t = setInterval(advance, SCENE_MS) + return () => clearInterval(t) + } + let cancelled = false + let timer: ReturnType + const rest = () => { + setPhase("resting") + timer = setTimeout(click, SCENE_MS - CLICK_MS) + } + const click = () => { + if (cancelled) return + setPhase("clicking") + timer = setTimeout(() => { + if (cancelled) return + advance() + rest() + }, CLICK_MS) + } + rest() + return () => { + cancelled = true + clearTimeout(timer) + } + }, [reduceMotion, paused]) + + const next = + TOUR_PAGES[(TOUR_PAGES.indexOf(route.page) + 1) % TOUR_PAGES.length] + return { route, phase, next, goTo } +} + +/* --------------------------------------------------------------- cursor */ + +function useDemoCursor( + phase: string, + next: DemoPage, + page: DemoPage, + interactive: boolean +) { + const x = useMotionValue(REST.queue.x) + const y = useMotionValue(REST.queue.y) + + useEffect(() => { + if (interactive) return + const target = + phase === "clicking" ? { x: NAV_X[next], y: NAV_Y } : REST[page] + const spring = { + type: "spring", + visualDuration: 0.7, + bounce: 0.05, + } as const + const ax = animate(x, target.x, spring) + const ay = animate(y, target.y, spring) + return () => { + ax.stop() + ay.stop() + } + }, [interactive, phase, next, page, x, y]) + + return { x, y } +} + +function TourCursor({ + x, + y, + dip, +}: { + x: ReturnType> + y: ReturnType> + dip: "tour" | "press" | null +}) { + return ( + +
+ + ) +} + +/* ---------------------------------------------------------------- screen */ + +type PanelState = + | { kind: "item"; id: string } + | { kind: "rule"; id: string } + | null + +export function DemoScreen({ + onEngagement, +}: { + /** Fires when the visitor's mouse takes / releases the screen. */ + onEngagement?: (engaged: boolean) => void +}) { + const [reduceMotion, setReduceMotion] = useState(false) + useEffect(() => { + setReduceMotion( + window.matchMedia?.("(prefers-reduced-motion: reduce)")?.matches ?? false + ) + }, []) + + // -- takeover: the tour runs until the visitor's mouse claims the screen + const [interactive, setInteractive] = useState(false) + const [pressed, setPressed] = useState(false) + const rootRef = useRef(null) + const idleTimer = useRef | null>(null) + const touch = () => { + setInteractive(true) + if (idleTimer.current) clearTimeout(idleTimer.current) + idleTimer.current = setTimeout(() => setInteractive(false), 5000) + } + useEffect( + () => () => { + if (idleTimer.current) clearTimeout(idleTimer.current) + }, + [] + ) + + useEffect(() => { + onEngagement?.(interactive) + }, [interactive, onEngagement]) + + const { route, phase, next, goTo } = useTour(reduceMotion, interactive) + const cursor = useDemoCursor(phase, next, route.page, interactive) + + // -- app state: theme, queue items, rules, open panel + const [theme, setTheme] = useState("dark") + const [items, setItems] = useState(QUEUE_ITEMS) + const [rules, setRules] = useState(RULES) + const [panel, setPanel] = useState(null) + + const navigate = (page: DemoPage) => { + setPanel(null) // real navigation drops the detail view too + goTo(routeFor(page)) + } + const openMetric = (source: "moderation" | "automod", metric: Metric) => { + setPanel(null) + goTo({ page: "analytics", source, metric: metric.key }) + } + const toggleRule = (rule: Rule) => + setRules((rs) => + rs.map((r) => (r.id === rule.id ? { ...r, enabled: !r.enabled } : r)) + ) + + const panelItem = + panel?.kind === "item" ? items.find((i) => i.id === panel.id) : undefined + const panelRule = + panel?.kind === "rule" ? rules.find((r) => r.id === panel.id) : undefined + + const onPointerMove = (e: React.PointerEvent) => { + touch() + const rect = rootRef.current?.getBoundingClientRect() + if (!rect) return + cursor.x.set((e.clientX - rect.left) / 0.6) + cursor.y.set((e.clientY - rect.top) / 0.6) + } + + const scene = (() => { + switch (route.page) { + case "queue": + return ( + + setPanel((p) => + p?.kind === "item" && p.id === item.id + ? null + : { kind: "item", id: item.id } + ) + } + onOpenMetric={(m) => openMetric("moderation", m)} + /> + ) + case "automod": + return ( + + setPanel((p) => + p?.kind === "rule" && p.id === rule.id + ? null + : { kind: "rule", id: rule.id } + ) + } + onToggleRule={toggleRule} + onOpenMetric={(m) => openMetric("automod", m)} + /> + ) + case "analytics": + return ( + + navigate(route.source === "automod" ? "automod" : "queue") + } + onFocusMetric={(key) => + goTo({ page: "analytics", source: route.source, metric: key }) + } + /> + ) + case "integrations": + return + } + })() + + return ( +
setPressed(false)} + onPointerMove={onPointerMove} + onPointerDown={() => { + touch() + setPressed(true) + }} + onPointerUp={() => setPressed(false)} + > +
+ interactive && navigate(page)} + onToggleTheme={() => + setTheme((t) => (t === "dark" ? "light" : "dark")) + } + /> + + + + {scene} + + + } + panel={ + panelItem ? ( + setPanel(null)} + onResolve={() => { + setItems((list) => list.filter((i) => i.id !== panelItem.id)) + setPanel(null) + }} + /> + ) : panelRule ? ( + setPanel(null)} + onToggle={() => toggleRule(panelRule)} + /> + ) : null + } + /> + + {!reduceMotion && ( + + )} +
+ + {/* scanlines — plain lines, not a glow */} +
+
+ ) +} diff --git a/apps/web/src/components/layout/landing/demo/analytics-scene.tsx b/apps/web/src/components/layout/landing/demo/analytics-scene.tsx new file mode 100644 index 00000000..0a48e618 --- /dev/null +++ b/apps/web/src/components/layout/landing/demo/analytics-scene.tsx @@ -0,0 +1,230 @@ +"use client" + +import { + ArrowLeftIcon, + BanIcon, + PackagePlusIcon, + SparklesIcon, + TrendingDownIcon, + TrendingUpIcon, +} from "lucide-react" +import { AnimatePresence, motion } from "motion/react" +import { useState } from "react" +import { Area, AreaChart, Tooltip } from "#/components/dither-kit" +import { Delta, type Metric, StatCard } from "./chrome" +import { ANALYTICS_EVENTS, METRICS } from "./data" +import { listStagger, rowIn } from "./motion" +import { ACCENT, V } from "./theme" + +const EVENT_ICONS = [ + TrendingUpIcon, + TrendingDownIcon, + BanIcon, + SparklesIcon, + PackagePlusIcon, +] + +/** + * modkit's analytics view: back link, the focused metric's big number (pinned + * to wherever you click on the chart), the edge-masked dither chart, the + * events feed (committed point pulls its nearest event to the top), and the + * bottom metrics sheet that lifts the whole page when opened. + */ +export function AnalyticsScene({ + source, + metricKey, + onBack, + onFocusMetric, +}: { + source: "moderation" | "automod" + metricKey: string + onBack: () => void + onFocusMetric: (key: string) => void +}) { + const metrics = METRICS[source] + const metric = metrics.find((m) => m.key === metricKey) ?? metrics[0] + const [committed, setCommitted] = useState(null) + const [sheetOpen, setSheetOpen] = useState(false) + + const len = metric.series.length + const at = committed ?? len - 1 + const value = metric.series[at] + const ago = (len - 1 - at) * 2 // 2h buckets, like the real seed + const chartData = metric.series.map((v) => ({ v })) + + // committed point pulls the event closest to it to the top of the feed + const focusedEvent = + committed == null + ? null + : [...ANALYTICS_EVENTS].sort( + (a, b) => Math.abs(a.at - committed) - Math.abs(b.at - committed) + )[0] + const events = focusedEvent + ? [ + focusedEvent, + ...ANALYTICS_EVENTS.filter((e) => e.id !== focusedEvent.id), + ] + : ANALYTICS_EVENTS + + return ( +
+ + + + Back to {source === "automod" ? "Automod" : "Moderation"} + + + + + {metric.label} + +
+ + {value} + {metric.suffix ?? ""} + + + + {ago === 0 ? "now" : `−${ago}h`} + +
+
+ + {/* full-bleed chart, edge-masked like the real page; click to commit */} + { + const rect = e.currentTarget.getBoundingClientRect() + const t = (e.clientX - rect.left) / rect.width + setCommitted( + Math.max(0, Math.min(len - 1, Math.round(t * (len - 1)))) + ) + }} + > + + + `${val}${metric.suffix ?? ""}`} + /> + + + + {/* activity feed */} + + {events.map((e, i) => { + const Icon = EVENT_ICONS[e.id.charCodeAt(1) % EVENT_ICONS.length] + const focused = focusedEvent?.id === e.id + return ( + + +
+ + {e.title} + + + {e.detail} · {e.ago} + +
+ {e.impact && ( + + {e.impact.label} + + )} + {i >= 3 ? null : null} +
+ ) + })} +
+
+ + {/* metrics sheet — the tab lifts the whole page height when opened */} +
+ setSheetOpen((o) => !o)} + className="relative flex items-center gap-2 rounded-t-xl px-3 py-1.5" + style={{ background: V.muted }} + > + + {sheetOpen ? "Close Metrics" : "Show Metrics"} + + + {metrics.length} + + +
+ + +
+ {metrics.map((m: Metric) => ( + { + onFocusMetric(m.key) + setCommitted(null) + }} + /> + ))} +
+
+
+
+ ) +} diff --git a/apps/web/src/components/layout/landing/demo/automod-scene.tsx b/apps/web/src/components/layout/landing/demo/automod-scene.tsx new file mode 100644 index 00000000..b8521a64 --- /dev/null +++ b/apps/web/src/components/layout/landing/demo/automod-scene.tsx @@ -0,0 +1,175 @@ +"use client" + +import { ShieldIcon } from "lucide-react" +import { motion } from "motion/react" +import { useState } from "react" +import { Sparkline } from "#/components/dither-kit" +import { + FilterChip, + type Metric, + MiniSwitch, + PageHeader, + SectionHeader, + SortToggle, + StatCard, +} from "./chrome" +import { METRICS, type Rule } from "./data" +import { listStagger, rowIn } from "./motion" +import { V } from "./theme" + +const CATEGORIES = [ + "All categories", + "Blocklist", + "Heuristic", + "Classifier", + "Regex", +] as const + +/** modkit's automod page: stat cards, sort + category chips, rule rows with + * live switches — rows open the rule detail panel. */ +export function AutomodScene({ + rules, + activeRuleId, + onOpenRule, + onToggleRule, + onOpenMetric, +}: { + rules: Rule[] + activeRuleId: string | null + onOpenRule: (rule: Rule) => void + onToggleRule: (rule: Rule) => void + onOpenMetric: (metric: Metric) => void +}) { + const [sort, setSort] = useState<"active" | "fp" | "name">("active") + const [category, setCategory] = + useState<(typeof CATEGORIES)[number]>("All categories") + + const visible = rules + .filter((r) => category === "All categories" || r.category === category) + .sort((a, b) => + sort === "active" + ? b.hits - a.hits + : sort === "fp" + ? b.fpRate - a.fpRate + : a.name.localeCompare(b.name) + ) + + return ( + + + + + + + {METRICS.automod.map((m) => ( + onOpenMetric(m)} /> + ))} + + + + + } + /> +
+ {CATEGORIES.map((c) => ( + setCategory(c)} + > + {c} + + ))} +
+
+ + + {visible.map((r) => ( + + onOpenRule(r)} + className="flex min-w-0 flex-1 items-center gap-3" + style={{ opacity: r.enabled ? 1 : 0.5 }} + > + + + + {r.name} + + + {r.action} · {r.scope} + + + + + + + + + {r.hits} + + + 24h + + + + + onToggleRule(r)} /> + + ))} + {visible.length === 0 && ( +

+ No automod rules match this category. +

+ )} +
+
+ ) +} diff --git a/apps/web/src/components/layout/landing/demo/chrome.tsx b/apps/web/src/components/layout/landing/demo/chrome.tsx new file mode 100644 index 00000000..4f0bf590 --- /dev/null +++ b/apps/web/src/components/layout/landing/demo/chrome.tsx @@ -0,0 +1,385 @@ +"use client" + +import { + BarChart3Icon, + CircleDotIcon, + MoonIcon, + SearchIcon, + ShieldIcon, + SunIcon, + WrenchIcon, +} from "lucide-react" +import type { ReactNode } from "react" +import { Sparkline } from "#/components/dither-kit" +import { ACCENT, type DemoTheme, V } from "./theme" + +/** The demo's route space — a miniature of modkit's router. */ +export type DemoRoute = + | { page: "queue" } + | { page: "automod" } + | { page: "integrations" } + | { + page: "analytics" + source: "moderation" | "automod" + metric: string + } + +export type DemoPage = DemoRoute["page"] + +/* ------------------------------------------------------------------ nav */ + +export const NAV_ITEMS: { + page: DemoPage + label: string + icon: typeof ShieldIcon + count?: number +}[] = [ + { page: "queue", label: "Queue", icon: CircleDotIcon, count: 6 }, + { page: "automod", label: "Automod", icon: ShieldIcon, count: 5 }, + { page: "analytics", label: "Analytics", icon: BarChart3Icon }, + { page: "integrations", label: "Integrations", icon: WrenchIcon }, +] + +export function TopNav({ + active, + theme, + onNavigate, + onToggleTheme, +}: { + active: DemoPage + theme: DemoTheme + onNavigate: (page: DemoPage) => void + onToggleTheme: () => void +}) { + return ( +
+ + tripwire + +
+ {NAV_ITEMS.map((item) => ( + onNavigate(item.page)} + className="flex h-7 items-center gap-1.5 rounded-md px-2.5 text-[12px] font-medium" + style={ + item.page === active + ? { background: V.surface0, color: V.fg } + : { color: V.fg2 } + } + > + + {item.label} + {typeof item.count === "number" && ( + + {item.count} + + )} + + ))} +
+
+ + + Search reports… + +
+ {/* theme toggle — scoped to the demo */} + + {theme === "dark" ? : } + + +
+ ) +} + +/* ---------------------------------------------------------------- atoms */ + +/** The bg-surface-0 pill pair used by every list header's sort control. */ +export function SortToggle({ + options, + value, + onChange, +}: { + options: { key: T; label: string }[] + value: T + onChange: (key: T) => void +}) { + return ( +
+ {options.map((o) => ( + onChange(o.key)} + className="rounded-[5px] px-2.5 py-1 text-[11px] font-medium" + style={ + o.key === value + ? { background: V.card, color: V.fg } + : { color: V.fg2 } + } + > + {o.label} + + ))} +
+ ) +} + +export function FilterChip({ + active, + onClick, + children, +}: { + active: boolean + onClick: () => void + children: string +}) { + return ( + + {children} + + ) +} + +export function ReasonPill({ children }: { children: string }) { + return ( + + {children} + + ) +} + +export const SEVERITY_DOT: Record = { + Critical: ACCENT.red600, + High: ACCENT.amber, + Medium: "color-mix(in srgb, var(--d-fg2) 60%, transparent)", + Low: "color-mix(in srgb, var(--d-fg2) 30%, transparent)", +} + +export function SeverityBadge({ label }: { label: string }) { + return ( + + + + {label} + + + ) +} + +export function CategoryPill({ children }: { children: string }) { + return {children} +} + +export function MiniSwitch({ + on, + onToggle, +}: { + on: boolean + onToggle?: () => void +}) { + return ( + + + + ) +} + +/** Section header: "Moderation queue (6)" + right-side controls. */ +export function SectionHeader({ + title, + count, + right, +}: { + title: string + count?: number + right?: ReactNode +}) { + return ( +
+
+

+ {title} +

+ {typeof count === "number" && ( + + {count} + + )} +
+ {right} +
+ ) +} + +export function PageHeader({ + title, + subtitle, +}: { + title: string + subtitle: string +}) { + return ( +
+

+ {title} +

+

+ {subtitle} +

+
+ ) +} + +/* ------------------------------------------------------------ stat card */ + +export type DitherColor = + | "red" + | "blue" + | "purple" + | "orange" + | "pink" + | "green" + | "grey" + +export type Metric = { + key: string + label: string + value: string + delta: number + invertDelta?: boolean + color: DitherColor + series: number[] + suffix?: string +} + +export function Delta({ + delta, + invertDelta, +}: { + delta: number + invertDelta?: boolean +}) { + const good = invertDelta ? delta < 0 : delta > 0 + return ( + + {delta > 0 ? "▲" : "▼"} + {Math.abs(delta)} + + ) +} + +/** modkit's DitherStatCard: label, value + delta, chart flush at the foot. */ +export function StatCard({ + metric, + onClick, + focused = false, + compact = false, +}: { + metric: Metric + onClick?: () => void + /** Ringed, like the analytics sheet's focused metric. */ + focused?: boolean + compact?: boolean +}) { + return ( +
+
+ + {metric.label} + +
+ + {metric.value} + {metric.suffix ?? ""} + + +
+
+
+ +
+
+ ) +} diff --git a/apps/web/src/components/layout/landing/demo/data.ts b/apps/web/src/components/layout/landing/demo/data.ts new file mode 100644 index 00000000..94b1b7b7 --- /dev/null +++ b/apps/web/src/components/layout/landing/demo/data.ts @@ -0,0 +1,472 @@ +import { + CircleDotIcon, + GitPullRequestIcon, + MessageSquareIcon, +} from "lucide-react" +import type { Metric } from "./chrome" + +/** Mock content lifted from modkit's fixtures so the miniature reads real. */ + +export type QueueItem = { + id: string + icon: typeof MessageSquareIcon + kind: string + title: string + repo: string + number: number + reportedAgo: string + ageMinutes: number + reason: "Spam" | "Harassment" | "Off-topic" | "Automod" | "NSFW" + severity: "Critical" | "High" | "Medium" | "Low" + severityWeight: number + preview: string + author: string + authorHue: number + reporter: string + comments: number + reactions: number +} + +export const QUEUE_ITEMS: QueueItem[] = [ + { + id: "q1", + icon: MessageSquareIcon, + kind: "Comment", + title: "buy cheap followers + crypto airdrop 🚀🚀 link in bio", + repo: "facebook/react", + number: 31204, + reportedAgo: "12m ago", + ageMinutes: 12, + reason: "Spam", + severity: "Critical", + severityWeight: 4, + preview: + "🚀🚀 GET 10K FOLLOWERS CHEAP + free $SLOP airdrop for the first 500 wallets — link in bio, dont miss out fam 🚀🚀", + author: "a1rdr0p_k1ng", + authorHue: 210, + reporter: "automod", + comments: 3, + reactions: 1, + }, + { + id: "q2", + icon: GitPullRequestIcon, + kind: "Pull request", + title: "Add 4000 lines of unrelated vendored code", + repo: "biomejs/biome", + number: 4711, + reportedAgo: "38m ago", + ageMinutes: 38, + reason: "Spam", + severity: "High", + severityWeight: 3, + preview: + "This PR vendors an entire UI framework into /lib3 to fix a typo in the README. Adds 4,102 lines across 96 files.", + author: "helpful-dev", + authorHue: 20, + reporter: "maintainer-em", + comments: 12, + reactions: 4, + }, + { + id: "q3", + icon: MessageSquareIcon, + kind: "Comment", + title: "you people are clueless, this whole library is garbage", + repo: "honojs/hono", + number: 2988, + reportedAgo: "1h ago", + ageMinutes: 62, + reason: "Harassment", + severity: "High", + severityWeight: 3, + preview: + "you people are clueless, this whole library is garbage and whoever merged this should never touch a keyboard again", + author: "rage-quit-99", + authorHue: 0, + reporter: "community-mod", + comments: 7, + reactions: 2, + }, + { + id: "q4", + icon: MessageSquareIcon, + kind: "Comment", + title: "Automod: matched blocklist pattern in comment", + repo: "cloudflare/workers-sdk", + number: 6120, + reportedAgo: "1h ago", + ageMinutes: 71, + reason: "Automod", + severity: "Medium", + severityWeight: 2, + preview: + "check out my new site totally-not-a-scam[.]xyz for free credits — pattern `(free credits|airdrop)` matched twice.", + author: "new-user-3021", + authorHue: 280, + reporter: "automod", + comments: 0, + reactions: 0, + }, + { + id: "q5", + icon: MessageSquareIcon, + kind: "Comment", + title: "checkout my onlyfans 🔥 not safe for work content here", + repo: "facebook/react", + number: 31190, + reportedAgo: "2h ago", + ageMinutes: 118, + reason: "NSFW", + severity: "High", + severityWeight: 3, + preview: + "hey everyone checkout my page 🔥🔥 18+ content, link below (removed) — posted to 14 threads in 6 minutes.", + author: "spicy_content4u", + authorHue: 320, + reporter: "automod", + comments: 1, + reactions: 0, + }, + { + id: "q6", + icon: CircleDotIcon, + kind: "Issue", + title: "+1 +1 +1 please merge this is urgent for my job interview", + repo: "drizzle-team/drizzle-orm", + number: 3350, + reportedAgo: "3h ago", + ageMinutes: 185, + reason: "Off-topic", + severity: "Low", + severityWeight: 1, + preview: + "+1 +1 +1 pls merge i have a job interview tomorrow and need this feature, also can someone review my resume", + author: "urgent-merger", + authorHue: 140, + reporter: "maintainer-em", + comments: 21, + reactions: 9, + }, +] + +export type RuleMatch = { + id: string + where: string + ago: string + snippet: string +} + +export type Rule = { + id: string + name: string + category: "Blocklist" | "Heuristic" | "Classifier" | "Regex" + description: string + pattern: string + action: string + scope: string + hits: number + fpRate: number + lastFired: string + enabled: boolean + series: number[] + matches: RuleMatch[] +} + +export const RULES: Rule[] = [ + { + id: "r1", + name: "Known spam domains", + category: "Blocklist", + description: + "Blocks links to a maintained blocklist of spam and scam domains, updated hourly from three shared feeds.", + pattern: "url.domain in @blocklists/spam-domains", + action: "Hide + report", + scope: "Comments · Issues · PRs", + hits: 41, + fpRate: 2, + lastFired: "9m ago", + enabled: true, + series: [3, 5, 4, 7, 6, 9, 8, 11], + matches: [ + { + id: "m1", + where: "facebook/react #31204", + ago: "12m ago", + snippet: + "🚀 free $SLOP airdrop for the first 500 wallets — link in bio", + }, + { + id: "m2", + where: "honojs/hono #3001", + ago: "44m ago", + snippet: + "grab discounted followers at follow-farm[.]shop before it's gone", + }, + ], + }, + { + id: "r2", + name: "New-account burst", + category: "Heuristic", + description: + "Flags accounts younger than 7 days posting more than 5 times in 10 minutes across the org.", + pattern: "account.age < 7d AND posts(10m) > 5", + action: "Flag for review", + scope: "All activity", + hits: 23, + fpRate: 9, + lastFired: "31m ago", + enabled: true, + series: [2, 4, 3, 6, 5, 7, 6, 8], + matches: [ + { + id: "m3", + where: "cloudflare/workers-sdk #6120", + ago: "1h ago", + snippet: "posted to 14 threads in 6 minutes from a 2-day-old account", + }, + ], + }, + { + id: "r3", + name: "Crypto & airdrop promos", + category: "Regex", + description: + "Catches token-drop, wallet-drainer, and follower-farm promotions in any commentable surface.", + pattern: "/(airdrop|free (credits|tokens)|10k followers)/i", + action: "Hide + report", + scope: "Comments", + hits: 17, + fpRate: 4, + lastFired: "2h ago", + enabled: true, + series: [5, 4, 6, 3, 5, 4, 6, 5], + matches: [ + { + id: "m4", + where: "biomejs/biome #4699", + ago: "2h ago", + snippet: "free credits airdrop happening now, connect wallet to claim", + }, + ], + }, + { + id: "r4", + name: "Harassment & threats", + category: "Classifier", + description: + "Language-model classifier for hostile, demeaning, or threatening language aimed at people.", + pattern: "classifier(harassment) > 0.86", + action: "Flag for review", + scope: "Comments · Issues", + hits: 4, + fpRate: 16, + lastFired: "5h ago", + enabled: false, + series: [1, 2, 1, 3, 2, 2, 1, 2], + matches: [ + { + id: "m5", + where: "honojs/hono #2988", + ago: "1h ago", + snippet: "whoever merged this should never touch a keyboard again", + }, + ], + }, + { + id: "r5", + name: "Profanity classifier", + category: "Classifier", + description: + "Softer profanity filter that hides rather than reports; tuned for false-positive caution.", + pattern: "classifier(profanity) > 0.92", + action: "Hide only", + scope: "Comments", + hits: 11, + fpRate: 6, + lastFired: "26m ago", + enabled: true, + series: [2, 3, 4, 3, 5, 4, 5, 6], + matches: [ + { + id: "m6", + where: "drizzle-team/drizzle-orm #3344", + ago: "26m ago", + snippet: "(hidden) — matched at 0.95 confidence, author notified", + }, + ], + }, +] + +/* ------------------------------------------------------------ analytics */ + +export const METRICS: Record<"moderation" | "automod", Metric[]> = { + moderation: [ + { + key: "pending", + label: "Pending reports", + value: "14", + delta: 3, + invertDelta: true, + color: "red", + series: [6, 9, 7, 12, 10, 15, 13, 18, 14, 19, 16, 22], + }, + { + key: "resolved", + label: "Resolved today", + value: "38", + delta: 12, + color: "blue", + series: [8, 10, 9, 12, 14, 13, 16, 15, 18, 21, 19, 24], + }, + { + key: "automod", + label: "Automod hits · 24h", + value: "112", + delta: -9, + invertDelta: true, + color: "purple", + series: [12, 9, 11, 8, 10, 7, 9, 6, 8, 5, 7, 4], + }, + { + key: "banned", + label: "Banned users", + value: "6", + delta: 2, + color: "orange", + series: [2, 3, 2, 4, 3, 5, 4, 4, 6, 5, 6, 7], + }, + ], + automod: [ + { + key: "rules", + label: "Active rules", + value: "5", + delta: 1, + color: "blue", + series: [3, 3, 4, 4, 4, 5, 5, 5, 4, 5, 5, 5], + }, + { + key: "matches", + label: "Matches · 24h", + value: "96", + delta: -14, + invertDelta: true, + color: "purple", + series: [14, 12, 13, 10, 11, 9, 10, 8, 9, 7, 8, 6], + }, + { + key: "fp", + label: "False-positive rate", + value: "7", + suffix: "%", + delta: -2, + invertDelta: true, + color: "pink", + series: [11, 10, 9, 10, 8, 9, 8, 7, 8, 7, 7, 6], + }, + { + key: "actioned", + label: "Auto-actioned · 24h", + value: "61", + delta: 8, + color: "orange", + series: [4, 6, 5, 8, 7, 9, 8, 11, 10, 12, 11, 14], + }, + ], +} + +export type AnalyticsEvent = { + id: string + title: string + detail: string + ago: string + at: number // series index the event belongs to + impact?: { label: string; good: boolean } +} + +export const ANALYTICS_EVENTS: AnalyticsEvent[] = [ + { + id: "e1", + title: "Blocklist feed sync added 214 domains", + detail: "automod · Known spam domains", + ago: "2h ago", + at: 10, + impact: { label: "▼ 12", good: true }, + }, + { + id: "e2", + title: "Spam wave hit facebook/react", + detail: "31 reports in 40 minutes", + ago: "5h ago", + at: 7, + impact: { label: "▲ 31", good: false }, + }, + { + id: "e3", + title: "grim banned a1rdr0p_k1ng", + detail: "repeat offender · 3 strikes", + ago: "8h ago", + at: 5, + }, + { + id: "e4", + title: "New-account burst rule enabled", + detail: "automod · heuristic", + ago: "12h ago", + at: 3, + impact: { label: "▼ 8", good: true }, + }, + { + id: "e5", + title: "Nightly sweep resolved 22 stale reports", + detail: "queue hygiene", + ago: "1d ago", + at: 1, + }, +] + +/* ---------------------------------------------------------- integrations */ + +export type Repo = { name: string; owner: string; isPrivate: boolean } + +const VERCEL_REPOS = [ + "next.js", + "turbo", + "ai", + "swr", + "satori", + "commerce", + "examples", + "analytics", + "edge-runtime", + "platforms", +] +const RIPGRIM_REPOS = ["modkit", "tripwire", "honeypot", "dotfiles", "lander"] + +export const REPOS: Repo[] = [ + ...VERCEL_REPOS.map((name) => ({ + name, + owner: "vercel", + isPrivate: false, + })), + ...RIPGRIM_REPOS.map((name) => ({ + name, + owner: "ripgrim", + isPrivate: name !== "modkit", + })), +] + +export const ACCOUNTS = [ + { + login: "vercel", + type: "Organization", + avatar: "https://github.com/vercel.png", + }, + { + login: "ripgrim", + type: "Personal account", + avatar: "https://github.com/ripgrim.png", + }, +] diff --git a/apps/web/src/components/layout/landing/demo/integrations-scene.tsx b/apps/web/src/components/layout/landing/demo/integrations-scene.tsx new file mode 100644 index 00000000..3aa1d0b0 --- /dev/null +++ b/apps/web/src/components/layout/landing/demo/integrations-scene.tsx @@ -0,0 +1,223 @@ +"use client" + +import { + CheckIcon, + ChevronLeftIcon, + ChevronRightIcon, + GithubIcon, + InfoIcon, + SearchIcon, +} from "lucide-react" +import { motion } from "motion/react" +import { useState } from "react" +import { ACCOUNTS, REPOS } from "./data" +import { listStagger, rowIn } from "./motion" +import { ACCENT, V } from "./theme" + +const PAGE_SIZE = 5 + +/** modkit's GitHub integrations page: account cards, the active-repository + * picker with working pagination and set-active. */ +export function IntegrationsScene() { + const [page, setPage] = useState(0) + const [activeRepo, setActiveRepo] = useState("vercel/next.js") + + const pageCount = Math.ceil(REPOS.length / PAGE_SIZE) + const rows = REPOS.slice(page * PAGE_SIZE, (page + 1) * PAGE_SIZE) + + return ( + + +

+ GitHub +

+

+ Connect GitHub to{" "} + + acme + {" "} + and choose the repository modkit moderates. +

+
+ + {/* connected accounts */} + + {ACCOUNTS.map((a) => ( +
+
+ {a.login} +
+ + {a.login} + + + {a.type} + +
+
+
+ + Manage + + + Uninstall + +
+
+ ))} + + + Connect another account + +
+ + {/* active repository */} + +
+

+ Active repository +

+ + {REPOS.length} connected + +
+ +
+ + + Filter repositories… + +
+ +
+ {rows.map((repo, i) => { + const full = `${repo.owner}/${repo.name}` + const active = full === activeRepo + return ( +
setActiveRepo(full)} + className="flex w-full items-center gap-3 px-3 py-2.5" + style={{ + borderTop: i > 0 ? `1px solid ${V.border}` : undefined, + }} + > + +
+ + {repo.name} + + + {repo.owner} + {repo.isPrivate ? " · private" : ""} + +
+
+ {active ? ( + + + Active + + ) : ( + + Set active + + )} +
+
+ ) + })} +
+ + {/* pagination */} +
+ setPage((p) => Math.max(0, p - 1))} + className="inline-flex h-7 items-center gap-1 rounded-lg px-2 text-[11px] font-medium" + style={{ color: V.fg2, opacity: page === 0 ? 0.5 : 1 }} + > + + Prev + +
+ {Array.from({ length: pageCount }, (_, i) => ( + setPage(i)} + className="inline-flex size-7 items-center justify-center rounded-lg border text-[11px] font-medium" + style={ + i === page + ? { + borderColor: V.border, + background: V.surface0, + color: V.fg, + } + : { borderColor: "transparent", color: V.fg2 } + } + > + {i + 1} + + ))} +
+ setPage((p) => Math.min(pageCount - 1, p + 1))} + className="inline-flex h-7 items-center gap-1 rounded-lg px-2 text-[11px] font-medium" + style={{ color: V.fg2, opacity: page >= pageCount - 1 ? 0.5 : 1 }} + > + Next + + +
+ +

+ + + The active repository is the one modkit watches — its issues, pull + requests, and comments flow into your queue and run through automod. + +

+
+
+ ) +} diff --git a/apps/web/src/components/layout/landing/demo/motion.ts b/apps/web/src/components/layout/landing/demo/motion.ts new file mode 100644 index 00000000..c9c39eaa --- /dev/null +++ b/apps/web/src/components/layout/landing/demo/motion.ts @@ -0,0 +1,13 @@ +/** Shared entrance choreography — pages stagger in like real loads. */ +export const listStagger = { + animate: { transition: { staggerChildren: 0.07, delayChildren: 0.08 } }, +} + +export const rowIn = { + initial: { opacity: 0, y: 10 }, + animate: { + opacity: 1, + y: 0, + transition: { type: "spring", visualDuration: 0.4, bounce: 0.1 }, + }, +} as const diff --git a/apps/web/src/components/layout/landing/demo/queue-scene.tsx b/apps/web/src/components/layout/landing/demo/queue-scene.tsx new file mode 100644 index 00000000..baae4e5a --- /dev/null +++ b/apps/web/src/components/layout/landing/demo/queue-scene.tsx @@ -0,0 +1,144 @@ +"use client" + +import { motion } from "motion/react" +import { useState } from "react" +import { + FilterChip, + type Metric, + PageHeader, + ReasonPill, + SectionHeader, + SeverityBadge, + SortToggle, + StatCard, +} from "./chrome" +import { METRICS, type QueueItem } from "./data" +import { listStagger, rowIn } from "./motion" +import { V } from "./theme" + +const REASONS = [ + "All reasons", + "Spam", + "Harassment", + "Off-topic", + "Automod", + "NSFW", +] as const + +/** modkit's home: stat cards over the moderation queue, with the real sort + * toggle, reason chips, and rows that open the detail panel. */ +export function QueueScene({ + items, + activeItemId, + onOpenItem, + onOpenMetric, +}: { + items: QueueItem[] + activeItemId: string | null + onOpenItem: (item: QueueItem) => void + onOpenMetric: (metric: Metric) => void +}) { + const [sort, setSort] = useState<"severity" | "newest">("severity") + const [reason, setReason] = useState<(typeof REASONS)[number]>("All reasons") + + const visible = items + .filter((i) => reason === "All reasons" || i.reason === reason) + .sort((a, b) => + sort === "severity" + ? b.severityWeight - a.severityWeight + : a.ageMinutes - b.ageMinutes + ) + + return ( + + + + + + {/* the four dither stat cards — each opens its analytics view */} + + {METRICS.moderation.map((m) => ( + onOpenMetric(m)} /> + ))} + + + + + } + /> +
+ {REASONS.map((r) => ( + setReason(r)} + > + {r} + + ))} +
+
+ + + {visible.map((q) => ( + onOpenItem(q)} + className="flex items-center gap-3 rounded-lg px-3 py-2.5" + style={{ + background: activeItemId === q.id ? V.muted : "transparent", + }} + > + +
+ + {q.title} + + + {q.repo} #{q.number} · {q.reportedAgo} + +
+
+ {q.reason} + +
+
+ ))} + {visible.length === 0 && ( +

+ Nothing flagged for this reason. Nice. +

+ )} +
+
+ ) +} diff --git a/apps/web/src/components/layout/landing/demo/side-panel.tsx b/apps/web/src/components/layout/landing/demo/side-panel.tsx new file mode 100644 index 00000000..f0131c9a --- /dev/null +++ b/apps/web/src/components/layout/landing/demo/side-panel.tsx @@ -0,0 +1,460 @@ +"use client" + +import { + BanIcon, + CheckIcon, + ExternalLinkIcon, + MessageSquareIcon, + ShieldIcon, + ThumbsUpIcon, + Trash2Icon, + XIcon, +} from "lucide-react" +import { motion } from "motion/react" +import { type ReactNode, useState } from "react" +import { Sparkline } from "#/components/dither-kit" +import { ACCENT, V } from "./theme" +import { CategoryPill, MiniSwitch, ReasonPill, SeverityBadge } from "./chrome" +import type { QueueItem, Rule } from "./data" + +/** + * The dashboard's side panel, exactly as modkit lays it out: the page is a + * two-column grid whose right column springs between 0 and 328px, the main + * content living in a rounded card that shrinks to make room. + */ +export function PanelGrid({ + open, + main, + panel, +}: { + open: boolean + main: ReactNode + panel: ReactNode +}) { + return ( + +
+ {main} +
+
+ + {panel} + +
+
+ ) +} + +function PanelShell({ + icon, + kind, + onClose, + children, + footer, +}: { + icon: ReactNode + kind: string + onClose: () => void + children: ReactNode + footer?: ReactNode +}) { + return ( +
+
+
+ {icon} + + {kind} + +
+ + + +
+
+
+ {children} +
+ {footer && ( + <> +
+
{footer}
+ + )} +
+ ) +} + +function Field({ label, children }: { label: string; children: ReactNode }) { + return ( +
+
{label}
+
+ {children} +
+
+ ) +} + +function AvatarDot({ hue }: { hue: number }) { + return ( + + ) +} + +/* --------------------------------------------------- moderation detail */ + +type Action = "approve" | "ban" | "remove" + +const ACTION_META: Record< + Action, + { + label: string + confirm: string + icon: typeof CheckIcon + bg: string + fg: string + } +> = { + approve: { + label: "Approve", + confirm: "Confirm Approve", + icon: CheckIcon, + bg: ACCENT.emerald600, + fg: "#ffffff", + }, + ban: { + label: "Ban", + confirm: "Confirm Ban", + icon: BanIcon, + bg: ACCENT.red600, + fg: "#ffffff", + }, + remove: { + label: "Remove", + confirm: "Confirm Remove", + icon: Trash2Icon, + bg: "var(--d-primary)", + fg: "var(--d-primary-fg)", + }, +} + +/** modkit's arm-then-confirm action bar: the armed button swells to fill the + * row while its siblings collapse away. Confirming resolves the item. */ +function ConfirmActions({ onResolve }: { onResolve: (a: Action) => void }) { + const [armed, setArmed] = useState(null) + const actions: Action[] = ["approve", "ban", "remove"] + return ( +
+ {actions.map((a) => { + const meta = ACTION_META[a] + const isArmed = armed === a + const hidden = armed !== null && !isArmed + return ( + (isArmed ? onResolve(a) : setArmed(a))} + className="flex h-8 items-center justify-center gap-1.5 overflow-hidden rounded-md text-[11px] font-medium whitespace-nowrap" + animate={{ + flexGrow: hidden ? 0.0001 : isArmed ? 6 : 1, + opacity: hidden ? 0 : 1, + }} + transition={{ type: "spring", stiffness: 500, damping: 40 }} + style={{ + flexBasis: 0, + pointerEvents: hidden ? "none" : "auto", + background: isArmed ? meta.bg : "transparent", + color: isArmed ? meta.fg : V.fg2, + }} + > + + {isArmed ? meta.confirm : meta.label} + + ) + })} + {armed !== null && ( + setArmed(null)} + className="flex h-8 w-8 shrink-0 items-center justify-center rounded-md" + style={{ color: V.fg2 }} + > + + + )} +
+ ) +} + +export function ModerationDetail({ + item, + onClose, + onResolve, +}: { + item: QueueItem + onClose: () => void + onResolve: () => void +}) { + return ( + } + kind={`${item.kind} · #${item.number}`} + onClose={onClose} + footer={ onResolve()} />} + > +
+
+
+ {item.reason} + +
+

+ {item.title} +

+ + {item.repo} + + +
+ +
+

+ Problematic content: +

+

+ {item.preview} +

+
+ +
+ + + {item.author} + + + {item.reporter === "automod" ? ( + automod · {item.reason} + ) : ( + <> + + {item.reporter} + + )} + + {item.reportedAgo} + + + + {item.comments} + + + + {item.reactions} + + +
+
+
+ ) +} + +/* --------------------------------------------------------- rule detail */ + +export function RuleDetail({ + rule, + onClose, + onToggle, +}: { + rule: Rule + onClose: () => void + onToggle: () => void +}) { + const [verdicts, setVerdicts] = useState< + Record + >({}) + return ( + } + kind="Automod rule" + onClose={onClose} + > +
+
+
+ {rule.category} + +
+

+ {rule.name} +

+

+ {rule.description} +

+
+ + + {rule.pattern} + + +
+
+
+

+ {rule.hits} +

+

+ matches · 24h +

+
+
+ +
+
+
+ +
+ {rule.scope} + {rule.action} + + = 15 ? ACCENT.amber : V.fg }} + > + {rule.fpRate}% + + + {rule.lastFired} +
+ +
+

+ Recent matches +

+ {rule.matches.map((m) => { + const verdict = verdicts[m.id] + return ( +
+
+ {m.where} + · + {m.ago} +
+

+ {m.snippet} +

+ {verdict ? ( + + + {verdict === "false-positive" + ? "False positive" + : "Confirmed"} + + ) : ( +
+ + setVerdicts((v) => ({ ...v, [m.id]: "confirmed" })) + } + className="flex items-center gap-1 rounded-md px-1.5 py-0.5 text-[10px] font-medium" + style={{ background: V.surface2, color: V.fg2 }} + > + + Confirm + + + setVerdicts((v) => ({ + ...v, + [m.id]: "false-positive", + })) + } + className="flex items-center gap-1 rounded-md px-1.5 py-0.5 text-[10px] font-medium" + style={{ background: V.surface2, color: V.fg2 }} + > + + False positive + +
+ )} +
+ ) + })} +
+
+
+ ) +} diff --git a/apps/web/src/components/layout/landing/demo/theme.ts b/apps/web/src/components/layout/landing/demo/theme.ts new file mode 100644 index 00000000..faf9da9d --- /dev/null +++ b/apps/web/src/components/layout/landing/demo/theme.ts @@ -0,0 +1,92 @@ +/** + * modkit's two palettes, resolved to hex from its oklch tokens and exposed as + * CSS custom properties scoped to the demo root — the tube gets a real theme + * toggle without touching anything outside the glass. + */ + +export type DemoTheme = "dark" | "light" + +type Palette = { + bg: string + card: string + fg: string + fg2: string // muted-foreground + muted: string // row hover/active + sheet bg + border: string + surface0: string + surface1: string + surface2: string + primary: string + primaryFg: string +} + +const DARK: Palette = { + bg: "#09090b", + card: "#18181b", + fg: "#fafafa", + fg2: "#a1a1aa", + muted: "#111113", + border: "#27272a", + surface0: "#1f1f23", + surface1: "#232327", + surface2: "#2b2b30", + primary: "#fafafa", + primaryFg: "#18181b", +} + +const LIGHT: Palette = { + bg: "#ffffff", + card: "#ffffff", + fg: "#09090b", + fg2: "#71717a", + muted: "#f4f4f5", + border: "#e4e4e7", + surface0: "#ececee", + surface1: "#f4f4f5", + surface2: "#e6e6e9", + primary: "#18181b", + primaryFg: "#fafafa", +} + +/** CSS custom properties for the demo root. Components read `var(--d-*)`. */ +export function themeVars(theme: DemoTheme): Record { + const p = theme === "dark" ? DARK : LIGHT + return { + "--d-bg": p.bg, + "--d-card": p.card, + "--d-fg": p.fg, + "--d-fg2": p.fg2, + "--d-muted": p.muted, + "--d-border": p.border, + "--d-surface0": p.surface0, + "--d-surface1": p.surface1, + "--d-surface2": p.surface2, + "--d-primary": p.primary, + "--d-primary-fg": p.primaryFg, + } +} + +// Fixed accents (same in both themes, like the app's tailwind colors). +export const ACCENT = { + good: "#10b981", // emerald-500 + bad: "#ef4444", // red-500 + amber: "#f59e0b", + emerald600: "#059669", + red600: "#dc2626", + emerald400: "#34d399", +} as const + +/** Shorthand for var() lookups in inline styles. */ +export const V = { + bg: "var(--d-bg)", + card: "var(--d-card)", + fg: "var(--d-fg)", + fg2: "var(--d-fg2)", + muted: "var(--d-muted)", + border: "var(--d-border)", + surface0: "var(--d-surface0)", + surface1: "var(--d-surface1)", + surface2: "var(--d-surface2)", + primary: "var(--d-primary)", + primaryFg: "var(--d-primary-fg)", +} as const diff --git a/apps/web/src/components/layout/landing/header.tsx b/apps/web/src/components/layout/landing/header.tsx index fbe7d0d0..0a614b66 100644 --- a/apps/web/src/components/layout/landing/header.tsx +++ b/apps/web/src/components/layout/landing/header.tsx @@ -2,56 +2,34 @@ import { Link } from "@tanstack/react-router" import type { AuthClientSession } from "@tripwire/auth/client" import { TripwireLogo } from "@tripwire/ui/icons/tripwire-logo" -// The terminal's barrel curvature makes the top edge of the visible screen bow -// upward in the middle (corners recede). Both side groups translate slightly -// down to sit where the curved screen edge actually is at the corners, and -// rotate outward so each group's baseline aligns with the curve tangent at its -// position. Contents stay crisp because we never touch pixels — only layout. -const SIDE_DROP_PX = 4 -const SIDE_TILT_DEG = 1.6 - export function LandingHeader({ session }: { session: AuthClientSession }) { return (
-
- - +
+ + tripwire
-
+
{session ? ( <> - - Welcome back - + Welcome back dashboard ) : ( <> - + Already have access? login diff --git a/apps/web/src/components/layout/landing/retro-computer.tsx b/apps/web/src/components/layout/landing/retro-computer.tsx new file mode 100644 index 00000000..e0ab249c --- /dev/null +++ b/apps/web/src/components/layout/landing/retro-computer.tsx @@ -0,0 +1,482 @@ +"use client" + +import { motion } from "motion/react" +import { useEffect, useRef, useState } from "react" +import { DemoScreen } from "./demo-screen" + +/** + * A late-80s cream desktop PC (Philips P3120 XT energy) built entirely from + * CSS. At rest only the system unit sits at the bottom of the page; its power + * button boots the machine — the monitor rises out of the case, the screen + * warms up onto a miniature of the tripwire moderation dashboard, and the + * keyboard slides out from underneath. Every drawn key mirrors the real + * keyboard, and the arrow keys boot the game straight onto the CRT. + * + * Deliberately flat: hard bevels and plastic seams only. No glows, no blurs. + */ + +/* ------------------------------------------------------------- palette */ +// The classic beige-box plastics, plus the screen's near-black. +const C = { + shell: "#d6cfba", // main housing + shellLight: "#e6e0cc", // top bevels (light source above) + shellDark: "#b3ab93", // bottom bevels + shellDeep: "#8f876f", // seams and recesses + key: "#e2dcc8", // keycap top + keySide: "#aaa189", // keycap front + screenBed: "#3a352a", // bezel recess around the tube + tube: "#14130f", // CRT glass + accent: "#b03a2e", // the Philips-style red stripe + led: "#67e19f", // power light +} + +const bevel = (raise = true) => ({ + borderTop: `2px solid ${raise ? C.shellLight : C.shellDeep}`, + borderLeft: `2px solid ${raise ? C.shellLight : C.shellDeep}`, + borderRight: `2px solid ${raise ? C.shellDark : C.shellLight}`, + borderBottom: `2px solid ${raise ? C.shellDark : C.shellLight}`, +}) + +// One spring for the whole boot choreography — snappy with a hint of mass. +const POP = { type: "spring", visualDuration: 0.55, bounce: 0.18 } as const + +/* -------------------------------------------------------- demo content */ + +const SCANLINES = { + backgroundImage: + "repeating-linear-gradient(to bottom, rgba(0,0,0,0.22) 0px, rgba(0,0,0,0.22) 1px, transparent 1px, transparent 3px)", +} as const + +/** Mounts the live game canvas as the tube's picture. */ +function GameScreen({ canvas }: { canvas: HTMLCanvasElement }) { + const host = useRef(null) + useEffect(() => { + const el = host.current + if (!el) return + canvas.style.width = "100%" + canvas.style.height = "100%" + canvas.style.display = "block" + el.appendChild(canvas) + return () => { + canvas.remove() + } + }, [canvas]) + return ( +
+
+ {/* same scanlines as the demo — it's the same tube */} +
+
+ ) +} + +/* ------------------------------------------------------------ keyboard */ + +/** Simplified XT key rows — label only where it matters. */ +const KEY_ROWS: string[][] = [ + ["esc", "1", "2", "3", "4", "5", "6", "7", "8", "9", "0", "-", "=", "bksp"], + ["tab", "q", "w", "e", "r", "t", "y", "u", "i", "o", "p", "[", "]", "\\"], + ["ctrl", "a", "s", "d", "f", "g", "h", "j", "k", "l", ";", "'", "enter"], + ["shift", "z", "x", "c", "v", "b", "n", "m", ",", ".", "/", "shift"], +] + +const WIDE_KEYS: Record = { + bksp: 2, + tab: 1.6, + "\\": 1.4, + ctrl: 1.8, + enter: 2.2, + shift: 2.4, +} + +function Key({ + label = "", + w = 1, + pressed = false, +}: { + label?: string + w?: number + pressed?: boolean +}) { + return ( +
+ {label} +
+ ) +} + +/** Physical key → drawn keycap label, for everything the board draws. */ +function labelOfKey(e: KeyboardEvent): string { + const named: Record = { + Escape: "esc", + Backspace: "bksp", + Tab: "tab", + Control: "ctrl", + Enter: "enter", + Shift: "shift", + Alt: "alt", + CapsLock: "caps", + " ": "space", + ArrowUp: "ArrowUp", + ArrowDown: "ArrowDown", + ArrowLeft: "ArrowLeft", + ArrowRight: "ArrowRight", + } + return named[e.key] ?? e.key.toLowerCase() +} + +/** Tracks every physically-pressed key so the whole drawn board mirrors the + * real one — type anywhere and the machine types with you. */ +function usePressedKeys() { + const [pressed, setPressed] = useState>(new Set()) + useEffect(() => { + const down = (e: KeyboardEvent) => { + setPressed((prev) => new Set(prev).add(labelOfKey(e))) + } + const up = (e: KeyboardEvent) => { + setPressed((prev) => { + const next = new Set(prev) + next.delete(labelOfKey(e)) + return next + }) + } + // Keys held while the window loses focus would stick down forever. + const clear = () => setPressed(new Set()) + window.addEventListener("keydown", down) + window.addEventListener("keyup", up) + window.addEventListener("blur", clear) + return () => { + window.removeEventListener("keydown", down) + window.removeEventListener("keyup", up) + window.removeEventListener("blur", clear) + } + }, []) + return pressed +} + +function Keyboard() { + const pressed = usePressedKeys() + return ( + // Lies flat on the desk: the deck tilts away from the viewer so the far + // edge (at the case) is narrower than the near edge — like a real board. +
+
+
+ {/* main key block — every cap mirrors the physical board */} +
+ {KEY_ROWS.map((row) => ( +
+ {row.map((k, i) => ( + // biome-ignore lint/suspicious/noArrayIndexKey: static layout + + ))} +
+ ))} +
+ + + +
+
+ + {/* arrow cluster */} +
+
+
+ +
+
+
+ + + +
+ + play + +
+
+
+
+ ) +} + +/* ------------------------------------------------------------- monitor */ + +function Monitor({ + gameCanvas, + onDemoEngagement, +}: { + gameCanvas: HTMLCanvasElement | null + onDemoEngagement?: (engaged: boolean) => void +}) { + return ( +
+ {/* housing */} +
+ {/* recessed bed around the tube */} +
+ {/* the tube — 4:3, softly rounded corners like real glass */} +
+ {/* the picture warms in a beat after the tube arrives */} + + {gameCanvas ? ( + + ) : ( + + )} + +
+
+ {/* chin: moulded brand, model sticker, controls, power light */} +
+
+ {/* brand moulded into the plastic — debossed, not printed */} + + tripwire + + {/* the little metallic model plate every CRT carried */} + + cm 1431 + +
+
+ + + + {/* tube power light */} + +
+
+
+ {/* neck + foot */} +
+
+
+ ) +} + +/* ----------------------------------------------------------- system unit */ + +function SystemUnit({ + powered, + onPowerToggle, +}: { + powered: boolean + onPowerToggle: () => void +}) { + return ( +
+
+ {/* vent slats + red stripe, Philips-style */} +
+
+ + + p3120 + +
+
+
+ + {/* drive bays — slot, activity light, eject. no labels, like the + real thing */} +
+ {["upper", "lower"].map((bay) => ( +
+ + + +
+ ))} +
+ + {/* power button + LED — the machine's one real control */} +
+ + {/* plain moulded push button — latched in while the machine runs */} +
+
+
+ ) +} + +/* ---------------------------------------------------------------- shell */ + +export function RetroComputer({ + powered, + onPowerToggle, + gameCanvas = null, + onDemoEngagement, +}: { + powered: boolean + onPowerToggle: () => void + /** When set, the live game replaces the dashboard demo on the CRT. */ + gameCanvas?: HTMLCanvasElement | null + /** Fires when the visitor's mouse takes / releases the demo screen. */ + onDemoEngagement?: (engaged: boolean) => void +}) { + return ( +
+ {/* monitor rises out of the case top */} + + + + +
+ +
+ + {/* keyboard slides out from under the case, a beat behind the monitor */} + + +

+ {gameCanvas + ? "arrows move, space shoots, esc hands the machine back" + : "press an arrow key to play"} +

+
+
+ ) +} diff --git a/apps/web/src/components/layout/landing/space-invaders.tsx b/apps/web/src/components/layout/landing/space-invaders.tsx index 74d0add7..d63a03aa 100644 --- a/apps/web/src/components/layout/landing/space-invaders.tsx +++ b/apps/web/src/components/layout/landing/space-invaders.tsx @@ -164,8 +164,8 @@ function drawExplosion( } } -// The game renders to an offscreen canvas that gets fed into FaultyTerminal's shader as a texture. -// No visible DOM element — the terminal IS the display. +// The game renders to an offscreen 4:3 canvas sized for the landing page's +// CRT — the RetroComputer mounts it inside the tube as the display. export function useSpaceInvaders( active: boolean, onExit: () => void @@ -211,8 +211,9 @@ export function useSpaceInvaders( return } - const W = window.innerWidth - const H = window.innerHeight + // Fixed 4:3 — the CRT's aspect ratio, downscaled by CSS into the tube. + const W = 800 + const H = 600 const c = document.createElement("canvas") c.width = W c.height = H diff --git a/apps/web/src/routes/index.tsx b/apps/web/src/routes/index.tsx index 71796de8..062d64be 100644 --- a/apps/web/src/routes/index.tsx +++ b/apps/web/src/routes/index.tsx @@ -1,50 +1,12 @@ import { createFileRoute, Link } from "@tanstack/react-router" +import { motion } from "motion/react" import { useEffect, useState, useCallback } from "react" import { buildSeo } from "#/lib/seo" import { authClient } from "@tripwire/auth/client" import { LandingHeader } from "#/components/layout/landing/header" import { useSpaceInvaders } from "#/components/layout/landing/space-invaders" -import FaultyTerminal from "#/components/layout/landing/faulty-terminal" -import { - TRIPWIRE_EYE_OUTER_PATH, - TRIPWIRE_EYE_OUTER_VIEWBOX, - TRIPWIRE_EYE_SOCKET_PATH, - TRIPWIRE_EYE_SOCKET_VIEWBOX, - TRIPWIRE_EYE_SOCKET_RECT_IN_OUTER, - TRIPWIRE_EYE_PUPIL_PATH, - TRIPWIRE_EYE_PUPIL_VIEWBOX, - TRIPWIRE_EYE_PUPIL_RECT_IN_OUTER, -} from "@tripwire/ui/icons/tripwire-eye" - -const EYE_CURSOR_MASK = { - viewBox: TRIPWIRE_EYE_OUTER_VIEWBOX, - width: 1.05, - layers: [ - { - path: TRIPWIRE_EYE_OUTER_PATH, - viewBox: TRIPWIRE_EYE_OUTER_VIEWBOX, - rect: [ - 0, - 0, - TRIPWIRE_EYE_OUTER_VIEWBOX[0], - TRIPWIRE_EYE_OUTER_VIEWBOX[1], - ] as const, - mode: "add" as const, - }, - { - path: TRIPWIRE_EYE_SOCKET_PATH, - viewBox: TRIPWIRE_EYE_SOCKET_VIEWBOX, - rect: TRIPWIRE_EYE_SOCKET_RECT_IN_OUTER, - mode: "subtract" as const, - }, - { - path: TRIPWIRE_EYE_PUPIL_PATH, - viewBox: TRIPWIRE_EYE_PUPIL_VIEWBOX, - rect: TRIPWIRE_EYE_PUPIL_RECT_IN_OUTER, - mode: "add" as const, - }, - ], -} +import { CloudEye } from "#/components/layout/landing/cloud-eye" +import { RetroComputer } from "#/components/layout/landing/retro-computer" export const Route = createFileRoute("/")({ component: LandingPage, @@ -60,83 +22,86 @@ export const Route = createFileRoute("/")({ function LandingPage() { const { data: session } = authClient.useSession() + const [powered, setPowered] = useState(false) const [gameActive, setGameActive] = useState(false) - const [transitioning, setTransitioning] = useState(false) + const [demoEngaged, setDemoEngaged] = useState(false) - const exitGame = useCallback(() => { - setGameActive(false) - setTransitioning(false) + const exitGame = useCallback(() => setGameActive(false), []) + const togglePower = useCallback(() => { + setPowered((on) => { + if (on) setGameActive(false) // powering off takes the game with it + return !on + }) }, []) + // The game boots straight onto the retro computer's CRT, replacing the + // dashboard demo — but only once the machine is switched on. const gameCanvas = useSpaceInvaders(gameActive, exitGame) useEffect(() => { - if (gameActive || transitioning) return + if (!powered || gameActive) return const onKey = (e: KeyboardEvent) => { - if (e.key.startsWith("Arrow")) { - setTransitioning(true) - setTimeout(() => setGameActive(true), 600) - } + if (e.key.startsWith("Arrow")) setGameActive(true) } window.addEventListener("keydown", onKey) return () => window.removeEventListener("keydown", onKey) - }, [gameActive, transitioning]) + }, [powered, gameActive]) return ( -
- {/* Terminal — the game renders INSIDE it via the gameCanvas texture */} -
- + // The stock desktop wallpaper, teal underneath while it loads. +
+ {/* The eye, as a cloud in the sky, riding the cursor */} +
+
- {/* Landing content — fades out when game activates */} -
+ {/* Hero — dead centre, above the machine */} +
-
-

+ {/* fades away while the machine is running — it has the stage */} + +

catch slop before it catches up with you

{session ? ( get started ) : ( login )} -

+ +
+ + {/* The machine waits at the bottom edge; its power button does the rest */} +
+
)