File size: 4,293 Bytes
921d377
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
/**
 * Step 2 — Branch shape: count, depth, scenes-per-branch.
 *
 * The wizard fetches `/health` once and passes the limits in via
 * props so the inputs hard-cap to whatever the backend allows.
 * The estimated total node count is recomputed live so users see
 * "≈18 nodes" before they ever press Create.
 */

import React, { useMemo } from "react";
import { AlertTriangle } from "lucide-react";
import type { WizardForm } from "../wizardState";

export interface Step2Props {
  form: WizardForm;
  setForm: (patch: Partial<WizardForm>) => void;
  limits: { max_branches: number; max_depth: number; max_nodes_per_experience: number };
}

export function Step2Branches({ form, setForm, limits }: Step2Props) {
  const estimatedNodes = useMemo(
    () => form.branch_count * form.depth * Math.max(1, form.scenes_per_branch),
    [form.branch_count, form.depth, form.scenes_per_branch],
  );
  const overCap = estimatedNodes > limits.max_nodes_per_experience;

  return (
    <div className="flex flex-col gap-5">
      <NumberField
        label="Choices"
        hint="How many decision paths the experience offers."
        value={form.branch_count}
        min={1}
        max={limits.max_branches}
        onChange={(v) => setForm({ branch_count: v })}
      />
      <NumberField
        label="Steps"
        hint="How many decision rounds before the longest path ends."
        value={form.depth}
        min={1}
        max={limits.max_depth}
        onChange={(v) => setForm({ depth: v })}
      />
      <NumberField
        label="Video length"
        hint="How many scenes per path before branches merge."
        value={form.scenes_per_branch}
        min={1}
        max={20}
        onChange={(v) => setForm({ scenes_per_branch: v })}
      />

      <div
        className={[
          "rounded-md border p-3 text-sm flex items-start gap-3",
          overCap
            ? "border-amber-500/40 bg-amber-500/5 text-amber-300"
            : "border-[#3f3f3f] bg-[#121212] text-[#cfd8dc]",
        ].join(" ")}
        aria-live="polite"
      >
        {overCap && <AlertTriangle className="w-4 h-4 mt-0.5 shrink-0" aria-hidden />}
        <div>
          <div>
            Upper-bound estimate: <span className="font-semibold text-[#f1f1f1]">≈{estimatedNodes} nodes</span>
            {" "}(choices × steps × scenes; the merge collapser usually trims this).
          </div>
          {overCap && (
            <div className="mt-1 text-xs">
              Exceeds the configured cap of {limits.max_nodes_per_experience}. Reduce choices, steps, or video length — or the planner will cap it for you.
            </div>
          )}
        </div>
      </div>
    </div>
  );
}

function NumberField({
  label, hint, value, min, max, onChange,
}: {
  label: string;
  hint?: string;
  value: number;
  min: number;
  max: number;
  onChange: (v: number) => void;
}) {
  const id = `ix_${label.replace(/\s+/g, "_").toLowerCase()}`;
  return (
    <div className="flex flex-col gap-1.5">
      <label htmlFor={id} className="text-xs font-medium text-[#cfd8dc]">{label}</label>
      {hint && <p className="text-xs text-[#777] -mt-0.5">{hint}</p>}
      <div className="flex items-center gap-3">
        <input
          id={id}
          type="range"
          min={min}
          max={max}
          step={1}
          value={value}
          onChange={(e) => onChange(parseInt(e.target.value, 10) || min)}
          className="flex-1 accent-[#3ea6ff]"
          aria-valuemin={min}
          aria-valuemax={max}
          aria-valuenow={value}
        />
        <input
          type="number"
          min={min}
          max={max}
          value={value}
          onChange={(e) => onChange(clamp(parseInt(e.target.value, 10) || min, min, max))}
          className="w-20 bg-[#121212] border border-[#3f3f3f] rounded-md px-2 py-1.5 text-sm text-center outline-none focus:border-[#3ea6ff]"
        />
        <span className="text-[11px] text-[#777]">/ {max} max</span>
      </div>
    </div>
  );
}

function clamp(n: number, lo: number, hi: number): number {
  if (n < lo) return lo;
  if (n > hi) return hi;
  return n;
}

/** Always valid — caps are enforced by the input components. */
export function step2Valid(_f: WizardForm): boolean {
  return true;
}