feat: cyberpunk HUD charts + drop "test" from faucet copy
All checks were successful
deploy / deploy (push) Successful in 1m41s

- SlippageCurve: segmented depth-charge meter with a volt redline at the 2%
  slippage limit, replacing the flat bar.
- DepthHistory: area trace with survey grid, peak marker and a pulsing live
  'now' node.
- HealthRadial: ticked radial dial with reticle crosshairs instead of the
  plain arc.
- Faucet/action copy: "Get test hUSD" -> "Get hUSD", "Get test {SYM}" ->
  "Get {SYM}", "test hUSD minted" -> "hUSD minted".

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Денис Зефиров 2026-07-11 23:41:04 +04:00
parent 9f00841623
commit a0da0712c7
5 changed files with 167 additions and 85 deletions

View file

@ -196,9 +196,9 @@ export function DepositPanel({
? `Enter ${symbol} amount`
: "Enter amount"
: needsUsdFaucet
? "Get test hUSD"
? "Get hUSD"
: needsStockFaucet
? `Get test ${symbol}`
? `Get ${symbol}`
: needsUsdApprove
? "Approve hUSD"
: needsStockApprove

View file

@ -1,11 +1,14 @@
/**
* 30-day depth sparkline. Historical points are estimated: a deterministic,
* 30-day depth trace. Historical points are estimated: a deterministic,
* per-market random walk that converges to the current live depth figure, so
* the line is stable across renders and always ends at truth.
* the line is stable across renders and always ends at truth. Rendered as a
* cyberpunk area trace with grid, glow and hi/now field markers.
*/
const W = 220;
const H = 48;
const W = 240;
const H = 58;
const DAYS = 30;
const TOP = 7;
const BOT = H - 8;
function seeded(i: number, salt: number) {
const x = Math.sin(i * 91.7 + salt * 47.3) * 24634.5453;
@ -34,40 +37,50 @@ export function DepthHistory({
const max = Math.max(...values);
const min = Math.min(...values);
const span = max - min || 1;
const pts = values
.map((v, i) => {
const x = (i / (DAYS - 1)) * W;
const y = H - 6 - ((v - min) / span) * (H - 12);
return `${x.toFixed(1)},${y.toFixed(1)}`;
})
.join(" ");
const xAt = (i: number) => (i / (DAYS - 1)) * W;
const yAt = (v: number) => BOT - ((v - min) / span) * (BOT - TOP);
const pts = values.map((v, i) => `${xAt(i).toFixed(1)},${yAt(v).toFixed(1)}`).join(" ");
const idxMax = values.indexOf(max);
const gid = `dh-${salt}`;
return (
<svg
viewBox={`0 0 ${W} ${H}`}
className="h-12 w-full"
className="h-14 w-full"
aria-hidden
style={{ filter: "drop-shadow(0 0 4px rgba(200, 255, 0, 0.45))" }}
style={{ filter: "drop-shadow(0 0 3px rgba(200, 255, 0, 0.4))" }}
>
<polyline
points={pts}
fill="none"
stroke="#c8ff00"
strokeWidth="1.5"
strokeOpacity="0.85"
/>
<polygon
points={`0,${H} ${pts} ${W},${H}`}
fill="#c8ff00"
opacity="0.045"
/>
<line x1="0" y1={H - 0.5} x2={W} y2={H - 0.5} stroke="#2a2a2a" strokeWidth="1" />
<circle
cx={W}
cy={H - 6 - ((values[DAYS - 1] - min) / span) * (H - 12)}
r="2.5"
fill="#c8ff00"
/>
<defs>
<linearGradient id={gid} x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stopColor="#c8ff00" stopOpacity="0.22" />
<stop offset="100%" stopColor="#c8ff00" stopOpacity="0.015" />
</linearGradient>
</defs>
{/* survey grid */}
{[0.25, 0.5, 0.75].map((g) => (
<line key={g} x1="0" y1={TOP + g * (BOT - TOP)} x2={W} y2={TOP + g * (BOT - TOP)} stroke="#1c1c1c" strokeWidth="1" />
))}
{[0.25, 0.5, 0.75].map((g) => (
<line key={g} x1={g * W} y1={TOP} x2={g * W} y2={BOT} stroke="#1c1c1c" strokeWidth="1" />
))}
<polygon points={`0,${BOT} ${pts} ${W},${BOT}`} fill={`url(#${gid})`} />
<polyline points={pts} fill="none" stroke="#c8ff00" strokeWidth="1.5" strokeOpacity="0.9" />
{/* peak marker */}
<circle cx={xAt(idxMax)} cy={yAt(max)} r="2" fill="none" stroke="#e9ffb3" strokeWidth="1" />
{/* baseline */}
<line x1="0" y1={BOT + 0.5} x2={W} y2={BOT + 0.5} stroke="#2a2a2a" strokeWidth="1" />
{/* live 'now' node */}
<line x1={W - 0.5} y1={TOP} x2={W - 0.5} y2={BOT} stroke="#c8ff00" strokeOpacity="0.35" strokeWidth="1" strokeDasharray="2 3" />
<circle cx={W} cy={yAt(values[DAYS - 1])} r="2.6" fill="#c8ff00">
<animate attributeName="r" values="2.6;4;2.6" dur="2.4s" repeatCount="indefinite" />
<animate attributeName="fill-opacity" values="1;0.55;1" dur="2.4s" repeatCount="indefinite" />
</circle>
</svg>
);
}

View file

@ -1,51 +1,70 @@
import { healthLabel } from "@/lib/math";
/** SVG radial gauge for the Market Health Score. */
const TICKS = 34; // segments around the 270° dial
const START = 135; // degrees, y-down; gap sits at the bottom
const SWEEP = 270;
const CX = 40;
const CY = 40;
function pt(r: number, deg: number): [number, number] {
const a = (deg * Math.PI) / 180;
return [CX + r * Math.cos(a), CY + r * Math.sin(a)];
}
/** Ticked radial dial for the Market Health Score. */
export function HealthRadial({ score }: { score: number }) {
const { label, tone } = healthLabel(score);
const color = tone === "mint" ? "#c8ff00" : tone === "violet" ? "#8a5cff" : "#ff3b00";
const r = 34;
const c = 2 * Math.PI * r;
const filled = (score / 100) * c * 0.75; // 270 degree arc
const lit = Math.round((Math.max(0, Math.min(score, 100)) / 100) * TICKS);
return (
<div className="flex items-center gap-3">
<svg viewBox="0 0 80 80" className="h-16 w-16" aria-hidden>
<g transform="rotate(135 40 40)">
<circle
cx="40"
cy="40"
r={r}
fill="none"
stroke="#2a2a2a"
strokeWidth="6"
strokeDasharray={`${c * 0.75} ${c}`}
strokeLinecap="round"
/>
<circle
cx="40"
cy="40"
r={r}
fill="none"
stroke={color}
strokeWidth="6"
strokeDasharray={`${filled} ${c}`}
strokeLinecap="butt"
style={{ filter: `drop-shadow(0 0 4px ${color})` }}
>
<animate
attributeName="stroke-dasharray"
from={`0 ${c}`}
to={`${filled} ${c}`}
dur="0.9s"
fill="freeze"
calcMode="spline"
keySplines="0.25 0.1 0.25 1"
/>
</circle>
{/* outer reticle ring */}
<circle cx={CX} cy={CY} r="37" fill="none" stroke="#1c1c1c" strokeWidth="1" />
{/* dial ticks */}
<g style={{ filter: `drop-shadow(0 0 2.5px ${color})` }}>
{Array.from({ length: TICKS }, (_, i) => {
const deg = START + (i / (TICKS - 1)) * SWEEP;
const on = i < lit;
const [x1, y1] = pt(24, deg);
const [x2, y2] = pt(on ? 33 : 30, deg);
return (
<line
key={i}
x1={x1}
y1={y1}
x2={x2}
y2={y2}
stroke={on ? color : "#2a2a2a"}
strokeWidth={on ? 2 : 1.4}
strokeLinecap="butt"
>
{on && (
<animate
attributeName="stroke-opacity"
from="0"
to="1"
dur="0.4s"
begin={`${i * 0.02}s`}
fill="freeze"
/>
)}
</line>
);
})}
</g>
{/* crosshair ticks at 12 / 3 / 9 o'clock */}
{[270, 0, 180].map((deg) => {
const [x1, y1] = pt(15, deg);
const [x2, y2] = pt(18, deg);
return <line key={deg} x1={x1} y1={y1} x2={x2} y2={y2} stroke="#3d3d3d" strokeWidth="1" />;
})}
<text
x="40"
x={CX}
y="44"
textAnchor="middle"
fill="#f2f2f2"

View file

@ -2,26 +2,76 @@ import { slippageFor } from "@/lib/math";
import { fmtUsd } from "@/lib/format";
const SIZES = [1_000, 5_000, 10_000, 25_000, 50_000, 100_000];
const CELLS = 34; // meter segments per row
const STEP = 3; // cell pitch in viewBox units
/** Horizontal bars: estimated slippage per trade size at current depth. */
/**
* Segmented depth-charge meter: estimated slippage per trade size at current
* depth, drawn as a lit-cell HUD bar with a volt redline at the 2% limit.
*/
export function SlippageCurve({ liquidityUsd }: { liquidityUsd: number }) {
const max = slippageFor(SIZES[SIZES.length - 1], liquidityUsd);
// shared 2% danger limit, mapped onto the meter (off-scale in deep books)
const limitX = max > 0 ? Math.min((0.02 / max), 1) * CELLS * STEP : CELLS * STEP;
return (
<div className="space-y-2">
{SIZES.map((size) => {
<div className="space-y-[7px]">
{SIZES.map((size, row) => {
const s = slippageFor(size, liquidityUsd);
const width = max > 0 ? (s / max) * 100 : 0;
const frac = max > 0 ? s / max : 0;
const hot = s > 0.02;
const on = Math.max(1, Math.round(frac * CELLS));
const color = hot ? "#ff3b00" : "#c8ff00";
return (
<div key={size} className="flex items-center gap-3 text-xs">
<span className="num w-14 shrink-0 text-right text-fog">{fmtUsd(size)}</span>
<svg className="h-3 flex-1" preserveAspectRatio="none" viewBox="0 0 100 10" aria-hidden>
<rect x="0" y="3" width="100" height="4" fill="#2a2a2a" />
<rect x="0" y="3" width={Math.max(width, 1)} height="4" fill={hot ? "#ff3b00" : "#c8ff00"}>
<animate attributeName="width" from="0" to={Math.max(width, 1)} dur="0.7s" fill="freeze" />
</rect>
<div key={size} className="flex items-center gap-3">
<span className="num w-14 shrink-0 text-right font-mono text-[10px] text-fog">
{fmtUsd(size)}
</span>
<svg
className="h-3.5 flex-1"
preserveAspectRatio="none"
viewBox={`0 0 ${CELLS * STEP} 12`}
aria-hidden
style={{ filter: `drop-shadow(0 0 2.5px ${color})` }}
>
{Array.from({ length: CELLS }, (_, i) => {
const lit = i < on;
return (
<rect
key={i}
x={i * STEP}
y="2"
width={STEP - 0.9}
height="8"
fill={lit ? color : "#2a2a2a"}
opacity={lit ? 1 : 0.55}
>
{lit && (
<animate
attributeName="opacity"
from="0"
to="1"
dur="0.4s"
begin={`${row * 0.05 + i * 0.011}s`}
fill="freeze"
/>
)}
</rect>
);
})}
{limitX < CELLS * STEP && (
<line
x1={limitX}
y1="0"
x2={limitX}
y2="12"
stroke="#5200ff"
strokeWidth="0.9"
strokeDasharray="1.6 1.6"
/>
)}
</svg>
<span className={`num w-14 shrink-0 ${hot ? "text-danger" : "text-mint"}`}>
<span className={`num w-14 shrink-0 font-mono text-xs ${hot ? "text-danger" : "text-mint"}`}>
{(s * 100).toFixed(2)}%
</span>
</div>

View file

@ -129,7 +129,7 @@ export default function AppBoard() {
if (!isConnected) return openConnectModal?.();
if (amount === 0n) return say("enter an amount to deposit");
if (needsFaucet) {
lastAction.current = "test hUSD minted to wallet";
lastAction.current = "hUSD minted to wallet";
writeContract({
address: deployment.usd,
abi: mockERC20Abi,
@ -189,7 +189,7 @@ export default function AppBoard() {
: amount === 0n
? "Enter amount"
: needsFaucet
? "Get test hUSD"
? "Get hUSD"
: needsApprove
? "Approve hUSD"
: "Deposit hUSD";