diff --git a/.changeset/base-schema-visible-predicate.md b/.changeset/base-schema-visible-predicate.md
new file mode 100644
index 000000000..ee0ca3c43
--- /dev/null
+++ b/.changeset/base-schema-visible-predicate.md
@@ -0,0 +1,23 @@
+---
+'@object-ui/types': minor
+---
+
+`BaseSchema.visible` accepts the predicate string the renderer evaluates
+
+`visible` was declared `boolean`, but the renderer never read it as one: it
+evaluates the key — `SchemaRenderer.tsx:382` calls
+`evaluator.evaluateCondition(schema.visible)`, and `evaluateCondition` is
+declared `(condition: string | boolean | undefined, context?) => boolean`. The
+sibling keys `visibleWhen` and the deprecated `visibleOn` are `string` for that
+same reason; `visible` simply under-reported a capability it already had, and
+fixtures exercising it had to cast past the declaration.
+
+Now `boolean | string` — exactly what the evaluator accepts, no wider.
+
+Graded **minor** by position analysis of the published `.d.ts`: the only diff is
+`visible?: boolean` becoming `visible?: boolean | string` on an
+authored-input-dominant property, with no union member removed and no other
+declaration touched — the same shape as #4586/#4591. Authors gain a spelling;
+nothing that previously type-checked stops doing so. Code that READS
+`schema.visible` was already coping with `any` through `BaseSchema`'s index
+signature.
diff --git a/packages/react/src/__tests__/SchemaRenderer.expressions.test.tsx b/packages/react/src/__tests__/SchemaRenderer.expressions.test.tsx
index f6e1e1859..1e8b7517a 100644
--- a/packages/react/src/__tests__/SchemaRenderer.expressions.test.tsx
+++ b/packages/react/src/__tests__/SchemaRenderer.expressions.test.tsx
@@ -11,11 +11,15 @@ import { render, screen } from '@testing-library/react';
import React from 'react';
import { ComponentRegistry } from '@object-ui/core';
import { SchemaRenderer } from '../SchemaRenderer';
-// `@object-ui/types` declares `BaseSchema.visible` / `.disabled` as `boolean`,
-// but BOTH accept a predicate STRING here — that is the capability these cases
-// exercise, and the renderer evaluates it (`evaluateCondition`). The declaration
-// is the narrow one; until it is widened these fixtures state their real shape
-// through `BaseSchema`'s index signature (objectui#4548 measured the gap).
+// `BaseSchema.visible` now declares `boolean | string` (objectui#4581), so the
+// two visibility cases below state their predicate strings directly — no cast.
+//
+// `.disabled` is the SAME gap and is NOT yet widened: the renderer evaluates it
+// through the same `evaluateCondition` (`SchemaRenderer.tsx:466`) and the
+// `disabledOn?: string` sibling exists for the same reason, but #4581 named only
+// `visible` and `ariaLabel`, so widening `disabled` was left to its own card
+// rather than taken unruled. The two `disabled` casts below are what remains of
+// the gap — drop them when that lands.
import type { BaseSchema } from '@object-ui/types';
import { SchemaRendererContext } from '../context/SchemaRendererContext';
@@ -49,7 +53,7 @@ describe('SchemaRenderer Expression Integration', () => {
it('evaluates visible expression string', () => {
render(
-
+
);
expect(screen.getByTestId('test-component')).toBeInTheDocument();
@@ -58,7 +62,7 @@ describe('SchemaRenderer Expression Integration', () => {
it('hides when visible expression evaluates to false', () => {
const { container } = render(
-
+
);
expect(container.innerHTML).toBe('');
diff --git a/packages/types/src/__tests__/base-schema-visible-predicate.test.ts b/packages/types/src/__tests__/base-schema-visible-predicate.test.ts
new file mode 100644
index 000000000..325f20faa
--- /dev/null
+++ b/packages/types/src/__tests__/base-schema-visible-predicate.test.ts
@@ -0,0 +1,100 @@
+// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
+
+/**
+ * `BaseSchema.visible` admits the predicate string the renderer evaluates
+ * (objectui#4581).
+ *
+ * `BaseSchema` declared `visible?: boolean`. The renderer does not treat it as
+ * a boolean — `packages/react/src/SchemaRenderer.tsx:382` reads:
+ *
+ * ```ts
+ * if (newSchema.visible !== undefined) {
+ * return !evaluator.evaluateCondition(newSchema.visible);
+ * }
+ * ```
+ *
+ * and `evaluateCondition` is declared
+ * `(condition: string | boolean | undefined, context?) => boolean`
+ * (`packages/core/dist/evaluator/ExpressionEvaluator.d.ts:143`). So a predicate
+ * STRING is a supported, evaluated input — the sibling keys `visibleWhen` and
+ * the deprecated `visibleOn` are both `string` for the same reason — and the
+ * declared type simply under-reported it. Two fixtures in
+ * `packages/react/src/__tests__/SchemaRenderer.expressions.test.tsx` exercised
+ * exactly that capability through `as unknown as BaseSchema`; PR #4578 is what
+ * made the gap visible, by typing `schema` honestly for the first time.
+ *
+ * The widening is `boolean | string` — what `evaluateCondition` accepts, no
+ * wider. It is authored-input dominant: authors write `visible`, and code that
+ * READS `schema.visible` already had to cope with `any` through
+ * `BaseSchema`'s index signature.
+ *
+ * ## Predictions, written before the first run (red-first)
+ *
+ * Against `origin/main` (`92250d648`), `tsc -p packages/types/tsconfig.test.json`
+ * must report:
+ *
+ * 1. `predicateStringIsAuthorable` — TS2322, `Type 'string' is not
+ * assignable to type 'boolean | undefined'`.
+ * 2. `assertionVisible` — `Equal< BaseSchema['visible'], boolean | string |
+ * undefined >` resolves `false`, failing `Expect`'s constraint (TS2344).
+ *
+ * After the fix both compile clean.
+ *
+ * `assertionVisible` is INVARIANT on purpose. A `satisfies`-style check, or a
+ * one-way `extends`, would be vacuous here in both directions: the narrow
+ * `boolean` is assignable to the wide `boolean | string`, so a widening that
+ * never happened and a widening that overshot to `any` would both stay green.
+ * Pinning the exact union is the only assertion that can go red for the right
+ * reason — and `BaseSchema`'s `[key: string]: any` index signature makes the
+ * overshoot a live risk rather than a hypothetical one, since deleting the
+ * declared property altogether would leave `visible` typed `any` and every
+ * fixture below still compiling.
+ */
+
+import { describe, it, expect } from 'vitest';
+import type { BaseSchema } from '../base';
+
+/* ── Type-level helpers ──────────────────────────────────────────────────── */
+
+/** Invariant equality — `extends` both ways would accept a narrowing. */
+type Equal< A, B > =
+ (< T >() => T extends A ? 1 : 2) extends (< T >() => T extends B ? 1 : 2) ? true : false;
+type Expect< T extends true > = T;
+
+/* ── The declared type is exactly what the evaluator accepts ─────────────── */
+
+export type assertionVisible = Expect<
+ Equal< BaseSchema['visible'], boolean | string | undefined >
+>;
+
+/* ── Authorable fixtures ─────────────────────────────────────────────────── */
+
+/** The capability the renderer implements, now declared. */
+export const predicateStringIsAuthorable: BaseSchema = {
+ type: 'test-component',
+ visible: 'record.status == "open"',
+};
+
+/** The template-expression spelling the shipped fixtures use. */
+export const templateExpressionIsAuthorable: BaseSchema = {
+ type: 'test-component',
+ visible: '${data.role === "admin"}',
+};
+
+/** The boolean form is untouched — this is a widening, not a replacement. */
+export const booleanIsStillAuthorable: BaseSchema = {
+ type: 'test-component',
+ visible: false,
+};
+
+/* ── Runtime companion ───────────────────────────────────────────────────── */
+
+describe('BaseSchema.visible (objectui#4581)', () => {
+ it('type-level: visible is boolean | string, pinned invariantly', () => {
+ // Erased at runtime; `tsc -p tsconfig.test.json` is the checker, chained
+ // from this package's `type-check` script. The runtime case exists so a
+ // green vitest run is not mistaken for the proof.
+ expect(predicateStringIsAuthorable.visible).toBe('record.status == "open"');
+ expect(booleanIsStillAuthorable.visible).toBe(false);
+ });
+});
diff --git a/packages/types/src/base.ts b/packages/types/src/base.ts
index a8471420f..28064c22a 100644
--- a/packages/types/src/base.ts
+++ b/packages/types/src/base.ts
@@ -102,9 +102,21 @@ export interface BaseSchema {
/**
* Controls whether the component is visible.
* When false, component is not rendered (display: none).
+ *
+ * Accepts a PREDICATE STRING as well as a boolean (objectui#4581): the
+ * renderer does not read this key as a boolean, it evaluates it —
+ * `SchemaRenderer.tsx:382` calls `evaluator.evaluateCondition(schema.visible)`,
+ * and `evaluateCondition` is declared
+ * `(condition: string | boolean | undefined, context?) => boolean`. The
+ * sibling keys `visibleWhen` and the deprecated `visibleOn` are `string` for
+ * the same reason; this one simply under-reported the capability, and
+ * fixtures exercising it had to cast past the declaration.
+ *
* @default true
+ * @example true
+ * @example "${data.role === 'admin'}"
*/
- visible?: boolean;
+ visible?: boolean | string;
/**
* Canonical conditional-visibility predicate (ADR-0089) — the element is shown