Selaa lähdekoodia

Fix/autofix dangling children (#300)

* autoFixSpec prunes children references to undefined elements

Dangling references are the dominant remaining first-attempt validation
failure in benchmarks, and models frequently fail to repair them even given
the exact error (observed: three repair turns, same dangling footer each
time). The renderer already skips missing children at runtime, so pruning
yields the identical rendered output while letting the spec validate. Each
removal is reported in fixes.

* Classify autoFixSpec fixes as lossy or lossless

Pruning a dangling child reference changes what renders; relocating a
misplaced field does not. Callers with a repair loop need to tell these
apart: accept lossless fixes silently, prefer re-prompting over lossy fixes,
and keep the lossy-fixed spec as a last resort. Adds fixDetails alongside the
existing fixes strings (additive, no signature break).

* Validate visible conditions in validateSpec; document filtered-list pattern

Malformed visible conditions (e.g. mixing $state and $item in one object)
silently evaluate to hidden at runtime: evaluateCondition dispatches on the
first recognized key and non-strict parsing strips the rest, so whole regions
of UI disappear with a valid-looking spec. Benchmarked worst case: a kanban
board that rendered zero task cards.

- core: VisibilityConditionStrictSchema (strict objects, exported)
- core: validateSpec rejects malformed visible with a repairable message
  listing the valid forms (code: invalid_visible)
- framework prompts: FILTERED LISTS rule showing the repeat + per-item
  visible pattern models keep reaching for and inventing syntax around

* Support filtered lists: repeat + $item visible on the same element

Models across vendors consistently write {repeat, visible: {$item: ...}} on
one container to mean a filtered list (kanban columns, status sections).
Previously $item had no meaning outside the repeat scope, the condition
evaluated false, and the whole region silently disappeared — the worst
visual failures in benchmarks were boards rendering zero cards this way.

Outside a repeat scope that spelling was always broken, so claiming it is
backward compatible: the renderer now applies such a condition per item,
preserving original indices for item state paths. Container-level $state
conditions and per-child $item conditions behave as before.

- core: conditionUsesItemScope helper (exported)
- react: RepeatChildren filters items by the container's $item condition
- framework prompts: FILTERED LISTS rule teaches the container spelling
- react: repeat-filter test suite (filtering, no-filter, $state container
  visibility, per-child $item)

Other framework renderers (vue, svelte, solid, react-native) still evaluate
the container condition outside scope and should adopt the same semantics.

* Validate repeat containers: require children and matching state arrays

Two silent empty-region failures seen repeatedly in benchmarks, both passing
validation today: a repeat element with no children (nothing to clone per
item) and a repeat statePath pointing at a missing or non-array state value.
Both now fail validateSpec with repairable messages (repeat_without_children,
repeat_state_mismatch). State checks only run when the spec provides state;
runtime-fed state is unaffected.

* Address review: lossy-aware hook repair, react-only filtered-list rule, reuse getByPath

- ink/react-native useUIStream repair loops no longer accept lossy autofixes
  unconditionally: lossless relocations apply immediately, pruned content
  holds back while retries remain (so validation fails and the model repairs
  the missing elements) and applies only as a last resort.
- FILTERED LISTS prompt rule removed from schemas whose renderers do not
  implement the per-item filter yet (everything except react). Renderer
  parity tracked as follow-up.
- repeat_state_mismatch validation reuses getByPath instead of a local JSON
  Pointer lookup that skipped ~0/~1 unescaping.

* Address review: split mixed repeat visibility, apply lossless fixes eagerly

- splitRepeatVisibility (core, exported): AND-composed conditions on a repeat
  container partition into a container gate ($state conjuncts, hides the
  shell) and a per-item filter ($item/$index conjuncts). Mixed $or cannot
  partition soundly and stays fully per-item, documented.
- react renderer uses the split, so {$and: [{$state gate}, {$item filter}]}
  hides the empty shell when the gate is false instead of rendering a husk.
- autoFixSpec gains { lossy?: boolean } (default true, additive): ink and
  react-native repair loops now apply lossless relocations immediately and
  withhold only the pruning until retries are exhausted, matching the stated
  intent.

* Address review: RN final-validation error path, repeat prune guard, docs

- react-native useUIStream mirrors ink: when retries are exhausted and the
  spec still fails validation, report through onError instead of silently
  calling onComplete with an invalid spec.
- autoFixSpec never prunes a repeat container to zero children; that would
  trade missing_child for repeat_without_children and leave the last-resort
  spec unrenderable. The dangling template reference stays visible to repair.
- Docs for the new surface: core README + skill (validateSpec issue codes,
  fixDetails, lossy option), react README + skill and web visibility docs
  (filtered-list pattern, mixed-condition splitting, framework support note).
Chris Tate 1 kuukausi sitten
vanhempi
commit
4e4dc46a37

+ 18 - 0
apps/web/app/(main)/docs/visibility/page.mdx

@@ -199,6 +199,24 @@ With comparison:
 
 This shows the divider for every item except the first (index 0).
 
+### Filtered lists — `$item` on the repeat container
+
+Putting an `$item` condition directly on the element that declares `repeat` filters which items render. This is the natural way to build kanban columns, tabbed lists, or status sections from one state array:
+
+```json
+{
+  "type": "Stack",
+  "repeat": { "statePath": "/tasks", "key": "id" },
+  "visible": { "$item": "status", "eq": "todo" },
+  "children": ["task-card"]
+}
+```
+
+One child renders per matching item; non-matching items are skipped. When the condition is an `$and` (or array) that mixes scopes, the `$state` parts gate the container itself (a false gate hides the whole shell) while the `$item`/`$index` parts filter items. A mixed `$or` cannot be split and is applied entirely per item.
+
+Filtered lists are currently implemented by the React renderer; other renderers evaluate the container condition outside the repeat scope.
+
+
 `$item` and `$index` conditions support the same comparison operators as `$state` (`eq`, `neq`, `gt`, `gte`, `lt`, `lte`, `not`).
 
 ## Complex Example

+ 11 - 2
packages/core/README.md

@@ -230,7 +230,7 @@ Schema options:
 | Export | Purpose |
 |--------|---------|
 | `validateSpec(spec, options?)` | Validate spec structure and return issues |
-| `autoFixSpec(spec)` | Auto-fix common spec issues (returns corrected copy) |
+| `autoFixSpec(spec, options?)` | Auto-fix common spec issues; `fixDetails` classifies each fix as lossy or lossless, `{ lossy: false }` withholds pruning |
 | `formatSpecIssues(issues)` | Format validation issues as readable strings |
 
 ### Actions
@@ -553,7 +553,16 @@ const { valid, issues } = validateSpec(spec);
 console.log(formatSpecIssues(issues));
 
 // Auto-fix common issues (returns a corrected copy)
-const fixed = autoFixSpec(spec);
+const { spec: fixed, fixes, fixDetails } = autoFixSpec(spec);
+```
+
+`validateSpec` checks structure beyond the catalog schema: missing or dangling `children` references, malformed `visible` conditions (anything outside the documented forms evaluates to hidden at runtime, so it is rejected with code `invalid_visible`), `repeat` containers with no children (`repeat_without_children`), and `repeat.statePath` values that do not reference an array in the spec's own `state` (`repeat_state_mismatch`).
+
+`autoFixSpec` distinguishes lossless fixes (relocating `visible`/`on`/`repeat`/`watch` out of `props`) from lossy ones (pruning `children` references to elements that were never defined). Each entry in `fixDetails` carries `{ message, lossy }`. Callers with a repair loop should apply lossless fixes immediately and prefer re-prompting over lossy fixes, passing `{ lossy: false }` to withhold pruning until retries are exhausted:
+
+```typescript
+const lastAttempt = retriesUsed >= maxRetries;
+const { spec: fixed, fixDetails } = autoFixSpec(spec, { lossy: lastAttempt });
 ```
 
 ## State Watchers

+ 3 - 0
packages/core/src/index.ts

@@ -67,6 +67,9 @@ export type { VisibilityContext } from "./visibility";
 
 export {
   VisibilityConditionSchema,
+  VisibilityConditionStrictSchema,
+  conditionUsesItemScope,
+  splitRepeatVisibility,
   evaluateVisibility,
   visibility,
 } from "./visibility";

+ 232 - 0
packages/core/src/spec-validator.test.ts

@@ -147,7 +147,239 @@ describe("validateSpec", () => {
 // autoFixSpec
 // =============================================================================
 
+describe("repeat validation", () => {
+  it("rejects repeat without children", () => {
+    const result = validateSpec({
+      root: "list",
+      state: { items: [{ id: "1" }] },
+      elements: {
+        list: {
+          type: "Stack",
+          props: {},
+          repeat: { statePath: "/items" },
+          children: [],
+        },
+      },
+    });
+    expect(result.valid).toBe(false);
+    expect(
+      result.issues.some((i) => i.code === "repeat_without_children"),
+    ).toBe(true);
+  });
+
+  it("rejects repeat over a non-array state value", () => {
+    const result = validateSpec({
+      root: "list",
+      state: { items: { "1": { title: "x" } } },
+      elements: {
+        list: {
+          type: "Stack",
+          props: {},
+          repeat: { statePath: "/items" },
+          children: ["card"],
+        },
+        card: { type: "Text", props: {}, children: [] },
+      },
+    });
+    expect(result.valid).toBe(false);
+    expect(result.issues.some((i) => i.code === "repeat_state_mismatch")).toBe(
+      true,
+    );
+  });
+
+  it("rejects repeat over a missing state path when state is provided", () => {
+    const result = validateSpec({
+      root: "list",
+      state: { other: [] },
+      elements: {
+        list: {
+          type: "Stack",
+          props: {},
+          repeat: { statePath: "/items" },
+          children: ["card"],
+        },
+        card: { type: "Text", props: {}, children: [] },
+      },
+    });
+    expect(result.valid).toBe(false);
+    expect(result.issues.some((i) => i.code === "repeat_state_mismatch")).toBe(
+      true,
+    );
+  });
+
+  it("accepts a well-formed repeat and skips state checks when no state is provided", () => {
+    const withState = validateSpec({
+      root: "list",
+      state: { items: [{ id: "1" }] },
+      elements: {
+        list: {
+          type: "Stack",
+          props: {},
+          repeat: { statePath: "/items" },
+          children: ["card"],
+        },
+        card: { type: "Text", props: {}, children: [] },
+      },
+    });
+    expect(withState.valid).toBe(true);
+    const runtimeState = validateSpec({
+      root: "list",
+      elements: {
+        list: {
+          type: "Stack",
+          props: {},
+          repeat: { statePath: "/items" },
+          children: ["card"],
+        },
+        card: { type: "Text", props: {}, children: [] },
+      },
+    });
+    expect(runtimeState.valid).toBe(true);
+  });
+});
+
+describe("visible condition validation", () => {
+  const base = (visible: unknown): Spec => ({
+    root: "root",
+    elements: {
+      root: {
+        type: "Text",
+        props: { text: "hi" },
+        children: [],
+        visible: visible as Spec["elements"][string]["visible"],
+      },
+    },
+  });
+
+  it("accepts documented forms", () => {
+    for (const visible of [
+      true,
+      false,
+      { $state: "/tab", eq: "home" },
+      { $item: "status", eq: "todo" },
+      { $index: true, lt: 3 },
+      [
+        { $state: "/a", eq: 1 },
+        { $item: "b", neq: 2 },
+      ],
+      { $or: [{ $state: "/a", eq: 1 }, { $and: [{ $item: "b", not: true }] }] },
+    ]) {
+      expect(validateSpec(base(visible)).valid).toBe(true);
+    }
+  });
+
+  it("rejects conditions mixing $state and $item (silently hidden at runtime)", () => {
+    const result = validateSpec(
+      base([{ $state: "/tasks", $item: "status", eq: "todo" }]),
+    );
+    expect(result.valid).toBe(false);
+    expect(
+      result.issues.some((issue) => issue.code === "invalid_visible"),
+    ).toBe(true);
+  });
+
+  it("rejects unknown condition shapes", () => {
+    const result = validateSpec(base({ when: "/tasks", is: "todo" }));
+    expect(result.valid).toBe(false);
+    expect(result.issues[0]!.code).toBe("invalid_visible");
+  });
+});
+
 describe("autoFixSpec", () => {
+  it("prunes children references to undefined elements", () => {
+    const spec: Spec = {
+      root: "root",
+      elements: {
+        root: { type: "Card", props: {}, children: ["text", "ghost"] },
+        text: { type: "Text", props: { text: "hi" }, children: [] },
+      },
+    };
+    const { spec: fixed, fixes, fixDetails } = autoFixSpec(spec);
+    expect(fixed.elements.root!.children).toEqual(["text"]);
+    expect(fixes).toEqual([
+      'Removed reference to undefined element "ghost" from children of "root".',
+    ]);
+    expect(fixDetails).toEqual([
+      {
+        message:
+          'Removed reference to undefined element "ghost" from children of "root".',
+        lossy: true,
+      },
+    ]);
+    expect(validateSpec(fixed).valid).toBe(true);
+  });
+
+  it("leaves intact children untouched", () => {
+    const spec: Spec = {
+      root: "root",
+      elements: {
+        root: { type: "Card", props: {}, children: ["text"] },
+        text: { type: "Text", props: { text: "hi" }, children: [] },
+      },
+    };
+    const { spec: fixed, fixes } = autoFixSpec(spec);
+    expect(fixed.elements.root!.children).toEqual(["text"]);
+    expect(fixes).toEqual([]);
+  });
+
+  it("does not prune a repeat container down to zero children", () => {
+    const spec: Spec = {
+      root: "list",
+      state: { items: [{ id: "1" }] },
+      elements: {
+        list: {
+          type: "Stack",
+          props: {},
+          repeat: { statePath: "/items" },
+          children: ["ghost"],
+        },
+      },
+    };
+    const { spec: fixed, fixDetails } = autoFixSpec(spec);
+    expect(fixed.elements.list!.children).toEqual(["ghost"]);
+    expect(fixDetails).toEqual([]);
+    // The real problem (missing template) stays visible to the repair loop.
+    const result = validateSpec(fixed);
+    expect(result.valid).toBe(false);
+    expect(result.issues.some((i) => i.code === "missing_child")).toBe(true);
+  });
+
+  it("withholds lossy fixes when options.lossy is false", () => {
+    const spec: Spec = {
+      root: "root",
+      elements: {
+        root: {
+          type: "Card",
+          props: { visible: true },
+          children: ["ghost"],
+        },
+      },
+    };
+    const { spec: fixed, fixDetails } = autoFixSpec(spec, { lossy: false });
+    expect(fixed.elements.root!.children).toEqual(["ghost"]);
+    expect(fixDetails.every((fix) => !fix.lossy)).toBe(true);
+    expect(fixDetails.length).toBeGreaterThan(0);
+  });
+
+  it("classifies field relocations as lossless", () => {
+    const { fixDetails } = autoFixSpec({
+      root: "root",
+      elements: {
+        root: {
+          type: "Text",
+          props: { text: "hi", visible: true },
+          children: [],
+        },
+      },
+    });
+    expect(fixDetails).toEqual([
+      {
+        message: 'Moved "visible" from props to element level on "root".',
+        lossy: false,
+      },
+    ]);
+  });
+
   it("moves visible from props to element level", () => {
     const spec: Spec = {
       root: "root",

+ 105 - 3
packages/core/src/spec-validator.ts

@@ -1,4 +1,6 @@
 import type { Spec, UIElement } from "./types";
+import { getByPath } from "./types";
+import { VisibilityConditionStrictSchema } from "./visibility";
 
 // =============================================================================
 // Spec Structural Validation
@@ -24,6 +26,9 @@ export interface SpecIssue {
     | "missing_root"
     | "root_not_found"
     | "missing_child"
+    | "invalid_visible"
+    | "repeat_without_children"
+    | "repeat_state_mismatch"
     | "visible_in_props"
     | "orphaned_element"
     | "empty_spec"
@@ -122,6 +127,44 @@ export function validateSpec(
       }
     }
 
+    // 3b. Repeat containers that can never render anything. Both shapes pass
+    // schema validation but produce silently empty regions at runtime.
+    if (element.repeat !== undefined) {
+      if (!element.children || element.children.length === 0) {
+        issues.push({
+          severity: "error",
+          message: `Element "${key}" has "repeat" but no children. The repeated template must be a child element: add a child that renders one item (it may read fields with {"$item": "field"}).`,
+          elementKey: key,
+          code: "repeat_without_children",
+        });
+      }
+      if (spec.state !== undefined) {
+        const value = getByPath(spec.state, element.repeat.statePath);
+        if (!Array.isArray(value)) {
+          issues.push({
+            severity: "error",
+            message: `Element "${key}" repeats over "${element.repeat.statePath}" but state${value === undefined ? " has no value there" : ` has a ${typeof value} there`}. Repeat statePath must reference an array in state; add sample items to state at that path.`,
+            elementKey: key,
+            code: "repeat_state_mismatch",
+          });
+        }
+      }
+    }
+
+    // 3b. Malformed visible condition. Unrecognized shapes silently evaluate
+    // to hidden at runtime, so catch them here with a repairable message.
+    if (
+      element.visible !== undefined &&
+      !VisibilityConditionStrictSchema.safeParse(element.visible).success
+    ) {
+      issues.push({
+        severity: "error",
+        message: `Element "${key}" has an invalid "visible" condition: ${JSON.stringify(element.visible)}. Valid forms: true, false, {"$state":"/path","eq":value}, {"$item":"field","eq":value}, {"$index":true,"eq":n}, an array of those (AND), or {"$and":[...]} / {"$or":[...]}. Use exactly one of $state, $item, or $index per condition object.`,
+        elementKey: key,
+        code: "invalid_visible",
+      });
+    }
+
     // 3b. `visible` inside props
     const props = element.props as Record<string, unknown> | undefined;
     if (props && "visible" in props && props.visible !== undefined) {
@@ -209,11 +252,42 @@ export function validateSpec(
  *
  * Returns the fixed spec and a list of fixes applied.
  */
-export function autoFixSpec(spec: Spec): {
+export interface SpecFix {
+  message: string;
+  /**
+   * Lossy fixes change what renders (e.g. pruning a dangling child
+   * reference); lossless fixes only relocate misplaced fields. Callers with a
+   * repair loop should prefer re-prompting over accepting lossy fixes, and
+   * use the lossy-fixed spec as a last resort.
+   */
+  lossy: boolean;
+}
+
+export interface AutoFixOptions {
+  /**
+   * Apply lossy fixes (content pruning). Default true. Callers with a repair
+   * loop should pass false while retries remain so the model regenerates the
+   * missing content, then true as a last resort.
+   */
+  lossy?: boolean;
+}
+
+export function autoFixSpec(
+  spec: Spec,
+  options: AutoFixOptions = {},
+): {
   spec: Spec;
   fixes: string[];
+  /** Structured fix records; fixes is the plain-message projection. */
+  fixDetails: SpecFix[];
 } {
-  const fixes: string[] = [];
+  const applyLossy = options.lossy !== false;
+  const fixDetails: SpecFix[] = [];
+  const fixes = {
+    push(message: string, lossy = false) {
+      fixDetails.push({ message, lossy });
+    },
+  };
   const fixedElements: Record<string, UIElement> = {};
 
   for (const [key, element] of Object.entries(spec.elements)) {
@@ -277,9 +351,37 @@ export function autoFixSpec(spec: Spec): {
     fixedElements[key] = fixed;
   }
 
+  // Drop references to elements that were never defined. The renderer skips
+  // missing children at runtime, so pruning produces the same rendered output
+  // while letting the spec pass validation instead of hard-failing.
+  if (applyLossy)
+    for (const [key, element] of Object.entries(fixedElements)) {
+      if (!element.children || element.children.length === 0) continue;
+      const present = element.children.filter(
+        (child) => child in fixedElements,
+      );
+      if (present.length === element.children.length) continue;
+      if (element.repeat !== undefined && present.length === 0) {
+        // Pruning every child of a repeat container would only trade the
+        // missing_child error for repeat_without_children; keep the dangling
+        // reference so repair targets the real problem (the missing template).
+        continue;
+      }
+      for (const child of element.children) {
+        if (!(child in fixedElements)) {
+          fixes.push(
+            `Removed reference to undefined element "${child}" from children of "${key}".`,
+            true,
+          );
+        }
+      }
+      fixedElements[key] = { ...element, children: present };
+    }
+
   return {
     spec: { root: spec.root, elements: fixedElements, state: spec.state },
-    fixes,
+    fixes: fixDetails.map((fix) => fix.message),
+    fixDetails,
   };
 }
 

+ 55 - 1
packages/core/src/visibility.test.ts

@@ -1,5 +1,9 @@
 import { describe, it, expect } from "vitest";
-import { evaluateVisibility, visibility } from "./visibility";
+import {
+  evaluateVisibility,
+  splitRepeatVisibility,
+  visibility,
+} from "./visibility";
 
 describe("evaluateVisibility", () => {
   describe("undefined / boolean", () => {
@@ -725,3 +729,53 @@ describe("visibility helper", () => {
     });
   });
 });
+
+describe("splitRepeatVisibility", () => {
+  it("passes through pure container conditions", () => {
+    const cond = { $state: "/show", eq: true };
+    expect(splitRepeatVisibility(cond)).toEqual({
+      container: cond,
+      itemFilter: undefined,
+    });
+    expect(splitRepeatVisibility(undefined)).toEqual({
+      container: undefined,
+      itemFilter: undefined,
+    });
+    expect(splitRepeatVisibility(true)).toEqual({
+      container: true,
+      itemFilter: undefined,
+    });
+  });
+
+  it("routes pure item conditions to the item filter", () => {
+    const cond = { $item: "status", eq: "todo" };
+    expect(splitRepeatVisibility(cond)).toEqual({
+      container: undefined,
+      itemFilter: cond,
+    });
+  });
+
+  it("partitions AND-composed mixed conditions", () => {
+    const state = { $state: "/show", eq: true };
+    const item = { $item: "status", eq: "todo" };
+    for (const cond of [[state, item], { $and: [state, item] }]) {
+      expect(splitRepeatVisibility(cond as never)).toEqual({
+        container: { $and: [state] },
+        itemFilter: { $and: [item] },
+      });
+    }
+  });
+
+  it("keeps mixed $or entirely as an item filter", () => {
+    const cond = {
+      $or: [
+        { $state: "/all", eq: true },
+        { $item: "pinned", eq: true },
+      ],
+    };
+    expect(splitRepeatVisibility(cond)).toEqual({
+      container: undefined,
+      itemFilter: cond,
+    });
+  });
+});

+ 83 - 0
packages/core/src/visibility.ts

@@ -70,6 +70,89 @@ export const VisibilityConditionSchema: z.ZodType<VisibilityCondition> = z.lazy(
     ]),
 );
 
+const StrictSingleConditionSchema = z.union([
+  z.strictObject({ $state: z.string(), ...comparisonOps }),
+  z.strictObject({ $item: z.string(), ...comparisonOps }),
+  z.strictObject({ $index: z.literal(true), ...comparisonOps }),
+]);
+
+/**
+ * Strict variant for spec validation: rejects unknown keys, so malformed
+ * conditions (e.g. mixing $state and $item in one object) are caught at
+ * validation time instead of silently evaluating to hidden at runtime.
+ */
+/**
+ * True when a condition references the repeat-item scope ($item or $index)
+ * anywhere in its tree. Renderers use this to apply a repeat container's own
+ * visible condition as a per-item filter instead of evaluating it (and
+ * failing) outside the repeat scope.
+ */
+export function conditionUsesItemScope(
+  condition: VisibilityCondition | undefined,
+): boolean {
+  if (condition === undefined || typeof condition === "boolean") return false;
+  if (Array.isArray(condition)) return condition.some(conditionUsesItemScope);
+  if (typeof condition !== "object" || condition === null) return false;
+  if ("$item" in condition || "$index" in condition) return true;
+  if ("$and" in condition)
+    return (condition as { $and: VisibilityCondition[] }).$and.some(
+      conditionUsesItemScope,
+    );
+  if ("$or" in condition)
+    return (condition as { $or: VisibilityCondition[] }).$or.some(
+      conditionUsesItemScope,
+    );
+  return false;
+}
+
+/**
+ * Splits a repeat container's visible condition into a container-level gate
+ * and a per-item filter. Top-level AND structures (arrays, $and) partition
+ * cleanly: conjuncts that reference $item/$index filter items, the rest gate
+ * the container. An $or that mixes scopes cannot be partitioned soundly and
+ * is applied entirely per item (state parts still evaluate correctly there;
+ * the container shell just cannot be hidden by it).
+ */
+export function splitRepeatVisibility(
+  condition: VisibilityCondition | undefined,
+): {
+  container: VisibilityCondition | undefined;
+  itemFilter: VisibilityCondition | undefined;
+} {
+  if (condition === undefined || !conditionUsesItemScope(condition)) {
+    return { container: condition, itemFilter: undefined };
+  }
+  const partition = (parts: VisibilityCondition[]) => {
+    const container = parts.filter((part) => !conditionUsesItemScope(part));
+    const item = parts.filter((part) => conditionUsesItemScope(part));
+    return {
+      container: container.length > 0 ? { $and: container } : undefined,
+      itemFilter: item.length > 0 ? { $and: item } : undefined,
+    };
+  };
+  if (Array.isArray(condition)) return partition(condition);
+  if (
+    typeof condition === "object" &&
+    condition !== null &&
+    "$and" in condition
+  ) {
+    return partition((condition as { $and: VisibilityCondition[] }).$and);
+  }
+  // Single item-scoped condition or an $or that mixes scopes.
+  return { container: undefined, itemFilter: condition };
+}
+
+export const VisibilityConditionStrictSchema: z.ZodType<VisibilityCondition> =
+  z.lazy(() =>
+    z.union([
+      z.boolean(),
+      StrictSingleConditionSchema,
+      z.array(StrictSingleConditionSchema),
+      z.strictObject({ $and: z.array(VisibilityConditionStrictSchema) }),
+      z.strictObject({ $or: z.array(VisibilityConditionStrictSchema) }),
+    ]),
+  );
+
 // =============================================================================
 // Context
 // =============================================================================

+ 9 - 3
packages/ink/src/hooks.ts

@@ -417,10 +417,16 @@ export function useUIStream({
           }
 
           // ---------------------------------------------------------------
-          // Post-stream: auto-fix deterministic issues (no retry needed)
+          // Post-stream: auto-fix deterministic issues. Lossless fixes (field
+          // relocations) apply immediately. Lossy fixes (pruned content) are
+          // held back while retries remain so validation fails and the model
+          // is asked to repair; they apply as a last resort once retries are
+          // exhausted, trading dropped content for a renderable spec.
           // ---------------------------------------------------------------
-          const { spec: fixedSpec, fixes } = autoFixSpec(currentSpec);
-          if (fixes.length > 0) {
+          const { spec: fixedSpec, fixDetails } = autoFixSpec(currentSpec, {
+            lossy: retriesUsed >= maxRetries,
+          });
+          if (fixDetails.length > 0) {
             currentSpec = fixedSpec;
             setSpec({ ...currentSpec });
           }

+ 24 - 3
packages/react-native/src/hooks.ts

@@ -523,10 +523,16 @@ export function useUIStream({
           }
 
           // ---------------------------------------------------------------
-          // Post-stream: auto-fix deterministic issues (no retry needed)
+          // Post-stream: auto-fix deterministic issues. Lossless fixes (field
+          // relocations) apply immediately. Lossy fixes (pruned content) are
+          // held back while retries remain so validation fails and the model
+          // is asked to repair; they apply as a last resort once retries are
+          // exhausted, trading dropped content for a renderable spec.
           // ---------------------------------------------------------------
-          const { spec: fixedSpec, fixes } = autoFixSpec(currentSpec);
-          if (fixes.length > 0) {
+          const { spec: fixedSpec, fixDetails } = autoFixSpec(currentSpec, {
+            lossy: retriesUsed >= maxRetries,
+          });
+          if (fixDetails.length > 0) {
             currentSpec = fixedSpec;
             setSpec({ ...currentSpec });
           }
@@ -557,6 +563,21 @@ export function useUIStream({
           // continue loop
         }
 
+        // If retries were exhausted and validation still fails, report error
+        // instead of silently treating partial/invalid specs as complete.
+        if (enableValidation && retriesUsed >= maxRetries && currentSpec.root) {
+          const finalValidation = validateSpec(currentSpec);
+          if (!finalValidation.valid) {
+            const issueText = formatSpecIssues(finalValidation.issues);
+            const validationError = new Error(
+              `Spec validation failed after ${maxRetries} retries:\n${issueText}`,
+            );
+            setError(validationError);
+            onError?.(validationError);
+            return;
+          }
+        }
+
         onComplete?.(currentSpec);
       } catch (err) {
         if ((err as Error).name === "AbortError") {

+ 3 - 1
packages/react/README.md

@@ -323,7 +323,9 @@ Any prop value can use data-driven expressions that resolve at render time. The
 }
 ```
 
-For two-way binding, use `{ "$bindState": "/path" }` on the natural value prop (e.g. `value`, `checked`, `pressed`). Inside repeat scopes, use `{ "$bindItem": "field" }` instead. Components receive resolved `bindings` with the state path for each bound prop; use `useBoundProp(props.value, bindings?.value)` to get `[value, setValue]`.
+For two-way binding, use `{ "$bindState": "/path" }` on the natural value prop (e.g. `value`, `checked`, `pressed`). Inside repeat scopes, use `{ "$bindItem": "field" }` instead.
+
+To render a filtered list (kanban columns, status sections), put `repeat` and an `$item` visibility condition on the same container: `{ "repeat": { "statePath": "/tasks", "key": "id" }, "visible": { "$item": "status", "eq": "todo" }, "children": ["task-card"] }` renders one child per matching item. AND-composed `$state` conjuncts still gate the container itself. Components receive resolved `bindings` with the state path for each bound prop; use `useBoundProp(props.value, bindings?.value)` to get `[value, setValue]`.
 
 ### `$template` and `$computed`
 

+ 34 - 3
packages/react/src/renderer.tsx

@@ -24,6 +24,7 @@ import {
   resolveElementProps,
   resolveBindings,
   resolveActionParam,
+  splitRepeatVisibility,
   evaluateVisibility,
   getByPath,
   isDevtoolsActive,
@@ -226,11 +227,22 @@ const ElementRenderer = React.memo(function ElementRenderer({
     return base;
   }, [ctx, repeatScope, functions, directives]);
 
+  // A repeat container whose own visible condition references $item/$index
+  // outside any repeat scope is (partly) a per-item filter: models and humans
+  // write {"repeat": ..., "visible": {"$item": "status", "eq": "todo"}} to
+  // mean a filtered list. AND-composed $state conjuncts still gate the
+  // container itself so a false gate hides the shell, not just the items.
+  const repeatVisibility =
+    element.repeat !== undefined && repeatScope == null
+      ? splitRepeatVisibility(element.visible)
+      : { container: element.visible, itemFilter: undefined };
+  const repeatItemFilter = repeatVisibility.itemFilter;
+
   // Evaluate visibility (now supports $item/$index inside repeat scopes)
   const isVisible =
-    element.visible === undefined
+    repeatVisibility.container === undefined
       ? true
-      : evaluateVisibility(element.visible, fullCtx);
+      : evaluateVisibility(repeatVisibility.container, fullCtx);
 
   // Create emit function that resolves events to action bindings.
   // Must be called before any early return to satisfy Rules of Hooks.
@@ -391,6 +403,7 @@ const ElementRenderer = React.memo(function ElementRenderer({
       registry={registry}
       loading={loading}
       fallback={fallback}
+      itemFilter={repeatItemFilter}
     />
   ) : (
     resolvedElement.children?.map((childKey) => {
@@ -459,22 +472,40 @@ function RepeatChildren({
   registry,
   loading,
   fallback,
+  itemFilter,
 }: {
   element: UIElement;
   spec: Spec;
   registry: ComponentRegistry;
   loading?: boolean;
   fallback?: ComponentRenderer;
+  itemFilter?: UIElement["visible"];
 }) {
   const { state } = useStateStore();
+  const { ctx } = useVisibility();
   const repeat = element.repeat!;
   const statePath = repeat.statePath;
 
   const items = (getByPath(state, statePath) as unknown[] | undefined) ?? [];
 
+  // Per-item filter from the container's own $item/$index visible condition.
+  // Original indices are preserved so item state paths still point at the
+  // right array entry.
+  const entries = items
+    .map((itemValue, index) => ({ itemValue, index }))
+    .filter(
+      ({ itemValue, index }) =>
+        itemFilter === undefined ||
+        evaluateVisibility(itemFilter, {
+          ...ctx,
+          repeatItem: itemValue,
+          repeatIndex: index,
+        }),
+    );
+
   return (
     <>
-      {items.map((itemValue, index) => {
+      {entries.map(({ itemValue, index }) => {
         // Use a stable key: prefer key field, fall back to index
         const key =
           repeat.key && typeof itemValue === "object" && itemValue !== null

+ 164 - 0
packages/react/src/repeat-filter.test.tsx

@@ -0,0 +1,164 @@
+import { describe, it, expect } from "vitest";
+import React from "react";
+import { render, screen } from "@testing-library/react";
+import type { Spec } from "@json-render/core";
+import {
+  JSONUIProvider,
+  Renderer,
+  type ComponentRenderProps,
+} from "./renderer";
+
+function Stack({ children }: ComponentRenderProps) {
+  return <div data-testid="stack">{children}</div>;
+}
+
+function Text({ element }: ComponentRenderProps<{ text: unknown }>) {
+  return <span data-testid="text">{String(element.props.text)}</span>;
+}
+
+const registry = { Stack, Text };
+
+const tasks = [
+  { id: "1", title: "Auth flow", status: "todo" },
+  { id: "2", title: "Push notifications", status: "in-progress" },
+  { id: "3", title: "Dark mode", status: "todo" },
+  { id: "4", title: "App icon", status: "done" },
+];
+
+function mount(spec: Spec) {
+  return render(
+    <JSONUIProvider registry={registry} initialState={spec.state ?? {}}>
+      <Renderer spec={spec} registry={registry} />
+    </JSONUIProvider>,
+  );
+}
+
+describe("repeat with $item visible on the container (filtered list)", () => {
+  it("renders only the items matching the filter", () => {
+    mount({
+      root: "column",
+      state: { tasks },
+      elements: {
+        column: {
+          type: "Stack",
+          props: {},
+          repeat: { statePath: "/tasks", key: "id" },
+          visible: { $item: "status", eq: "todo" },
+          children: ["card"],
+        },
+        card: {
+          type: "Text",
+          props: { text: { $item: "title" } },
+          children: [],
+        },
+      },
+    });
+    const texts = screen.getAllByTestId("text").map((n) => n.textContent);
+    expect(texts).toEqual(["Auth flow", "Dark mode"]);
+  });
+
+  it("renders every item without a filter", () => {
+    mount({
+      root: "column",
+      state: { tasks },
+      elements: {
+        column: {
+          type: "Stack",
+          props: {},
+          repeat: { statePath: "/tasks", key: "id" },
+          children: ["card"],
+        },
+        card: {
+          type: "Text",
+          props: { text: { $item: "title" } },
+          children: [],
+        },
+      },
+    });
+    expect(screen.getAllByTestId("text")).toHaveLength(4);
+  });
+
+  it("still hides the whole list for a container-level $state condition", () => {
+    mount({
+      root: "column",
+      state: { tasks, showTasks: false },
+      elements: {
+        column: {
+          type: "Stack",
+          props: {},
+          repeat: { statePath: "/tasks", key: "id" },
+          visible: { $state: "/showTasks", eq: true },
+          children: ["card"],
+        },
+        card: {
+          type: "Text",
+          props: { text: { $item: "title" } },
+          children: [],
+        },
+      },
+    });
+    expect(screen.queryAllByTestId("text")).toHaveLength(0);
+  });
+
+  it("keeps per-child $item visible working inside the repeat scope", () => {
+    mount({
+      root: "column",
+      state: { tasks },
+      elements: {
+        column: {
+          type: "Stack",
+          props: {},
+          repeat: { statePath: "/tasks", key: "id" },
+          children: ["card"],
+        },
+        card: {
+          type: "Text",
+          props: { text: { $item: "title" } },
+          visible: { $item: "status", eq: "done" },
+          children: [],
+        },
+      },
+    });
+    const texts = screen.getAllByTestId("text").map((n) => n.textContent);
+    expect(texts).toEqual(["App icon"]);
+  });
+});
+
+describe("mixed container/item visibility on repeat containers", () => {
+  const mixedSpec = (showTasks: boolean): Spec => ({
+    root: "column",
+    state: { tasks, showTasks },
+    elements: {
+      column: {
+        type: "Stack",
+        props: {},
+        repeat: { statePath: "/tasks", key: "id" },
+        visible: {
+          $and: [
+            { $state: "/showTasks", eq: true },
+            { $item: "status", eq: "todo" },
+          ],
+        },
+        children: ["card"],
+      },
+      card: {
+        type: "Text",
+        props: { text: { $item: "title" } },
+        children: [],
+      },
+    },
+  });
+
+  it("hides the container shell when the $state gate is false", () => {
+    mount(mixedSpec(false));
+    expect(screen.queryAllByTestId("stack")).toHaveLength(0);
+    expect(screen.queryAllByTestId("text")).toHaveLength(0);
+  });
+
+  it("filters items when the $state gate is true", () => {
+    mount(mixedSpec(true));
+    expect(screen.getAllByTestId("stack")).toHaveLength(1);
+    const texts = screen.getAllByTestId("text").map((n) => n.textContent);
+    expect(texts).toEqual(["Auth flow", "Dark mode"]);
+  });
+});

+ 1 - 0
packages/react/src/schema.ts

@@ -78,6 +78,7 @@ export const schema = defineSchema(
       "CRITICAL INTEGRITY CHECK: Before outputting ANY element that references children, you MUST have already output (or will output) each child as its own element. If an element has children: ['a', 'b'], then elements 'a' and 'b' MUST exist. A missing child element causes that entire branch of the UI to be invisible.",
       "SELF-CHECK: After generating all elements, mentally walk the tree from root. Every key in every children array must resolve to a defined element. If you find a gap, output the missing element immediately.",
       'REQUIRED FIELDS: Every element MUST include a "children" array. Leaf elements (text, badges, inputs, images) use an empty array: "children": []. Omitting "children" fails validation.',
+      'FILTERED LISTS: To render only the items matching a field value (kanban columns, tabbed lists, status sections), put "repeat" and a "visible" condition with $item on the same container element: {"repeat": {"statePath": "/tasks", "key": "id"}, "visible": {"$item": "status", "eq": "todo"}} renders one child per matching item. A visible condition object must use exactly one of $state, $item, or $index — never combine them in one object.',
 
       // Field placement
       'CRITICAL: The "visible" field goes on the ELEMENT object, NOT inside "props". Correct: {"type":"<ComponentName>","props":{},"visible":{"$state":"/tab","eq":"home"},"children":[...]}.',

+ 9 - 2
skills/core/SKILL.md

@@ -176,7 +176,14 @@ Validate spec structure and auto-fix common issues:
 import { validateSpec, autoFixSpec } from "@json-render/core";
 
 const { valid, issues } = validateSpec(spec);
-const fixed = autoFixSpec(spec);
+// issues include: missing_child, invalid_visible (malformed conditions),
+// repeat_without_children, repeat_state_mismatch (statePath not an array in state)
+
+const { spec: fixed, fixDetails } = autoFixSpec(spec);
+// fixDetails entries are { message, lossy }. Lossless fixes relocate
+// misplaced fields; lossy fixes prune dangling children references.
+// In a repair loop, withhold lossy fixes until retries are exhausted:
+const attempt = autoFixSpec(spec, { lossy: retriesExhausted });
 ```
 
 ## Visibility Conditions
@@ -253,7 +260,7 @@ The `StateStore` interface: `get(path)`, `set(path, value)`, `update(updates)`,
 | `diffToPatches` | Generate RFC 6902 JSON Patch operations from object diff |
 | `EditMode` | Type: `"patch" \| "merge" \| "diff"` |
 | `validateSpec` | Validate spec structure |
-| `autoFixSpec` | Auto-fix common spec issues |
+| `autoFixSpec` | Auto-fix common spec issues; classifies fixes lossy/lossless, `{ lossy: false }` withholds pruning |
 | `createSpecStreamCompiler` | Stream JSONL patches into spec |
 | `createJsonRenderTransform` | TransformStream separating text from JSONL in mixed streams |
 | `parseSpecStreamLine` | Parse single JSONL line |

+ 1 - 0
skills/react/SKILL.md

@@ -118,6 +118,7 @@ Any prop value can be a data-driven expression resolved by the renderer before c
 - **`{ "$state": "/state/key" }`** - reads from state model (one-way read)
 - **`{ "$bindState": "/path" }`** - two-way binding: reads from state and enables write-back. Use on the natural value prop (value, checked, pressed, etc.) of form components.
 - **`{ "$bindItem": "field" }`** - two-way binding to a repeat item field. Use inside repeat scopes.
+- **Filtered lists**: `repeat` plus an `$item` visible condition on the same container renders only matching items: `{ "repeat": { "statePath": "/tasks", "key": "id" }, "visible": { "$item": "status", "eq": "todo" }, "children": ["task-card"] }`. AND-composed `$state` conjuncts gate the container shell; `$item`/`$index` conjuncts filter items.
 - **`{ "$cond": <condition>, "$then": <value>, "$else": <value> }`** - conditional value
 - **`{ "$template": "Hello, ${/name}!" }`** - interpolates state values into strings
 - **`{ "$computed": "fn", "args": { ... } }`** - calls registered functions with resolved args