{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "flag-playground",
  "type": "registry:component",
  "title": "Flag Playground",
  "description": "Paste a user as JSON and watch every flag decide, rule by rule: each condition ticks or crosses, the winning rule lights up, later ones grey out, and the served variant swaps in. Broken JSON and wrong types get called out instead of quietly failing.",
  "categories": [
    "component",
    "Filters"
  ],
  "dependencies": [
    "@qnot/core",
    "lucide-react",
    "motion"
  ],
  "registryDependencies": [
    "utils",
    "https://coss.com/ui/r/textarea.json"
  ],
  "files": [
    {
      "path": "registry/devl/flag-playground.tsx",
      "type": "registry:component",
      "content": "\"use client\";\n\nimport { compileCases, encode, type Fields, fieldLabel, matches, type Query, type QueryNode, type QueryOf, validateRule } from \"@qnot/core\";\nimport { textFormat } from \"@qnot/core/formats/text\";\nimport { CheckIcon, ChevronDownIcon, CircleAlertIcon, MinusIcon, XIcon } from \"lucide-react\";\nimport { AnimatePresence, motion, useReducedMotion } from \"motion/react\";\nimport { useId, useMemo, useState } from \"react\";\nimport { Textarea } from \"@/components/ui/textarea\";\nimport { cn } from \"@/lib/utils\";\n\nexport interface PlaygroundVariant {\n  id: string;\n  name: string;\n  tone: string;\n}\n\nexport interface PlaygroundRule {\n  name: string;\n  when: Query;\n  serve: string;\n}\n\nexport interface PlaygroundFlag {\n  key: string;\n  description: string;\n  variants: PlaygroundVariant[];\n  rules: PlaygroundRule[];\n  rollout: Record<string, number>;\n}\n\nexport interface ContextPreset {\n  name: string;\n  context: Record<string, unknown>;\n}\n\nexport interface FlagPlaygroundProps {\n  fields: Fields;\n  flags: PlaygroundFlag[];\n  presets: ContextPreset[];\n  className?: string;\n}\n\ntype Step = { state: \"match\" | \"miss\" | \"skipped\"; conditions: { text: string; pass: boolean }[] };\n\nconst easeOut = [0.23, 1, 0.32, 1] as const;\nconst sentence = textFormat();\n\nfunction bucketOf(id: string, salt: string) {\n  let h = 2166136261;\n  for (const ch of `${salt}:${id}`) h = Math.imul(h ^ ch.charCodeAt(0), 16777619);\n  return (h >>> 0) % 100;\n}\n\nfunction parseContext(text: string): { value: Record<string, unknown> } | { error: string } {\n  try {\n    const value = JSON.parse(text);\n    if (!value || typeof value !== \"object\" || Array.isArray(value)) return { error: \"The context must be a JSON object\" };\n    return { value };\n  } catch (e) {\n    const message = e instanceof Error ? e.message : \"Invalid JSON\";\n    const at = /position (\\d+)/.exec(message);\n    if (!at) return { error: message.replace(/^JSON\\.parse: /, \"\") };\n    const before = text.slice(0, Number(at[1]));\n    const line = before.split(\"\\n\").length;\n    const column = before.length - before.lastIndexOf(\"\\n\");\n    return { error: `${message.replace(/ in JSON at position \\d+.*$/, \"\").replace(/\\s*\\(line.*\\)$/, \"\")} on line ${line}, column ${column}` };\n  }\n}\n\nfunction evaluate(fields: Fields, flag: PlaygroundFlag, context: Record<string, unknown>) {\n  const route = compileCases(fields, { cases: flag.rules.map((r) => ({ when: r.when as QueryOf<Fields>, then: r.serve })) }, { incompleteRules: false });\n  const hit = route(context as never);\n  const steps: Step[] = flag.rules.map((rule, index) => {\n    const children: readonly QueryNode[] = rule.when.kind === \"group\" ? rule.when.children : [rule.when];\n    const conditions = children.map((child) => {\n      const single: Query = { kind: \"group\", combinator: \"and\", children: [child] };\n      return { text: encode(sentence, single, fields), pass: matches(fields, single as QueryOf<Fields>, context as never, { incompleteRules: false }) };\n    });\n    const state = hit.index === -1 || index < hit.index ? \"miss\" : index === hit.index ? \"match\" : \"skipped\";\n    return { state, conditions };\n  });\n  if (hit.index >= 0) return { variant: hit.value as string, reason: `Matched ${flag.rules[hit.index].name}`, steps, bucket: null };\n  const id = String(context.id ?? context.key ?? \"anonymous\");\n  const bucket = bucketOf(id, flag.key);\n  let edge = 0;\n  let variant = flag.variants[flag.variants.length - 1].id;\n  for (const v of flag.variants) {\n    edge += flag.rollout[v.id] ?? 0;\n    if (bucket < edge) {\n      variant = v.id;\n      break;\n    }\n  }\n  return { variant, reason: `Rollout bucket ${bucket}`, steps, bucket };\n}\n\nexport function FlagPlayground({ fields, flags, presets, className }: FlagPlaygroundProps) {\n  const reduce = useReducedMotion();\n  const editorId = useId();\n  const [preset, setPreset] = useState(0);\n  const [text, setText] = useState(() => JSON.stringify(presets[0].context, null, 2));\n  const [open, setOpen] = useState<string | null>(flags[0]?.key ?? null);\n  const parsed = useMemo(() => parseContext(text), [text]);\n  const [lastGood, setLastGood] = useState(presets[0].context);\n  if (\"value\" in parsed && parsed.value !== lastGood) {\n    setLastGood(parsed.value);\n  }\n  const context = \"value\" in parsed ? parsed.value : lastGood;\n  const stale = \"error\" in parsed;\n\n  const typeIssues = useMemo(\n    () =>\n      Object.entries(context).flatMap(([key, value]) => {\n        const def = fields[key];\n        if (!def || value === null) return [];\n        return validateRule(fields, { kind: \"rule\", field: key, operator: \"eq\", value })\n          .filter((i) => i.code === \"type\" || i.code === \"option\")\n          .map((i) => ({ key, label: fieldLabel(key, def), message: i.message }));\n      }),\n    [context, fields],\n  );\n\n  const evaluated = useMemo(() => {\n    if (!typeIssues.length) return context;\n    const ignored = new Set(typeIssues.map((i) => i.key));\n    return Object.fromEntries(Object.entries(context).filter(([key]) => !ignored.has(key)));\n  }, [context, typeIssues]);\n\n  const results = useMemo(() => flags.map((flag) => ({ flag, ...evaluate(fields, flag, evaluated) })), [flags, fields, evaluated]);\n\n  return (\n    <div\n      className={cn(\n        \"flex w-full min-w-0 max-w-[600px] flex-col overflow-hidden rounded-2xl border bg-card text-sm shadow-xs/5\",\n        className,\n      )}\n    >\n      <div className=\"flex flex-col gap-2.5 px-4 pt-3.5 pb-3\">\n        <div className=\"flex items-baseline justify-between gap-3\">\n          <label className=\"font-medium\" htmlFor={editorId}>\n            User context\n          </label>\n          <span className=\"text-muted-foreground text-xs\">Edit the JSON or pick someone</span>\n        </div>\n        <div aria-label=\"Example users\" className=\"flex flex-wrap gap-1.5\" role=\"group\">\n          {presets.map((p, i) => (\n            <button\n              aria-pressed={preset === i}\n              className={cn(\n                \"h-7 rounded-full border px-2.5 text-xs outline-none transition-[background-color,border-color,color,scale] duration-150 focus-visible:ring-2 focus-visible:ring-ring active:scale-[0.97]\",\n                preset === i\n                  ? \"border-foreground/16 bg-foreground/6 font-medium text-foreground dark:bg-foreground/10\"\n                  : \"border-input bg-background text-muted-foreground hover:text-foreground dark:bg-input/32\",\n              )}\n              key={p.name}\n              onClick={() => {\n                setPreset(i);\n                setText(JSON.stringify(p.context, null, 2));\n              }}\n              type=\"button\"\n            >\n              {p.name}\n            </button>\n          ))}\n        </div>\n        <Textarea\n          aria-describedby={`${editorId}-status`}\n          aria-invalid={stale || undefined}\n          className=\"font-mono text-[12px] leading-5 sm:text-[12px] [&_textarea]:h-49 [&_textarea]:resize-none [&_textarea]:[field-sizing:fixed]\"\n          id={editorId}\n          onChange={(e) => {\n            setText(e.target.value);\n            setPreset(-1);\n          }}\n          rows={8}\n          spellCheck={false}\n          value={text}\n        />\n        <div className=\"min-h-5 text-xs\" id={`${editorId}-status`}>\n          {stale ? (\n            <p className=\"flex items-start gap-1.5 text-destructive-foreground\">\n              <CircleAlertIcon className=\"mt-px size-3.5 shrink-0\" />\n              <span className=\"text-pretty\">{parsed.error}. Showing the last valid context.</span>\n            </p>\n          ) : typeIssues.length > 0 ? (\n            <p className=\"flex items-start gap-1.5 text-warning-foreground\">\n              <CircleAlertIcon className=\"mt-px size-3.5 shrink-0\" />\n              <span className=\"text-pretty\">\n                Ignoring {typeIssues.map((i) => `${i.label} (${i.message.toLowerCase()})`).join(\", \")}\n              </span>\n            </p>\n          ) : (\n            <p className=\"flex items-center gap-1.5 text-muted-foreground\">\n              <CheckIcon className=\"size-3.5 shrink-0 text-success-foreground\" />\n              Valid context with {Object.keys(context).length} attributes\n            </p>\n          )}\n        </div>\n      </div>\n\n      <ul className={cn(\"flex flex-col border-t transition-opacity duration-200\", stale && \"opacity-64\")}>\n        {results.map(({ flag, variant, reason, steps }) => {\n          const served = flag.variants.find((v) => v.id === variant) ?? flag.variants[0];\n          const expanded = open === flag.key;\n          return (\n            <li className=\"border-b last:border-b-0\" key={flag.key}>\n              <button\n                aria-expanded={expanded}\n                className=\"flex w-full items-center gap-3 px-4 py-2.5 text-start outline-none transition-colors hover:bg-accent/40 focus-visible:bg-accent/40\"\n                onClick={() => setOpen(expanded ? null : flag.key)}\n                type=\"button\"\n              >\n                <span className=\"min-w-0 flex-1\">\n                  <span className=\"block truncate font-medium font-mono text-xs\">{flag.key}</span>\n                  <span className=\"block truncate text-muted-foreground text-xs\">{reason}</span>\n                </span>\n                <AnimatePresence initial={false} mode=\"popLayout\">\n                  <motion.span\n                    animate={{ opacity: 1, y: 0, filter: \"blur(0px)\" }}\n                    className=\"flex shrink-0 items-center gap-1.5 rounded-full border bg-background px-2 py-0.5 font-medium text-xs dark:bg-input/32\"\n                    exit={{ opacity: 0, y: reduce ? 0 : -6, filter: \"blur(2px)\" }}\n                    initial={{ opacity: 0, y: reduce ? 0 : 6, filter: \"blur(2px)\" }}\n                    key={served.id}\n                    transition={{ duration: 0.2, ease: easeOut }}\n                  >\n                    <span className={cn(\"size-1.5 rounded-full\", served.tone)} />\n                    {served.name}\n                  </motion.span>\n                </AnimatePresence>\n                <ChevronDownIcon\n                  className={cn(\"size-4 shrink-0 text-muted-foreground transition-transform duration-200\", expanded && \"rotate-180\")}\n                />\n              </button>\n              <AnimatePresence initial={false}>\n                {expanded && (\n                  <motion.div\n                    animate={{ height: \"auto\", opacity: 1 }}\n                    className=\"overflow-hidden\"\n                    exit={{ height: 0, opacity: 0 }}\n                    initial={{ height: 0, opacity: 0 }}\n                    transition={{ duration: reduce ? 0 : 0.22, ease: easeOut }}\n                  >\n                    <ol className=\"flex flex-col gap-1.5 px-4 pb-3\">\n                      {flag.rules.map((rule, i) => (\n                        <RuleStep\n                          index={i}\n                          key={rule.name}\n                          name={rule.name}\n                          serve={flag.variants.find((v) => v.id === rule.serve) ?? flag.variants[0]}\n                          step={steps[i]}\n                        />\n                      ))}\n                      <FallthroughStep flag={flag} matched={steps.some((s) => s.state === \"match\")} served={served} />\n                    </ol>\n                  </motion.div>\n                )}\n              </AnimatePresence>\n            </li>\n          );\n        })}\n      </ul>\n    </div>\n  );\n}\n\nfunction RuleStep({ step, name, index, serve }: { step: Step; name: string; index: number; serve: PlaygroundVariant }) {\n  return (\n    <li\n      className={cn(\n        \"rounded-xl border px-3 py-2 transition-[background-color,border-color,color] duration-200\",\n        step.state === \"match\" && \"border-success/32 bg-success/6 dark:bg-success/10\",\n        step.state === \"skipped\" && \"text-muted-foreground\",\n      )}\n    >\n      <div className=\"flex items-center gap-2 text-xs\">\n        <span className=\"flex size-4.5 shrink-0 items-center justify-center rounded-md bg-muted font-medium text-[10px] tabular-nums\">\n          {index + 1}\n        </span>\n        <span className=\"min-w-0 flex-1 truncate font-medium\">{name}</span>\n        <span className={cn(\"shrink-0 text-muted-foreground\", step.state === \"match\" && \"max-sm:sr-only\")}>\n          {step.state === \"match\" ? \"Matched, serves\" : step.state === \"skipped\" ? \"Not reached\" : \"No match\"}\n        </span>\n        {step.state === \"match\" && (\n          <span className=\"flex shrink-0 items-center gap-1 font-medium\">\n            <span className={cn(\"size-1.5 rounded-full\", serve.tone)} />\n            {serve.name}\n          </span>\n        )}\n      </div>\n      <ul className=\"mt-1.5 flex flex-col gap-1 ps-6.5\">\n        {step.conditions.map((c) => (\n          <li className=\"flex items-start gap-1.5 text-xs\" key={c.text}>\n            {step.state === \"skipped\" ? (\n              <MinusIcon className=\"mt-0.5 size-3 shrink-0 text-muted-foreground\" />\n            ) : c.pass ? (\n              <CheckIcon className=\"mt-0.5 size-3 shrink-0 text-success-foreground\" />\n            ) : (\n              <XIcon className=\"mt-0.5 size-3 shrink-0 text-destructive-foreground\" />\n            )}\n            <span className={cn(\"min-w-0 text-pretty\", !c.pass && step.state !== \"skipped\" && \"text-muted-foreground\")}>\n              {c.text}\n            </span>\n          </li>\n        ))}\n      </ul>\n    </li>\n  );\n}\n\nfunction FallthroughStep({ flag, matched, served }: { flag: PlaygroundFlag; matched: boolean; served: PlaygroundVariant }) {\n  const split = flag.variants.map((v) => `${flag.rollout[v.id] ?? 0}% ${v.name}`).join(\" · \");\n  return (\n    <li\n      className={cn(\n        \"flex items-center gap-2 rounded-xl border border-dashed px-3 py-2 text-xs transition-[background-color,border-color,color] duration-200\",\n        matched ? \"text-muted-foreground\" : \"border-success/32 border-solid bg-success/6 dark:bg-success/10\",\n      )}\n    >\n      <span className=\"min-w-0 flex-1 truncate\">\n        <span className=\"font-medium\">Everyone else</span>\n        <span className=\"text-muted-foreground\"> · {split}</span>\n      </span>\n      {!matched && (\n        <span className=\"flex shrink-0 items-center gap-1 font-medium\">\n          <span className={cn(\"size-1.5 rounded-full\", served.tone)} />\n          {served.name}\n        </span>\n      )}\n    </li>\n  );\n}\n"
    }
  ]
}