feat(macro-vibes): rename BTC Signal → Macro Vibes; add MacroPanel UI

Module rename across page H1, navbar tab, URL (/en/btc → /en/macro),
all metadata/JSON-LD, sitemap, llms.txt, opengraph image, and SystemControl
copy. Old /btc route fully removed.

New components/btc/MacroPanel.tsx polls /api/macro/snapshot and lays out
the 8 indicators in four sections (Valuation / Bottom trigger reference /
Market structure / Sentiment & flows / Positioning) with tone-coloured
values, current-band threshold chips, and a single CoinGlass / source
chart link per card. Composite -100..+100 needle pulses on score change.

Also fixes the pinned bottom-reversal alert on the homepage, which still
linked to the now-404 /en/btc — now routes to /en/macro.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
k
2026-05-26 01:05:18 +08:00
parent d72323b1c6
commit f34ae9eb00
33 changed files with 1810 additions and 228 deletions
+760 -2
View File
@@ -48,8 +48,8 @@
--shadow-2: 0 4px 16px rgba(20, 18, 14, 0.06), 0 1px 2px rgba(20, 18, 14, 0.03);
--shadow-3: 0 12px 40px rgba(20, 18, 14, 0.08), 0 2px 4px rgba(20, 18, 14, 0.04);
--sans: var(--font-geist-sans), ui-sans-serif, system-ui, -apple-system, 'Helvetica Neue', sans-serif;
--mono: var(--font-geist-mono), ui-monospace, 'SF Mono', Menlo, monospace;
--sans: 'Geist', ui-sans-serif, system-ui, -apple-system, 'Helvetica Neue', sans-serif;
--mono: 'Geist Mono', ui-monospace, 'SF Mono', Menlo, monospace;
}
html[data-theme="dark"] {
@@ -331,6 +331,647 @@ html[data-theme="dark"] .asset-switch button.on { background: var(--surface-2);
margin-bottom: 0;
}
/* PageHint — the prominent "what is this page" tagline directly under
.page-title. Stronger contrast + bigger than .page-sub so it actually
gets read (page-sub was so faint it functioned as decoration).
Use via <PageHint>One sentence about the page.</PageHint>. */
.page-hint {
color: var(--ink-2);
font-size: 16px;
line-height: 1.5;
margin: 10px 0 0;
max-width: 64ch;
}
.page-hint-count {
color: var(--ink-4);
font-size: 13px;
margin-left: 10px;
padding-left: 10px;
border-left: 1px solid var(--line);
}
/* SectionHint — always-visible explanation block. Pairs with a section/tab
to tell the user "what does this view actually do" in 13 sentences.
More detailed than PageHint, less detail-y than an InfoTip. Spans the
full content width on purpose — narrow boxes inside a wide page looked
like accidentally-floating callouts. */
.section-hint {
background: var(--bg-sunk);
border: 1px solid var(--line);
border-radius: 8px;
padding: 12px 16px;
margin-bottom: 14px;
color: var(--ink-2);
font-size: 14px;
line-height: 1.55;
}
.section-hint strong {
color: var(--ink);
font-weight: 600;
}
/* InfoTip — small "?" pill that reveals a one-line tooltip on hover/focus.
No JS, pure CSS. Pattern mimics the (i) dots in Linear/Hyperliquid:
subtle until needed, never blocks the underlying content. */
.infotip {
position: relative;
display: inline-flex;
align-items: center;
justify-content: center;
vertical-align: middle;
margin-left: 6px;
cursor: help;
outline: none;
/* The icon should sit on the text baseline without nudging line-height. */
line-height: 1;
}
.infotip-icon {
display: inline-flex;
align-items: center;
justify-content: center;
width: 14px;
height: 14px;
border-radius: 50%;
background: var(--bg-sunk);
color: var(--ink-3);
font-size: 10px;
font-weight: 700;
border: 1px solid var(--line);
transition: background 0.12s, color 0.12s, border-color 0.12s;
user-select: none;
}
.infotip:hover .infotip-icon,
.infotip:focus-visible .infotip-icon {
background: var(--ink);
color: var(--bg);
border-color: var(--ink);
}
.infotip-bubble {
position: absolute;
z-index: 50;
width: var(--tip-w, 240px);
padding: 8px 10px;
/* Always-dark bubble — common pattern (Linear/Stripe/GitHub) — but needs
a visible border in dark mode so it doesn't blend into the page bg. */
background: #0f0d0a;
color: #f5f2ea;
border: 1px solid transparent;
border-radius: 6px;
font-size: 12px;
font-weight: 400;
line-height: 1.5;
letter-spacing: 0;
text-transform: none;
text-align: left;
box-shadow: 0 6px 24px rgba(0,0,0,0.25), 0 1px 2px rgba(0,0,0,0.2);
pointer-events: none;
opacity: 0;
transform: translateY(2px);
transition: opacity 0.12s, transform 0.12s;
white-space: normal;
}
/* Dark mode: page bg is ~oklch(15%), bubble is #0f0d0a — visually identical.
Lighten the bubble slightly and add a 1px ring so the shape stays defined. */
html[data-theme="dark"] .infotip-bubble {
background: oklch(28% 0.008 85);
border-color: oklch(38% 0.01 85);
box-shadow: 0 6px 24px rgba(0,0,0,0.6), 0 1px 2px rgba(0,0,0,0.4);
}
/* MacroPanel cell — subtle hover so the grid feels alive without becoming
a giant heat-map of click affordances. Just a tiny background lift. */
.macro-cell:hover {
background: var(--bg-sunk) !important;
}
.macro-panel {
padding: 0;
margin-bottom: 12px;
overflow: hidden;
}
.macro-panel-head {
padding: 20px 22px 18px;
border-bottom: 1px solid var(--line);
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 16px;
}
.macro-panel-title {
display: flex;
align-items: center;
gap: 8px;
font-size: 15px;
font-weight: 700;
}
.macro-panel-dot {
width: 7px;
height: 7px;
border-radius: 999px;
background: var(--up);
display: inline-block;
}
.macro-panel-subtitle {
margin-top: 6px;
font-size: 12px;
line-height: 1.45;
color: var(--ink-3);
}
.macro-panel-meta {
text-align: right;
font-size: 11px;
line-height: 1.45;
color: var(--ink-4);
flex-shrink: 0;
}
.macro-section {
border-top: 1px solid var(--line);
}
.macro-section-title {
display: flex;
align-items: center;
gap: 10px;
padding: 14px 22px 12px;
/* Was 10px / ink-3 / weight 700 — too quiet, users couldn't see the
section structure. Bumped to 12px / ink-2 / weight 800. Still uppercase
+ tracked so it reads as a label, not a heading that competes with the
metric values. */
font-size: 12px;
color: var(--ink-2);
letter-spacing: 0.10em;
text-transform: uppercase;
font-weight: 800;
background: var(--bg-sunk);
border-bottom: 1px solid var(--line);
}
/* Tiny dot anchor on the left — visual signal that "this is a section
header" without using a heavy colored bar. */
.macro-section-title::before {
content: '';
width: 5px;
height: 5px;
border-radius: 50%;
background: var(--ink-3);
flex-shrink: 0;
}
.macro-grid {
display: grid;
gap: 14px;
padding: 14px;
}
.macro-grid.one {
grid-template-columns: minmax(0, 1fr);
}
.macro-grid.two {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.macro-metric-card,
.macro-guide-card {
background: var(--surface);
border: 1px solid var(--line);
border-radius: 12px;
padding: 18px;
min-width: 0;
min-height: 248px;
display: flex;
flex-direction: column;
}
.macro-metric-card {
box-shadow: var(--shadow-1);
}
.macro-metric-card.tone-up {
border-color: color-mix(in oklab, var(--up) 28%, var(--line));
}
.macro-metric-card.tone-down {
border-color: color-mix(in oklab, var(--down) 28%, var(--line));
}
/* Stat card layout — label STACKED ABOVE the big number, not side-by-side.
Previous side-by-side layout pushed the title to the far left and the
value to the far right with too much air between them on wide screens.
The eye now reads top→bottom: label → value → summary → chips → button. */
.macro-metric-head {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 6px;
}
.macro-metric-title {
display: flex;
align-items: center;
gap: 8px;
min-width: 0;
/* Slightly larger + bolder so "AHR999" doesn't disappear next to the
big number below. Treated as a label, not a heading. */
font-size: 15px;
font-weight: 650;
color: var(--ink-2);
}
.macro-rank {
font-size: 11px;
color: var(--ink-4);
letter-spacing: 0.08em;
font-weight: 700;
flex-shrink: 0;
}
.macro-metric-value {
/* Same size as before but sits directly under the label now, so the
eye-distance between "AHR999" and "0.4663" is ~6px instead of the
full card width. */
font-size: 34px;
line-height: 1;
letter-spacing: -0.03em;
font-weight: 800;
color: var(--ink);
text-align: left;
flex-shrink: 0;
}
.macro-guide-title {
font-size: 20px;
line-height: 1.15;
letter-spacing: -0.02em;
font-weight: 700;
color: var(--ink);
}
.macro-summary {
margin-top: 12px;
font-size: 14px;
line-height: 1.55;
color: var(--ink-2);
min-height: 66px;
}
.macro-thresholds {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-top: 14px;
}
/* THRESHOLD CHIPS — "you are here" treatment
─────────────────────────────────────────────────────────
Design goal: the chip that the CURRENT value falls into must be the
single most-eye-catching element in the cell. Everything else fades back.
Two states:
base (inactive) — flat, outlined, muted gray. Tone is suppressed
so a "down (red)" inactive chip doesn't visually
outshout an active "neutral" chip. Stripping
tone here is intentional.
.active — filled pill with the tone colour, larger, raised
on shadow, scale 1.05. The eye can't miss it.
Hover the row → all chips bump to 0.8 opacity so you can read the
other bands' rules without losing the active one. */
.macro-threshold-chip {
display: inline-flex;
align-items: center;
min-height: 28px;
padding: 5px 12px;
border-radius: 999px;
border: 1px solid var(--line);
background: transparent;
font-size: 11.5px;
line-height: 1.35;
color: var(--ink-4);
font-weight: 500;
opacity: 0.45;
transition: opacity 160ms, transform 180ms cubic-bezier(.2,.8,.2,1),
box-shadow 180ms, background 180ms, color 180ms;
}
.macro-thresholds:hover .macro-threshold-chip { opacity: 0.8; }
/* When INACTIVE, suppress tone colors. They'd compete with the active chip
for attention. Tone returns the moment the chip becomes .active below. */
.macro-threshold-chip:not(.active) {
color: var(--ink-4) !important;
background: transparent !important;
border-color: var(--line) !important;
}
/* ACTIVE — the band the current value falls into. */
.macro-threshold-chip.active {
opacity: 1;
font-weight: 700;
font-size: 12px;
padding: 6px 14px;
transform: scale(1.06);
letter-spacing: 0.01em;
}
.macro-threshold-chip.up.active {
background: var(--up);
color: #fff;
border-color: var(--up);
box-shadow:
0 6px 18px color-mix(in oklab, var(--up) 35%, transparent),
0 1px 0 rgba(255,255,255,0.25) inset;
}
.macro-threshold-chip.down.active {
background: var(--down);
color: #fff;
border-color: var(--down);
box-shadow:
0 6px 18px color-mix(in oklab, var(--down) 35%, transparent),
0 1px 0 rgba(255,255,255,0.25) inset;
}
.macro-threshold-chip.neutral.active {
/* Amber = "caution" signal — between safe and danger. Lets the user
know "you are here, no extreme yet" without the alarming red. */
background: var(--amber);
color: oklch(20% 0.04 75);
border-color: var(--amber);
box-shadow:
0 6px 18px color-mix(in oklab, var(--amber) 35%, transparent),
0 1px 0 rgba(255,255,255,0.4) inset;
}
/* Dark mode tweaks — the soft white-shimmer inset doesn't read on dark cards,
so swap to a subtle darker outline. */
html[data-theme="dark"] .macro-threshold-chip.up.active,
html[data-theme="dark"] .macro-threshold-chip.down.active,
html[data-theme="dark"] .macro-threshold-chip.neutral.active {
box-shadow:
0 8px 24px color-mix(in oklab, currentColor 30%, transparent),
0 0 0 1px rgba(0,0,0,0.25) inset;
}
/* Section variants — reference cards (rules-only, no live number) get a
muted treatment so users instantly see they're explanatory, not live.
We REPLACE the dot anchor with a 📐 emoji (overriding the .macro-section-
title::before above) and dim the text color. */
.macro-section.reference .macro-section-title {
color: var(--ink-3);
}
.macro-section.reference .macro-section-title::before {
content: '📐';
width: auto;
height: auto;
background: none;
border-radius: 0;
font-size: 13px;
line-height: 1;
filter: grayscale(0.3);
}
.macro-guide-card {
background: color-mix(in oklab, var(--bg-sunk) 60%, var(--surface));
border-style: dashed;
}
.macro-guide-card .macro-guide-title::before {
content: 'RULES ';
font-size: 9px;
font-weight: 800;
letter-spacing: 0.12em;
padding: 2px 6px;
border-radius: 4px;
background: color-mix(in oklab, var(--ink) 8%, transparent);
color: var(--ink-3);
margin-right: 8px;
vertical-align: middle;
}
/* Action row — single small button, aligned to the bottom-right of the card.
Was previously a full-width dual-button group; pinning a compact button to
the corner keeps the card's eye-flow on the value + thresholds, with the
"go look at history" affordance present but quiet. */
.macro-actions {
display: flex;
justify-content: flex-end;
margin-top: auto;
padding-top: 14px;
}
.macro-action-btn {
display: inline-flex;
align-items: center;
gap: 6px;
/* Compact natural width — fits "Open chart CoinGlass ↗" comfortably */
padding: 7px 12px;
border-radius: 8px;
border: 1px solid var(--line);
background: color-mix(in oklab, var(--ink) 4%, var(--surface));
color: var(--ink);
text-decoration: none;
transition: background 120ms, border-color 120ms, transform 120ms, box-shadow 120ms;
box-shadow: inset 0 1px 0 rgba(255,255,255,0.4);
}
.macro-action-btn:hover {
background: color-mix(in oklab, var(--ink) 7%, var(--surface));
border-color: color-mix(in oklab, var(--ink) 12%, var(--line));
transform: translateY(-1px);
box-shadow: var(--shadow-1);
}
.macro-action-btn.chart {
background: color-mix(in oklab, var(--amber) 10%, var(--surface));
border-color: color-mix(in oklab, var(--amber) 18%, var(--line));
}
/* The kicker ("OPEN CHART" uppercase eyebrow) used to sit ABOVE the label
in a 2-row layout. With the new compact button it lives inline next to
the label as a tiny prefix tag — same words, less vertical space. */
.macro-action-copy {
display: inline-flex;
align-items: baseline;
gap: 8px;
min-width: 0;
}
.macro-action-kicker {
font-size: 9.5px;
line-height: 1;
letter-spacing: 0.1em;
text-transform: uppercase;
color: var(--ink-4);
font-weight: 700;
}
.macro-action-label {
font-size: 13px;
line-height: 1;
color: var(--ink);
font-weight: 600;
white-space: nowrap;
}
.macro-action-arrow {
font-size: 13px;
line-height: 1;
color: var(--ink-3);
flex-shrink: 0;
}
.macro-disclosure {
margin-top: 14px;
border-top: 1px dashed var(--line);
padding-top: 12px;
}
.macro-disclosure summary {
list-style: none;
cursor: pointer;
font-size: 12px;
font-weight: 600;
color: var(--ink-2);
}
.macro-disclosure summary::-webkit-details-marker {
display: none;
}
.macro-disclosure-body {
margin-top: 8px;
font-size: 12px;
line-height: 1.55;
color: var(--ink-3);
}
.macro-composite {
border-top: 1px solid var(--line);
padding: 20px 22px 22px;
background: var(--bg-sunk);
}
.macro-composite-head {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 16px;
margin-bottom: 14px;
}
.macro-composite-label {
font-size: 11px;
letter-spacing: 0.12em;
text-transform: uppercase;
color: var(--ink-3);
font-weight: 700;
}
.macro-composite-right {
display: flex;
align-items: center;
gap: 10px;
flex-wrap: wrap;
justify-content: flex-end;
}
.macro-composite-value {
font-size: 32px;
line-height: 0.95;
font-weight: 800;
letter-spacing: -0.03em;
}
.macro-regime-pill {
display: inline-flex;
align-items: center;
min-height: 28px;
padding: 0 10px;
border-radius: 999px;
border: 1px solid var(--line);
font-size: 11px;
font-weight: 700;
letter-spacing: 0.06em;
}
.macro-composite-track {
position: relative;
height: 12px;
border-radius: 999px;
box-shadow: inset 0 1px 2px rgba(0,0,0,0.08);
}
.macro-composite-needle {
position: absolute;
top: -4px;
width: 20px;
height: 20px;
border-radius: 50%;
border: 3px solid var(--ink);
background: var(--surface);
box-shadow: var(--shadow-2);
transition: left 0.4s cubic-bezier(.2,.8,.2,1);
/* React re-mounts this element via `key={composite_score}` whenever the
score changes, which re-triggers the pulse animation below — a brief
attention cue for users tracking how the macro tilt shifts day to day. */
animation: macro-needle-pulse 0.9s ease-out 1;
}
@keyframes macro-needle-pulse {
0% { transform: scale(0.85); box-shadow: 0 0 0 0 currentColor; }
40% { transform: scale(1.18); box-shadow: 0 0 0 8px color-mix(in oklab, currentColor 18%, transparent); }
100% { transform: scale(1); box-shadow: var(--shadow-2); }
}
.macro-composite-scale {
display: flex;
justify-content: space-between;
gap: 8px;
margin-top: 8px;
font-size: 10px;
letter-spacing: 0.04em;
color: var(--ink-4);
text-transform: uppercase;
}
.infotip:hover .infotip-bubble,
.infotip:focus-visible .infotip-bubble {
opacity: 1;
transform: translateY(0);
}
/* Placement variants (default = top) */
.infotip-top .infotip-bubble {
bottom: calc(100% + 8px);
left: 50%; transform: translate(-50%, 2px);
}
.infotip-top:hover .infotip-bubble,
.infotip-top:focus-visible .infotip-bubble {
transform: translate(-50%, 0);
}
.infotip-bottom .infotip-bubble {
top: calc(100% + 8px);
left: 50%; transform: translate(-50%, -2px);
}
.infotip-bottom:hover .infotip-bubble,
.infotip-bottom:focus-visible .infotip-bubble {
transform: translate(-50%, 0);
}
.infotip-left .infotip-bubble {
right: calc(100% + 8px);
top: 50%; transform: translate(2px, -50%);
}
.infotip-left:hover .infotip-bubble,
.infotip-left:focus-visible .infotip-bubble {
transform: translate(0, -50%);
}
.infotip-right .infotip-bubble {
left: calc(100% + 8px);
top: 50%; transform: translate(-2px, -50%);
}
.infotip-right:hover .infotip-bubble,
.infotip-right:focus-visible .infotip-bubble {
transform: translate(0, -50%);
}
/* ============================================================
Cards + primitives
============================================================ */
@@ -1273,6 +1914,116 @@ html[data-theme="dark"] .ai-reasoning-card {
grid-template-columns: 1fr;
}
.macro-panel-head,
.macro-composite-head {
flex-direction: column;
align-items: flex-start;
}
.macro-panel-meta {
text-align: left;
}
.macro-grid.two {
grid-template-columns: 1fr;
}
.macro-grid {
padding: 12px;
gap: 12px;
}
.macro-metric-card,
.macro-guide-card {
padding: 16px;
min-height: 0;
}
.macro-metric-head {
/* Desktop is now also flex-direction: column. Just override the gap
for mobile to keep label-to-value spacing comfortable on small fonts. */
gap: 8px;
}
.macro-metric-value {
font-size: 30px;
}
.macro-guide-title {
font-size: 18px;
}
.macro-summary {
font-size: 13px;
line-height: 1.5;
min-height: 0;
}
.macro-threshold-chip {
font-size: 11.5px;
/* Bump tap target on mobile — iOS HIG asks for ≥44pt, this is the
inline-flex bubble so we go a bit smaller but still comfortable. */
min-height: 36px;
padding: 7px 12px;
}
.macro-threshold-chip.active {
font-size: 12px;
padding: 8px 14px;
/* Stronger scale on mobile — small screens lose subtle differences. */
transform: scale(1.08);
}
.macro-action-btn {
/* Stays compact on mobile too — bottom-right corner pill, not full-width. */
padding: 8px 12px;
}
/* Composite scale labels — "BEAR / NEUTRAL / BULL" letters crowd at narrow
widths. Shorten them. */
.macro-composite-scale {
font-size: 9.5px;
letter-spacing: 0.02em;
}
/* InfoTip — boost tap area and clamp bubble inside viewport */
.infotip {
/* Pad outside the visible icon to enlarge the hit zone without making
the icon itself look bloated. */
padding: 8px;
margin: -8px -2px -8px 4px;
}
.infotip-icon {
width: 18px;
height: 18px;
font-size: 11px;
}
.infotip-bubble {
/* On small viewports a 280px-wide bubble can hang off the edge when
the tip sits near the right margin. Clamp to viewport width minus
a 16px breathing room each side. */
max-width: calc(100vw - 32px) !important;
}
/* Touch-only browsers fire :active on tap. Combined with :hover, this
ensures bubble shows on first tap (not the doubled-tap that some
CSS-hover-only tooltips require). */
.infotip:active .infotip-bubble {
opacity: 1;
transform: none;
}
.macro-action-label {
font-size: 12px;
white-space: normal;
}
.macro-composite {
padding: 18px 16px 20px;
}
.macro-composite-right {
justify-content: flex-start;
}
.row.between {
align-items: flex-start;
}
@@ -1325,6 +2076,13 @@ html[data-theme="dark"] .ai-reasoning-card {
.modal, .dialog { width: calc(100vw - 24px) !important; max-width: calc(100vw - 24px) !important; }
}
/* Ultra-narrow screens — drop the regime descriptive words on the composite
scale so it doesn't wrap. Keep just the numeric anchors. The .word spans
inside each label are set in MacroPanel.tsx so this hide works cleanly. */
@media (max-width: 420px) {
.macro-composite-scale .word { display: none; }
}
@media (max-width: 380px) {
.nav { padding: 8px 12px; }
.page { padding: 16px 12px 48px; }