palabras: gobernanza del esquema — bubble (marcas) + slash (bloques)

El esquema (fijado por el desarrollador, NO editable por el usuario) ahora gobierna qué
marcas y qué tipos de bloque puede usar el usuario, no solo el panel. Allowlists top-level
`marks` + `blocks` en PalabrasScheme (doctrina «sin declarar = todo permitido», como
`editable`) + lectores `schemeAllowsMark` / `schemeAllowsBlock`.

- Bubble: filtra las 5 marcas booleanas + gatea sub/sup/tamaño/familia por `marks` (los
  valores paramétricos siguen saliendo de fonts/sizes/palette).
- Slash: filtrado en el MOTOR vía nuevo opt `insertableBlocks` del provider (para que el
  índice de teclado no derive) — filterSlashCommandItems(query, allowed) con mapeo canónico
  id→tipo.
- Menús eidos (menubar / panel / column-inserter): filtran vía insertableBlockTypes(scheme).

ESQUEMA.md §6b documentado. Verificado: 64 tests (gobernanza + provider con el opt), check
0 errores en palabras, repro Playwright default (bubble 5 marcas + Aa, slash 14 items, 0
errores de consola).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
alpha-0.1-sec-dom
dev 3 months ago
parent 18026dfd84
commit 6bd7c43398

@ -121,6 +121,26 @@ editable{
sliders del componente `ImageAdjustments` (`adjustments` a secas = los 8).
- Tipo declarado VACÍO = nada editable (el panel muestra el tipo pero sin campos).
### 6b. `marks` + `blocks` — qué puede APLICAR/INSERTAR (gobiernan bubble y slash)
`editable` gobierna el PANEL. Las otras dos superficies tienen su propia lista blanca
top-level (misma doctrina «sin declarar = todo permitido»):
```
marks = bold, italic, underline, strike, code, sub, sup, fontSize, fontFamily;
blocks = paragraph, heading, list, quote; ← el slash / «+» solo ofrecen estos
```
- **`marks`** — marcas inline que el usuario puede aplicar (bubble + atajos): booleanas
(`bold`/`italic`/`underline`/`strike`/`code`/`sub`/`sup`) y paramétricas (`fontSize`/
`fontFamily`/`color`/`background`). Sin declarar → todas; declarada VACÍA → ninguna. Los
VALORES de las paramétricas siguen saliendo de `fonts`/`sizes`/`palette`.
- **`blocks`** — tipos de bloque insertables (slash + menús «+»/Insertar), en vocabulario
canónico (`heading` cubre h1–h6, `list` las tres listas). Sin declarar → todos los
registrados; declarada VACÍA → ninguno. El slash se filtra en el MOTOR (opt
`insertableBlocks` del provider) para que el índice de teclado no derive; los menús
eidos filtran vía `schemeAllowsBlock` sobre `insertableBlockTypes(scheme)`.
---
## 7. El panel: las TRES clases de propiedades

@ -10,6 +10,8 @@
*/
import { defaultPalabrasSchema, type PalabrasBlockMenuEntry } from '$soma/components/palabras';
import type { Component } from 'svelte';
import { schemeAllowsBlock } from './palabras-scheme';
import type { PalabrasScheme } from './types';
import {
Type,
Heading,
@ -83,10 +85,22 @@ export function blockTypeIcon(type: string | null | undefined): Component {
return (type && BLOCK_TYPE_ICONS[type]) || Type;
}
/** The curated insert options paired with their engine entry. */
export function insertableBlockTypes(): PalabrasBlockType[] {
/** Registry id → canonical block type (`heading-1`→`heading`, `unordered-list`
* →`list`, `code-block`→`code`) — the vocabulary the scheme's `blocks`
* allowlist speaks. */
export function canonicalBlockType(id: string): string {
if (id.startsWith('heading')) return 'heading';
if (id.endsWith('-list')) return 'list';
if (id === 'code-block') return 'code';
return id;
}
/** The curated insert options paired with their engine entry, gated by the
* scheme's `blocks` allowlist (no scheme / undeclared `blocks` → all). */
export function insertableBlockTypes(scheme?: PalabrasScheme): PalabrasBlockType[] {
const byId = new Map(defaultPalabrasSchema.insertable().map((e) => [e.id, e]));
return CURATED.flatMap((c) => {
if (scheme && !schemeAllowsBlock(scheme, canonicalBlockType(c.registryId))) return [];
const entry = byId.get(c.registryId);
return entry ? [{ id: c.registryId, label: c.label, icon: c.icon, entry }] : [];
});

@ -39,7 +39,7 @@
ArrowLeft
} from '$uix/eidos/components/icon';
import { PALABRAS_FONTS } from './panel-schema';
import { schemeSizeFor } from './palabras-scheme';
import { schemeAllowsMark, schemeSizeFor } from './palabras-scheme';
import type { ProviderSnippetProps, PalabrasMark } from '$soma/components/palabras';
import type { PalabrasScheme } from './types';
@ -59,6 +59,15 @@
// ── Range typography (F2.4) — engine marks over the live selection. ──
const fontGroups = $derived(scheme.fonts ?? PALABRAS_FONTS);
const allFonts = $derived(fontGroups.flatMap((g) => g.fonts));
// Scheme governance: the bubble offers ONLY the marks the scheme permits
// (undeclared `marks` → all). Boolean marks filter the row; sub/sup/size/
// family gate the «Aa» typography sub-row (the «Aa» button hides when none).
const allowedMarks = $derived(MARKS.filter((m) => schemeAllowsMark(scheme, m.command)));
const showSub = $derived(schemeAllowsMark(scheme, 'sub'));
const showSup = $derived(schemeAllowsMark(scheme, 'sup'));
const showSize = $derived(schemeAllowsMark(scheme, 'fontSize'));
const showFamily = $derived(schemeAllowsMark(scheme, 'fontFamily'));
const showTypo = $derived(showSub || showSup || showSize || showFamily);
const marks = $derived(api.activeMarks);
const hasBool = (m: 'sub' | 'sup') => marks.includes(m);
const paramValue = <T,>(type: 'fontSize' | 'fontFamily'): T | undefined => {
@ -158,32 +167,37 @@
>
{#snippet icon()}<ArrowLeft />{/snippet}
</Button>
<span data-palabras-bubble-divider></span>
<Button
variant="ghost"
size="sm"
iconOnly
data-active={hasBool('sub') ? '' : undefined}
aria-pressed={hasBool('sub')}
aria-label={ts('#?components.palabras.mark.sub|Subíndice')}
onpointerdown={keepSelection}
onclick={() => toggleBool('sub')}
>
{#snippet icon()}<Subscript />{/snippet}
</Button>
<Button
variant="ghost"
size="sm"
iconOnly
data-active={hasBool('sup') ? '' : undefined}
aria-pressed={hasBool('sup')}
aria-label={ts('#?components.palabras.mark.sup|Superíndice')}
onpointerdown={keepSelection}
onclick={() => toggleBool('sup')}
>
{#snippet icon()}<Superscript />{/snippet}
</Button>
<span data-palabras-bubble-divider></span>
{#if showSub || showSup}<span data-palabras-bubble-divider></span>{/if}
{#if showSub}
<Button
variant="ghost"
size="sm"
iconOnly
data-active={hasBool('sub') ? '' : undefined}
aria-pressed={hasBool('sub')}
aria-label={ts('#?components.palabras.mark.sub|Subíndice')}
onpointerdown={keepSelection}
onclick={() => toggleBool('sub')}
>
{#snippet icon()}<Subscript />{/snippet}
</Button>
{/if}
{#if showSup}
<Button
variant="ghost"
size="sm"
iconOnly
data-active={hasBool('sup') ? '' : undefined}
aria-pressed={hasBool('sup')}
aria-label={ts('#?components.palabras.mark.sup|Superíndice')}
onpointerdown={keepSelection}
onclick={() => toggleBool('sup')}
>
{#snippet icon()}<Superscript />{/snippet}
</Button>
{/if}
{#if (showSub || showSup) && (showSize || showFamily)}<span data-palabras-bubble-divider></span>{/if}
{#if showSize}
<div data-palabras-bubble-size>
<NumberField
size="sm"
@ -199,6 +213,8 @@
<NumberField.IncrementTrigger>+</NumberField.IncrementTrigger>
</NumberField>
</div>
{/if}
{#if showFamily}
<div data-palabras-bubble-family>
<Select
selectionMode="single"
@ -228,9 +244,10 @@
</Select.Portal>
</Select>
</div>
{/if}
</div>
{:else}
{#each MARKS as mark (mark.command)}
{#each allowedMarks as mark (mark.command)}
{@const Glyph = mark.icon}
<Palabras.CommandButton command={mark.command} aria-label={ts(mark.label)}>
{#snippet child({ props })}
@ -241,18 +258,20 @@
</Palabras.CommandButton>
{/each}
<span data-palabras-bubble-divider></span>
{#if allowedMarks.length}<span data-palabras-bubble-divider></span>{/if}
<Button
variant="ghost"
size="sm"
iconOnly
aria-label={ts('#?components.palabras.mark.typography|Tipografía')}
onpointerdown={keepSelection}
onclick={() => (typoOpen = true)}
>
{#snippet icon()}<Type />{/snippet}
</Button>
{#if showTypo}
<Button
variant="ghost"
size="sm"
iconOnly
aria-label={ts('#?components.palabras.mark.typography|Tipografía')}
onpointerdown={keepSelection}
onclick={() => (typoOpen = true)}
>
{#snippet icon()}<Type />{/snippet}
</Button>
{/if}
<Button
variant="ghost"
size="sm"

@ -23,15 +23,18 @@
import { Plus } from '$uix/eidos/components/icon';
import { blockToInsert, insertableBlockTypes, type PalabrasBlockType } from './palabras-block-types';
import { insertBlockInColumn } from './palabras-block-actions';
import type { PalabrasScheme } from './types';
let {
api,
content,
dom
dom,
scheme
}: {
api: ProviderSnippetProps;
content: HTMLElement;
dom: ActiveDom;
scheme: PalabrasScheme;
} = $props();
const eidos = ActiveEidos.require();
@ -42,7 +45,7 @@
);
// Types offered inside a column: everything EXCEPT columns (no columns-in-columns).
const colTypes = insertableBlockTypes().filter((t) => t.id !== 'columns');
const colTypes = insertableBlockTypes(scheme).filter((t) => t.id !== 'columns');
type Inserter = {
key: string;

@ -73,8 +73,8 @@
// Insertable types — columns never nest (same filter as the panel's grid).
const insertTypes = $derived(
loc && loc.path.length > 1
? insertableBlockTypes().filter((t) => t.id !== 'columns')
: insertableBlockTypes()
? insertableBlockTypes(scheme).filter((t) => t.id !== 'columns')
: insertableBlockTypes(scheme)
);
// ── Editar menu — active-element reorder + clipboard. Move / paste ride engine

@ -829,8 +829,8 @@
// active one, then follow it (its id is assigned up front; the engine keeps it).
const insertTypes = $derived(
loc && loc.path.length > 1
? insertableBlockTypes().filter((t) => t.id !== 'columns')
: insertableBlockTypes()
? insertableBlockTypes(scheme).filter((t) => t.id !== 'columns')
: insertableBlockTypes(scheme)
);
function doInsertType(bt: PalabrasBlockType) {
// Shared semantics (panel + menubar): next sibling of the active block;

@ -7,13 +7,35 @@ import { parsePalabrasScheme } from './palabras-scheme-parse';
import {
resolvePalabrasScheme,
resolveSchemeColor,
schemeAllowsBlock,
schemeAllowsField,
schemeAllowsMark,
schemeCssVars,
schemeImageFilter,
schemePaletteValues,
schemeSubControls
} from './palabras-scheme';
describe('scheme governance — marks / blocks allowlists', () => {
it('undeclared marks/blocks → everything allowed', () => {
const s = resolvePalabrasScheme('text=17');
expect(schemeAllowsMark(s, 'bold')).toBe(true);
expect(schemeAllowsMark(s, 'code')).toBe(true);
expect(schemeAllowsBlock(s, 'code')).toBe(true);
expect(schemeAllowsBlock(s, 'table')).toBe(true);
});
it('declared allowlists gate to the listed values (merged over the default)', () => {
const s = resolvePalabrasScheme({
marks: ['bold', 'italic'],
blocks: ['paragraph', 'heading']
});
expect(schemeAllowsMark(s, 'bold')).toBe(true);
expect(schemeAllowsMark(s, 'code')).toBe(false);
expect(schemeAllowsBlock(s, 'heading')).toBe(true);
expect(schemeAllowsBlock(s, 'table')).toBe(false);
});
});
describe('parsePalabrasScheme (compact notation)', () => {
it('parses the scale shortcut and style blocks with short props', () => {
const s = parsePalabrasScheme(

@ -171,6 +171,8 @@ export function mergePalabrasScheme(base: PalabrasScheme, over: Partial<Palabras
sizes: { ...base.sizes, ...over.sizes },
palette: over.palette ?? base.palette,
editable: over.editable ?? base.editable,
marks: over.marks ?? base.marks,
blocks: over.blocks ?? base.blocks,
styles,
...(fields ? { fields } : {})
};
@ -435,6 +437,18 @@ export function schemeSubControls(
return allowed.filter((k) => k.startsWith(`${fieldKey}.`)).map((k) => k.slice(fieldKey.length + 1));
}
/** Whether the user may APPLY an inline mark (bubble / shortcut gating).
* Undeclared `marks` → all allowed; declared → only listed. */
export function schemeAllowsMark(scheme: PalabrasScheme, mark: string): boolean {
return !scheme.marks || scheme.marks.includes(mark);
}
/** Whether the user may INSERT a block type (slash / insert-menu gating).
* Undeclared `blocks` → every registered type; declared → only listed. */
export function schemeAllowsBlock(scheme: PalabrasScheme, blockType: string): boolean {
return !scheme.blocks || scheme.blocks.includes(blockType);
}
/** The scheme's extra panel fields for a block type (designer level). */
export function schemeExtraFields(scheme: PalabrasScheme, blockType: string | undefined) {
return blockType ? (scheme.fields?.[blockType] ?? []) : [];

@ -90,6 +90,7 @@
{#if mounted}
<Palabras.Provider
{...headlessProps}
insertableBlocks={scheme.blocks}
bind:value
data-palabras
data-scheme-style={schemeName}
@ -188,7 +189,7 @@
{/if}
<!-- A "+" over each EMPTY column → the block-type grid → inserts into
that column (Palabras' `insertBlockInColumn`). -->
<PalabrasColumnInserter {api} content={contentEl} {dom} />
<PalabrasColumnInserter {api} content={contentEl} {dom} {scheme} />
{/if}
</div>
</div>

@ -224,6 +224,20 @@ export interface PalabrasScheme {
* `adjustments.saturation`… (`typography` / `adjustments` alone = all).
*/
readonly editable?: Readonly<Record<string, readonly string[]>>;
/**
* WHITELIST of inline MARKS the user may apply (bubble / shortcuts):
* boolean `bold` `italic` `underline` `strike` `code` `sub` `sup` and
* parametric `fontSize` `fontFamily` `color` `background`. Undeclared →
* all marks allowed; declared EMPTY → none. Governs the bubble.
*/
readonly marks?: readonly string[];
/**
* WHITELIST of block TYPES the user may INSERT (slash / insert menus).
* Undeclared → every registered type; declared EMPTY → none. Values are
* canonical block types (`paragraph`, `heading`, `quote`, `code`, `list`,
* `table`, `image`, `divider`, `callout`, `columns`, plugin types…).
*/
readonly blocks?: readonly string[];
/** Base style per element ROUTE (open keys: `'h1'`, `'quote.cite'`,
* `'callout.risk'`, `'table.header'`, `'mark.bold'`, `'doc'`, plugin
* types…). See `PalabrasElementStyle`. */

@ -27,6 +27,7 @@
onSelectionChange,
onValueCommit,
onUploadImage,
insertableBlocks,
children,
child,
...restProps
@ -58,7 +59,8 @@
onValueChange: readableActive(() => onValueChange),
onSelectionChange: readableActive(() => onSelectionChange),
onValueCommit: readableActive(() => onValueCommit),
onUploadImage: readableActive(() => onUploadImage)
onUploadImage: readableActive(() => onUploadImage),
insertableBlocks: readableActive(() => insertableBlocks)
});
const mergedProps = $derived(mergeProps(restProps, state.props));

@ -107,7 +107,8 @@ function palabrasOpts(root = document.createElement('div'), value = createEmptyD
>(undefined),
onUploadImage: state<((file: File) => Promise<{ url: string; alt?: string }>) | undefined>(
undefined
)
),
insertableBlocks: state<readonly string[] | undefined>(undefined)
};
}

@ -120,6 +120,7 @@ interface PalabrasOpts
onSelectionChange: OnChangeFn<PalabrasSelection | null> | undefined;
onValueCommit: ((document: PalabrasDocument, reason: PalabrasCommitReason) => void) | undefined;
onUploadImage: PalabrasOnUploadImage | undefined;
insertableBlocks: readonly string[] | undefined;
}> {}
interface PalabrasContentOpts extends WithRefOpts {}
@ -608,7 +609,7 @@ export class PalabrasProvider {
readonly slashMenuAvailable = $derived.by(() => this.slashMenuMounts > 0);
readonly slashMenuContext = $derived.by(() => this.resolveSlashMenuContext());
readonly slashMenuItems = $derived.by(() =>
filterSlashCommandItems(this.slashMenuContext?.query ?? '')
filterSlashCommandItems(this.slashMenuContext?.query ?? '', this.opts.insertableBlocks.current)
);
readonly slashMenuOpen = $derived.by(() => {
const context = this.slashMenuContext;
@ -3404,8 +3405,21 @@ function isEmptyEditableTextBlock(block: unknown): boolean {
// The slash menu's items ARE the registry's insertable entries — one source
// of truth shared with the gutter, so the two lists cannot drift and plugin
// blocks appear automatically.
function filterSlashCommandItems(query: string): readonly PalabrasBlockMenuEntry[] {
const all = defaultPalabrasSchema.insertable();
/** Registry id → canonical block type — the `insertableBlocks` allowlist vocab
* (`heading-1`→`heading`, `unordered-list`→`list`, `code-block`→`code`). */
function canonicalSlashType(id: string): string {
if (id.startsWith('heading')) return 'heading';
if (id.endsWith('-list')) return 'list';
if (id === 'code-block') return 'code';
return id;
}
function filterSlashCommandItems(
query: string,
allowed?: readonly string[]
): readonly PalabrasBlockMenuEntry[] {
let all = defaultPalabrasSchema.insertable();
if (allowed) all = all.filter((item) => allowed.includes(canonicalSlashType(item.id)));
const normalized = query.trim().toLowerCase();
if (!normalized) return all;
return all.filter((item) => {

@ -380,6 +380,10 @@ export type PalabrasProps = WithChild<
commitOnBlur?: boolean;
/** Async upload pipeline for pasted/dropped image files. */
onUploadImage?: PalabrasOnUploadImage;
/** WHITELIST of block types the slash / imperative insert may create
* (canonical: `paragraph`, `heading`, `list`, `code`…). Undefined →
* every registered type. */
insertableBlocks?: readonly string[];
'aria-label'?: string;
onValueChange?: OnChangeFn<PalabrasDocument>;
onSelectionChange?: OnChangeFn<PalabrasSelection | null>;

Loading…
Cancel
Save

Powered by TurnKey Linux.