From 2aa12b9ec57d1d2ca38ad793b1d24d71cfc88c88 Mon Sep 17 00:00:00 2001 From: asepharyana Date: Tue, 25 Aug 2026 23:17:39 +0700 Subject: [PATCH] feat(frontend): revamp channels into signal-map roster with GSAP stagger --- .../plans/2026-08-26-fe-rombak-gsap-wide.md | 81 ++++++++++++++++ services/frontend/pnpm-lock.yaml | 76 +++++---------- .../src/app/(dashboard)/channels/view.tsx | 18 +++- .../src/components/ChannelCultureGlossary.tsx | 94 +++++++++++++++---- 4 files changed, 195 insertions(+), 74 deletions(-) create mode 100644 services/frontend/.hermes/plans/2026-08-26-fe-rombak-gsap-wide.md diff --git a/services/frontend/.hermes/plans/2026-08-26-fe-rombak-gsap-wide.md b/services/frontend/.hermes/plans/2026-08-26-fe-rombak-gsap-wide.md new file mode 100644 index 00000000..5e4303e3 --- /dev/null +++ b/services/frontend/.hermes/plans/2026-08-26-fe-rombak-gsap-wide.md @@ -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//. +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 ` — do not touch unrelated routes. diff --git a/services/frontend/pnpm-lock.yaml b/services/frontend/pnpm-lock.yaml index 2caaf80e..dd442da4 100644 --- a/services/frontend/pnpm-lock.yaml +++ b/services/frontend/pnpm-lock.yaml @@ -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 diff --git a/services/frontend/src/app/(dashboard)/channels/view.tsx b/services/frontend/src/app/(dashboard)/channels/view.tsx index 9e33728e..26c5cb68 100644 --- a/services/frontend/src/app/(dashboard)/channels/view.tsx +++ b/services/frontend/src/app/(dashboard)/channels/view.tsx @@ -12,7 +12,23 @@ export function ChannelsView({ }) { const { data: cultures } = useChannelCultures(100, initialCultures); return ( -
+
+
+
+
+ + +
+

+ CHANNEL ROSTER · SIGNAL_MAP +

+
+
+ NODES: + {cultures?.length ?? 0} +
+
+ {cultures ? ( ) : ( diff --git a/services/frontend/src/components/ChannelCultureGlossary.tsx b/services/frontend/src/components/ChannelCultureGlossary.tsx index 99be3629..9a332b79 100644 --- a/services/frontend/src/components/ChannelCultureGlossary.tsx +++ b/services/frontend/src/components/ChannelCultureGlossary.tsx @@ -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(".channel-node", { + stagger: 0.045, + y: 14, + dependencies: [cultures], + }); + return ( + + Channel Culture Glossary + + } action={ cultures.length > 0 ? ( ) : null } /> {cultures.length === 0 ? ( -

- No channel cultures captured yet. +

+ NO CHANNEL TELEMETRY CAPTURED YET

) : ( -
- {cultures.map((c) => ( -
-
- - {c.channel_name ?? c.channel_id} +
+ {ranked.map(({ c, signal }) => ( +
+
+ + #{c.channel_name ?? c.channel_id.slice(0, 8)} - {c.last_analyzed_at && ( - - {formatRelativeTime(c.last_analyzed_at)} - - )} +
+ + {c.last_analyzed_at && ( + + {formatRelativeTime(c.last_analyzed_at)} + + )} +
+ {c.culture_summary ? ( -

{c.culture_summary}

+

+ {c.culture_summary} +

) : ( - + (no summary captured) )} + + {/* Signal strength readout bar */} +
+
+
))}