@setsquare/ui-blocks
v0.2.0
Published
A page builder for @setsquare/ui on Puck: pages made from blocks, with every task by drag, by press and by keyboard, and accessibility checks that run on the rendered page.
Maintainers
Readme
@setsquare/ui-blocks
A page builder for @setsquare/ui: people make web pages from blocks. Puck 0.23 holds the page data, the canvas and the undo history. Setsquare UI draws everything a person operates: the header, the block library, the outline, the settings, the block toolbar and the checks. Every task works three ways:
- by dragging with a pointer;
- by pressing, with no drag;
- from the keyboard, with a screen reader.
It aims at WCAG 2.2 AAA for the editor, and at ATAG 2.0 for helping authors make accessible pages. Nothing in it has been tested with a screen reader yet, and automated checks are evidence, not conformance.
0.1.1. The first release was 0.1.0; 0.1.1 fixes a hydration error when the server and the browser are on different platforms (see the changelog). It has the editor (
ContentBuilder, and each of its surfaces for a layout of your own), the blocks API (defineBlock,defineBlocks,fields), the checks (in the editor, andrunChecksfor the server), the help tour, sessions, and./renderfor the published page. The design is in handoff/ui-blocks/spec/content-builder-spec.md, and every place this release departs from it is in PLAN.md.
It's a separate package because Puck is large. Puck 0.23's editor, with the Tiptap and dnd-kit it brings, comes to about 353 KB gzipped, more than everything @setsquare/ui exports (about 254 KB, React Aria included). Both were measured on 8 October 2026 with esbuild, minified, React left out, and gzip -9. A product that doesn't build pages shouldn't carry it.
Install
pnpm add @setsquare/ui-blocks @setsquare/ui @puckeditor/core react-aria-componentsIt needs @setsquare/ui 0.5 or later, Puck 0.23, React 19.2 or later, and Tailwind CSS 4 in the app. In the app's global stylesheet, after @setsquare/ui's:
@import "tailwindcss";
@import "@setsquare/ui/styles.css";
@import "@setsquare/ui-blocks/styles.css";styles.css brings in Puck's stylesheet as a file (its no-external.css, which fetches no font from another site). Loaded that way, Puck doesn't inject its own at runtime and keep the editor hidden until it loads, so a strict Content-Security-Policy can't leave the editor invisible. It also has an @source that points Tailwind at the package's compiled components.
Puck is pinned to one minor version (~0.23.0). Its API is pre-1.0, and the builder relies on parts of it that a minor release could change. A contract test covers each of them.
Three entries
| Import | What it is | Where it runs |
| --- | --- | --- |
| @setsquare/ui-blocks | The editor and its parts, and the blocks API | Client components; the blocks API in server components too |
| @setsquare/ui-blocks/render | Render and resolveAllData for the published page, over @puckeditor/core/rsc | Server components |
| @setsquare/ui-blocks/checks | runChecks(html, { rules }), the same rules the editor runs | The browser and Node, with no React |
Blocks
import { defineBlock, defineBlocks, fields } from '@setsquare/ui-blocks';
const Hero = defineBlock({
label: 'Hero',
description: 'The page’s main heading, at the top.',
category: 'text',
evidence: { level: 'AAA', href: '/evidence/hero' },
fields: { heading: fields.heading({ label: 'Heading', levels: [1, 2] }) },
defaultProps: { heading: { text: 'Welcome', level: 1 } },
render: ({ heading }) => <Heading {...heading} />,
});
export const blocks = defineBlocks({
categories: { text: 'Text', layout: 'Layout and media' },
blocks: { Hero, Text, Image, Columns, CallToAction },
root: { fields: { title: fields.text({ label: 'Page title' }), language: fields.language({ label: 'Language' }) } },
headingBlock: 'Hero',
});- A block is a Puck component config with a few Setsquare keys:
descriptionandiconfor the library,categoryfor its group,summaryfor a few words of its text in the outline,evidencefor the level it's been tested at, andchecksfor its own rules. Anything Puck takes still works:resolveData,resolveFields,defaultProps,inline, slots. fieldsare Puck fields that draw Setsquare UI controls in Settings, each with its label, hint and checks:text,textarea,number,select,multiSelect,radio,switch,heading(only the levels the block allows, saying which block has Heading 1),image(alt text or Decorative, through your media library),link(its words checked),richText(only the headings the block allows; pasting from Word and Google Docs keeps headings, lists and links and says what it left out),language,colour(its contrast checked),list,slot,externalandcustom.defineBlocksgroups the blocks for the library and namesheadingBlock, the block Checks can add when a page has no Heading 1. Nothing the builder does inserts placeholder text.blockRootputs a block's own element, such as anli, where Puck's wrapper would go, so a slot of blocks can be aulofli.
ContentBuilder
'use client';
import { ContentBuilder } from '@setsquare/ui-blocks';
<ContentBuilder
config={blocks}
data={page}
breadcrumbs={[{ label: 'Pages', href: '/pages' }]}
preview={{ href: `/preview/${page.id}` }}
canvas={{ stylesheets: ['/site/theme.css'] }}
onChange={saveDraft}
onPublish={publish}
/>- Its own width. It lays itself out by its own width, not the window's. From 1280 px: Outline and Blocks in one panel, the canvas, and Settings, side by side. From 1024 px a rail opens Outline or Blocks beside the canvas; from 768 px, one panel at a time, Settings included; below that, one view at a time. Panels push the canvas, never cover it.
- Three ways to do everything. Add a block by dragging it, by pressing it in the library, or with Enter. Move one by dragging, with the block toolbar or the row's Actions menu, with Move to… (no drag at all), with Alt+Shift+↑ and ↓, or by a keyboard drag from the row's drag button.
- Focus and words. After every change, focus goes somewhere named, never to the page, and one live region says what happened and where ("Image moved into Left column, 2 of 2"). F6 moves between the header, the page structure, the canvas and Settings, from inside the canvas too.
- The canvas is the page at its real size, in your site's styles (
canvas.stylesheetsandcanvas.attributes), never scaled. In Edit its links and buttons are inert and its media still; Interact makes the page work. - Checks run as the page is edited, on the page as it renders. Each finding says what, where and how to fix it, often in one press, and Undo takes a fix back. While anything is under Must fix, Publish is locked: still focusable, with the reason in its name.
rulessets each rule's severity, or turns it off; Checks still lists a rule that's off, with what that risks, andonRulesChangelets an author turn it back on. - Publishing opens a review of the changes since the last publish.
onPublishgets the page and its report, and only when nothing is under Must fix. Check again on the server (below). - Saving.
onChangeis called with the page after every change. Return a promise and the header shows Saving until it settles. - Sessions.
session={{ key: page.id, expiresAt, extend, onExpired }}warns at least two minutes before the session ends, with a single Stay signed in. Withkey, the page, the selection and the history are kept in this browser for at least 21 hours, and come back after signing in again. - Help has the tour, the keyboard shortcuts (people can change them;
shortcutsandonShortcutsChangekeep theirs), what the checks look for, and "Words used here" (add your own withglossary). The tour opens by itself for a person who hasn't seen it when you passtour: see the help tour. - Words. Every word it says comes from
labels, with{name}placeholders, over English defaults (defaultLabels). - Puck.
puckpassesplugins,permissionsandviewportsto Puck. The rest is the builder's: see Using it with Puck. - On a phone, narrow and touch, a notice asks for a bigger screen and leads into outline view. Width alone never shows it, so a laptop at 400% zoom goes straight to the outline.
A layout of your own
ContentBuilder's children replace its layout. Build one from its surfaces: BuilderHeader, OutlineTree, OutlineSearch, BlockLibrary, BuilderCanvas and Inspector, and place the dialogs the outline, the toolbar and Help open: MoveToDialog, ChecksDialog, ShortcutsDialog and GlossaryDialog. useBuilder() gives the page, the selection and every command (insert, move, duplicate, remove, undo, redo), each named in the history, announced, and saying where focus goes. useBuilderShell() gives the layout's state: which panel is open, the dialogs, the checks.
Checks on the server
The lock in the editor is only as strong as the server behind it. runChecks runs the same rules on the HTML your publish endpoint is about to serve, so the endpoint can refuse a page with must-fix issues even when a request skips the editor:
import { renderToStaticMarkup } from 'react-dom/server';
import { Render } from '@setsquare/ui-blocks/render';
import { runChecks } from '@setsquare/ui-blocks/checks';
const html = renderToStaticMarkup(<Render config={blocks} data={page} />);
const report = runChecks(html, { title: page.root.props.title, rules });
if (report.must > 0) return Response.json(report, { status: 422 });builtInRules lists the rules and their default severities, each with what it rests on: a WCAG criterion, or a house rule where WCAG advises rather than requires. defineRule writes a block's own, with its fixes.
The published page
import { Render, resolveAllData } from '@setsquare/ui-blocks/render';
const data = await resolveAllData(page, blocks, metadata);
return <Render config={blocks} data={data} metadata={metadata} />;Server-safe: nothing in ./render is a client module. Render leaves out the editor's own props, so nothing the builder keeps on a block reaches the page.
What's checked
"Checked" here means a check in this repository asserts it. Automated checks aren't conformance, and nothing has been tested with a real screen reader yet: a script for people to follow is ready for that.
Every task, three ways (the blocks browser suites). Insert, select, move up and down, into and out of a column with Move to… and with a keyboard drag, duplicate, delete, undo, fix and publish:
- from the keyboard alone, checking the page's data and where focus is after every step;
- with clicks alone, with a guard that fails if the pointer moves while a button is held;
- by dragging blocks on the canvas, and from the library onto it.
Still presses of 150 to 1000 ms with a mouse, a finger and a pen add a block and never start a drag.
Each suite runs eight times: AA and AAA, light and dark, and two brands.
Words and focus. Every announcement is compared with the expected words, in English and with French labels. Shortcuts are run with US, German and French keyboard layouts' keys. Focus appearance (2.4.13 at AAA), focus not obscured (2.4.11 and 2.4.12) with a toast and each panel open, forced colours, 1.4.12's text spacing, and zoom at 200% and 400%.
Part B. The H1 rule catches a hard-coded
<h1>, one in rich text and a header that makes the site's name one. A file name is never offered as alt text. Pasting from Word and Google Docs keeps what it should. The demo's blocks pass axe at the level they claim, once Checks' fixes are applied. The editor's checks andrunChecksagree, and a publish endpoint built as above, in a real Next.js app, refuses must-fix issues.Puck. A contract test for each Puck behaviour the builder relies on or works around, in Node and in the browser, so an upgrade that changes one fails first.
The rest of the repository's guards reach this package: the lint rules (
client-directive, andno-builder-surface-overridefor products), the layers, positioning and axe suites on every builder card, the docs site's page suite, andpackage:verify, which installs the packed package into a fresh Next.js app.
Licence
Apache-2.0. Puck (@puckeditor/core) is MIT-licensed and is installed alongside this package, not bundled into it, with the Tiptap, dnd-kit and Radix packages it depends on. parse5 (MIT), for reading HTML in ./checks, and lucide-react (ISC), for icons, are dependencies.
