/* TitleVerifi — Nodes mark system
The chain of title, drawn as a graph.
One canonical mark, with animated and dense variations.
*/
/* ============================================================
ANIMATION KEYFRAMES — one calm traveling pulse along the chain
============================================================ */
if (typeof document !== 'undefined' && !document.getElementById('tv-mark-anim')) {
const s = document.createElement('style');
s.id = 'tv-mark-anim';
s.textContent = `
/* A single circle travels the chain path; nodes brighten as it passes. */
@keyframes tv-travel {
0% { offset-distance: 0%; opacity: 0; }
6% { opacity: 1; }
94% { opacity: 1; }
100% { offset-distance: 100%; opacity: 0; }
}
/* Each node receives a brief brightening at its time-slot in the cycle. */
@keyframes tv-node-active-1 {
0%, 8%, 100% { fill-opacity: 0.45; r: 3.2; }
14%, 22% { fill-opacity: 1; r: 3.8; }
}
@keyframes tv-node-active-2 {
0%, 32%, 100% { fill-opacity: 0.45; r: 3.2; }
38%, 46% { fill-opacity: 1; r: 3.8; }
}
@keyframes tv-node-active-3 {
0%, 56%, 100% { fill-opacity: 0.45; r: 3.2; }
62%, 70% { fill-opacity: 1; r: 3.8; }
}
@keyframes tv-node-active-4 {
0%, 80%, 100% { fill-opacity: 0.45; r: 3.2; }
86%, 94% { fill-opacity: 1; r: 3.8; }
}
/* Subtle accent pulse — only on the highlighted node when set explicitly. */
@keyframes tv-accent-glow {
0%, 100% { fill-opacity: 1; r: 4; }
50% { fill-opacity: 0.7; r: 4.4; }
}
/* === SEMANTIC STATES =================================================
Each "state" tells the mark what's happening in the UI.
The sequence runs over a configurable cycle length.
==================================================================== */
/* PROCESSING — node "scans": each node briefly inflates with an accent ring
and brightens, in sequence. Reads as "examining each record." */
@keyframes tv-proc-scan {
0%, 100% { r: 3.2; fill-opacity: 0.4; }
18%, 28% { r: 4.4; fill-opacity: 1; }
}
@keyframes tv-proc-ring {
0%, 10% { r: 3.2; opacity: 0; stroke-width: 1.2; }
18% { r: 4.4; opacity: 0.9; }
40% { r: 7; opacity: 0; stroke-width: 0.4; }
100% { r: 7; opacity: 0; }
}
/* Edges dim, then briefly carry charge along when the upstream node fires. */
@keyframes tv-proc-edge {
0%, 100% { opacity: 0.2; }
15%, 30% { opacity: 0.85; }
}
/* CONNECTING — hub lands first, edges draw outward from hub,
then peripheral nodes materialize at the edge tips. Loops cleanly. */
@keyframes tv-conn-hub {
0% { transform: scale(0); opacity: 0; }
8% { transform: scale(1.25); opacity: 1; }
14%, 100%{ transform: scale(1); opacity: 1; }
}
@keyframes tv-conn-edge {
0%, 14% { stroke-dashoffset: 24; opacity: 0; }
28%, 88% { stroke-dashoffset: 0; opacity: 0.55; }
96%, 100% { stroke-dashoffset: 0; opacity: 0; }
}
@keyframes tv-conn-node {
0%, 28% { transform: scale(0); opacity: 0; }
36% { transform: scale(1.35); opacity: 1; }
44%, 88% { transform: scale(1); opacity: 1; }
96%, 100% { transform: scale(1); opacity: 0; }
}
/* RESOLVING — whole mark rotates 360° once, then settles. */
@keyframes tv-resolve-spin {
0% { transform: rotate(0deg); }
80% { transform: rotate(360deg); }
100% { transform: rotate(360deg); }
}
/* COMPLETE — brief outward bloom on the accent, all nodes settle lit. */
@keyframes tv-complete-bloom {
0% { r: 3.5; fill-opacity: 0.7; }
40% { r: 5; fill-opacity: 1; }
100% { r: 4; fill-opacity: 1; }
}
@keyframes tv-complete-ring {
0% { r: 3.5; opacity: 0.6; }
100% { r: 12; opacity: 0; }
}
/* ERROR — one node shakes laterally, accent flashes. */
@keyframes tv-error-shake {
0%, 100% { transform: translateX(0); }
20% { transform: translateX(-1.5px); }
40% { transform: translateX(1.5px); }
60% { transform: translateX(-1px); }
80% { transform: translateX(0.5px); }
}
@keyframes tv-error-flash {
0%, 100% { fill-opacity: 1; }
30%, 60% { fill-opacity: 0.4; }
}
/* TV monogram — peripheral nodes brighten as the pulse passes their
position on the figure-8 chain. Times are tuned to the pulsePath. */
@keyframes tv-tv-node-0 { 0%, 100% { fill-opacity: 0.55; r: 2.1; } 8%, 14% { fill-opacity: 1; r: 2.5; } }
@keyframes tv-tv-node-2 { 0%, 100% { fill-opacity: 0.55; r: 2.1; } 26%, 32% { fill-opacity: 1; r: 2.5; } }
@keyframes tv-tv-node-4 { 0%, 100% { fill-opacity: 0.55; r: 2.1; } 44%, 52% { fill-opacity: 1; r: 2.5; } }
@keyframes tv-tv-node-3 { 0%, 100% { fill-opacity: 0.55; r: 2.1; } 78%, 86% { fill-opacity: 1; r: 2.5; } }
@keyframes cv-shimmer {
0%,100% { fill-opacity: var(--shimmer-base, 0.78); }
50% { fill-opacity: 1; }
}
@keyframes cv-canonical {
0%,100% { fill-opacity: 1; }
50% { fill-opacity: 0.74; }
}
`;
document.head.appendChild(s);
}
/* ============================================================
CANONICAL — Nodes (4 nodes), now with semantic UI states.
Props:
- state: 'idle' | 'processing' | 'connecting' | 'resolving'
| 'complete' | 'error'
Default 'idle' (calm traveling pulse, the rest state).
- activeNode: 0..3 → lock that node lit (overrides animation).
Use this for tab/section wayfinding.
- cycle: animation cycle length in seconds (default 2.4s for active
states, 4s for idle).
- animate: master kill-switch (false = static, used by print).
============================================================ */
const MarkNodes = ({
size = 32, color = 'currentColor', accent = 'var(--accent)',
animate = true, activeNode = null, state = 'idle', cycle,
}) => {
// 4 nodes: a chain that reads left → center → right → bottom-right.
const nodes = [
{ x: 7, y: 9 }, // 0: prior record
{ x: 16, y: 16 }, // 1: hub (the active title) — accent
{ x: 25, y: 9 }, // 2: prior record
{ x: 25, y: 23 }, // 3: derived record
];
const edges = [
{ from: 0, to: 1, dur: 0.0 }, // edge 0
{ from: 2, to: 1, dur: 0.15 }, // edge 1
{ from: 3, to: 1, dur: 0.30 }, // edge 2
];
// Path the pulse travels along.
const pulsePath = "M 7 9 L 16 16 L 25 9 L 16 16 L 25 23 L 16 16 L 7 9";
const isLocked = activeNode !== null && activeNode !== undefined;
const isStatic = !animate;
// When activeNode is set, force idle behavior + lock — wayfinding wins.
const effectiveState = isLocked ? 'locked' : (isStatic ? 'static' : state);
const c = cycle || (effectiveState === 'idle' ? 4 : 2.4);
// ---- Per-state node renderer -------------------------------------------
const renderNode = (n, i) => {
const isHub = i === 1;
const baseFill = isHub ? accent : color;
// LOCKED — exactly one node lit, others dimmed, no animation.
if (effectiveState === 'locked') {
const lit = activeNode === i;
return (
);
}
// STATIC — print/favicon, no animation.
if (effectiveState === 'static') {
return ;
}
// IDLE — calm traveling pulse: nodes sequentially brighten.
if (effectiveState === 'idle') {
return (
);
}
// PROCESSING — each node "scans": staggered inflate + accent ring sweep.
if (effectiveState === 'processing') {
// Scan order: 0 → 2 → 1 (hub) → 3. Hub fires last so it reads as the resolution.
const order = [0, 2, 1, 3];
const slot = order.indexOf(i);
const delay = slot * (c / 4);
return (
);
}
// CONNECTING — hub lands first, then peripheral nodes materialize at edge tips.
if (effectiveState === 'connecting') {
// Hub: lands at t=0. Peripheral nodes: land after their edge draws.
// Edges draw 14%→28% with stagger 0/0.15/0.30. Node pops at 36% absolute.
const peripheralOrder = { 0: 0, 2: 1, 3: 2 }; // 0,2,3 stagger
const stagger = isHub ? 0 : (peripheralOrder[i] || 0) * 0.12;
return (
);
}
// RESOLVING — handled at group level; just render the nodes lit.
if (effectiveState === 'resolving') {
return ;
}
// COMPLETE — all nodes settle lit, accent blooms once + ring expands.
if (effectiveState === 'complete') {
return (
);
}
// ERROR — accent flashes; node 3 shakes (the "wrong" record).
if (effectiveState === 'error') {
const isShaker = i === 3;
return (
);
}
return null;
};
// ---- Per-state edge renderer -------------------------------------------
const renderEdge = (e, i) => {
const a = nodes[e.from], b = nodes[e.to];
if (effectiveState === 'connecting') {
// Draw FROM hub OUTWARD to peripheral node. Edge order: 0→1, 2→1, 3→1
// becomes hub(b) → peripheral(a). Stagger: 0 / 0.12 / 0.24.
const stagger = i * 0.12;
// Use pathLength so dashoffset works regardless of geometry.
return (
);
}
if (effectiveState === 'processing') {
// Edge brightens when its upstream (non-hub) node fires.
const order = [0, 2, 1, 3];
const slot = order.indexOf(e.from);
const delay = slot * (c / 4);
return (
);
}
return (
);
};
// ---- Group-level wrapper for resolving (full 360° spin) ----------------
const groupStyle = effectiveState === 'resolving' ? {
transformOrigin: '16px 16px',
transformBox: 'fill-box',
animation: `tv-resolve-spin ${c}s cubic-bezier(.65,.05,.35,1) infinite`,
} : undefined;
return (
);
};
/* ============================================================
STATIC — Nodes, no animation
============================================================ */
const MarkNodesStatic = ({ size = 32, color = 'currentColor', accent = 'var(--accent)' }) => (
);
/* ============================================================
Wordmark only
============================================================ */
const MarkNone = () => null;
/* ============================================================
TV — node-monogram. The letterforms T and V drawn as a graph.
N0 ───── N1 ───── N2 T's bar, with N1 as the shared joint
│ ╲ ╲
│ ╲ ╲
│ ╲ ╲
N3 N4 T's stem foot · V's vertex
Strokes:
N0—N1 T-bar (left half)
N1—N2 T-bar (right half) — also the visual "lid" of the V
N1—N3 T-stem (vertical)
N1—N4 V-left-stroke
N2—N4 V-right-stroke
N1 is the 4-way hub — it carries the accent.
============================================================ */
const MarkTV = ({
size = 32, color = 'currentColor', accent = 'var(--accent)',
animate = true, activeNode = null, state = 'idle', cycle,
}) => {
const nodes = [
{ x: 4, y: 7 }, // 0 T-bar-left tip
{ x: 13, y: 7 }, // 1 HUB · shared joint (T-stem top + T-bar mid + V-left top)
{ x: 24, y: 7 }, // 2 T-bar-right tip · V-right top
{ x: 13, y: 22 }, // 3 T-stem foot
{ x: 18.5, y: 22 },// 4 V-vertex (bottom point)
];
const edges = [
[0, 1], // T-bar L
[1, 2], // T-bar R (also V-lid)
[1, 3], // T-stem
[1, 4], // V-left
[2, 4], // V-right
];
const HUB = 1;
const pulsePath = "M 4 7 L 13 7 L 24 7 L 18.5 22 L 13 7 L 13 22 L 13 7";
const isLocked = activeNode !== null && activeNode !== undefined;
const isStatic = !animate;
const effectiveState = isLocked ? 'locked' : (isStatic ? 'static' : state);
const c = cycle || (effectiveState === 'idle' ? 4.5 : 2.4);
return (
);
};
/* ============================================================== */
/* CANONICAL MARK · The Chain · Strata (B1) */
/* ============================================================== */
/* Four stacked deed bars, oldest faded at top, canonical at */
/* bottom in vermilion. Vertical tie-spine on the left. */
/* The unbroken chain of title made visible. */
/* */
/* This component intentionally ignores `state`/`activeNode` */
/* props passed by legacy callsites — the LOGO is fixed. */
/* Status indicators live in product UI, not on the mark. */
const MarkStrata = ({ size = 32, color = 'currentColor', accent = 'var(--accent)', animate }) => {
const motion = animate === false ? 'static' : 'ambient';
/* Four grey strata (oldest faded) on a 3.5-unit rhythm, then the vermilion canonical bar.
Spine ends exactly flush with canonical bottom. */
const bars = [
{ y: 6, h: 1.6, op: 0.35 },
{ y: 9.5, h: 1.6, op: 0.55 },
{ y: 13, h: 1.6, op: 0.75 },
{ y: 16.5, h: 1.6, op: 0.92 },
];
return (
);
};
const MARKS = {
nodes: MarkStrata, // ★ canonical — Chain · Strata (B1)
strata: MarkStrata, // explicit alias
tv: MarkStrata, // legacy alias → canonical
nodesStatic: (props) => ,
none: MarkNone,
};
const Mark = ({ variant = 'nodes', size = 32, color = 'currentColor', accent = 'var(--accent)', animate }) => {
const C = MARKS[variant] || MarkStrata;
if (variant === 'none') return null;
return ;
};
const WordmarkStrataMark = ({ size = 18, color = 'currentColor', accent = 'var(--accent)', animate }) => {
const motion = animate === false ? 'static' : 'ambient';
const width = size;
const height = size / 1.2;
const bars = [
{ y: 0, h: 1.55, op: 0.35 },
{ y: 4.2, h: 1.55, op: 0.55 },
{ y: 8.4, h: 1.55, op: 0.75 },
{ y: 12.6, h: 1.55, op: 0.92 },
];
return (
);
};
const Wordmark = ({
size = 18, color = 'var(--ink)', accent = 'var(--accent)',
variant = 'nodes', accentLetter = 'V', animate, activeNode, state,
}) => {
const showMark = variant !== 'none';
let nameEl;
if (accentLetter === 'V') nameEl = <>TitleVerifi>;
else if (accentLetter === 'i-dot') nameEl = <>TitleVerifi>;
else nameEl = 'TitleVerifi';
return (
{showMark && }
{nameEl}
);
};
window.Mark = Mark;
window.MARKS = MARKS;
window.Wordmark = Wordmark;