{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "lint-playground",
  "type": "registry:ui",
  "title": "Lint Playground",
  "description": "LintPlayground — paste code, watch analysis light it up.",
  "author": "ofri-peretz <https://github.com/ofri-peretz>",
  "categories": [
    "blog",
    "pattern"
  ],
  "dependencies": [],
  "registryDependencies": [
    "https://ds.interlace.tools/r/theme.json",
    "https://ds.interlace.tools/r/cn.json",
    "https://ds.interlace.tools/r/code-editor.json"
  ],
  "files": [
    {
      "path": "registry/interlace-ui/patterns/lint-playground.tsx",
      "target": "components/ui/patterns/lint-playground.tsx",
      "type": "registry:ui",
      "content": "'use client';\n\nimport * as React from 'react';\n\n// @interlace/lint-playground v1.0.0 — Interlace design system.\n// Docs, props and live preview: https://ds.interlace.tools/c/lint-playground\n// What changed since: https://ds.interlace.tools/c/lint-playground#history\n// Generated banner — keep it, the upgrade diff reads this version.\n\nimport { cn } from '@/lib/utils';\nimport { CodeEditor, type CodeEditorDiagnostic } from '@/components/ui/code-editor';\n\n/**\n * LintPlayground — paste code, watch analysis light it up.\n *\n * ## The seam: the consumer brings the analyzer\n *\n * `lint` is an injected async function. The DS owns the surface —\n * editor, findings list, status — and stays free of any linting\n * dependency; the app owns HOW linting happens (a web worker bundling\n * a real linter, a WASM tool, a mock in Storybook). That inversion is\n * what keeps this a pattern rather than a product: the same component\n * demos an ESLint plugin, a formatter, or anything else that maps code\n * to line-anchored findings.\n *\n * ## Honesty rules\n *\n * - Findings are TEXT first: every diagnostic renders as a list row\n *   (line, rule, message); the editor's bars are the same facts as\n *   position. Colour-and-position alone never carries the message.\n * - Stale results never paint: each keystroke advances a sequence\n *   number and only the newest lint's answer lands. A slow answer to\n *   old code is a wrong answer to current code.\n * - A failed lint says so (`role=\"status\"`, not a silent empty list) —\n *   \"no findings\" and \"could not analyze\" license different\n *   conclusions.\n * - The footer states the privacy fact that makes pasting real code\n *   reasonable: analysis runs where the reader is, nothing leaves.\n *\n * | Rule | Concept                    | Where in this file                    |\n * | ---- | -------------------------- | ------------------------------------- |\n * | R5   | testid required, no default| `'data-testid': string`               |\n * | R6   | data-slot on every part    | `\"lint-playground\" / \"-status\" / \"-findings\" / \"-footer\"` |\n * | R11  | Composition over kind-props| the analyzer is injected, not enumerated |\n * | R16  | No internal coupling       | renders CodeEditor, brings no linter  |\n * | R24  | Product-neutral            | no plugin names, no product copy      |\n * | R25  | Client component           | debounce + async state                |\n */\n\n/** One finding, line-anchored. `message` is the full human sentence. */\nexport interface PlaygroundDiagnostic {\n  line: number;\n  /**\n   * Kept on the type for analyzers that report it, but deliberately NOT\n   * rendered (review): the editor bar already gives position at line\n   * granularity, and a column number in prose is noise a reader cannot\n   * act on in a plain textarea. Consumers needing it render their own list.\n   */\n  column?: number;\n  /** Analyzer's rule id, or null for parse-level failures. */\n  ruleId: string | null;\n  severity: 'error' | 'warn';\n  message: string;\n}\n\nexport interface LintPlaygroundProps\n  extends Omit<React.ComponentPropsWithoutRef<'section'>, 'children'> {\n  /** Stable selector for E2E tests; consumer provides — no default (R5). */\n  'data-testid': string;\n  /** Accessible name for the editor inside. */\n  label: string;\n  /** The code the exhibit opens on — usually a vulnerable-by-design sample. */\n  initialCode: string;\n  /**\n   * The analyzer. Rejections render the failed state, never an empty\n   * list. Identity changes do NOT re-trigger analysis (the newest\n   * function is simply used on the next run) — an inline arrow in JSX\n   * is safe and will not flash \"Analyzing…\" on unrelated re-renders.\n   */\n  lint: (code: string) => Promise<readonly PlaygroundDiagnostic[]>;\n  /**\n   * Quiet time after the last keystroke before analyzing.\n   * @default 300\n   */\n  debounceMs?: number;\n}\n\ntype Status = 'linting' | 'ready' | 'failed';\n\nexport const LintPlayground = React.forwardRef<HTMLElement, LintPlaygroundProps>(\n  function LintPlayground(\n    {\n      'data-testid': testId,\n      label,\n      initialCode,\n      lint,\n      debounceMs = 300,\n      className,\n      ...rest\n    },\n    ref,\n  ) {\n    const [code, setCode] = React.useState(initialCode);\n    const [status, setStatus] = React.useState<Status>('linting');\n    const [findings, setFindings] = React.useState<readonly PlaygroundDiagnostic[]>([]);\n    const seq = React.useRef(0);\n\n    // The latest-ref pattern (review): an unstable `lint` reference — an\n    // inline arrow recreated every parent render — must not re-run the\n    // effect below, or every unrelated re-render flashes \"Analyzing…\"\n    // and resets state. The effect depends on the CODE; the ref always\n    // holds the newest analyzer.\n    const lintRef = React.useRef(lint);\n    React.useEffect(() => {\n      lintRef.current = lint;\n    });\n\n    React.useEffect(() => {\n      const mine = ++seq.current;\n      setStatus('linting');\n      const timer = window.setTimeout(() => {\n        lintRef.current(code).then(\n          (result) => {\n            if (seq.current !== mine) return; // stale — newer code exists\n            setFindings(result);\n            setStatus('ready');\n          },\n          () => {\n            if (seq.current !== mine) return;\n            setFindings([]);\n            setStatus('failed');\n          },\n        );\n      }, debounceMs);\n      return () => window.clearTimeout(timer);\n    }, [code, debounceMs]);\n\n    const bars: CodeEditorDiagnostic[] = findings.map((f) => ({\n      line: f.line,\n      severity: f.severity,\n    }));\n\n    return (\n      <section\n        ref={ref}\n        data-slot=\"lint-playground\"\n        data-testid={testId}\n        data-status={status}\n        aria-label={label}\n        className={cn('flex flex-col gap-3', className)}\n        {...rest}\n      >\n        <CodeEditor\n          data-testid={`${testId}-editor`}\n          label={label}\n          value={code}\n          onValueChange={setCode}\n          diagnostics={status === 'ready' ? bars : []}\n        />\n\n        {/* One live region carries the state sentence; the list below is\n            plain content, so a screen reader is told the COUNT changed\n            and can then read each finding at its own pace. */}\n        <p\n          data-slot=\"lint-playground-status\"\n          role=\"status\"\n          className=\"text-xs text-muted-foreground\"\n        >\n          {status === 'linting' && 'Analyzing…'}\n          {status === 'failed' &&\n            'Could not analyze this code — the result is unknown, not clean.'}\n          {status === 'ready' &&\n            (findings.length === 0\n              ? 'No findings.'\n              : `${findings.length} finding${findings.length === 1 ? '' : 's'}.`)}\n        </p>\n\n        {status === 'ready' && findings.length > 0 && (\n          <ol\n            data-slot=\"lint-playground-findings\"\n            className=\"m-0 flex list-none flex-col gap-2 p-0\"\n          >\n            {findings.map((f, index) => (\n              <li\n                key={`${f.line}-${f.ruleId ?? 'parse'}-${index}`}\n                className={cn(\n                  'rounded-md border-l-2 bg-muted/40 px-3 py-2 text-xs',\n                  f.severity === 'error' ? 'border-destructive' : 'border-chart-4',\n                )}\n              >\n                <span className=\"font-medium tabular-nums\">L{f.line}</span>\n                {f.ruleId && (\n                  <span className=\"ml-2 font-mono text-muted-foreground\">{f.ruleId}</span>\n                )}\n                {/* The analyzer's sentence verbatim, line breaks kept —\n                    a CWE-tagged multi-line message IS the product. */}\n                <pre className=\"mt-1 whitespace-pre-wrap font-mono text-xs text-foreground\">\n                  {f.message}\n                </pre>\n              </li>\n            ))}\n          </ol>\n        )}\n\n        <p\n          data-slot=\"lint-playground-footer\"\n          className=\"text-xs text-muted-foreground\"\n        >\n          Edits as you type · analysis runs entirely in your browser — nothing\n          you type leaves this page.\n        </p>\n      </section>\n    );\n  },\n);\n"
    }
  ],
  "meta": {
    "tier": "pattern",
    "client": true,
    "minViewport": null,
    "loading": false,
    "version": "1.0.0",
    "since": null
  },
  "docs": "## @interlace/lint-playground\n\nInstalled to `components/ui/patterns/lint-playground.tsx`.\n\n```tsx\nimport { /* … */ } from '@/components/ui/patterns/lint-playground';\n```\n\nProps, a11y contract, live preview and source: https://ds.interlace.tools/c/lint-playground\n\nRequires the `@interlace/theme` CSS baseline (installed automatically as a registry dependency)."
}
