feat(frontend): revamp channels into signal-map roster with GSAP stagger
This commit is contained in:
@@ -0,0 +1,81 @@
|
||||
# GMW FE Rombak — Extend Tactical-HUD/GSAP Treatment Wide (2026-08-26)
|
||||
|
||||
## Context / lessons that constrain this plan
|
||||
- 2026-08-24: full novel navigation metaphor (constellation/three.js) was
|
||||
SHIPPED green on every gate then REJECTED by user for hurting usability.
|
||||
Rolled back entirely. Lesson: do NOT invent a new nav metaphor.
|
||||
- 2026-08-24: accepted direction = monochrome theme + game-menu style sidebar
|
||||
animation ON TOP of the familiar shell (rail/topbar/panel kept).
|
||||
- 2026-08-25: `dashboard` and `media` routes were already revamped into a
|
||||
"Tactical HUD" style with GSAP (`@gsap/react` useGSAP) reactive stages —
|
||||
commits 183a510, cab451f. This is the established, ACCEPTED visual language.
|
||||
- Remaining routes (`channels`, `voice`, `messages`, `moderation`,
|
||||
`recordings`, `analysis`, `glossary`) have NOT received the tactical-HUD/GSAP
|
||||
treatment — last touch was an error-handling/state pass (31e303c) or the
|
||||
constellation revert (eda5c75).
|
||||
|
||||
## Scope of THIS request
|
||||
User: "rombak fe dengan sekeren mungkin tapi tidak menyusahkan user, pewarnaan
|
||||
sesuai tidak berlebihan, pakai gsap, sekreatif mungkin."
|
||||
|
||||
Read against the history above, this means: EXTEND the already-accepted
|
||||
tactical-HUD + GSAP + monochrome visual language to the remaining routes,
|
||||
adding creative micro-interactions and view-specific "reactive stage" touches
|
||||
— NOT a new structural/navigation metaphor, NOT a color explosion.
|
||||
|
||||
## Hard constraints (do not violate)
|
||||
1. Keep the existing shell: top bar / nav rail / mobile bottom dock. No new
|
||||
navigation metaphor.
|
||||
2. Monochrome/greyscale token system stays as-is (`--color-signal/amber/
|
||||
vermilion` luminance greys). Do not introduce saturated colors; accent use
|
||||
stays restrained (used sparingly for state: active/alert/success).
|
||||
3. GSAP via `@gsap/react` `useGSAP({ scope, dependencies })` + `clearProps` on
|
||||
cleanup, per `dashboard-rombak/references/tactical-hud-gsap-pattern.md`.
|
||||
Respect `prefers-reduced-motion` (extend the kill-list in globals.css for
|
||||
any new `animate-*` class).
|
||||
4. Mobile lightness gate: any WebGL/heavy canvas work must gate off on
|
||||
<768px / pointer:coarse / saveData / deviceMemory<=4 (see AmbientCanvas
|
||||
pattern already in the codebase).
|
||||
5. Each route ships independently: edit → `pnpm format` → `npx @biomejs/biome
|
||||
check --write` → `pnpm lint` (0 errors, 0 warnings) → `tsc --noEmit` →
|
||||
`pnpm build` → commit (no Co-Authored-By trailer) → push → `gh run watch`
|
||||
for Build & Deploy (Nix) → live verify via browser_vision on
|
||||
imphnen.asepharyana.my.id/<route>/.
|
||||
6. NEVER claim "done" from reading code — every route gets a live screenshot
|
||||
check confirming: (a) not a stock template, (b) monochrome/restrained,
|
||||
(c) GSAP entrance/interaction actually fires, (d) nav/theme toggle/command
|
||||
palette still work.
|
||||
|
||||
## Per-route creative direction (reactive-stage motifs, one distinct idea per
|
||||
route so it doesn't feel copy-pasted from dashboard/media)
|
||||
- **channels**: node/roster telemetry — GSAP stagger-in channel cards with a
|
||||
"signal strength" bar-fill tween keyed to activity; hover reveals HUD-style
|
||||
meta readout.
|
||||
- **voice**: live speaker HUD — waveform/ring pulse tied to WS voice_state
|
||||
(GSAP timeline looping while speaking, `clearProps` on stop); ambient tile
|
||||
glow keyed to active speaker count.
|
||||
- **messages**: scan-line reveal on list mount + WS-driven "new message" slide
|
||||
insert (respect existing sortMessages fix — do not reintroduce the race).
|
||||
- **moderation**: alert-priority HUD — flagged severity as inverted mono
|
||||
badge with a pulse GSAP loop (no red, per accepted mono direction);
|
||||
queue-clear micro celebration (subtle, no confetti-color).
|
||||
- **recordings**: waveform scrub deck echoing media's frequency-deck visual
|
||||
language but for static recordings (progress-tied GSAP scrub, not looping).
|
||||
- **analysis**: gauge/metric reveal using the existing `radial-gauge.tsx`
|
||||
driven by a GSAP number tween (count-up) instead of an instant value snap.
|
||||
- **glossary**: lightweight — accordion/definition reveal with GSAP height
|
||||
auto + fade, kept minimal since it's low-traffic reference content.
|
||||
|
||||
## Verification checklist (must pass before calling any route "shipped")
|
||||
- [ ] tsc --noEmit clean
|
||||
- [ ] biome check: 0 errors AND 0 warnings
|
||||
- [ ] next build succeeds
|
||||
- [ ] CI green (Build & Deploy (Nix))
|
||||
- [ ] Live screenshot: monochrome, GSAP motion visible, nav/theme/command
|
||||
palette intact, not a stock template
|
||||
- [ ] Reduced-motion: new animate-* classes added to the kill-list
|
||||
- [ ] Mobile: WebGL/heavy work gated per existing pattern
|
||||
|
||||
## Rollback plan
|
||||
Every route ships as its own atomic commit. If user rejects a specific route's
|
||||
direction, `git revert <that commit>` — do not touch unrelated routes.
|
||||
Generated
+22
-54
@@ -8,18 +8,21 @@ importers:
|
||||
|
||||
.:
|
||||
dependencies:
|
||||
'@gsap/react':
|
||||
specifier: ^2.1.2
|
||||
version: 2.1.2(gsap@3.15.0)(react@19.2.8)
|
||||
'@orpc/client':
|
||||
specifier: 1.15.0
|
||||
version: 1.15.0
|
||||
clsx:
|
||||
specifier: ^2.1.1
|
||||
version: 2.1.1
|
||||
gsap:
|
||||
specifier: ^3.15.0
|
||||
version: 3.15.0
|
||||
lucide-react:
|
||||
specifier: ^1.33.0
|
||||
version: 1.33.0(react@19.2.8)
|
||||
motion:
|
||||
specifier: ^13.1.1
|
||||
version: 13.1.1(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
|
||||
next:
|
||||
specifier: 16.3.2
|
||||
version: 16.3.2(@babel/core@7.29.7)(@types/node@26.2.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
|
||||
@@ -212,6 +215,12 @@ packages:
|
||||
'@emnapi/runtime@1.11.3':
|
||||
resolution: {integrity: sha512-Xz4Tpyki7XyrpbUK1jR1AhdAdaXyhhY4lZ3neLodmhpuWfy2PAQN5B46sAiU4liOXGLkHypn/qU+jvfWSCYYLA==}
|
||||
|
||||
'@gsap/react@2.1.2':
|
||||
resolution: {integrity: sha512-JqliybO1837UcgH2hVOM4VO+38APk3ECNrsuSM4MuXp+rbf+/2IG2K1YJiqfTcXQHH7XlA0m3ykniFYstfq0Iw==}
|
||||
peerDependencies:
|
||||
gsap: ^3.12.5
|
||||
react: '>=17'
|
||||
|
||||
'@img/colour@1.1.0':
|
||||
resolution: {integrity: sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==}
|
||||
engines: {node: '>=18'}
|
||||
@@ -803,17 +812,6 @@ packages:
|
||||
fflate@0.8.3:
|
||||
resolution: {integrity: sha512-tbZNuJrLwGUp3zshBtdy4W+ORxZuIh8a5ilyIEQDC5rY1f3U20JMry0Ll3WBzU58EZKsEuJFXhb5gwv8CsPvgA==}
|
||||
|
||||
framer-motion@13.1.1:
|
||||
resolution: {integrity: sha512-B/xn2TPS4f61cEBLFjiYlQFnBZUW1YVj/LM+C+N4OP8Rs95VLEI2ot/RlfBg111la/EiyECFaJJi/A3FWA8MUA==}
|
||||
peerDependencies:
|
||||
react: ^18.0.0 || ^19.0.0
|
||||
react-dom: ^18.0.0 || ^19.0.0
|
||||
peerDependenciesMeta:
|
||||
react:
|
||||
optional: true
|
||||
react-dom:
|
||||
optional: true
|
||||
|
||||
gensync@1.0.0-beta.2:
|
||||
resolution: {integrity: sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==}
|
||||
engines: {node: '>=6.9.0'}
|
||||
@@ -829,6 +827,9 @@ packages:
|
||||
graceful-fs@4.2.11:
|
||||
resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==}
|
||||
|
||||
gsap@3.15.0:
|
||||
resolution: {integrity: sha512-dMW4CWBTUK1AEEDeZc1g4xpPGIrSf9fJF960qbTZmN/QwZIWY5wgliS6JWl9/25fpTGJrMRtSjGtOmPnfjZB+A==}
|
||||
|
||||
jiti@2.7.0:
|
||||
resolution: {integrity: sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==}
|
||||
hasBin: true
|
||||
@@ -941,23 +942,6 @@ packages:
|
||||
resolution: {integrity: sha512-gN54ddmyzEg10orwZ2u4OOv+bjpMWdIl5jIkodK97bMq8QBSL5c0D7YX0lT1Ooz+99S7+PvFbnxzdjgHo1r41g==}
|
||||
engines: {node: '>=18.0.0'}
|
||||
|
||||
motion-dom@13.1.1:
|
||||
resolution: {integrity: sha512-XSf8VYWSB6G/0IY3rWVbyLcxWXtAVHkN1PQE2agTaCv3u8RGvbwu56TyyR/MNzBqqNavEBTZzErcxI1TxBrjcA==}
|
||||
|
||||
motion-utils@13.0.0:
|
||||
resolution: {integrity: sha512-7DnN7TmbLcYXcG4RVadXIihWlyuM9afoUww8Y5Agg431kGKiuL2/OMyP4mJ5wLz+pvN3t5ySClLOaVXJ+wekRQ==}
|
||||
|
||||
motion@13.1.1:
|
||||
resolution: {integrity: sha512-WNZoK6xiF+kkTqkZ5K7FDDh6A8BG4i5Hc7KXtW8gtTxkpJFds+hIOrDaQGKjQj/AE/i4hJqAaUHEqp/Qo02y6Q==}
|
||||
peerDependencies:
|
||||
react: ^18.0.0 || ^19.0.0
|
||||
react-dom: ^18.0.0 || ^19.0.0
|
||||
peerDependenciesMeta:
|
||||
react:
|
||||
optional: true
|
||||
react-dom:
|
||||
optional: true
|
||||
|
||||
ms@2.1.3:
|
||||
resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==}
|
||||
|
||||
@@ -1335,6 +1319,11 @@ snapshots:
|
||||
tslib: 2.8.1
|
||||
optional: true
|
||||
|
||||
'@gsap/react@2.1.2(gsap@3.15.0)(react@19.2.8)':
|
||||
dependencies:
|
||||
gsap: 3.15.0
|
||||
react: 19.2.8
|
||||
|
||||
'@img/colour@1.1.0':
|
||||
optional: true
|
||||
|
||||
@@ -1755,15 +1744,6 @@ snapshots:
|
||||
|
||||
fflate@0.8.3: {}
|
||||
|
||||
framer-motion@13.1.1(react-dom@19.2.8(react@19.2.8))(react@19.2.8):
|
||||
dependencies:
|
||||
motion-dom: 13.1.1
|
||||
motion-utils: 13.0.0
|
||||
tslib: 2.8.1
|
||||
optionalDependencies:
|
||||
react: 19.2.8
|
||||
react-dom: 19.2.8(react@19.2.8)
|
||||
|
||||
gensync@1.0.0-beta.2:
|
||||
optional: true
|
||||
|
||||
@@ -1773,6 +1753,8 @@ snapshots:
|
||||
|
||||
graceful-fs@4.2.11: {}
|
||||
|
||||
gsap@3.15.0: {}
|
||||
|
||||
jiti@2.7.0: {}
|
||||
|
||||
js-tokens@4.0.0:
|
||||
@@ -1852,20 +1834,6 @@ snapshots:
|
||||
|
||||
modern-tar@0.8.4: {}
|
||||
|
||||
motion-dom@13.1.1:
|
||||
dependencies:
|
||||
motion-utils: 13.0.0
|
||||
|
||||
motion-utils@13.0.0: {}
|
||||
|
||||
motion@13.1.1(react-dom@19.2.8(react@19.2.8))(react@19.2.8):
|
||||
dependencies:
|
||||
framer-motion: 13.1.1(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
|
||||
tslib: 2.8.1
|
||||
optionalDependencies:
|
||||
react: 19.2.8
|
||||
react-dom: 19.2.8(react@19.2.8)
|
||||
|
||||
ms@2.1.3:
|
||||
optional: true
|
||||
|
||||
|
||||
@@ -12,7 +12,23 @@ export function ChannelsView({
|
||||
}) {
|
||||
const { data: cultures } = useChannelCultures(100, initialCultures);
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
<div className="space-y-4">
|
||||
<div className="flex flex-wrap items-center justify-between gap-3 border-b border-hairline pb-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="relative flex size-3 items-center justify-center">
|
||||
<span className="absolute inline-flex size-full animate-ping rounded-full bg-signal opacity-75" />
|
||||
<span className="relative inline-flex size-2 rounded-full bg-signal" />
|
||||
</div>
|
||||
<h1 className="font-mono text-xs font-semibold tracking-widest text-ink uppercase">
|
||||
CHANNEL ROSTER · SIGNAL_MAP
|
||||
</h1>
|
||||
</div>
|
||||
<div className="inline-flex items-center gap-1.5 rounded-sm bg-surface px-2 py-0.5 font-mono text-[11px] text-ink-soft">
|
||||
<span className="text-ink-faint">NODES:</span>
|
||||
<span className="font-bold text-signal">{cultures?.length ?? 0}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{cultures ? (
|
||||
<ChannelCultureGlossary cultures={cultures} />
|
||||
) : (
|
||||
|
||||
@@ -1,21 +1,60 @@
|
||||
"use client";
|
||||
|
||||
import { Radio, Signal } from "lucide-react";
|
||||
import { useMemo } from "react";
|
||||
import { GlassPanel } from "@/components/primitives";
|
||||
import { SectionHeader } from "@/components/shared";
|
||||
import { useStaggerReveal } from "@/hooks/use-gsap-animation";
|
||||
import { downloadCsv } from "@/lib/csv";
|
||||
import { formatRelativeTime } from "@/lib/format";
|
||||
import type { ChannelCultureRow } from "@/lib/types";
|
||||
|
||||
/**
|
||||
* Deterministic pseudo signal-strength (0..1) derived from recency + summary
|
||||
* richness — purely cosmetic telemetry framing, no backend field for this.
|
||||
*/
|
||||
function deriveSignalStrength(c: ChannelCultureRow): number {
|
||||
let score = 0.25;
|
||||
if (c.culture_summary) {
|
||||
score += Math.min(0.45, c.culture_summary.length / 400);
|
||||
}
|
||||
if (c.last_analyzed_at) {
|
||||
const ageMs = Date.now() - c.last_analyzed_at;
|
||||
const days = ageMs / (1000 * 60 * 60 * 24);
|
||||
score += Math.max(0, 0.3 - days * 0.02);
|
||||
}
|
||||
return Math.max(0.08, Math.min(1, score));
|
||||
}
|
||||
|
||||
export function ChannelCultureGlossary({
|
||||
cultures,
|
||||
}: {
|
||||
cultures: ChannelCultureRow[];
|
||||
}) {
|
||||
const ranked = useMemo(
|
||||
() =>
|
||||
cultures
|
||||
.map((c) => ({ c, signal: deriveSignalStrength(c) }))
|
||||
.sort((a, b) => b.signal - a.signal),
|
||||
[cultures],
|
||||
);
|
||||
|
||||
const hudRef = useStaggerReveal<HTMLDivElement>(".channel-node", {
|
||||
stagger: 0.045,
|
||||
y: 14,
|
||||
dependencies: [cultures],
|
||||
});
|
||||
|
||||
return (
|
||||
<GlassPanel className="lg:col-span-3">
|
||||
<SectionHeader
|
||||
eyebrow="culture"
|
||||
title="Channel Culture Glossary"
|
||||
eyebrow="knowledge · roster"
|
||||
title={
|
||||
<span className="flex items-center gap-2">
|
||||
<Radio className="size-4 text-signal" />
|
||||
Channel Culture Glossary
|
||||
</span>
|
||||
}
|
||||
action={
|
||||
cultures.length > 0 ? (
|
||||
<button
|
||||
@@ -30,38 +69,55 @@ export function ChannelCultureGlossary({
|
||||
})),
|
||||
)
|
||||
}
|
||||
className="flex items-center gap-1.5 text-xs text-ink-soft hover:text-ink"
|
||||
className="flex items-center gap-1.5 font-mono text-[11px] text-ink-soft hover:text-ink"
|
||||
>
|
||||
CSV
|
||||
EXPORT_CSV
|
||||
</button>
|
||||
) : null
|
||||
}
|
||||
/>
|
||||
{cultures.length === 0 ? (
|
||||
<p className="py-6 text-center text-xs text-ink-faint">
|
||||
No channel cultures captured yet.
|
||||
<p className="py-6 text-center font-mono text-xs text-ink-faint">
|
||||
NO CHANNEL TELEMETRY CAPTURED YET
|
||||
</p>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{cultures.map((c) => (
|
||||
<div key={c.channel_id} className="text-sm">
|
||||
<div className="flex items-baseline justify-between">
|
||||
<span className="font-medium text-ink">
|
||||
{c.channel_name ?? c.channel_id}
|
||||
<div ref={hudRef} className="space-y-2">
|
||||
{ranked.map(({ c, signal }) => (
|
||||
<div
|
||||
key={c.channel_id}
|
||||
className="channel-node group relative overflow-hidden rounded-md border border-hairline bg-surface/40 p-3 backdrop-blur-md transition-colors hover:border-signal/40"
|
||||
>
|
||||
<div className="flex items-baseline justify-between gap-3">
|
||||
<span className="truncate font-mono text-xs font-semibold text-ink">
|
||||
#{c.channel_name ?? c.channel_id.slice(0, 8)}
|
||||
</span>
|
||||
{c.last_analyzed_at && (
|
||||
<span className="text-xs text-ink-faint">
|
||||
{formatRelativeTime(c.last_analyzed_at)}
|
||||
</span>
|
||||
)}
|
||||
<div className="flex items-center gap-2 shrink-0">
|
||||
<Signal className="size-3 text-ink-faint" />
|
||||
{c.last_analyzed_at && (
|
||||
<span className="font-mono text-[10px] text-ink-faint">
|
||||
{formatRelativeTime(c.last_analyzed_at)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{c.culture_summary ? (
|
||||
<p className="mt-1 text-ink-faint">{c.culture_summary}</p>
|
||||
<p className="mt-1.5 line-clamp-2 text-[13px] text-ink-soft">
|
||||
{c.culture_summary}
|
||||
</p>
|
||||
) : (
|
||||
<span className="text-xs text-ink-faint">
|
||||
<span className="mt-1.5 block font-mono text-[11px] text-ink-faint">
|
||||
(no summary captured)
|
||||
</span>
|
||||
)}
|
||||
|
||||
{/* Signal strength readout bar */}
|
||||
<div className="mt-2 h-1 w-full overflow-hidden rounded-full bg-canvas-2">
|
||||
<div
|
||||
className="h-full rounded-full bg-signal transition-[width] duration-700 ease-out"
|
||||
style={{ width: `${Math.round(signal * 100)}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user