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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
130 changes: 130 additions & 0 deletions app/home-poc/_components/ComplianceGraph.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,130 @@
'use client';

import { motion, useReducedMotion } from 'framer-motion';

/**
* Signature visual: the FormaOS compliance graph. A central Control hub
* wired to its obligation, owner, evidence and audit nodes. Wires draw
* themselves (pathLength) as the section enters view; the evidence wire
* carries the single vermilion accent. Pure SVG, scales to any width.
*/

type Node = {
id: string;
x: number;
y: number;
label: string;
sub: string;
accent?: boolean;
};

const CENTER = { x: 480, y: 235 };

const NODES: Node[] = [
{ id: 'obligation', x: 150, y: 110, label: 'OBLIGATION', sub: 'NDIS Practice Std 4.2' },
{ id: 'framework', x: 810, y: 110, label: 'FRAMEWORK', sub: 'ISO 27001 · SOC 2' },
{ id: 'owner', x: 150, y: 360, label: 'OWNER', sub: 'Named · accountable' },
{ id: 'evidence', x: 810, y: 360, label: 'EVIDENCE', sub: 'Immutable chain', accent: true },
];

const NODE_W = 196;
const NODE_H = 58;

function curve(a: { x: number; y: number }, b: { x: number; y: number }) {
const mx = (a.x + b.x) / 2;
return `M ${a.x} ${a.y} Q ${mx} ${a.y} ${mx} ${(a.y + b.y) / 2} T ${b.x} ${b.y}`;
}

export function ComplianceGraph() {
const reduce = useReducedMotion();

return (
<svg
viewBox="0 0 960 470"
width="100%"
role="img"
aria-label="The FormaOS compliance graph: a central control wired to its obligation, framework, owner and evidence."
style={{ display: 'block' }}
>
{/* wires (drawn first, behind nodes) */}
{NODES.map((n, i) => (
<motion.path
key={`wire-${n.id}`}
d={curve(CENTER, n)}
fill="none"
stroke={n.accent ? 'var(--accent)' : 'rgba(28,30,31,0.32)'}
strokeWidth={n.accent ? 1.6 : 1.1}
initial={reduce ? false : { pathLength: 0, opacity: 0 }}
whileInView={{ pathLength: 1, opacity: 1 }}
viewport={{ once: true, amount: 0.5 }}
transition={{ duration: 1, delay: 0.2 + i * 0.18, ease: [0.22, 1, 0.36, 1] }}
/>
))}

{/* travelling pulse along the evidence wire */}
{!reduce && (
<motion.circle
r="3"
fill="var(--accent)"
initial={{ opacity: 0 }}
whileInView={{ opacity: [0, 1, 1, 0] }}
viewport={{ once: true, amount: 0.5 }}
transition={{ duration: 1.4, delay: 1.1, ease: 'easeInOut' }}
>
<animateMotion dur="1.4s" begin="1.1s" fill="freeze" path={curve(CENTER, NODES[3])} />
</motion.circle>
)}

{/* center hub */}
<motion.g
initial={reduce ? false : { opacity: 0, scale: 0.92 }}
whileInView={{ opacity: 1, scale: 1 }}
viewport={{ once: true, amount: 0.5 }}
transition={{ duration: 0.6, ease: [0.22, 1, 0.36, 1] }}
>
<rect
x={CENTER.x - 92}
y={CENTER.y - 34}
width={184}
height={68}
rx={5}
fill="#1c1e1f"
/>
<text x={CENTER.x} y={CENTER.y - 6} textAnchor="middle" className="poc-node-label" fill="#f6f4ef">
CONTROL
</text>
<text x={CENTER.x} y={CENTER.y + 14} textAnchor="middle" className="poc-node-sub" fill="#9a9d9f">
Enforced · evaluated · scored
</text>
</motion.g>

{/* satellite nodes */}
{NODES.map((n, i) => (
<motion.g
key={`node-${n.id}`}
initial={reduce ? false : { opacity: 0, y: 10 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true, amount: 0.5 }}
transition={{ duration: 0.5, delay: 0.5 + i * 0.18, ease: [0.22, 1, 0.36, 1] }}
>
<rect
x={n.x - NODE_W / 2}
y={n.y - NODE_H / 2}
width={NODE_W}
height={NODE_H}
rx={5}
fill="#fbfaf7"
stroke={n.accent ? 'var(--accent)' : 'rgba(28,30,31,0.28)'}
strokeWidth={n.accent ? 1.4 : 1}
/>
<text x={n.x} y={n.y - 6} textAnchor="middle" className="poc-node-label">
{n.label}
</text>
<text x={n.x} y={n.y + 13} textAnchor="middle" className="poc-node-sub">
{n.sub}
</text>
</motion.g>
))}
</svg>
);
}
93 changes: 93 additions & 0 deletions app/home-poc/_components/PostureRing.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
'use client';

import { useEffect, useRef, useState } from 'react';
import { useInView, useReducedMotion } from 'framer-motion';

/**
* Live "compliance posture" vignette: a thin ring that fills and a
* number that counts up when scrolled into view. Monochrome ink ring,
* a single forest tick for the cleared arc — no glow.
*/
export function PostureRing({ target = 98 }: { target?: number }) {
const ref = useRef<HTMLDivElement>(null);
const inView = useInView(ref, { once: true, amount: 0.6 });
const reduce = useReducedMotion();
const [value, setValue] = useState(reduce ? target : 0);

useEffect(() => {
if (!inView || reduce) {
if (reduce) setValue(target);
return;
}
let raf = 0;
const duration = 1400;
let start: number | null = null;
const tick = (t: number) => {
if (start === null) start = t;
const p = Math.min((t - start) / duration, 1);
// easeOutCubic
const eased = 1 - Math.pow(1 - p, 3);
setValue(Math.round(eased * target));
if (p < 1) raf = requestAnimationFrame(tick);
};
raf = requestAnimationFrame(tick);
return () => cancelAnimationFrame(raf);
}, [inView, reduce, target]);

const r = 52;
const circ = 2 * Math.PI * r;
const dash = circ * (value / 100);
Comment on lines +11 to +39

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Clamp target to a valid percentage range.

Without normalization, values <0 or >100 produce invalid UI state (number + ring arc).

Proposed fix
 export function PostureRing({ target = 98 }: { target?: number }) {
+  const normalizedTarget = Math.max(0, Math.min(100, target));
   const ref = useRef<HTMLDivElement>(null);
   const inView = useInView(ref, { once: true, amount: 0.6 });
   const reduce = useReducedMotion();
-  const [value, setValue] = useState(reduce ? target : 0);
+  const [value, setValue] = useState(reduce ? normalizedTarget : 0);

   useEffect(() => {
     if (!inView || reduce) {
-      if (reduce) setValue(target);
+      if (reduce) setValue(normalizedTarget);
       return;
     }
@@
-      setValue(Math.round(eased * target));
+      setValue(Math.round(eased * normalizedTarget));
       if (p < 1) raf = requestAnimationFrame(tick);
     };
@@
-  }, [inView, reduce, target]);
+  }, [inView, reduce, normalizedTarget]);
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
export function PostureRing({ target = 98 }: { target?: number }) {
const ref = useRef<HTMLDivElement>(null);
const inView = useInView(ref, { once: true, amount: 0.6 });
const reduce = useReducedMotion();
const [value, setValue] = useState(reduce ? target : 0);
useEffect(() => {
if (!inView || reduce) {
if (reduce) setValue(target);
return;
}
let raf = 0;
const duration = 1400;
let start: number | null = null;
const tick = (t: number) => {
if (start === null) start = t;
const p = Math.min((t - start) / duration, 1);
// easeOutCubic
const eased = 1 - Math.pow(1 - p, 3);
setValue(Math.round(eased * target));
if (p < 1) raf = requestAnimationFrame(tick);
};
raf = requestAnimationFrame(tick);
return () => cancelAnimationFrame(raf);
}, [inView, reduce, target]);
const r = 52;
const circ = 2 * Math.PI * r;
const dash = circ * (value / 100);
export function PostureRing({ target = 98 }: { target?: number }) {
const normalizedTarget = Math.max(0, Math.min(100, target));
const ref = useRef<HTMLDivElement>(null);
const inView = useInView(ref, { once: true, amount: 0.6 });
const reduce = useReducedMotion();
const [value, setValue] = useState(reduce ? normalizedTarget : 0);
useEffect(() => {
if (!inView || reduce) {
if (reduce) setValue(normalizedTarget);
return;
}
let raf = 0;
const duration = 1400;
let start: number | null = null;
const tick = (t: number) => {
if (start === null) start = t;
const p = Math.min((t - start) / duration, 1);
// easeOutCubic
const eased = 1 - Math.pow(1 - p, 3);
setValue(Math.round(eased * normalizedTarget));
if (p < 1) raf = requestAnimationFrame(tick);
};
raf = requestAnimationFrame(tick);
return () => cancelAnimationFrame(raf);
}, [inView, reduce, normalizedTarget]);
const r = 52;
const circ = 2 * Math.PI * r;
const dash = circ * (value / 100);
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@app/home-poc/_components/PostureRing.tsx` around lines 11 - 39, PostureRing
currently accepts a target that can be <0 or >100, which breaks the ring
rendering; normalize/clamp target to 0–100 at the top of the component (e.g.,
compute a const clampedTarget = Math.min(100, Math.max(0, target))) and then use
clampedTarget everywhere instead of raw target (use in initial state
useState(reduce ? clampedTarget : 0), in the animation setValue(Math.round(eased
* clampedTarget)), and for any calculations of dash/circ/value) so the displayed
number and SVG arc always stay within valid percentage bounds.


return (
<div ref={ref} style={{ display: 'flex', alignItems: 'center', gap: 18 }}>
<svg width="128" height="128" viewBox="0 0 128 128" aria-hidden>
<circle
cx="64"
cy="64"
r={r}
fill="none"
stroke="rgba(28,30,31,0.12)"
strokeWidth="6"
/>
<circle
cx="64"
cy="64"
r={r}
fill="none"
stroke="#2f6f57"
strokeWidth="6"
strokeLinecap="round"
strokeDasharray={`${dash} ${circ}`}
transform="rotate(-90 64 64)"
/>
</svg>
<div>
<div
className="poc-serif"
style={{
fontWeight: 300,
fontSize: 44,
lineHeight: 1,
letterSpacing: '-0.02em',
color: 'var(--ink)',
}}
>
{value}
<span style={{ fontSize: 22, color: 'var(--grey)' }}>%</span>
</div>
<div
className="poc-mono"
style={{
fontSize: 11,
letterSpacing: '0.08em',
textTransform: 'uppercase',
color: 'var(--grey)',
marginTop: 4,
}}
>
Audit-ready
</div>
</div>
</div>
);
}
36 changes: 36 additions & 0 deletions app/home-poc/_components/Reveal.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
'use client';

import { motion, useReducedMotion } from 'framer-motion';
import type { CSSProperties, ReactNode } from 'react';

/**
* Editorial reveal: a quiet fade + short rise as the element enters view.
* One motion gesture, no bounce, respects reduced-motion.
*/
export function Reveal({
children,
delay = 0,
y = 18,
className,
style,
}: {
children: ReactNode;
delay?: number;
y?: number;
className?: string;
style?: CSSProperties;
}) {
const reduce = useReducedMotion();
return (
<motion.div
className={className}
style={style}
initial={reduce ? false : { opacity: 0, y }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true, amount: 0.3, margin: '0px 0px -10% 0px' }}
transition={{ duration: 0.7, delay, ease: [0.22, 1, 0.36, 1] }}
>
{children}
</motion.div>
);
}
36 changes: 36 additions & 0 deletions app/home-poc/_components/StickyCTA.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
'use client';

import { useEffect, useState } from 'react';

/**
* Mobile-only sticky CTA bar. Appears after the hero scrolls away so the
* primary action is always one tap from anywhere on the page.
*/
export function StickyCTA() {
const [show, setShow] = useState(false);

useEffect(() => {
const onScroll = () => setShow(window.scrollY > 620);
onScroll();
window.addEventListener('scroll', onScroll, { passive: true });
return () => window.removeEventListener('scroll', onScroll);
}, []);

if (!show) return null;

return (
<div className="poc-sticky-cta lg:hidden">
<div>
<div className="poc-mono" style={{ fontSize: 11, color: 'var(--grey)', letterSpacing: '0.04em' }}>
FORMAOS
</div>
<div style={{ fontSize: 13, color: 'var(--ink)', fontWeight: 500 }}>
Prove compliance, faster.
</div>
</div>
<a href="#" className="poc-btn poc-btn-primary" style={{ padding: '0.7rem 1.1rem' }}>
Book a walkthrough
</a>
</div>
);
}
49 changes: 49 additions & 0 deletions app/home-poc/layout.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
import type { Metadata } from 'next';
import { Fraunces, Hanken_Grotesk, IBM_Plex_Mono } from 'next/font/google';
import './poc.css';

// Editorial display serif — optical sizing on, expressive but enterprise.
// Deliberately NOT Sora; this is the "design annual" voice.
const fraunces = Fraunces({
subsets: ['latin'],
display: 'swap',
variable: '--poc-serif',
axes: ['opsz', 'SOFT', 'WONK'],
weight: 'variable',
style: ['normal', 'italic'],
});

// Body grotesque — humanist, quiet, not Inter.
const hanken = Hanken_Grotesk({
subsets: ['latin'],
display: 'swap',
variable: '--poc-sans',
weight: ['400', '500', '600', '700'],
});

// Technical mono for eyebrows, indices and numerals.
const plexMono = IBM_Plex_Mono({
subsets: ['latin'],
display: 'swap',
variable: '--poc-mono',
weight: ['400', '500'],
});

export const metadata: Metadata = {
title: 'FormaOS — Homepage POC (Editorial Monochrome)',
robots: { index: false, follow: false },
};

export default function HomePocLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<div
className={`${fraunces.variable} ${hanken.variable} ${plexMono.variable} poc-root`}
>
{children}
</div>
);
}
Loading
Loading