diff --git a/docs/research/12-elementor-parity-matrix.md b/docs/research/12-elementor-parity-matrix.md index 46d1d87..65c455e 100644 --- a/docs/research/12-elementor-parity-matrix.md +++ b/docs/research/12-elementor-parity-matrix.md @@ -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. @@ -59,10 +70,18 @@ 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 @@ -70,5 +89,6 @@ che loro vendono (popup/forms/overlay) e una (theme-toggle) che non hanno. Il di 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). diff --git a/e2e/patterns.spec.ts b/e2e/patterns.spec.ts new file mode 100644 index 0000000..6e2a43a --- /dev/null +++ b/e2e/patterns.spec.ts @@ -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); + }); +}); diff --git a/packages/builder-plugin/src/sidebar/PatternsPanel.tsx b/packages/builder-plugin/src/sidebar/PatternsPanel.tsx new file mode 100644 index 0000000..58d44e1 --- /dev/null +++ b/packages/builder-plugin/src/sidebar/PatternsPanel.tsx @@ -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(); + patterns.forEach((p) => { + const list = grouped.get(p.category) ?? []; + list.push(p); + grouped.set(p.category, list); + }); + + const save = async (): Promise => { + const ok = await savePattern(title.trim()); + if (ok) { + setTitle(''); + setSavedFlash(true); + setTimeout(() => setSavedFlash(false), 2000); + } + }; + + return ( +
+

+ {t('patterns.hint', 'Insert a ready-made section at the end of the page.')} +

+ {[...grouped.entries()].map(([category, list]) => ( +
+

+ {category} +

+
+ {list.map((p) => ( +
+ + {p.id.startsWith('user-') && ( + + )} +
+ ))} +
+
+ ))} +
+ setTitle((e.target as HTMLInputElement).value)} + /> + +
+ {savedFlash && ( +

{t('patterns.saved', 'Pattern saved.')}

+ )} +
+ ); +}; diff --git a/packages/builder-plugin/src/sidebar/Sidebar.tsx b/packages/builder-plugin/src/sidebar/Sidebar.tsx index b13aed2..f02c2a5 100644 --- a/packages/builder-plugin/src/sidebar/Sidebar.tsx +++ b/packages/builder-plugin/src/sidebar/Sidebar.tsx @@ -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'; @@ -122,7 +123,7 @@ export const Sidebar: FunctionComponent = () => { style={{ width: 'var(--builder-sidebar-width)' }} >
- {(['blocks', 'pages', 'templates', 'theme'] as const).map((panel) => ( + {(['blocks', 'patterns', 'pages', 'templates', 'theme'] as const).map((panel) => (