Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 24 additions & 4 deletions docs/research/12-elementor-parity-matrix.md
Original file line number Diff line number Diff line change
@@ -1,55 +1,66 @@
# 12 — Matrice di parità Elementor free (e oltre) — 2026-09-16

Fonte: lista ufficiale wp.org/plugins/elementor (114 li) + lista widget canonica free. Contro inventario reale
blocky: 102 tipi bky/* registrati in Registry.php, 84 renderers (verificato via grep, non a memoria).
blocky: 102 tipi bky/\* registrati in Registry.php, 84 renderers (verificato via grep, non a memoria).

Legenda: ✓ coperto · ⚠ parziale/via alternativa · ✗ assente

## Widget contenuti

heading ✓ · image ✓ · text ✓ · button ✓ · image-box ✓ · icon ✓ · icon-box ✓ · alert ✓ · quote ✓ ·
divider ✓ · spacer ✓ · counter ✓ · progress-bar ✓ · star-rating ✓ · testimonial ✓ · flip-box ✓ ·
animated-headline ✓ · table-of-contents ✓ · call-to-action ✓ · card ✓ · html ✓ · code-highlight ✓

## Media ed embed

video ✓ · embed-google-maps ✓ · embed-iframe ✓ (copre SoundCloud/Facebook/Twitter embed) ·
image-gallery ✓ · basic-gallery ✓ · image-carousel ✓ · slider ✓ · content-carousel ✓ ·
before-after-slider ✓ · hotspot ✓ · lottie ⚠ (unico runtime JS, lazy — accettabile by-design) ·
lightbox ✓ · gallery-carousel ✓ · text-path ✗ (SVG/JS, fuori DNA — valutare se serve davvero)

## Navigazione e struttura

nav-menu ✓ · mega-menu ✓ · anchor (menu anchor) ✓ · breadcrumbs ✓ · pagination ✓ · back-to-top ✓ ·
scroll-progress ✓ · sticky-bar ✓ · post-navigation ✓ · search-form ✓ · sitemap ✓

## Overlay e interazione (il LORO Pro, da noi free)

popup ✓ · modal ✓ · modal-trigger ✓ · offcanvas ✓ · drawer ✓ · dialog-confirm ✓ ·
notification-toast ✓ · tooltip ✓ · popover ✓ · cookie-banner ✓ (Elementor non ce l ha free)

## Tabs/accordion

tabs ✓ · accordion ✓ · toggle ✓

## Form (il LORO Pro, da noi free e illimitato)

form ✓ · form-fields completi (text/textarea/select/radio/checkbox/date/file/hidden/honeypot) ✓ ·
form-submit ✓ · submissions private in WP ✓ · login-form ✓ · register-form ✓ · contact-form ✓ · comment-form ✓

## Contenuti dynamic WP (primitive theme builder, già presenti come elementi)

posts ✓ (posts-grid/posts-list/archive-posts/featured-posts) · wp-post-title ✓ · wp-post-content ✓ ·
wp-featured-image ✓ · author-box ✓ · comments ✓ · wp-shortcode ✓ · wp-template-part ✓ · wp-hook ✓ ·
theme-toggle ✓ (loro non nativo) · data-field ✓ · taxonomy-list ✓

## Listini

price-list ✓ · price-table ✓ · PayPal-button ✗ (scelta: dipendenza esterna, fuori roadmap)

## Icone

icon/icon-box/list ✓ MA libreria icone gestita (carica/SVG set/selettore) ⚠ — gap 1

## What Elementor free claims vs noi (strutturale, non feature)

- Reduced DOM output: noi by-design (renderer PHP minimi, verificabile in e2e)
- Speed improvement claims: CSS compilato per pagina hashato, zero JS di default — misurabile
- No Google Fonts by default · i18n UI completa 6 lingue · build deterministica/CI · API REST+key/MCP

## I VERI gap strutturali (tutto il resto è descriptor work)

1. **Theme builder template system** (header/footer/archive/single/404 come template con condizioni di
display): le PRIMITIVE c esistono gi (wp-* + nav-menu + condizioni del builder menu), manca il CRUD UI
display): le PRIMITIVE c esistono gi (wp-\* + nav-menu + condizioni del builder menu), manca il CRUD UI
dei template-part e le conditions. È il pezzo che rende il confronto possibile con Elementor Pro.
2. **WooCommerce** (loro pagina lista ~15 widget prodotto free/pro): 0 coverage. Il gap singolo
più grande. Sottoinsieme ragionato: product-grid, add-to-cart, gallery, meta — su islands runtime.
Expand All @@ -59,16 +70,25 @@ icon/icon-box/list ✓ MA libreria icone gestita (carica/SVG set/selettore) ⚠
closed-set 'icon' (picker+cerca+upload+glyph legacy), renderer inline SVG con classe lucide-{name}.
Il core blocca gli upload SVG di default: filtri upload_mimes temporanei attorno alla sola
scrittura gia sanificata.
4. **Template/Pattern library cloud**: contenuto, non codice — leva GG-Ally studio (lead-gen),
self-hosted v1 su JSON deterministico (già formato naturale dei nostri documenti).
4. **Template/Pattern library cloud** — v1 FATTA (feat/pattern-library): 5 section pattern
bundled (JSON deterministico in templates/patterns/, validati contro il Registry) + pannello
sidebar Patterns con insert a fine pagina (cloni con id freschi) e salvataggio della pagina
corrente come pattern utente (option blocky_user_patterns, cap 30, DELETE solo per user-\*).
Attenzione ingegneristica: i default dei columns salvano gli slot column-N con indici
1-based (column-1..): i pattern con column-0 non vengono renderizzati (ColumnsRenderer
itera 1..count). Il cloud/lead-gen resta idea di business, non codice.
Il core blocca gli upload SVG di default: filtri upload_mimes temporanei attorno alla sola
scrittura gia sanificata.

## Verdetto numerico

Sulla lista FREE core Elementor: copertura attuale ~90% (su ~55 widget significativi: ~49 ✓, 2 ⚠,
2 ✗ di cui 1 scelta deliberata). "Fare tutto il free" non è una roadmap di anni: è una roadmap di
_descriptor work_ + i 4 gap strutturali sopra. Il free lo abbiamo gia quasi intero e con 3 cose
che loro vendono (popup/forms/overlay) e una (theme-toggle) che non hanno. Il differenziale
vincevole resta il layer 3: CSS per pagina, zero JS, misurabile.

## Non fare

Clone marketing-suite/A-B/maintenance; PayPal/Stripe buttons; text-path; qualsiasi cosa che
reintroduca JS o CSS inline by-default (regole 2/4).
98 changes: 98 additions & 0 deletions e2e/patterns.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
import { execFileSync } from 'node:child_process';
import { expect, test } from '@playwright/test';

import { builderPreviewFrame, loginToBuilder } from './helpers/builder';

/*
* Reusable section patterns (matrix item 4, v1 self-hosted): insert a bundled
* pattern from the sidebar, persist it, and see it render on the frontend;
* save the current page as a user pattern, see it after a reload, delete it.
*/

const SITE = process.env.BLOCKY_E2E_SITE ?? 'http://192.168.1.8:8888';
const CONTAINER = process.env.BLOCKY_E2E_CONTAINER ?? 'local-wp-wp-1';

function wp(args: string[]): string {
return execFileSync('docker', ['exec', CONTAINER, 'wp', ...args, '--allow-root'], {
encoding: 'utf-8',
}).trim();
}

let postId = 0;

test.beforeAll(() => {
wp(['option', 'delete', 'blocky_user_patterns']);
postId = Number(
wp([
'post',
'create',
'--post_title=Patterns E2E',
'--post_type=page',
'--post_status=publish',
'--porcelain',
])
);
});

test.afterAll(() => {
if (postId) wp(['post', 'delete', String(postId), '--force']);
wp(['option', 'delete', 'blocky_user_patterns']);
});

function toolbar(page: import('@playwright/test').Page) {
return page.locator('header');
}
function panel(page: import('@playwright/test').Page) {
return page.locator('aside');
}

test.describe('Section patterns', () => {
test('insert a bundled pattern, save, frontend renders it', async ({ page }) => {
await loginToBuilder(page, { postId });
await page.getByRole('button', { name: /^Patterns$/i }).click();
await panel(page).getByRole('button', { name: 'Feature grid' }).click();

const frame = builderPreviewFrame(page);
await expect(frame.getByText('Why teams switch')).toBeVisible();
await expect(frame.locator('svg.lucide-zap').first()).toBeVisible();

await expect(toolbar(page).getByRole('button', { name: 'Save', exact: true })).toBeEnabled({
timeout: 5000,
});
const saved = page.waitForResponse(
(r) =>
new URL(r.url()).pathname.endsWith('/documents/' + postId) &&
r.request().method() === 'POST',
{ timeout: 30000 }
);
await toolbar(page).getByRole('button', { name: 'Save', exact: true }).click();
expect((await saved).status()).toBe(200);

await page.goto(SITE + '/?page_id=' + postId, { waitUntil: 'networkidle' });
await expect(page.getByRole('heading', { name: 'Why teams switch' })).toBeVisible();
await expect(page.locator('svg.lucide-zap').first()).toBeVisible();
});

test('save the page as a pattern, survives reload, deletes', async ({ page }) => {
await loginToBuilder(page, { postId });
await page.getByRole('button', { name: /^Patterns$/i }).click();

await panel(page).getByPlaceholder('Name this page as a pattern…').fill('My saved page');
await panel(page).getByRole('button', { name: 'Save', exact: true }).click();
await expect(panel(page).getByText('Pattern saved.')).toBeVisible();
await expect(
panel(page).getByRole('button', { name: 'My saved page', exact: true })
).toBeVisible();

await page.reload({ waitUntil: 'networkidle' });
await page.getByRole('button', { name: /^Patterns$/i }).click();
await expect(
panel(page).getByRole('button', { name: 'My saved page', exact: true })
).toBeVisible();

await panel(page).getByRole('button', { name: 'Delete My saved page' }).click();
await expect(
panel(page).getByRole('button', { name: 'My saved page', exact: true })
).toHaveCount(0);
});
});
99 changes: 99 additions & 0 deletions packages/builder-plugin/src/sidebar/PatternsPanel.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
import { useEffect, useState } from 'preact/hooks';
import type { FunctionComponent } from 'preact';

import { t } from '../i18n';
import { useDocumentStore } from '../store/document';

/*
* Sidebar 'Patterns' panel: insert bundled or saved section patterns at the
* end of the page, and save the current page as a reusable pattern.
*/

export const PatternsPanel: FunctionComponent = () => {
const patterns = useDocumentStore((s) => s.patterns);
const patternsLoaded = useDocumentStore((s) => s.patternsLoaded);
const loadPatterns = useDocumentStore((s) => s.loadPatterns);
const insertPattern = useDocumentStore((s) => s.insertPattern);
const savePattern = useDocumentStore((s) => s.savePattern);
const deletePattern = useDocumentStore((s) => s.deletePattern);
const [title, setTitle] = useState('');
const [savedFlash, setSavedFlash] = useState(false);

useEffect(() => {
if (!patternsLoaded) void loadPatterns();
}, [patternsLoaded, loadPatterns]);

const grouped = new Map<string, typeof patterns>();
patterns.forEach((p) => {
const list = grouped.get(p.category) ?? [];
list.push(p);
grouped.set(p.category, list);
});

const save = async (): Promise<void> => {
const ok = await savePattern(title.trim());
if (ok) {
setTitle('');
setSavedFlash(true);
setTimeout(() => setSavedFlash(false), 2000);
}
};

return (
<div class="flex min-h-0 flex-1 flex-col gap-2 overflow-auto p-2">
<p class="text-[11px] leading-4 text-text-muted">
{t('patterns.hint', 'Insert a ready-made section at the end of the page.')}
</p>
{[...grouped.entries()].map(([category, list]) => (
<div key={category}>
<h3 class="mb-1 text-[10px] font-semibold uppercase tracking-wide text-text-faint">
{category}
</h3>
<div class="flex flex-col gap-1">
{list.map((p) => (
<div key={p.id} class="flex items-center gap-1">
<button
type="button"
class="flex-1 rounded-input border border-border-base px-2 py-1.5 text-left text-xs hover:bg-surface-hover"
onClick={() => insertPattern(p)}
>
{p.title}
</button>
{p.id.startsWith('user-') && (
<button
type="button"
aria-label={'Delete ' + p.title}
class="px-1 text-xs text-text-muted hover:text-feedback-danger"
onClick={() => void deletePattern(p.id)}
>
×
</button>
)}
</div>
))}
</div>
</div>
))}
<div class="mt-auto flex items-center gap-1 border-t border-border-subtle pt-2">
<input
type="text"
class="min-w-0 flex-1 rounded-input border border-border-base bg-surface-base px-2 py-1 text-xs"
placeholder={t('patterns.savePlaceholder', 'Name this page as a pattern…')}
value={title}
onInput={(e) => setTitle((e.target as HTMLInputElement).value)}
/>
<button
type="button"
class="rounded-input bg-accent-base px-2 py-1 text-xs font-medium text-text-on-accent disabled:opacity-40"
disabled={!title.trim()}
onClick={() => void save()}
>
{t('patterns.save', 'Save')}
</button>
</div>
{savedFlash && (
<p class="text-[11px] text-feedback-success">{t('patterns.saved', 'Pattern saved.')}</p>
)}
</div>
);
};
6 changes: 5 additions & 1 deletion packages/builder-plugin/src/sidebar/Sidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import type { BlockPresetDefinition } from '../generated/blockPresetCatalog';
import { ThemePanel } from '../theme/ThemePanel';
import { OverlayPanel } from './OverlayPanel';
import { PagesPanel } from './PagesPanel';
import { PatternsPanel } from './PatternsPanel';
import { TemplatesPanel } from './TemplatesPanel';
import { t } from '../i18n';

Expand Down Expand Up @@ -122,7 +123,7 @@ export const Sidebar: FunctionComponent = () => {
style={{ width: 'var(--builder-sidebar-width)' }}
>
<div class="grid grid-cols-2 gap-1 border-b border-border-subtle p-2">
{(['blocks', 'pages', 'templates', 'theme'] as const).map((panel) => (
{(['blocks', 'patterns', 'pages', 'templates', 'theme'] as const).map((panel) => (
<button
key={panel}
type="button"
Expand All @@ -137,6 +138,7 @@ export const Sidebar: FunctionComponent = () => {
{
{
blocks: t('sidebar.blocks', 'Blocks'),
patterns: t('sidebar.patterns', 'Patterns'),
pages: t('sidebar.pages', 'Pages'),
templates: t('sidebar.templates', 'Templates'),
theme: t('sidebar.theme', 'Theme'),
Expand All @@ -148,6 +150,8 @@ export const Sidebar: FunctionComponent = () => {

{activePanel === 'pages' && <PagesPanel />}

{activePanel === 'patterns' && <PatternsPanel />}

{activePanel === 'templates' && <TemplatesPanel />}

{activePanel === 'theme' && <ThemePanel />}
Expand Down
Loading
Loading