{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "gantt-ascii-critical-path",
  "title": "Gantt ASCII Critical Path",
  "description": "An ASCII Gantt chart where a real CPM forward/backward pass decides which bars are critical: zero-float tasks draw heavy `━` in accent, the rest light `─`, and dragging a task re-runs the pass so the critical chain moves.",
  "dependencies": [],
  "files": [
    {
      "path": "registry/core/gantt-ascii-critical-path/component.tsx",
      "content": "\"use client\";\n\nimport { useEffect, useMemo, useRef, useState } from \"react\";\n\n// ---------------------------------------------------------------------------\n// GanttAsciiCriticalPath — a project schedule whose critical path is COMPUTED,\n// not annotated. A real CPM pass (Kahn topological order -> ES/EF forward ->\n// LS/LF backward -> totalFloat = LS - ES) runs in one useMemo and decides one\n// thing: a task with zero float draws the heavy accented set (┝━━┥), every\n// other task the light set (├──┤). Dragging a bar re-runs the pass on the same\n// frame, so handing slack to a different chain swaps the weights mid-gesture.\n//\n// No canvas, no rAF: every recompute is a direct response to pointer or key\n// input. Empty timeline cells render nothing at all — the ink sits in the bars,\n// the week ticks and the one `┊` today column.\n//\n// NO DEPENDENCY ARROWS. A connector lane anchored to the successor's row can\n// place a predecessor's corner glyph at the right COLUMN but never at the right\n// ROW, so every edge reads as leaving whatever task sits directly above. Doing\n// it truthfully needs vertical routing across intervening rows — more apparatus\n// than the edge earns: the bar WEIGHT already says which chain the ship date\n// rides on, and that reads at rest with no pointer.\n//\n// PITCH: the timeline grid is laid out in `ch` units, not pixels. In a\n// monospace face 1ch is exactly one advance width, so consecutive `─`/`━`\n// glyphs butt up into a continuous rule at ANY font size and in any fallback\n// font — a hardcoded px pitch dashes the bars the moment the two disagree.\n// The drag handler needs a pixel figure, so it measures the row grid's real\n// width / cols at pointerdown rather than assuming one.\n// ---------------------------------------------------------------------------\n\nexport interface GanttTask {\n  /** stable id, referenced by other tasks' deps */\n  id: string;\n  label: string;\n  /** duration in days */\n  duration: number;\n  /** ids this task cannot start before */\n  deps?: string[];\n}\n\nexport interface GanttAsciiCriticalPathProps {\n  /** the scheduled tasks */\n  tasks?: GanttTask[];\n  /** timeline cells drawn per row */\n  cols?: number;\n  /** day index marked by the `┊` column. A fixed prop, never new Date() */\n  today?: number;\n  /** week number printed at the first tick */\n  startWeek?: number;\n  /** heading above the chart */\n  title?: string;\n  /** extra classes merged onto the rendered root element */\n  className?: string;\n}\n\nconst LABEL_CHARS = 16;\n\nconst DEFAULT_TASKS: GanttTask[] = [\n  { id: \"discovery\", label: \"Discovery interviews\", duration: 4, deps: [] },\n  { id: \"wireframes\", label: \"Wireframe review\", duration: 3, deps: [\"discovery\"] },\n  { id: \"design-system\", label: \"Design system\", duration: 5, deps: [\"wireframes\"] },\n  { id: \"api-contract\", label: \"API contract\", duration: 3, deps: [\"discovery\"] },\n  { id: \"integrate-payments\", label: \"Integrate payments\", duration: 7, deps: [\"api-contract\"] },\n  { id: \"checkout-ui\", label: \"Checkout UI build\", duration: 6, deps: [\"design-system\"] },\n  { id: \"launch-comms\", label: \"Launch comms\", duration: 2, deps: [\"design-system\"] },\n  {\n    id: \"qa-regression\",\n    label: \"QA regression pass\",\n    duration: 4,\n    deps: [\"checkout-ui\", \"integrate-payments\"],\n  },\n  { id: \"ship\", label: \"Production rollout\", duration: 3, deps: [\"qa-regression\", \"launch-comms\"] },\n];\n\ntype Computed = {\n  es: number;\n  ef: number;\n  ls: number;\n  lf: number;\n  float: number;\n  critical: boolean;\n  cyclic: boolean;\n};\n\nfunction padLabel(label: string): string {\n  if (label.length <= LABEL_CHARS) return label.padEnd(LABEL_CHARS, \" \");\n  return `${label.slice(0, LABEL_CHARS - 1)}…`;\n}\n\nexport function GanttAsciiCriticalPath({\n  tasks = DEFAULT_TASKS,\n  cols = 32,\n  today = 9,\n  startWeek = 12,\n  title = \"Checkout replatform\",\n  className = \"\",\n}: GanttAsciiCriticalPathProps) {\n  const [offsets, setOffsets] = useState<Record<string, number>>({});\n  const [hovered, setHovered] = useState<string | null>(null);\n  const [focused, setFocused] = useState<string | null>(null);\n  const [dragId, setDragId] = useState<string | null>(null);\n  const dragRef = useRef<{\n    id: string;\n    startX: number;\n    startOffset: number;\n    prev: number;\n    cellPx: number;\n  } | null>(null);\n\n  // --- CPM: Kahn topological order, forward pass, backward pass -------------\n  const { plan, projectEnd, scale, bases } = useMemo(() => {\n    const byId = new Map(tasks.map((t) => [t.id, t]));\n    const indeg = new Map<string, number>();\n    const succ = new Map<string, string[]>();\n    for (const t of tasks) {\n      const deps = (t.deps ?? []).filter((d) => byId.has(d) && d !== t.id);\n      indeg.set(t.id, deps.length);\n      for (const d of deps) succ.set(d, [...(succ.get(d) ?? []), t.id]);\n    }\n    const queue = tasks.filter((t) => (indeg.get(t.id) ?? 0) === 0).map((t) => t.id);\n    const order: string[] = [];\n    while (queue.length) {\n      const id = queue.shift() as string;\n      order.push(id);\n      for (const s of succ.get(id) ?? []) {\n        const n = (indeg.get(s) ?? 0) - 1;\n        indeg.set(s, n);\n        if (n === 0) queue.push(s);\n      }\n    }\n    // Anything Kahn never drained sits on a cycle: excluded, never hung on.\n    const inOrder = new Set(order);\n\n    // FORWARD: ES = max(0, max(EF of deps) + manualOffset), EF = ES + duration\n    const es = new Map<string, number>();\n    const ef = new Map<string, number>();\n    // earliest day a task could start on dependencies alone, before its manual\n    // offset — the floor an ArrowLeft nudge must not drift below.\n    const base0 = new Map<string, number>();\n    for (const id of order) {\n      const t = byId.get(id) as GanttTask;\n      let base = 0;\n      for (const d of t.deps ?? []) if (inOrder.has(d)) base = Math.max(base, ef.get(d) ?? 0);\n      base0.set(id, base);\n      const start = Math.max(0, base + (offsets[id] ?? 0));\n      es.set(id, start);\n      ef.set(id, start + Math.max(1, t.duration));\n    }\n    const end = order.reduce((m, id) => Math.max(m, ef.get(id) ?? 0), 1);\n\n    // BACKWARD, reverse topological: LF = min(LS of successors) or projectEnd\n    const ls = new Map<string, number>();\n    const lf = new Map<string, number>();\n    for (let i = order.length - 1; i >= 0; i--) {\n      const id = order[i];\n      const t = byId.get(id) as GanttTask;\n      const outs = (succ.get(id) ?? []).filter((s) => inOrder.has(s));\n      const late = outs.length ? Math.min(...outs.map((s) => ls.get(s) ?? end)) : end;\n      lf.set(id, late);\n      ls.set(id, late - Math.max(1, t.duration));\n    }\n\n    const map = new Map<string, Computed>();\n    for (const t of tasks) {\n      if (!inOrder.has(t.id)) {\n        map.set(t.id, { es: 0, ef: 0, ls: 0, lf: 0, float: 0, critical: false, cyclic: true });\n        continue;\n      }\n      const f = (ls.get(t.id) as number) - (es.get(t.id) as number);\n      map.set(t.id, {\n        es: es.get(t.id) as number,\n        ef: ef.get(t.id) as number,\n        ls: ls.get(t.id) as number,\n        lf: lf.get(t.id) as number,\n        float: f,\n        // <= rather than ===: a drag can push a task to negative float\n        // mid-gesture, and that row is still the tight one, not a slack one.\n        critical: f <= 0,\n        cyclic: false,\n      });\n    }\n    return { plan: map, projectEnd: end, bases: base0, scale: Math.max(1, Math.ceil(end / cols)) };\n  }, [tasks, offsets, cols]);\n\n  // --- drag: pixel dx -> whole cells -> manualOffset, CPM re-runs live ------\n  useEffect(() => {\n    if (!dragId) return;\n    const move = (e: PointerEvent) => {\n      const d = dragRef.current;\n      if (!d) return;\n      const cells = Math.round((e.clientX - d.startX) / d.cellPx);\n      setOffsets((o) => ({ ...o, [d.id]: d.startOffset + cells * scale }));\n    };\n    const end = () => {\n      dragRef.current = null;\n      setDragId(null);\n    };\n    const key = (e: KeyboardEvent) => {\n      if (e.key !== \"Escape\") return;\n      const d = dragRef.current;\n      if (d) setOffsets((o) => ({ ...o, [d.id]: d.prev }));\n      dragRef.current = null;\n      setDragId(null);\n    };\n    window.addEventListener(\"pointermove\", move);\n    window.addEventListener(\"pointerup\", end);\n    window.addEventListener(\"pointercancel\", end);\n    window.addEventListener(\"keydown\", key);\n    return () => {\n      window.removeEventListener(\"pointermove\", move);\n      window.removeEventListener(\"pointerup\", end);\n      window.removeEventListener(\"pointercancel\", end);\n      window.removeEventListener(\"keydown\", key);\n    };\n  }, [dragId, scale]);\n\n  // Clamped both ways against the task's dependency-earliest start: at −base so\n  // repeated ArrowLeft on a task already sitting at day 0 cannot accumulate\n  // invisible negative offset that a later ArrowRight has to spend itself\n  // unwinding, and at cols*scale − base so held ArrowRight cannot run ES past\n  // aria-valuemax while the bar saturates at the last column. aria-valuenow\n  // (the clamped ES) therefore stays in step with the stored offset.\n  const nudge = (id: string, dir: number) =>\n    setOffsets((o) => {\n      const base = bases.get(id) ?? 0;\n      const next = (o[id] ?? 0) + dir * scale;\n      return { ...o, [id]: Math.min(cols * scale - base, Math.max(-base, next)) };\n    });\n\n  const colOf = (day: number) => Math.floor(day / scale);\n  const todayCol = colOf(today);\n\n  const geom = (id: string) => {\n    const c = plan.get(id) as Computed;\n    const start = Math.min(cols - 2, Math.max(0, colOf(c.es)));\n    const len = Math.max(2, Math.min(cols - start, Math.round((c.ef - c.es) / scale)));\n    return { start, len, c };\n  };\n\n  const active = hovered ?? focused;\n\n  const readout = (() => {\n    if (!active) {\n      const crit = tasks.filter((t) => plan.get(t.id)?.critical && !plan.get(t.id)?.cyclic).length;\n      const slack = tasks.length - crit;\n      return `${projectEnd} days  critical chain ${crit} tasks  ${slack} tasks carry float`;\n    }\n    const t = tasks.find((x) => x.id === active) as GanttTask;\n    const c = plan.get(active) as Computed;\n    if (c.cyclic) return `${t.label}  ×  circular dependency, excluded from the pass`;\n    return `${t.label}  ES ${c.es}  EF ${c.ef}  LS ${c.ls}  LF ${c.lf}  float ${c.float} day${\n      c.float === 1 ? \"\" : \"s\"\n    }`;\n  })();\n\n  // 1ch === one monospace advance, so `─` runs join with no seam at any size.\n  const cell = \"inline-block text-center\";\n  const cellStyle = { width: \"1ch\" } as const;\n  const labelStyle = { width: `${LABEL_CHARS}ch` } as const;\n  const trackStyle = { gridTemplateColumns: `repeat(${cols}, 1ch)` } as const;\n\n  return (\n    <section\n      data-gantt\n      aria-label={`${title} — Gantt chart with computed critical path`}\n      className={`inline-flex select-none flex-col gap-3 font-mono text-[15px] leading-none ${className}`}\n    >\n      <div className=\"flex items-baseline justify-between gap-6\">\n        <span className=\"text-[11px] uppercase tracking-[0.18em] text-ns-muted\">{title}</span>\n        <span className=\"text-[10px] uppercase tracking-[0.18em] text-ns-muted\">\n          <span className=\"text-ns-accent\">━</span> critical &nbsp; ─ has float\n        </span>\n      </div>\n\n      <div className=\"flex flex-col gap-[3px]\">\n        {/* week labels */}\n        <div className=\"flex items-center\">\n          <span className=\"shrink-0 whitespace-pre\" style={labelStyle} />\n          <span className={cell} style={cellStyle} />\n          <div className=\"grid\" style={trackStyle}>\n            {Array.from({ length: cols }, (_, i) => {\n              const day = i * scale;\n              if (day % 7 !== 0) return null;\n              return (\n                <span\n                  key={i}\n                  className=\"whitespace-pre text-[10px] leading-none text-ns-muted\"\n                  style={{ gridColumn: `${i + 1} / span 4` }}\n                >\n                  {`W${startWeek + day / 7}`}\n                </span>\n              );\n            })}\n          </div>\n        </div>\n\n        {/* ruler: `┬` every 7 days over a `─` rule, `┊` at today */}\n        <div className=\"flex items-center\">\n          <span className=\"shrink-0 whitespace-pre\" style={labelStyle} />\n          <span className={`${cell} text-border`} style={cellStyle}>\n            ┌\n          </span>\n          <div className=\"flex\">\n            {Array.from({ length: cols }, (_, i) => {\n              const day = i * scale;\n              const tick = day % 7 === 0;\n              const isToday = i === todayCol;\n              return (\n                <span\n                  key={i}\n                  className={`${cell} ${\n                    isToday ? \"text-foreground\" : tick ? \"text-ns-muted\" : \"text-ns-muted/40\"\n                  }`}\n                  style={cellStyle}\n                >\n                  {isToday ? \"┊\" : tick ? \"┬\" : \"─\"}\n                </span>\n              );\n            })}\n          </div>\n        </div>\n\n        {tasks.map((t) => {\n          const { start, len, c } = geom(t.id);\n          const isActive = active === t.id;\n          const barGlyphs = c.critical\n            ? `┝${\"━\".repeat(Math.max(0, len - 2))}┥`\n            : `├${\"─\".repeat(Math.max(0, len - 2))}┤`;\n          const todayFree = todayCol < start || todayCol >= start + len;\n\n          return (\n            <div\n              key={t.id}\n              data-task-row={t.id}\n              className=\"flex items-center\"\n              onPointerEnter={() => setHovered(t.id)}\n              onPointerLeave={() => setHovered((h) => (h === t.id ? null : h))}\n            >\n              <span\n                className={`shrink-0 whitespace-pre text-[13px] transition-colors duration-[140ms] motion-reduce:transition-none ${\n                  isActive || c.critical ? \"text-foreground\" : \"text-ns-muted\"\n                }`}\n                style={labelStyle}\n              >\n                {padLabel(t.label)}\n              </span>\n              <span className={`${cell} text-border`} style={cellStyle}>\n                │\n              </span>\n\n              {c.cyclic ? (\n                <span className=\"whitespace-pre pl-1 text-[13px] text-ns-muted\">\n                  ×  cycle — excluded\n                </span>\n              ) : (\n                <div className=\"relative grid\" style={trackStyle}>\n                  <button\n                    type=\"button\"\n                    role=\"slider\"\n                    aria-orientation=\"horizontal\"\n                    aria-valuemin={0}\n                    aria-valuemax={cols * scale}\n                    aria-valuenow={c.es}\n                    aria-valuetext={`starts day ${c.es}, ends day ${c.ef}, ${\n                      c.critical ? \"on the critical path, no float\" : `${c.float} days of float`\n                    }`}\n                    aria-label={`${t.label} schedule. Drag or use arrow keys to reschedule.`}\n                    className=\"flex cursor-ew-resize items-center rounded-[2px] outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ns-accent\"\n                    style={{ gridColumn: `${start + 1} / span ${len}`, gridRow: 1 }}\n                    onFocus={() => setFocused(t.id)}\n                    onBlur={() => setFocused((f) => (f === t.id ? null : f))}\n                    onPointerDown={(e) => {\n                      // Measure the real pitch off the laid-out track instead\n                      // of trusting a px constant to match the font metric.\n                      const track = e.currentTarget.parentElement;\n                      const w = track ? track.getBoundingClientRect().width : 0;\n                      dragRef.current = {\n                        id: t.id,\n                        startX: e.clientX,\n                        startOffset: offsets[t.id] ?? 0,\n                        prev: offsets[t.id] ?? 0,\n                        cellPx: w > 0 ? w / cols : 9,\n                      };\n                      setDragId(t.id);\n                    }}\n                    onKeyDown={(e) => {\n                      if (e.key === \"ArrowLeft\") {\n                        e.preventDefault();\n                        nudge(t.id, -1);\n                      } else if (e.key === \"ArrowRight\") {\n                        e.preventDefault();\n                        nudge(t.id, 1);\n                      } else if (e.key === \"Escape\" && !dragId) {\n                        // Guarded on !dragId: mid-drag the window keydown\n                        // listener owns Escape (it restores the pre-drag\n                        // offset) and both firing would fight.\n                        e.preventDefault();\n                        setOffsets((o) => ({ ...o, [t.id]: 0 }));\n                      }\n                    }}\n                  >\n                    {barGlyphs.split(\"\").map((ch, k) => (\n                      <span\n                        key={k}\n                        className={`${cell} ${c.critical ? \"text-ns-accent\" : \"text-ns-muted\"}`}\n                        style={cellStyle}\n                      >\n                        {ch}\n                      </span>\n                    ))}\n                  </button>\n\n                  {todayFree && todayCol >= 0 && todayCol < cols && (\n                    <span\n                      aria-hidden\n                      className={`${cell} text-foreground`}\n                      style={{\n                        gridColumn: `${todayCol + 1} / span 1`,\n                        gridRow: 1,\n                        width: \"1ch\",\n                      }}\n                    >\n                      ┊\n                    </span>\n                  )}\n                </div>\n              )}\n            </div>\n          );\n        })}\n      </div>\n\n      <p\n        aria-live=\"polite\"\n        className=\"rounded-sm border border-border bg-background px-3 py-2 text-[11px] tabular-nums text-ns-muted\"\n      >\n        {readout}\n      </p>\n    </section>\n  );\n}\n",
      "type": "registry:ui",
      "target": "components/ui/gantt-ascii-critical-path.tsx"
    }
  ],
  "cssVars": {
    "theme": {
      "color-ns-muted": "var(--ns-muted)",
      "color-ns-accent": "var(--ns-accent)"
    },
    "light": {
      "ns-muted": "#4d4d4d",
      "ns-accent": "#006bff"
    },
    "dark": {
      "ns-muted": "#8f8f8f"
    }
  },
  "meta": {
    "collection": "core",
    "tags": [
      "gantt",
      "schedule",
      "timeline",
      "ascii",
      "box-drawing",
      "critical-path",
      "drag",
      "project-management"
    ],
    "instruction": "Build <GanttAsciiCriticalPath tasks? cols? today? startWeek? title? className?> as pure DOM box-drawing text — no canvas, no rAF, every recompute a direct response to pointer or keyboard input. MECHANISM: a genuine Critical Path Method pass, computed in ONE useMemo keyed on tasks + manualOffset + cols. Topological order comes from Kahn's algorithm over the dependency graph (indegree map, zero-indegree queue, self-edges and dangling dep ids filtered out first); any task Kahn never drains sits on a cycle, so it is excluded from the pass and renders its row as `×  cycle — excluded` rather than hanging. FORWARD pass in topological order: ES = max(0, max(EF of deps) + manualOffset[id]), EF = ES + duration; projectEnd = max(EF). BACKWARD pass in REVERSE topological order: LF = min(LS of successors), or projectEnd when a task has none; LS = LF − duration; totalFloat = LS − ES; critical ⇔ totalFloat <= 0 (the <= rather than === is what keeps a row well-formed when a drag pushes a task into negative float mid-gesture — it is still the tight chain, not a slack one). ROW: a 16-char label column truncated with `…`, a `│` rule, then `cols` timeline cells (default 32) at scale = max(1, ceil(projectEnd / cols)) days per cell. BAR: non-critical = `├` + `─`×(len−2) + `┤` in text-ns-muted; critical = `┝` + `━`×(len−2) + `┥` in text-ns-accent — the WEIGHT change is the primary encoding, so the critical chain still reads in a pure-monochrome screenshot and hue is only reinforcement. Nothing else is drawn on the row: no slack run, no float glyphs — bar weight is the only encoding, and float stays a number in the readout. PITCH (the constant that makes or breaks the drawing): the timeline track is `grid-template-columns: repeat(cols, 1ch)` and every glyph cell is `width: 1ch`, NOT a hardcoded pixel figure. In a monospace face 1ch is exactly one advance width, so consecutive `─`/`━` glyphs butt into a continuous rule at any font size and under any fallback font; a px constant that disagrees with the font metric by even a pixel draws every bar as spaced dashes, which is the single most common way this component is got wrong. The drag handler is the one place a pixel number is needed, and it MEASURES it — `track.getBoundingClientRect().width / cols` read off the laid-out row at pointerdown — instead of assuming one. HEADER: `W12`-style labels computed from DAYS (startWeek + day/7, so they stay truthful at any scale) over a rule of `─` with `┬` at every 7-day tick; today is a fixed day-index prop (default 9, never derived from new Date(), so the frame is deterministic for a screenshot gate) drawn as a `┊` column in text-foreground repeated down every row, and drawn only where no bar already occupies that cell so glyphs never collide. Empty timeline cells render literally nothing — the ink concentrates in the bars, the ticks and the one `┊` instead of washing the frame in low-contrast filler. NO DEPENDENCY ARROWS ARE DRAWN, deliberately: a connector lane anchored to the successor's row can only place a predecessor's corner glyph at the right COLUMN, never at the right ROW, so every edge reads as leaving whichever task happens to sit directly above the successor; drawing edges truthfully needs vertical routing across intervening rows, which is more apparatus than the edge earns here. Dependencies stay in the data, where they drive ES/EF/LS/LF — the bar WEIGHT is what tells the reader which chain the ship date rides on, and that reads at rest with no pointer. INTERACTION: pointerdown on a bar records startX, the current offset and the measured cell pitch, then attaches window pointermove/pointerup/keydown listeners (window listeners, not setPointerCapture, which a synthetic pointerId would throw on); pointermove converts pixel dx to whole cells via round(dx / cellPx) and writes manualOffset[id], re-running the CPM memo on the same frame — which is the whole point: a task you drag can hand criticality to a different chain and you watch the `━`/`─` weights swap live. Escape during a drag restores the previous offset; Escape on a focused bar clears its manual offset entirely, returning the task to its computed schedule; ArrowLeft/ArrowRight nudge the focused task ±1 cell through the identical code path, clamped to [−base, cols*scale − base] where base is the task's dependency-earliest start: the lower clamp stops a left nudge on a task already sitting at day 0 from banking invisible negative offset that the next right nudge would spend unwinding, the upper stops a held right nudge from running ES past aria-valuemax while the bar saturates at the last column — every keypress moves the bar, and aria-valuenow stays in step with the stored offset. Hovering or focusing a task brightens its label and prints `Integrate payments  ES 7  EF 14  LS 11  LF 18  float 4 days` into an aria-live=polite readout, which at rest prints the project summary (`25 days  critical chain 6 tasks  3 tasks carry float`) instead. THEME: every colour is a token class (text-ns-accent, text-ns-muted, text-ns-muted/40, text-foreground, text-border, border-border, bg-background) — no hex anywhere, so light and dark both render correctly. --border is reserved for the structural `│` and `┌` rules, which may safely recede. REDUCED MOTION: there is no animation loop at all; the only transition is the 140ms colour ease on the row label, carrying motion-reduce:transition-none so prefers-reduced-motion snaps instead of easing and nothing is lost. A11Y: each bar is a real <button> carrying role=slider with aria-orientation=horizontal, aria-valuemin=0, aria-valuemax=cols*scale, aria-valuenow=ES and an aria-valuetext naming its day range and float — the arrow keys are then the role's own expected operation rather than an undocumented extra on a button — plus an aria-label naming the task and the available action, a visible focus-visible:ring-2 ring-inset ring-ns-accent on every bar, and full arrow-key operation; the readout is aria-live=polite so a screen reader hears the recomputed float. Props: tasks ({id,label,duration,deps}[], default a 9-task checkout replatform plan whose zero-float chain is discovery → wireframes → design-system → checkout-ui → qa-regression → ship, with api-contract carrying 4 days of float, integrate-payments 4 and launch-comms 8), cols (default 32), today (day index, default 9), startWeek (default 12), title, className."
  },
  "type": "registry:ui"
}