← Semua tutor

Hasil akhir · proses yang bisa dilihat

Lima paragraf jadi satu peta kerja.

Sebelum · daftar tahap yang di-scroll

Sesudah · diagram dari asset.inwebsite.online

Dua web yang sama, bedanya satu snippet. Di kanan, brief berjalan dari kotak surat sampai jadi paket file, dan tiap tahap menyala bergantian. Klik baris mana saja untuk melompat ke tahapnya.

Langkah 1 · Siapkan dulu

Yang perlu ada sebelum mulai.

  • Editor kode apa saja. VS Code, Cursor, atau yang kamu biasa pakai.
  • Node.js dan proyek React. Snippet berjalan di React dan memakai kelas Tailwind CSS.
  • AI agent (opsional, tapi paling cepat). Di panduan ini contohnya Claude Code.
  • Akun InWebAsset. Mencari dan merencanakan gratis tanpa login, tapi kode hanya bisa diambil setelah login.
Perlu loginTanpa login kamu bisa menjelajah, melihat pratinjau, dan memakai MCP untuk mencari. Tombol Copy kode dan API key MCP baru bisa dipakai setelah Sign in.
Batas harian: 5 salinanAkun gratis boleh menyalin kode snippet 5 kali sehari. Setiap salinan dihitung, termasuk snippet yang pernah kamu salin, dan kuotanya dipakai bersama antara situs dan MCP. Akun Pro tanpa batas.

Mencari, melihat pratinjau, dan perencanaan lewat agent tidak memakai kuota.

Langkah 2 · Jelajahi katalog

Pilih desain yang mau diambil.

Buka asset.inwebsite.online. Katalognya berisi 172 snippet. Persempit lewat kategori, tempat pemakaian, atau kata kunci.

  • Pilih kategori Cards & Sections.
  • Di Where it goes, pilih Features.
  • Ketik isometric di kolom cari. Tersisa satu snippet.
  • Arahkan kursor ke kartu untuk melihat pratinjau bergerak, lalu klik Isometric Flow Features.
REKAMAN · KATALOG
REKAMAN · HALAMAN SNIPPET

Langkah 3 · Buka halaman snippet

Lihat dulu, baru ambil.

  • Tab Preview: lantai grid muncul, empat simpul jatuh ke tempatnya, lalu paket data berjalan dari aplikasi ke penyimpanan. Klik baris di kiri untuk menyalakan simpulnya.
  • Tab Code: sebelum login, kodenya terkunci. Muncul tombol Sign in to copy.
  • Assets in this snippet: tujuh ikon isometrik. Klik salah satu untuk menukarnya dengan ikon lain dari keluarga yang sama; pratinjau dan kodenya ikut berubah.
  • Install: npm i gsap @gsap/react. Syaratnya React + Tailwind CSS.
  • For agents: ada tombol Copy prompt dan tautan registry untuk AI agent.
IngatUntuk menyalin kode kamu harus login, dan akun gratis dibatasi 5 salinan per hari.

Langkah 4 · Ambil kodenya

Dua jalur, pilih satu.

JALUR A · LEBIH CEPATPakai MCP lewat AI agent

Agent mencari, memilih, dan memasang snippet sendiri. Buka halaman For agents, login, buat API key, lalu salin perintahnya.

$claude mcp add --transport http InWebAsset https://asset.inwebsite.online/mcp --header "Authorization: Bearer <API key-mu>"

Perintah di atas untuk Claude Code. Halaman itu juga punya versi Claude Desktop, Cursor, dan VS Code. Jangan pernah membagikan API key-mu.

Mencari dan perencanaan tanpa kunci. Kodenya (get_snippet) baru keluar dengan API key, dan memakai jatah 5 salinan akun gratis.

JALUR B · MANUALSalin source code

Login, buka halaman snippet, tekan Copy di tab Code. Simpan sebagai komponen, lalu pasang sendiri atau kirim ke agent.

$ npm i gsap @gsap/react

Simpan sebagai components/IsometricFlowFeatures.jsx. Tiap Copy memakai 1 dari 5 jatah harian akun gratis.

REKAMAN · HALAMAN FOR AGENTS

Langkah 5 · Beri konteks ke agent

Bilang tahap mana yang jadi simpul.

Snippet ini butuh tepat empat tahap dan contohnya bertema API: ponsel, awan, server, database. Web contoh punya lima tahap studio branding. Kalau tidak diberi tahu, agent bisa membuang satu tahap sembarangan atau membiarkan ikon server di web studio desain.

Bagian web section #prosesFile Process.jsxData PROCESS di data.jsTahap 5 → 4Warna #e8461eStack React + CSS biasa
Kalau pakai Jalur B: tempel kodenya setelah prompt ini
Ini kode komponen dari asset.inwebsite.online. Pasang di section proses (src/sections/Process.jsx) menggantikan daftar tahap; label "03 — Proses", judul, dan paragraf pembukanya tetap memakai gaya judul situs. Ambil tahap dari PROCESS di src/data.js. Komponennya butuh tepat empat tahap: gabungkan "Tiga arah visual" dan "Revisi" jadi "Arah visual & revisi" (Minggu 4–7) dan ubah judul jadi "empat tahap". Tag di simpul menampilkan minggunya. Ganti ikon ponsel/awan/server/database dengan kotak surat, lampu, monitor, dan kardus dari keluarga isometrik yang sama. Animasi masuk baru jalan saat section terlihat, tahap berganti sendiri sampai kursor masuk. Proyek saya React + CSS biasa tanpa Tailwind: pasang Tailwind v4 tanpa preflight. Aksen #e8461e, warna dan huruf ambil dari variabel CSS situs.

<tempel kode di sini>
contoh · Claude Code (Jalur A)
# sekali saja: sambungkan MCP
$ claude mcp add --transport http InWebAsset https://asset.inwebsite.online/mcp --header "Authorization: Bearer <API key-mu>"

# masuk ke folder proyek, jalankan agent
$ claude

> Pakai InWebAsset MCP. Ambil snippet cards-sections/isometric-flow-features dan pasang di section proses (src/sections/Process.jsx) menggantikan daftar tahap; label, judul, dan paragraf pembuka tetap. Tahap dari PROCESS di src/data.js; komponennya butuh tepat empat, jadi gabungkan "Tiga arah visual" dan "Revisi" lalu ubah judul jadi "empat tahap". Tukar ikon lewat parameter swap ke ikon isometrik yang cocok untuk studio branding (mis. kotak surat, lampu, monitor, kardus). Animasi masuk menunggu section terlihat; tahap berganti sendiri sampai kursor masuk. Proyek saya React + CSS biasa tanpa Tailwind, jadi pasang Tailwind v4 tanpa preflight. Kirim look situs (terang, aksen #e8461e, sudut bulat, gerak tenang) ke get_snippet supaya dapat daftar penyesuaian. Cek dulu dengan includeCode:false, lalu ambil kodenya sekali. Jangan ubah bagian lain.

Yang kamu dapat

Snippet itu titik awal.

Kode di kanan adalah komponen yang terpasang di web contoh. Ini yang diubah dari versi asli di katalog:

  • dataempat langkah API contoh diganti tahap proses dari data situs. Lima tahap jadi empat: "Tiga arah visual" dan "Revisi" digabung, karena diagramnya memang dibuat untuk empat simpul.
  • ikonponsel, awan, server, database ditukar ke kotak surat, lampu, monitor, kardus (brief masuk → ide → desain → file dikemas) lewat parameter swap. Hiasan chip dan grafik jadi kalender dan pot tanaman.
  • tata letakkartu putih bergaya aplikasi dibuang. Label, judul besar, dan paragraf pembuka memakai gaya section lain di web contoh; diagram berdiri di kolom kanan, dan di HP pindah ke antara judul dan daftar tahap.
  • tag simpulangka metrik seperti <50 ms diganti minggu pengerjaan (Minggu 1–2, Minggu 3, …), yang juga tampil di samping judul tiap baris.
  • warnabiru #0B5FFF dan abu slate diganti aksen situs #e8461e dan variabel --ink, --paper, --line, sesuai saran "Fit this site" dari get_snippet. Varian dark: dibuang karena web contoh hanya mode terang.
  • masuk saat terlihatversi katalog memutar animasi masuk begitu halaman dimuat. Section proses ada di tengah halaman, jadi animasinya sekarang menunggu section terlihat (IntersectionObserver).
  • autoPlaydinyalakan, tiap tahap 3,5 detik, garis di bawah baris aktif jadi penghitungnya. Berhenti begitu kursor atau fokus masuk.
  • gerakpantulan back.out diganti power3.out supaya sejalan dengan gerak web contoh yang tenang. Cabang prefers-reduced-motion dibuang mengikuti aturan web contoh.
  • Tailwindweb contoh tidak memakai Tailwind. Dipasang v4 tanpa preflight (hanya theme + utilities); tombol baris diberi border-0 bg-transparent supaya gaya bawaan browser hilang.
components/IsometricFlowFeatures.jsx · terpasang di web contoh
/*
 * Isometric Flow Features
 * Diagram isometrik empat simpul di lantai grid, paket data berjalan di antara simpul,
 * dan tiap baris tahap menyalakan simpulnya.
 *
 * id: cards-sections/isometric-flow-features · v1.0.0
 * source: https://asset.inwebsite.online/snippets/cards-sections/isometric-flow-features
 *
 * Diadaptasi untuk Tandarupa: tahap proses dari data situs, warna & huruf situs,
 * animasi masuk menunggu section terlihat, ikon isometrik ditukar ke yang cocok untuk studio branding.
 *
 * Sprinkles (free to use directly from these URLs):
 *   - sprinkle/icons/iso-mailbox@v1, iso-lamp@v1, iso-monitor@v1, iso-package@v1 (simpul)
 *   - sprinkle/icons/iso-shield@v1, iso-calendar@v1, iso-plant@v1 (hiasan)
 */
import { useEffect, useRef, useState } from 'react'
import gsap from 'gsap'
import { useGSAP } from '@gsap/react'
import { MotionPathPlugin } from 'gsap/MotionPathPlugin'

gsap.registerPlugin(useGSAP, MotionPathPlugin)

const CDN = 'https://inassetweb-cdn.inwebsite.online/sprinkles/v1/icons/'
const SHIELD = CDN + 'iso-shield.png'
const CALENDAR = CDN + 'iso-calendar.png'
const PLANT = CDN + 'iso-plant.png'

/** Satu ikon per tahap, urut: brief masuk → ide → desain di layar → file dikemas. */
const NODE_ICONS = [CDN + 'iso-mailbox.png', CDN + 'iso-lamp.png', CDN + 'iso-monitor.png', CDN + 'iso-package.png']

/* ---------- Geometri lantai isometrik (panggung 400×300, sama dengan diagram 4:3) ---------- */

const W = 400
const H = 300
const G = 58
const OX = 200
const OY = 48
const iso = (i, j) => ({ x: OX + 0.866 * G * (i - j), y: OY + 0.5 * G * (i + j) })
const fmt = (p) => `${p.x.toFixed(1)} ${p.y.toFixed(1)}`

// Empat simpul membentuk Z: kiri atas, kanan atas, kiri bawah, kanan bawah.
const NODES = [iso(0, 2), iso(2, 0), iso(2, 4), iso(4, 2)]
const CONNECTORS = [
  `M${fmt(NODES[0])} L${fmt(iso(0, 0))} L${fmt(NODES[1])}`,
  `M${fmt(NODES[1])} L${fmt(NODES[2])}`,
  `M${fmt(NODES[2])} L${fmt(iso(4, 4))} L${fmt(NODES[3])}`,
]
const CENTER = iso(2, 2)

const FLOOR = (() => {
  const lines = []
  for (let c = -6; c <= 12; c += 0.5) {
    lines.push(`M${fmt(iso(c, -8))} L${fmt(iso(c, 14))}`, `M${fmt(iso(-8, c))} L${fmt(iso(14, c))}`)
  }
  return lines.join(' ')
})()

const secondsUntil = (at) => Math.max(0, (at - performance.now()) / 1000)
const pct = (p) => ({ left: `${(p.x / W) * 100}%`, top: `${(p.y / H) * 100}%` })

/** Detik per tahap saat berjalan sendiri (garis di bawah baris aktif jadi penghitungnya). */
const DWELL = 3.5
const HOP = 1.2
const SPAWN_EVERY = 0.9
const PACKET_POOL = 5

const BAR_START = { transform: 'scaleX(0)' }
const ACCENT_BAR_START = { transform: 'scaleY(0)' }
const SQUASH = { transform: 'scaleY(0.58)' }

/**
 * features: tepat empat tahap { title, desc, label, time }.
 * autoPlay: tahap berganti sendiri sampai kursor atau fokus masuk.
 */
export default function IsometricFlowFeatures({ features, eyebrow, heading, lead, accent = 'var(--accent)', packets = true, autoPlay = false }) {
  const root = useRef(null)
  const rowRefs = useRef([])
  const [active, setActive] = useState(0)
  const [announce, setAnnounce] = useState('')
  // Animasi masuk baru diputar saat section terlihat (section ini ada di tengah halaman).
  const [play, setPlay] = useState(false)
  const autoRef = useRef(false)
  const stopAutoRef = useRef(() => {})
  const entranceEndRef = useRef(0)

  useEffect(() => {
    const el = root.current
    if (!el) return
    const io = new IntersectionObserver(
      ([e]) => {
        if (e.isIntersecting) {
          setPlay(true)
          io.disconnect()
        }
      },
      { threshold: 0.3 },
    )
    io.observe(el)
    return () => io.disconnect()
  }, [])

  // Masuk, gerak diam (simpul naik-turun, garis putus merayap), dan paket data.
  useGSAP(
    (_, contextSafe) => {
      const q = gsap.utils.selector(root)
      if (!play) {
        gsap.set(q('.ifl-floor, .ifl-row, .ifl-sprite, .ifl-deco, .ifl-label'), { autoAlpha: 0 })
        gsap.set(q('.ifl-pad, .ifl-shadow, .ifl-shield-shadow'), { scale: 0 })
        return
      }
      entranceEndRef.current = performance.now() + 1150

      const startIdle = contextSafe(() => {
        q('.ifl-bob').forEach((el, i) => {
          const duration = 2.4 + i * 0.2
          const phase = (i * 0.37) % 1
          gsap.fromTo(el, { y: 4 }, { y: -4, duration, ease: 'sine.inOut', yoyo: true, repeat: -1 }).seek(duration * phase)
          const shadow = q('.ifl-shadow')[i]
          if (shadow)
            gsap
              .fromTo(shadow, { scale: 1, opacity: 1 }, { scale: 0.84, opacity: 0.65, duration, ease: 'sine.inOut', yoyo: true, repeat: -1 })
              .seek(duration * phase)
        })
        gsap.fromTo(q('.ifl-shield'), { rotation: -4, y: 3 }, { rotation: 4, y: -3, duration: 2.8, ease: 'sine.inOut', yoyo: true, repeat: -1 })
        gsap.fromTo(q('.ifl-shield-shadow'), { scale: 1 }, { scale: 0.85, duration: 2.8, ease: 'sine.inOut', yoyo: true, repeat: -1 })
        gsap.fromTo(q('.ifl-dash'), { strokeDashoffset: 0 }, { strokeDashoffset: -20, duration: 1.1, ease: 'none', repeat: -1 })

        if (!packets) return
        const flashes = q('.ifl-flash')
        const pulses = q('.ifl-sprite').map((sprite, n) =>
          gsap
            .timeline({ paused: true })
            .fromTo(sprite, { scale: 1.06 }, { scale: 1, duration: 0.45, ease: 'power3.out', transformOrigin: '50% 85%', immediateRender: false }, 0)
            .fromTo(flashes[n], { opacity: 0.45 }, { opacity: 0, duration: 0.6, ease: 'power2.out', immediateRender: false }, 0),
        )
        q('.ifl-packet').forEach((packet, p) => {
          const tl = gsap.timeline({ repeat: -1, delay: p * SPAWN_EVERY })
          tl.set(packet, { x: NODES[0].x, y: NODES[0].y }, 0).to(packet, { autoAlpha: 1, duration: 0.2 }, 0)
          CONNECTORS.forEach((path, k) => {
            tl.to(packet, { motionPath: { path }, duration: HOP, ease: 'power1.inOut' }, k * HOP).call(() => pulses[k + 1]?.restart(), undefined, (k + 1) * HOP)
          })
          tl.to(packet, { autoAlpha: 0, duration: 0.2 }, CONNECTORS.length * HOP - 0.15)
          tl.repeatDelay(Math.max(0, PACKET_POOL * SPAWN_EVERY - tl.duration()))
        })
      })

      const tl = gsap.timeline({ onComplete: startIdle })
      tl.fromTo(q('.ifl-floor'), { autoAlpha: 0 }, { autoAlpha: 1, duration: 0.6, ease: 'power1.out' }, 0)
        .fromTo(q('.ifl-row'), { x: -12, autoAlpha: 0 }, { x: 0, autoAlpha: 1, duration: 0.5, ease: 'power3.out', stagger: 0.07 }, 0.05)
        .fromTo(q('.ifl-pad'), { scale: 0 }, { scale: 1, duration: 0.5, ease: 'power3.out', stagger: 0.1 }, 0.15)
        .fromTo(q('.ifl-shadow, .ifl-shield-shadow'), { scale: 0 }, { scale: 1, duration: 0.5, ease: 'power2.out', stagger: 0.1 }, 0.25)
        .fromTo(q('.ifl-sprite'), { y: -40, autoAlpha: 0 }, { y: 0, autoAlpha: 1, duration: 0.4, ease: 'power2.in', stagger: 0.1 }, 0.3)
      q('.ifl-sprite').forEach((el, i) => {
        tl.fromTo(el, { scaleY: 0.92 }, { scaleY: 1, duration: 0.45, ease: 'power3.out', transformOrigin: '50% 90%' }, 0.7 + i * 0.1)
      })
      tl.fromTo(q('.ifl-deco'), { y: -16, autoAlpha: 0 }, { y: 0, autoAlpha: 1, duration: 0.5, ease: 'power3.out', stagger: 0.08 }, 0.6).fromTo(
        q('.ifl-label'),
        { autoAlpha: 0 },
        { autoAlpha: 1, duration: 0.4, stagger: 0.06 },
        0.8,
      )
      q('.ifl-dash').forEach((path) => {
        const len = path.getTotalLength()
        const k = Number(path.dataset.k ?? 0)
        tl.fromTo(
          path,
          { strokeDasharray: `${len} ${len}`, strokeDashoffset: len },
          { strokeDashoffset: 0, duration: 0.5, ease: 'power2.inOut', clearProps: 'strokeDasharray,strokeDashoffset' },
          0.75 + k * 0.15,
        )
      })
    },
    { scope: root, dependencies: [packets, play], revertOnUpdate: true },
  )

  // Berjalan sendiri: tiap baris aktif DWELL detik, garis bawahnya terisi sebagai penghitung.
  useEffect(() => {
    const el = root.current
    if (!autoPlay || !play || !el) return
    const bars = Array.from(el.querySelectorAll('.ifl-bar'))
    autoRef.current = true
    let current = 0
    let timer = null
    let fill = null

    const run = (i) => {
      current = i
      setActive(i)
      timer = gsap.fromTo(bars[i], { scaleX: 0 }, { scaleX: 1, duration: DWELL, ease: 'none', overwrite: true, onComplete: () => run((i + 1) % 4) })
    }
    const start = gsap.delayedCall(secondsUntil(entranceEndRef.current), () => run(0))

    stopAutoRef.current = () => {
      if (!autoRef.current) return
      autoRef.current = false
      start.kill()
      timer?.kill()
      fill = gsap.to(bars[current], { scaleX: 1, duration: 0.3, ease: 'power2.out', overwrite: true })
    }
    return () => {
      start.kill()
      timer?.kill()
      fill?.kill()
      if (autoRef.current) gsap.set(bars[current], { scaleX: 1 })
      autoRef.current = false
      stopAutoRef.current = () => {}
    }
  }, [autoPlay, play])

  // Tahap aktif: simpulnya terangkat, yang lain meredup, konektornya menyala, label waktunya muncul.
  useEffect(() => {
    const el = root.current
    if (!el || !play) return
    const q = (sel) => Array.from(el.querySelectorAll(sel))
    const delay = secondsUntil(entranceEndRef.current)
    const base = { overwrite: 'auto', delay }
    const tweens = []
    const add = (t) => tweens.push(t)

    q('.ifl-node').forEach((n, i) => add(gsap.to(n, { opacity: i === active ? 1 : 0.55, duration: 0.4, ...base })))
    q('.ifl-lift').forEach((n, i) => add(gsap.to(n, { y: i === active ? -12 : 0, duration: 0.55, ease: 'power3.out', ...base })))
    q('.ifl-ring').forEach((n, i) => add(gsap.to(n, { autoAlpha: i === active ? 1 : 0, duration: 0.35, ...base })))
    q('.ifl-tag').forEach((n, i) => {
      if (i === active) add(gsap.fromTo(n, { autoAlpha: 0, y: 6 }, { autoAlpha: 1, y: 0, duration: 0.4, ease: 'power3.out', ...base }))
      else add(gsap.to(n, { autoAlpha: 0, duration: 0.2, overwrite: 'auto' }))
    })
    q('.ifl-lit').forEach((n, k) => add(gsap.to(n, { opacity: k === active || k === active - 1 ? 1 : 0, duration: 0.4, ...base })))
    q('.ifl-tint').forEach((n, i) => add(gsap.to(n, { opacity: i === active ? 1 : 0, duration: 0.3, overwrite: 'auto' })))
    q('.ifl-accentbar').forEach((n, i) => add(gsap.to(n, { scaleY: i === active ? 1 : 0, duration: 0.35, ease: 'power3.out', overwrite: 'auto' })))
    q('.ifl-bar').forEach((n, i) => {
      if (i !== active) add(gsap.to(n, { scaleX: 0, duration: 0.2, overwrite: true }))
      else if (!autoRef.current) add(gsap.to(n, { scaleX: 1, duration: 0.5, ease: 'power2.out', overwrite: true }))
    })
    return () => tweens.forEach((t) => t.kill())
  }, [active, play])

  const stopAuto = () => stopAutoRef.current()

  const select = (i) => {
    stopAuto()
    if (i === active) return
    setActive(i)
    setAnnounce(`Tahap ${i + 1} dari 4: ${features[i].title}, ${features[i].time}.`)
  }

  const onRowKey = (e, i) => {
    const keys = { ArrowDown: i + 1, ArrowRight: i + 1, ArrowUp: i - 1, ArrowLeft: i - 1, Home: 0, End: 3 }
    if (!(e.key in keys)) return
    e.preventDefault()
    const next = (keys[e.key] + 4) % 4
    select(next)
    rowRefs.current[next]?.focus()
  }

  return (
    <div
      ref={root}
      style={{ '--ifl-accent': accent }}
      onPointerEnter={stopAuto}
      onPointerDown={stopAuto}
      onFocusCapture={stopAuto}
      className="mx-auto grid w-full max-w-[var(--max)] items-center gap-x-16 gap-y-8 lg:grid-cols-[minmax(0,1fr)_minmax(0,1.15fr)] lg:grid-rows-[auto_auto]"
    >
      {/* Judul: tetap memakai gaya judul situs */}
      <div className="order-1 grid gap-6 lg:order-none lg:col-start-1 lg:row-start-1 lg:self-end">
        <p className="mono label">{eyebrow}</p>
        <h2 data-split>{heading}</h2>
        {lead && <p className="process__lead">{lead}</p>}
      </div>

      {/* Baris tahap */}
      <div className="order-3 grid gap-1 lg:order-none lg:col-start-1 lg:row-start-2 lg:self-start" role="group" aria-label={eyebrow}>
        {features.map((f, i) => {
          const isActive = i === active
          return (
            <button
              key={i}
              ref={(el) => (rowRefs.current[i] = el)}
              type="button"
              aria-current={isActive ? 'step' : undefined}
              tabIndex={isActive ? 0 : -1}
              onClick={() => select(i)}
              onKeyDown={(e) => onRowKey(e, i)}
              className="ifl-row group relative grid w-full cursor-pointer grid-cols-[auto_1fr] gap-4 rounded-2xl border-0 bg-transparent px-4 py-4 text-left text-[var(--ink)] [font:inherit] focus-visible:ring-2 focus-visible:ring-[var(--ifl-accent)] focus-visible:outline-none"
            >
              <span
                aria-hidden
                className="ifl-tint pointer-events-none absolute inset-0 rounded-2xl bg-[color-mix(in_oklab,var(--ifl-accent)_8%,transparent)] opacity-0"
              />
              <span aria-hidden className="ifl-accentbar absolute top-4 bottom-4 left-0 w-[3px] rounded-full bg-[var(--ifl-accent)]" style={ACCENT_BAR_START} />
              <span
                aria-hidden
                className={`relative mt-1 grid size-7 place-items-center rounded-md text-[12px] tabular-nums [font-family:var(--mono)] transition-colors duration-300 ${
                  isActive ? 'bg-[var(--ifl-accent)] text-white' : 'bg-[rgba(20,19,17,0.07)] text-[var(--ink-2)] group-hover:bg-[rgba(20,19,17,0.12)]'
                }`}
              >
                {String(i + 1).padStart(2, '0')}
              </span>
              <span className="relative min-w-0">
                <span className="flex flex-wrap items-baseline gap-x-3">
                  <span
                    className={`text-[22px] leading-tight font-bold tracking-[-0.02em] transition-colors duration-300 ${
                      isActive ? 'text-[var(--ink)]' : 'text-[var(--ink-2)] group-hover:text-[var(--ink)]'
                    }`}
                  >
                    {f.title}
                  </span>
                  <span className="mono label">{f.time}</span>
                </span>
                <span className="mt-1 block max-w-[46ch] text-[15.5px] leading-snug text-[var(--ink-2)]">{f.desc}</span>
              </span>
              <span
                aria-hidden
                className={`absolute right-4 bottom-0 left-4 h-[2px] overflow-hidden rounded-full bg-[var(--line)] transition-opacity duration-300 ${
                  isActive ? 'opacity-100' : 'opacity-0'
                }`}
              >
                <span className="ifl-bar absolute inset-0 origin-left rounded-full bg-[var(--ifl-accent)]" style={BAR_START} />
              </span>
            </button>
          )
        })}
        <p className="sr-only" aria-live="polite">
          {announce}
        </p>
      </div>

      {/* Diagram isometrik */}
      <div
        aria-hidden
        className="relative order-2 aspect-[4/3] w-full overflow-hidden lg:order-none rounded-[var(--radius)] bg-[var(--paper)] ring-1 ring-[var(--line)] ring-inset lg:col-start-2 lg:row-span-2 lg:row-start-1"
      >
        <svg
          viewBox={`0 0 ${W} ${H}`}
          className="ifl-floor absolute inset-0 size-full [mask-image:radial-gradient(ellipse_72%_68%_at_50%_54%,#000_30%,transparent_100%)]"
        >
          <path d={FLOOR} fill="none" strokeWidth="0.7" stroke="rgba(20,19,17,0.16)" />
        </svg>

        <svg viewBox={`0 0 ${W} ${H}`} className="absolute inset-0 size-full">
          {CONNECTORS.map((d, k) => (
            <g key={k}>
              <path d={d} data-k={k} fill="none" strokeWidth="1.6" strokeDasharray="5 5" strokeLinejoin="round" stroke="rgba(20,19,17,0.24)" className="ifl-dash" />
              <g className="ifl-lit" opacity={0}>
                <path d={d} fill="none" strokeWidth="5" strokeLinejoin="round" stroke="var(--ifl-accent)" opacity={0.12} />
                <path d={d} data-k={k} fill="none" strokeWidth="2" strokeDasharray="5 5" strokeLinejoin="round" stroke="var(--ifl-accent)" className="ifl-dash" />
              </g>
            </g>
          ))}
          {packets &&
            Array.from({ length: PACKET_POOL }, (_, k) => (
              <g key={k} className="ifl-packet" style={{ visibility: 'hidden', opacity: 0 }}>
                <path d="M0 -6.5L11 0L0 6.5L-11 0Z" fill="var(--ifl-accent)" opacity={0.25} />
                <path d="M0 -3.6L6.2 0L0 3.6L-6.2 0Z" fill="var(--ifl-accent)" />
                <path d="M0 -1.6L2.8 0L0 1.6L-2.8 0Z" fill="#fff" opacity={0.9} />
              </g>
            ))}
        </svg>

        <Deco src={CALENDAR} at={{ x: 38, y: 280 }} />
        <Deco src={PLANT} at={{ x: 364, y: 172 }} />

        {/* Perisai di tengah: tahap disetujui bersama sebelum lanjut */}
        <div className="absolute w-[15%]" style={{ ...pct(CENTER), marginLeft: '-7.5%' }}>
          <div className="ifl-shield-shadow absolute left-[20%] -mt-[12%] aspect-[2.4/1] w-[60%] rounded-full bg-[rgba(20,19,17,0.1)] blur-[3px]" />
          <div className="absolute bottom-0 left-0 mb-[22%] w-full">
            <img src={SHIELD} alt="" draggable={false} className="ifl-shield ifl-deco block h-auto w-full select-none" />
          </div>
        </div>

        {features.map((f, i) => (
          <div
            key={i}
            onClick={() => select(i)}
            className={`ifl-node absolute w-[21%] cursor-pointer ${i === active ? 'z-20' : 'z-10'}`}
            style={{ ...pct(NODES[i]), marginLeft: '-10.5%' }}
          >
            <div className="ifl-pad absolute left-0 -mt-[50%] aspect-square w-full">
              <div className="absolute inset-0 translate-y-[4px]" style={SQUASH}>
                <div className="absolute inset-[14.6%] rotate-45 rounded-[16%] bg-[#d6cdbb]" />
              </div>
              <div className="absolute inset-0" style={SQUASH}>
                <div className="absolute inset-[14.6%] rotate-45 overflow-hidden rounded-[16%] bg-[#fbf9f4] ring-1 ring-[var(--line)]">
                  <div className="ifl-flash absolute inset-0 bg-[var(--ifl-accent)] opacity-0" />
                </div>
                <div className="ifl-ring invisible absolute inset-[10%] rotate-45 rounded-[18%] border-2 border-solid border-[var(--ifl-accent)] opacity-0 shadow-[0_0_18px_var(--ifl-accent)]" />
              </div>
            </div>
            <div className="ifl-shadow absolute left-[24%] -mt-[11%] aspect-[2.2/1] w-[52%] rounded-full bg-[rgba(20,19,17,0.1)] blur-[3px]" />
            <span className="ifl-label absolute top-0 left-1/2 mt-[35%] -translate-x-1/2 text-[10px] tracking-wider whitespace-nowrap text-[var(--ink-2)] uppercase [font-family:var(--mono)] sm:text-[11px]">
              {f.label}
            </span>
            <div className="ifl-lift absolute bottom-0 left-[6%] -mb-[12%] w-[88%]">
              <div className="ifl-bob">
                <img src={NODE_ICONS[i]} alt="" draggable={false} className="ifl-sprite block h-auto w-full select-none" />
              </div>
              <div className="absolute right-[-60%] bottom-[82%] left-[-60%] flex justify-center">
                <div className="ifl-tag invisible flex items-center gap-1.5 rounded-lg bg-[#fbf9f4] px-2 py-1 text-[10px] whitespace-nowrap opacity-0 shadow-[0_8px_20px_-8px_rgba(20,19,17,0.35)] ring-1 ring-[var(--line)] sm:text-[12px]">
                  <span className="font-bold text-[var(--ink)]">{f.title}</span>
                  <span className="rounded bg-[color-mix(in_oklab,var(--ifl-accent)_12%,transparent)] px-1 text-[var(--ifl-accent)] [font-family:var(--mono)]">{f.time}</span>
                </div>
              </div>
            </div>
          </div>
        ))}
      </div>
    </div>
  )
}

/** Ikon kecil di lantai (60% ukuran simpul). */
function Deco({ src, at }) {
  return (
    <div className="absolute w-[12.5%]" style={{ ...pct(at), marginLeft: '-6.25%' }}>
      <div className="absolute left-[18%] -mt-[10%] aspect-[2.4/1] w-[64%] rounded-full bg-[rgba(20,19,17,0.1)] blur-[2px]" />
      <img src={src} alt="" draggable={false} className="ifl-deco absolute bottom-0 left-0 -mb-[8%] block h-auto w-full opacity-90 select-none" />
    </div>
  )
}

Langkah 6 · Bandingkan

Sebelum vs sesudah.

Sebelum

Sesudah

Kartu kanan hidup: tahapnya berganti sendiri. Arahkan kursor ke dalamnya untuk berhenti, lalu klik baris atau simpul di diagram.

01 · SIAPKANEditor, React, agent, akun (login)
02 · JELAJAHCards & Sections → Features
03 · BUKAPratinjau, install, kode (login)
04 · PASANGBeri data tahap, batas empat simpul, dan warna ke agent