From fa4945ef88738576b8e3725193955582f29b7c16 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 3 Jun 2026 15:23:21 +0000 Subject: [PATCH 1/2] feat(rebemer): multiple blocks per edit + multiple modifiers per row MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Any non-root row can now be promoted to a sub-block root by clicking its type badge (ELEM → BLOCK). The row and all its descendants adopt a new BEM block scope, so a whole section can be reBEMed with distinct block prefixes (hero__*, card__*, etc.) in one Apply. Each row also gains a row.modifiers string[] field (replaces the old single modifier string). In add/rename/replace mode a --modifier placeholder input is shown per row; typing fills it in and an '+ Add modifier' button appears to attach additional modifiers. All non-empty modifiers are attached alongside the primary class. The standalone 'Add modifier' mode is removed — modifier attachment is now an inline feature of the three class-mutation modes. Implementation notes: - computeBlockAssignment(): new exported pure helper; stack-based depth walk that resolves each row to its owning block name. Shared by buildPlan() and BemPanel's rowBlockNames derived. - buildPlan(): uses computeBlockAssignment, validates all block roots, populates op.modifierSlugs after auto-numbering so modifier class names always reference the final (post-numbered) base name. - applyToSubtree(): appends modifier classes from op.modifierSlugs to nextIds before setElementClasses; uses per-op op.blockName for labelFromClass instead of a single root blockName. - BemPanel: rowBlockNames derived feeds per-row blockName to Row; previewClassNames now Map covering base + modifiers. - Row: type badge is a clickable toggle for non-root rows; modifier section supports dynamic list with add/remove. https://claude.ai/code/session_01MR5eh7i7GRVpvudPrCjm2w --- .../editor-app/src/components/BemPanel.svelte | 69 +++--- .../editor-app/src/components/Row.svelte | 111 ++++++--- .../bricks/editor-app/src/lib/apply.js | 213 +++++++++++------- .../bricks/editor-app/src/styles/panel.css | 30 ++- 4 files changed, 281 insertions(+), 142 deletions(-) diff --git a/plugins/SLASHED-for-WP/integrations/bricks/editor-app/src/components/BemPanel.svelte b/plugins/SLASHED-for-WP/integrations/bricks/editor-app/src/components/BemPanel.svelte index 182ede1c..75cc6e06 100644 --- a/plugins/SLASHED-for-WP/integrations/bricks/editor-app/src/components/BemPanel.svelte +++ b/plugins/SLASHED-for-WP/integrations/bricks/editor-app/src/components/BemPanel.svelte @@ -25,7 +25,7 @@ import * as api from '../lib/bricks-api.js'; import { slugify } from '../lib/slugify.js'; import { validateName } from '../lib/validate.js'; - import { applyToSubtree, buildPlan } from '../lib/apply.js'; + import { applyToSubtree, buildPlan, computeBlockAssignment } from '../lib/apply.js'; import { suggestElementName, isLayoutContainer, suggestContainerName, SOLE_CHILD_LABEL_OVERRIDES } from '../lib/element-types.js'; import { pickMigratableKeys, pickSkippedKeys } from '../lib/migrate-keys.js'; import Row from './Row.svelte'; @@ -34,11 +34,10 @@ let { rootId, onClose } = $props(); const MODE_HINTS = { - add: 'Attaches a new BEM class to each element without removing any existing classes.', - rename: 'Replaces the first existing class with a new name, seeding its settings from the old class.', - replace: 'Replaces ALL existing classes with a single new BEM class (clean slate, no settings carried over).', - modifier: 'Appends a --modifier variant. The base class is auto-added to the element if absent.', - migrate: 'Lifts inline element styles (padding, color, typography, etc.) into a new global class.', + add: 'Attaches a new BEM class to each element without removing any existing classes.', + rename: 'Replaces the first existing class with a new name, seeding its settings from the old class.', + replace: 'Replaces ALL existing classes with a single new BEM class (clean slate, no settings carried over).', + migrate: 'Lifts inline element styles (padding, color, typography, etc.) into a new global class.', }; let mode = $state('add'); @@ -49,9 +48,17 @@ let toast = $state(null); let panelEl = $state(null); - const blockName = $derived(slugify(rows[0]?.name ?? '')); const rootLabel = $derived(rows[0]?.originalLabel ?? ''); + /** + * Per-row owning block name for prefix display. Uses the same stack + * walk as buildPlan so the displayed prefix always matches the plan. + */ + const rowBlockNames = $derived.by(() => { + if (rows.length === 0) return new Map(); + return computeBlockAssignment(rows, rootId); + }); + /** * For migrate mode the panel-level summary tells the user how many * keys *would* be lifted across the included rows, and how many are @@ -72,17 +79,12 @@ }); /** - * Per-row preview of the post-numbering BEM class name that will - * actually be created/attached on Apply. Built from the same pure - * `buildPlan` step that the apply path uses, so the "use existing - * class" hint never disagrees with what apply does (semantic-review - * issue #7). Reactive on rows + mode + rootId. - * - * Important: `buildPlan` mutates the per-op `suggestedFrom` to - * `'auto-number'` when it renumbers. We don't write that back into - * the source rows here — the next derivation re-reads `row.suggestedFrom` - * from the original state, so the next preview is computed fresh - * from the user's authoritative inputs. + * Per-row preview of all class names that will be created/attached on + * Apply: the base finalClass plus any modifier classes derived from + * row.modifiers. Stored as string[] per row so the row component can + * surface "use existing" hints for any of them. Built from the same + * pure `buildPlan` step that the apply path uses, so hints always + * agree with apply-time behavior. */ const previewClassNames = $derived.by(() => { if (rows.length === 0) return new Map(); @@ -90,7 +92,9 @@ const map = new Map(); if (!result.ok) return map; for (const op of result.ops) { - map.set(op.row.id, op.finalClass); + const names = [op.finalClass]; + for (const slug of op.modifierSlugs ?? []) names.push(`${op.finalClass}--${slug}`); + map.set(op.row.id, names); } return map; }); @@ -128,7 +132,8 @@ bricksType: elementType, originalLabel: label || (isRoot ? 'block' : 'element'), name, - modifier: '', + modifiers: [''], + isBlockRoot: false, include: true, suggestedFrom, migrateKeys: pickMigratableKeys(el.settings), @@ -213,6 +218,16 @@ return; } + // Validate any sub-block roots before hitting applyToSubtree. + for (const row of rows) { + if (!row.isBlockRoot || !row.include) continue; + const sv = validateName(slugify(row.name ?? '')); + if (!sv.ok) { + toast = { kind: 'error', message: `Sub-block "${row.originalLabel}": ${sv.reason}` }; + return; + } + } + const result = applyToSubtree({ rootId, rows, mode, syncLabels }); if (!result.ok) { toast = { kind: 'error', message: result.error }; @@ -248,16 +263,11 @@ - @@ -287,10 +297,11 @@ {/each} diff --git a/plugins/SLASHED-for-WP/integrations/bricks/editor-app/src/components/Row.svelte b/plugins/SLASHED-for-WP/integrations/bricks/editor-app/src/components/Row.svelte index d7e963d9..29e52d92 100644 --- a/plugins/SLASHED-for-WP/integrations/bricks/editor-app/src/components/Row.svelte +++ b/plugins/SLASHED-for-WP/integrations/bricks/editor-app/src/components/Row.svelte @@ -21,28 +21,35 @@ * in apply.js's validateMigrate(). */ + import { slugify } from '../lib/slugify.js'; + let { row = $bindable(), mode, blockName, isRoot, + rootId = '', globalClasses = [], /** - * The post-numbering BEM class name `apply.js` will actually - * create/attach for this row. Empty string means the row is - * either skipped, has an invalid name, or the plan is otherwise - * incomplete (e.g. modifier mode with empty modifier). The panel - * derives this map from `buildPlan` so it always agrees with - * apply-time behavior. + * All class names apply.js will create/attach for this row: the + * base finalClass plus any modifier classes. Empty array means the + * row is skipped or has an invalid name. The panel derives this from + * `buildPlan` so it always agrees with apply-time behavior. */ - finalClassName = '', + finalClassNames = [], } = $props(); - const showModifier = $derived(mode === 'modifier'); + // Modifier inputs are shown in all modes except migrate. + const showModifier = $derived(mode !== 'migrate'); const showMigrate = $derived(mode === 'migrate'); const showRename = $derived(mode === 'rename'); const prefix = $derived(!isRoot && blockName ? `${blockName}__` : ''); + /** Whether this row is a sub-block root (not the overall tree root). */ + const isSubBlockRoot = $derived(row.isBlockRoot === true && row.id !== rootId); + /** Sub-block root toggle is available on all non-tree-root rows. */ + const canToggleBlockRoot = $derived(row.id !== rootId); + /** Style suggested vs user-typed names differently so the user can * see at a glance which rows reBEMer pre-filled. */ const isSuggested = $derived( @@ -50,17 +57,23 @@ ); /** - * Find an existing global class with the same name. Snapshot at - * panel open via the `globalClasses` prop, NOT live — refreshes - * only when the panel is reopened. Used to surface a one-click - * "use existing" affordance (§11.3 `recommendedAction: "attach"`). + * Find an existing global class matching any of this row's preview + * class names. Returns the first match, or null. */ const existingClassMatch = $derived.by(() => { - if (!finalClassName || !Array.isArray(globalClasses)) return null; - if (!row.include) return null; - return globalClasses.find(c => c && c.name === finalClassName) || null; + if (!finalClassNames.length || !row.include || !Array.isArray(globalClasses)) return null; + for (const name of finalClassNames) { + const found = globalClasses.find(c => c && c.name === name); + if (found) return found; + } + return null; }); + /** True when all modifier inputs are empty (or there are none). */ + const allModifiersEmpty = $derived( + !Array.isArray(row.modifiers) || row.modifiers.every(m => !slugify(m)) + ); + function markAsUserTyped() { if (row.suggestedFrom !== 'user') row.suggestedFrom = 'user'; } @@ -83,9 +96,18 @@
{row.originalLabel} - - {isRoot ? 'BLOCK' : (row.bricksType || 'ELEM').toUpperCase()} - + {#if canToggleBlockRoot} + + {:else} + BLOCK + {/if} {#if isSuggested && row.include}
{#if showModifier} - +
+ {#each row.modifiers as _mod, mi (mi)} +
+ + {#if row.modifiers.length > 1} + + {/if} +
+ {/each} + {#if !allModifiersEmpty} + + {/if} +
{/if} - {#if showModifier && row.include && !row.modifier} -

Enter a modifier name — the base class will be added automatically if absent.

- {/if} - {#if showRename && row.include && row.currentClassCount === 0}

This element has no existing classes. Rename will create a new class instead.

{:else if showRename && row.include && row.currentClassCount > 1} @@ -134,11 +175,11 @@ {#if existingClassMatch}

{#if showMigrate} - A class named {finalClassName} already exists. + A class named {existingClassMatch.name} already exists. On Apply, missing style keys will be merged into it. Conflicting values block the migration — pick a different name or use Add. {:else} - A class named {finalClassName} already exists + A class named {existingClassMatch.name} already exists globally. Apply will attach the existing class instead of creating a duplicate. {/if} @@ -150,7 +191,7 @@ {#if row.migrateKeys?.length} Migrate: {#each row.migrateKeys as key} - {chipLabel(key)} + {chipLabel(key)} {/each} {:else} No migratable keys on this element. diff --git a/plugins/SLASHED-for-WP/integrations/bricks/editor-app/src/lib/apply.js b/plugins/SLASHED-for-WP/integrations/bricks/editor-app/src/lib/apply.js index fbdf31ea..e5ec0182 100644 --- a/plugins/SLASHED-for-WP/integrations/bricks/editor-app/src/lib/apply.js +++ b/plugins/SLASHED-for-WP/integrations/bricks/editor-app/src/lib/apply.js @@ -1,15 +1,30 @@ /** * Apply BEM classes to a subtree in one pass. * - * Five modes: + * Four modes: * add — attach new BEM classes (keep any existing classes). * rename — detach old classes, create new ones seeded with old settings. * replace — detach old classes, create new ones with empty settings. - * modifier — keep existing classes, append a `--modifier` class. * migrate — keep existing classes, lift allowlisted element-settings * keys (padding, color, etc.) up into a new global class * and remove them from the element. * + * Modifiers + * --------- + * Any row in add/rename/replace mode may carry `row.modifiers: string[]`. + * Non-empty entries produce additional `finalClass--modSlug` classes that + * are attached alongside the primary class in the same Apply. Modifier + * slugs are resolved AFTER auto-numbering so they always reference the + * final (post-numbered) base class name. + * + * Multiple blocks per subtree + * --------------------------- + * Any non-root row may have `row.isBlockRoot: true`, promoting it to a + * sub-block root. Its `finalClass` becomes its own block name (not + * `parentBlock__elemName`), and all its descendants adopt it as their + * owning block. `computeBlockAssignment` implements the stack walk that + * resolves each row to its owning block. + * * Sibling auto-numbering * ---------------------- * Two rows under the same block can resolve to the same final class @@ -23,18 +38,12 @@ * - `'element-type' | 'fallback' | 'auto-number'` rows accept the * numeric suffix in document order. * - * Modifier-mode collisions - * ------------------------ - * Two siblings each producing `card__image--lg` is the canonical - * "attach the same modifier class to all of them" case, NOT a name - * conflict. We deliberately do NOT auto-number modifier-mode duplicates - * — `upsertGlobalClass` dedupes by name, so all colliding rows end up - * sharing the same class. - * * Skip toggle * ----------- * Rows with `include: false` are excluded from operations *and* from - * the collision tally — a skipped row never claims a number. + * the collision tally — a skipped row never claims a number. A skipped + * sub-block root is also not pushed onto the block stack, so its + * children fall back to the nearest active ancestor block. * * Two-step API * ------------ @@ -54,7 +63,7 @@ import { slugify } from './slugify.js'; import { MIGRATE_ALLOWLIST } from './migrate-keys.js'; import * as api from './bricks-api.js'; -const VALID_MODES = new Set(['add', 'rename', 'replace', 'modifier', 'migrate']); +const VALID_MODES = new Set(['add', 'rename', 'replace', 'migrate']); /** * Provenance values that are treated as authoritative — these names @@ -68,10 +77,13 @@ const AUTHORITATIVE_PROVENANCE = new Set(['user', 'label']); * @typedef {Object} Row * @property {string} id - Bricks element id. * @property {number} depth - 0 = root, > 0 = descendant. - * @property {string} name - Slugifiable label (block name for root, - * element label otherwise). - * @property {string} modifier - Slugifiable modifier (only used when - * mode is 'modifier'). + * @property {string} name - Slugifiable label (block name for root or + * sub-block roots, element label otherwise). + * @property {string[]} [modifiers] - Slugifiable modifier tokens. Each + * non-empty entry produces an extra `finalClass--modSlug` class + * attached alongside the primary class (add/rename/replace only). + * @property {boolean} [isBlockRoot] - When true (and row.id !== rootId), + * this row starts a new BEM block scope for itself and its descendants. * @property {boolean} include - When false the row is skipped: no * mutations, no collision tally, no preflight. * @property {'user'|'label'|'element-type'|'fallback'|'auto-number'} [suggestedFrom] @@ -87,11 +99,51 @@ const AUTHORITATIVE_PROVENANCE = new Set(['user', 'label']); /** * @typedef {Object} Op * @property {Row} row - * @property {boolean} isRoot - * @property {string} finalClass The post-numbering class name. - * @property {string} suggestedFrom Possibly mutated to 'auto-number'. + * @property {boolean} isRoot True for the tree root or a sub-block root. + * @property {string} blockName The owning block's slugified name. + * @property {string} finalClass The post-numbering class name. + * @property {string[]} modifierSlugs Resolved after auto-numbering; each + * entry produces a `finalClass--slug` class attached alongside the primary. + * @property {string} suggestedFrom Possibly mutated to 'auto-number'. */ +/** + * Walk rows in document order to determine the owning BEM block for each + * included row. Uses a depth-based stack: a sub-block root pushes itself, + * popping any prior entries at the same or greater depth first. + * + * Exported so BemPanel can reuse the result for live prefix display without + * re-running the full plan. + * + * @param {Row[]} rows + * @param {string} rootId + * @returns {Map} row.id → owning block's slugified name + */ +export function computeBlockAssignment(rows, rootId) { + const map = new Map(); + const stack = []; // { depth: number, blockName: string } + + for (const row of rows) { + while (stack.length && stack[stack.length - 1].depth >= row.depth) stack.pop(); + + const isRootRow = row.id === rootId; + const isSubBlock = !isRootRow && row.isBlockRoot === true; + + if (isRootRow || isSubBlock) { + const slug = slugify(row.name); + if (slug && row.include !== false) { + stack.push({ depth: row.depth, blockName: slug }); + map.set(row.id, slug); + } + } else { + const ownerSlug = stack.length ? stack[stack.length - 1].blockName : ''; + if (ownerSlug) map.set(row.id, ownerSlug); + } + } + + return map; +} + /** * Build the plan for a subtree apply: validate inputs, slug, run the * sibling auto-numbering pre-pass. Pure — no mutations to live Bricks @@ -100,7 +152,7 @@ const AUTHORITATIVE_PROVENANCE = new Set(['user', 'label']); * @param {object} opts * @param {string} opts.rootId * @param {Row[]} opts.rows - * @param {'add'|'rename'|'replace'|'modifier'|'migrate'} opts.mode + * @param {'add'|'rename'|'replace'|'migrate'} opts.mode * @returns {{ok:true, ops:Op[]} | {ok:false, error:string, ops:Op[]}} * On error, `ops` is an empty array so callers (e.g. previews) can * safely treat it as "nothing to display". @@ -113,18 +165,35 @@ export function buildPlan({ rootId, rows, mode }) { const blockRow = rows.find(r => r.id === rootId); if (!blockRow) return { ok: false, ops: [], error: 'Root row missing.' }; - const blockName = slugify(blockRow.name); - if (!blockName) return { ok: false, ops: [], error: 'Block name is empty.' }; + const rootBlockName = slugify(blockRow.name); + if (!rootBlockName) return { ok: false, ops: [], error: 'Block name is empty.' }; + + // Validate sub-block root names up-front so errors are reported before + // any op is built. Collect all bad names for a combined message. + const emptySubBlocks = []; + for (const row of rows) { + if (!row.isBlockRoot || !row.include || row.id === rootId) continue; + if (!slugify(row.name)) emptySubBlocks.push(row.originalLabel || row.id); + } + if (emptySubBlocks.length > 0) { + return { ok: false, ops: [], error: `Block name is empty for: ${emptySubBlocks.join(', ')}.` }; + } + + const blockMap = computeBlockAssignment(rows, rootId); // 1. Build per-row operations with intended (pre-numbering) class names. /** @type {Op[]} */ const ops = []; for (const row of rows) { if (!row.include) continue; - const isRoot = row.id === rootId; + const isRootRow = row.id === rootId; + const isSubBlock = !isRootRow && row.isBlockRoot === true; + + const blockName = blockMap.get(row.id); + if (!blockName) continue; let baseClass; - if (isRoot) { + if (isRootRow || isSubBlock) { baseClass = blockName; } else { const elemSlug = slugify(row.name); @@ -132,17 +201,12 @@ export function buildPlan({ rootId, rows, mode }) { baseClass = `${blockName}__${elemSlug}`; } - let finalClass = baseClass; - if (mode === 'modifier') { - const modSlug = slugify(row.modifier); - if (!modSlug) continue; - finalClass = `${baseClass}--${modSlug}`; - } - ops.push({ row, - isRoot, - finalClass, + isRoot: isRootRow || isSubBlock, + blockName, + finalClass: baseClass, + modifierSlugs: [], // populated after auto-numbering suggestedFrom: row.suggestedFrom || 'fallback', }); } @@ -152,9 +216,19 @@ export function buildPlan({ rootId, rows, mode }) { } // 2. Sibling auto-numbering (in-plan collision resolver). - const numberingResult = applyAutoNumbering(ops, mode); + const numberingResult = applyAutoNumbering(ops); if (!numberingResult.ok) return { ok: false, ops: [], error: numberingResult.error }; + // 3. Resolve modifier slugs against the now-final class names. + // Doing this after numbering ensures modifiers reference the + // correct (post-numbered) base class (e.g. `card__image-1--lg`). + if (mode !== 'migrate') { + for (const op of ops) { + const mods = Array.isArray(op.row.modifiers) ? op.row.modifiers : []; + op.modifierSlugs = mods.map(m => slugify(m)).filter(Boolean); + } + } + return { ok: true, ops }; } @@ -164,7 +238,7 @@ export function buildPlan({ rootId, rows, mode }) { * @param {object} opts * @param {string} opts.rootId * @param {Row[]} opts.rows - * @param {'add'|'rename'|'replace'|'modifier'|'migrate'} opts.mode + * @param {'add'|'rename'|'replace'|'migrate'} opts.mode * @param {boolean} opts.syncLabels * @returns {{ok:true, count:number} | {ok:false, error:string}} */ @@ -173,8 +247,6 @@ export function applyToSubtree({ rootId, rows, mode, syncLabels }) { if (!planResult.ok) return { ok: false, error: planResult.error }; const ops = planResult.ops; - const blockRow = rows.find(r => r.id === rootId); - const blockName = slugify(blockRow?.name ?? ''); const globalClasses = api.getGlobalClasses(); // 3. Pre-validate migrate-mode against live state BEFORE any @@ -191,11 +263,12 @@ export function applyToSubtree({ rootId, rows, mode, syncLabels }) { // save the exact setting keys that removeMigratedKeys will delete. const snapshot = new Map(); // id -> { classIds, label, migrateKeys? } for (const op of ops) { + if (snapshot.has(op.row.id)) continue; // same element, already snapshotted const el = api.findElement(op.row.id); if (!el) continue; const entry = { classIds: readClassIds(el.settings).slice(), - label: (syncLabels && mode !== 'modifier') ? (el.label ?? '') : null, + label: syncLabels ? (el.label ?? '') : null, }; if (mode === 'migrate' && Array.isArray(op.row.migrateKeys)) { const saved = {}; @@ -265,20 +338,6 @@ export function applyToSubtree({ rootId, rows, mode, syncLabels }) { : [...currentIds, newClassId]; break; - case 'modifier': { - // Guarantee the base class (without the --modifier suffix) is - // also attached — a modifier alone is meaningless without it. - const modMarker = op.finalClass.indexOf('--'); - const baseClassName = modMarker >= 0 ? op.finalClass.slice(0, modMarker) : null; - let ids = [...currentIds]; - if (baseClassName) { - const baseId = api.upsertGlobalClass(baseClassName, {}); - if (!ids.includes(baseId)) ids.push(baseId); - } - nextIds = ids.includes(newClassId) ? ids : [...ids, newClassId]; - break; - } - case 'rename': { // Start with the renamed base class. const renamedIds = [newClassId]; @@ -311,6 +370,15 @@ export function applyToSubtree({ rootId, rows, mode, syncLabels }) { break; } + // Append any modifier classes requested via row.modifiers. These + // piggyback on the primary class operation: the base is already in + // nextIds, so we only need to upsert and append the modifier classes. + for (const modSlug of op.modifierSlugs) { + const modClass = `${op.finalClass}--${modSlug}`; + const modId = api.upsertGlobalClass(modClass, {}); + if (!nextIds.includes(modId)) nextIds.push(modId); + } + api.setElementClasses(op.row.id, nextIds); // For 'migrate', remove the lifted keys from the element settings @@ -321,9 +389,9 @@ export function applyToSubtree({ rootId, rows, mode, syncLabels }) { removeMigratedKeys(el.settings, op.row.migrateKeys); } - // Sync label if enabled (skip for modifier — label reflects identity). - if (syncLabels && mode !== 'modifier') { - const label = labelFromClass(op.finalClass, blockName); + // Sync label if enabled. + if (syncLabels) { + const label = labelFromClass(op.finalClass, op.blockName); if (label) api.setElementLabel(op.row.id, label); } @@ -424,9 +492,8 @@ function validateMigrate(ops, globalClasses) { * result object — `{ ok: false, error }` on validation failure. * * @param {Op[]} ops - * @param {string} mode */ -function applyAutoNumbering(ops, mode) { +function applyAutoNumbering(ops) { const groups = new Map(); for (const op of ops) { const list = groups.get(op.finalClass) || []; @@ -437,11 +504,6 @@ function applyAutoNumbering(ops, mode) { for (const [name, group] of groups) { if (group.length === 1) continue; - // Modifier-mode duplicates are intentional ("attach this modifier - // to all N siblings"). upsertGlobalClass dedupes by name, so all - // rows end up sharing the single class. No numbering, no error. - if (mode === 'modifier') continue; - const authoritative = group.filter(o => AUTHORITATIVE_PROVENANCE.has(o.suggestedFrom)); if (authoritative.length > 1) { return { @@ -458,22 +520,19 @@ function applyAutoNumbering(ops, mode) { } } - // Post-numbering integrity check (semantic-review issue #4): catch - // the case where a user-typed `card__image-1` collides with an - // auto-numbered `card__image-1` produced from a different group. - // Modifier mode is exempt — duplicates there are by design. - if (mode !== 'modifier') { - const seen = new Map(); - for (const op of ops) { - const prior = seen.get(op.finalClass); - if (prior) { - return { - ok: false, - error: `"${op.finalClass}" is produced by 2 rows after auto-numbering (one ${prior}, one ${op.suggestedFrom}). Pick a different name for one of them.`, - }; - } - seen.set(op.finalClass, op.suggestedFrom); + // Post-numbering integrity check: catch the case where a user-typed + // `card__image-1` collides with an auto-numbered `card__image-1` + // produced from a different group. + const seen = new Map(); + for (const op of ops) { + const prior = seen.get(op.finalClass); + if (prior) { + return { + ok: false, + error: `"${op.finalClass}" is produced by 2 rows after auto-numbering (one ${prior}, one ${op.suggestedFrom}). Pick a different name for one of them.`, + }; } + seen.set(op.finalClass, op.suggestedFrom); } return { ok: true }; diff --git a/plugins/SLASHED-for-WP/integrations/bricks/editor-app/src/styles/panel.css b/plugins/SLASHED-for-WP/integrations/bricks/editor-app/src/styles/panel.css index 837994e3..c1678bb5 100644 --- a/plugins/SLASHED-for-WP/integrations/bricks/editor-app/src/styles/panel.css +++ b/plugins/SLASHED-for-WP/integrations/bricks/editor-app/src/styles/panel.css @@ -205,7 +205,18 @@ li[data-id]:focus-within > .rebemer-badge-host { .rebemer-row__include input { cursor: pointer; } .rebemer-row__meta { display: flex; gap: 6px; align-items: baseline; min-width: 0; } .rebemer-row__label { font-size: 11px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } -.rebemer-row__type { font-size: 9px; color: var(--rebemer-fg-muted); text-transform: uppercase; } +.rebemer-row__type { font-size: 9px; color: var(--rebemer-fg-muted); text-transform: uppercase; letter-spacing: .3px; } +/* Block-root toggle button — same visual as the static span by default */ +.rebemer-row__type--toggle { + background: transparent; border: 0; padding: 1px 3px; border-radius: 2px; + cursor: pointer; line-height: 1; +} +.rebemer-row__type--toggle:hover:not(:disabled) { + color: var(--rebemer-fg); background: rgba(255,255,255,.06); +} +.rebemer-row__type--toggle:disabled { cursor: default; opacity: .5; } +.rebemer-row__type--block { color: var(--rebemer-accent); background: rgba(226,185,59,.12); } +.rebemer-row__type--block:hover:not(:disabled) { background: rgba(226,185,59,.2); } .rebemer-row__hint { font-size: 9px; color: var(--rebemer-accent); @@ -236,6 +247,23 @@ li[data-id]:focus-within > .rebemer-badge-host { font-style: italic; } .rebemer-row__modifier { background: var(--rebemer-input-bg); border: 1px solid var(--rebemer-input-border); border-radius: var(--rebemer-radius); color: var(--rebemer-fg); font: inherit; padding: 4px 6px; width: 90px; } +/* Multi-modifier stack */ +.rebemer-row__modifiers { display: flex; flex-direction: column; gap: 4px; flex: 1; } +.rebemer-row__modifier-row { display: flex; align-items: center; gap: 4px; } +.rebemer-row__modifier-row .rebemer-row__modifier { flex: 1; width: auto; } +.rebemer-row__modifier-remove { + background: transparent; border: 1px solid var(--rebemer-input-border); + border-radius: var(--rebemer-radius); color: var(--rebemer-fg-muted); + font-size: 13px; line-height: 1; padding: 2px 6px; cursor: pointer; flex: 0 0 auto; +} +.rebemer-row__modifier-remove:hover:not(:disabled) { border-color: var(--rebemer-error); color: var(--rebemer-error); } +.rebemer-row__modifier-add { + background: transparent; border: 1px dashed var(--rebemer-input-border); + border-radius: var(--rebemer-radius); color: var(--rebemer-fg-muted); + font: inherit; font-size: 10px; padding: 3px 7px; cursor: pointer; + text-align: left; width: fit-content; +} +.rebemer-row__modifier-add:hover:not(:disabled) { border-color: var(--rebemer-fg-muted); color: var(--rebemer-fg); } /* Per-row recommendation hint (use-existing-class affordance). */ .rebemer-row__recommend { From 70a0516915707a4b9523da27951c84e11111bd59 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 3 Jun 2026 15:34:44 +0000 Subject: [PATCH 2/2] fix(rebemer): modifier input must not flip row.suggestedFrom; clarify JSDoc MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Typing into a modifier input was calling markAsUserTyped(), which flipped row.suggestedFrom to 'user' and silently locked the base name out of sibling auto-numbering — even though the user never touched the base name input. Modifier slugs are resolved after numbering and never affect the base class identity, so the oninput handler is removed from the modifier inputs. Also clarifies the computeBlockAssignment() JSDoc: element rows are assigned an owner regardless of their include status (intentional, for UI prefix preview), which was previously undocumented. https://claude.ai/code/session_01MR5eh7i7GRVpvudPrCjm2w --- .../bricks/editor-app/src/components/Row.svelte | 1 - .../integrations/bricks/editor-app/src/lib/apply.js | 7 +++++-- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/plugins/SLASHED-for-WP/integrations/bricks/editor-app/src/components/Row.svelte b/plugins/SLASHED-for-WP/integrations/bricks/editor-app/src/components/Row.svelte index 29e52d92..addfddee 100644 --- a/plugins/SLASHED-for-WP/integrations/bricks/editor-app/src/components/Row.svelte +++ b/plugins/SLASHED-for-WP/integrations/bricks/editor-app/src/components/Row.svelte @@ -137,7 +137,6 @@ type="text" class="rebemer-row__modifier" bind:value={row.modifiers[mi]} - oninput={markAsUserTyped} placeholder="--modifier" disabled={!row.include} spellcheck="false" diff --git a/plugins/SLASHED-for-WP/integrations/bricks/editor-app/src/lib/apply.js b/plugins/SLASHED-for-WP/integrations/bricks/editor-app/src/lib/apply.js index e5ec0182..ab51fc38 100644 --- a/plugins/SLASHED-for-WP/integrations/bricks/editor-app/src/lib/apply.js +++ b/plugins/SLASHED-for-WP/integrations/bricks/editor-app/src/lib/apply.js @@ -108,8 +108,11 @@ const AUTHORITATIVE_PROVENANCE = new Set(['user', 'label']); */ /** - * Walk rows in document order to determine the owning BEM block for each - * included row. Uses a depth-based stack: a sub-block root pushes itself, + * Walk rows in document order to determine the owning BEM block for each row. + * Block/sub-block roots are only pushed onto the stack when included; element + * rows are assigned an owner regardless of their include status (intentional: + * BemPanel uses this for live prefix display so excluded rows still show their + * would-be prefix). Uses a depth-based stack: a sub-block root pushes itself, * popping any prior entries at the same or greater depth first. * * Exported so BemPanel can reuse the result for live prefix display without