Skip to content

Document the design system, and fix what rendering it exposed - #23

Merged
Humanaice merged 13 commits into
mainfrom
issue-22
Sep 3, 2026
Merged

Document the design system, and fix what rendering it exposed#23
Humanaice merged 13 commits into
mainfrom
issue-22

Conversation

@ericof

@ericof ericof commented Sep 2, 2026

Copy link
Copy Markdown
Member

Prepares the repository to be read as a design system — by a person, and by tooling like Claude Design.

The work started as documentation. Writing it honestly meant rendering things, and rendering them turned up a set of defects that had been invisible precisely because nothing rendered them. Roughly half of this PR is those fixes.

What this changes

Storybook goes from 13 stories to 103, and from a handful of isolated components to a description of the whole system: every block an editor can reach, each listing variation, the navigation and fat menu, and complete pages for a Document, a News Item and an Event.

Every user-facing string is translatable. All of them were Portuguese defaultMessages with no catalogue behind them — and some were not messages at all, just hardcoded strings in aria-labels and block titles. There are now 147 extracted messages with pt_BR complete.

The design system is documented under docs/design-system/, from measurement rather than assumption.

The defects this surfaced

  • Keyboard focus was invisible on listing cards. createThemeDefinition maps 14 --theme-* properties; _root.scss defined 10. A var() with no definition and no fallback is invalid at computed-value time, so six outline declarations in _listing.scss were dropped entirely rather than degrading. Now fixed, and guarded by a test that fails if the contract breaks again.
  • documentByline presented itself as a weather block — its schema title read "Previsão do Tempo", contradicting its own registration title, and its exported interface declared location and measure.
  • heroBlock.fullWidth was unreachable. Both views branch on it, but it appeared in no fieldset, so no editor could set it.
  • Two listing variations formatted every date as pt-BR regardless of the active language.
  • Five blocks threw at render in the gallery once one existed to show them.

The pattern worth reviewing

Four times, a component looked unstyled in Storybook and was in fact fine — the story simply rendered it outside the DOM context its stylesheet is scoped to. _listing.scss needs body .block.listing; _navigation.scss needs #navigation.navigation.scNavigation; a themed block needs a wrapper supplying --theme-*.

The accessibility controls are the instructive counter-example: their selectors are unscoped, so their rules did apply, and they still looked wrong — the buttons are drawn as light pills for the dark header bar, and the story put them on a hardcoded grey. The CSS was never the problem; the ground was.

This is recorded in DESIGN_SYSTEM.md §7 as a rule for writing stories, because it will happen again.

What is deliberately not fixed

GAPS.md §7 lists fourteen decisions that need a person rather than a patch. Two are worth flagging here:

  • The shipped palette fails WCAG AA twice, both #f4822c on a near-white ground — accent text on elevated cards in the default theme and default colour mode, and the footer accent in dark mode. The footer one is fixable today with an existing token; the other needs a third brand shade that does not exist. Neither was applied, because both change shipped brand appearance and both depend on whether #f4822c is a deliberate default at all.
  • A site theme pins six tokens to one value in both colour modes, because the control panel serves one value per field and themeStyles.ts writes it flat rather than as a -light/-dark pair.

Known rough edges

  • Story imagery points at picsum.photos — arbitrary third-party photographs with unknown licence, and a network dependency at view time. public/images/ is wired and served, ready for replacements of known provenance.
  • The block preview pages under docs/design-system/previews/ were generated from a Storybook capture whose pipeline is not in the repo, so they cannot currently be regenerated. Either the generator gets committed or those four files should go; the foundation pages are hand-authored from the stylesheets and do not have this problem.
  • Eleven blocks in the gallery still render an empty state for want of sample data. None of them error.

Reviewing this

The six commits are meant to be read in order; each has a message explaining why, not just what. docs/design-system/DESIGN_SYSTEM.md is the best entry point for the system itself, and GAPS.md for what is still open.

Refs #22

createThemeDefinition maps 14 semantic --theme-* properties onto
--block-theme-{name}-* tokens, but _root.scss defined only 10 of them for
the default theme and 9 for brand. A var() with no definition and no
fallback is invalid at computed-value time, so the whole declaration is
dropped rather than degrading — which took out six focus outlines in
_listing.scss, leaving keyboard focus invisible on listing cards.

Measured after the fix: focus on a carousel slide now paints
"solid 2px rgb(0,0,0)" in light and "solid 2px rgb(255,255,255)" in dark,
with none when unfocused.

Also corrects a --theme-low-contrast-foreground reference in
_heroBlock.scss that was missing its -color suffix, and gives the brand
theme a border-width outside high-contrast mode.

Adds a test guarding the contract, because nothing else can catch this:
there is no typecheck in CI, stylelint does not resolve custom properties
across files, and a story renders happily without an outline. It reads the
:root block specifically — a token declared only under
[data-theme='high-contrast'] is still undefined in light and dark, which is
exactly what brand's border-width was. Verified by reintroducing both
original defects and confirming each one fails it.

Refs #22
Every string in the add-on was Portuguese, supplied as a defaultMessage
with no catalogue behind it — all four .po files were empty boilerplate.
Some were not even messages: listing variation titles, block titles and
sixteen carousel aria-labels were plain strings, and useHeroBlockContent
had no intl at all.

All of it now goes through react-intl with stable ids and English default
messages. Block and listing-variation titles are registered for extraction
in src/index.ts, because Volto translates those with
formatMessageWithFallback, which uses the string itself as the message id —
without that registration they never reach the catalogue.

pnpm i18n extracts 147 messages. pt_BR is complete and validates with
msgfmt --check; es and de stay empty, since inventing translations is not
mine to do.

Three defects surfaced while doing this and are fixed here:

- documentByline presented itself as a weather block. Its schema title read
  "Previsão do Tempo", contradicting its registration title, and its
  exported interface declared location and measure instead of the three
  fields the schema has. Its message ids were the Portuguese sentences
  themselves, which breaks extraction. Its boolean fields were also marked
  required, one of them defaulting to false, making the block unsaveable in
  a strict form.

- heroBlock.fullWidth was unreachable. Both views branch on it to render
  full-bleed when there is no image, but it appeared in no fieldset, so no
  editor could set it. It is now in the Layout fieldset.

- The carousel and highlight listing variations formatted dates as pt-BR
  regardless of the active language. Both now use the locale from
  react-intl. Neither had an accent, which is why a grep for Portuguese
  missed them and the message extraction found them.

Sample copy in the stories is now generic English; the named third parties
it previously carried were removed.

Refs #22
Stories rendered block views bare, so --theme-* was undefined in every one
of them and Storybook never exercised the block theme system at all.
HeroBlock alone carries 30 references to it, none of which resolved. That
is how the missing focus outlines went unnoticed.

The decorator adds three toolbar globals — colour mode (light, dark, high
contrast), block theme (Primary, Brand) and site theme, seeded from the
ISCVLTThemeDefinition fixtures the control panel stories already use. It
reuses createThemeDefinition rather than restating the 14-property mapping,
so a theme added to the block config needs no change here.

The colour mode has to be applied in two places, which is worth recording.
Upstream registers six tokens with @Property { syntax: '<color>' }. A
registered custom property is computed eagerly at the element that declares
it, so its light-dark() resolves once at :root and inherits as a flat
colour; an unregistered one resolves wherever it is used. Storybook's
preview resets color-scheme below <html>, so setting the mode on the root
alone leaves the unregistered half stuck, and setting it on a wrapper alone
leaves the registered half stuck. Setting both fixes all fourteen probed
tokens, matching an isolated render of _root.scss with no Volto present.

Every story is also constrained to --default-container-width and centred,
so components are seen at the measure they occupy on a page. Stories that
genuinely span the viewport opt out with parameters: { fullBleed: true }.

Storybook now serves the add-on's public folder at its root, so a story can
reference a bundled asset as /images/example.jpg rather than depending on a
remote image host at view time. public/images/README.md records the four
places a fixture names its image source.

Refs #22
Covers the four listing variations and the six navigation components,
which between them had no stories at all — the largest gaps in the
package. The listing fixtures are shaped like a querystring-search
response; the navigation ones already existed and had no importer.

Getting them to render turned up the same failure three times, and it is
worth stating as a pattern: a component's stylesheet is scoped to an
ancestor that only exists on a page, so a story rendering it bare gets none
of its styling — and the symptom always looks like missing CSS rather than
a missing wrapper.

- _listing.scss is scoped to "body .block.listing" and then to each
  variation, so the templates render inside "block listing <variation>".
  This is what makes the restored focus outlines observable.
- _navigation.scss is scoped to "#navigation.navigation" and
  "&.scNavigation". The existing decorator supplied the id and the first
  class but not the second, so nothing matched: the fat menu rendered its
  content but .submenu-items never became the two-column grid it declares
  and the links kept the browser's default colour.
- The fat menu panel is absolutely positioned against the item that
  triggers it, so it needs the .desktop-menu list and an item with width.
  Its open state is signalled by an "active" class, which two decorators
  had spelled differently.

The accessibility controls are the instructive counter-example. Their
selectors are unscoped, so their rules did apply — and they still looked
unstyled, because the buttons are drawn as light pills for the dark header
bar and the story put them on a hardcoded pale background. The hardcoded
greys in the header bar, accessibility and dropdown stories are now theme
tokens, so they follow the colour mode.

Two Portuguese labels left in the header bar mocks are translated; they
were story data rather than messages, so neither the extraction nor a grep
for accents had found them.

Refs #22
A site running this theme gives an editor 42 blocks; the add-on ships four
of them and extends two. Nothing showed the other 36, which this theme
restyles and is therefore very much its concern.

Rather than 36 hand-written story files that would rot against upstream,
two stories carry it:

- Blocks/Inventory reads config.blocks.blocksConfig at runtime, so it lists
  what an editor actually has and cannot drift — a block added by a
  dependency bump appears on its own, labelled by which layer supplies it.
- Blocks/Gallery renders each of them through RenderBlocks with sample
  data, so the whole vocabulary can be seen at once.

All 42 render without error. Five had to be fixed to get there: accordion
needs data.data with a nested blocks_layout, slateTable needs
data.table.rows, and highlight reads state.content.subrequests keyed by
block id — all three threw on undefined. search and eventCalendar dispatch
thunks on mount, which a plain mock store rejects, so the gallery uses
Volto's real-store wrapper.

Worth knowing when auditing this: RenderBlocks has its own per-block error
boundary and it is the inner one, so a failing block reports "Block error:
..." rather than reaching any boundary the gallery adds. A check that greps
for the outer message finds nothing and also counts the failure as rendered
content, because Volto's message is long enough to pass a length test. That
combination reported a clean run while five blocks were broken.

Sample data for the social embeds is taken from each block's own story in
@kitconcept/volto-social-blocks rather than invented — those are the URLs
the block authors test against.

Also adds page-level views through Volto's DefaultView (a Document, a News
Item and an Event, the last built from the initial blocks upstream defines
for the type), gridBlock at one to four columns, and the separator block's
three controls.

Stories are split by ownership: a story for a component this package ships
sits beside the component, and a story for a component from a dependency
lives in src/storybook next to the decorator and the shared fixtures.

Refs #22
Three documents under docs/design-system, with a short pointer at the
repository root.

DESIGN_SYSTEM.md is the manifest. It opens with the three layers that stack
here — Volto core, upstream volto-light-theme, this add-on, and a project's
overlay — because nearly everything confusing about the theme comes from
not knowing which layer you are looking at. It documents what the add-on
owns and points at the runtime inventory for what it inherits, rather than
repeating a list that goes stale. It also records two constraints that cost
real time to find: the six tokens upstream registers with @Property, which
cannot re-resolve on a descendant, and the ancestor chains each stylesheet
is scoped to.

INVENTORY.md carries the exhaustive tables — block schemas, listing
variations, every custom property with its resolved light and dark value,
the type scale, and where a story lives.

GAPS.md carries what is still open: a WCAG contrast audit of the shipped
palette, the defects not yet fixed, the coverage not yet built, and
fourteen decisions that need a human rather than a patch. Resolved items
are deliberately not kept there — the history is in the changelog.

Every claim was checked against source or measured in a browser. Two
earlier ones were wrong and are corrected here: the scalar typography
overrides are exact px-to-rem conversions of upstream's !default values
rather than duplicates, and --custom-main-font is read twice by upstream
even though nothing in this package uses it.

previews/ holds self-contained pages marked up for import into Claude
Design. The foundation pages are generated from the stylesheets; the block
pages were captured from a Storybook build whose pipeline is not in the
repo, so they cannot currently be regenerated — that is recorded in GAPS
§5.1 as something to resolve either way.

Refs #22
@ericof
ericof requested a review from Humanaice September 2, 2026 14:47

@Humanaice Humanaice left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGMT

…ypes

The three content-type tokens read `--event-color-override` and friends, which
nothing writes: a theme's `event_color_light` setting reaches the page as
`--event-color-light`. The control panel's new content-type fields therefore
saved values that changed nothing.

They now read the properties a theme sets, through `light-dark()` like every
other colour, so they follow the colour mode as well.

The tests checked that a field derives a custom property, but not that any
stylesheet reads it — which is exactly the half that was wrong. `themeStyles`
now checks both ends: that the pinned field list still matches the backend
schema, and that `_root.scss` reads every property the mapping derives. The
pinned list was itself stale, claiming to hold every colour field while listing
20 of 38.

Both guards were mutation-tested: restoring the old property name and dropping a
field from the list each turn the suite red.

Refs #22
Both WCAG AA failures are fixed, so §4.4 records what fixed them and why
`--brand-color-darker` had to exist rather than listing them as open. The
per-project overlay section described seven flat fields; the schema now serves
38 as `_light`/`_dark` pairs, which is what keeps a themed site in both colour
modes. The base ramp is ten values, and `2xs` has a line-height.

GAPS.md was renumbered when its resolved sections were removed, so every
cross-reference in DESIGN_SYSTEM.md, INVENTORY.md and the preview pages is
re-pointed. The identical `narrow` and `default` container widths came back as
an open gap: they were removed while still true.

The preview pages carried the old failing swatches and their "FAILS AA" flags,
and six of them cited GAPS sections that never matched — the block pages pointed
at §7 for the two-place colour-scheme trick, which lives in DESIGN_SYSTEM §4.3.
`block-themes.html` also had a footer sentence left half-rewritten.

Also fixes a broken table in DESIGN_SYSTEM §7, where the `_navigation.scss` row
sat below a blank line and rendered as its own one-row table.

Refs #22
Volto is a webpack application, not a publishable component package, and the
design-system converter bundles with esbuild. Five things bridge that gap; none
of them forks the converter, and `.design-sync/NOTES.md` explains each one with
the evidence behind it.

`build-overlay.py` builds a synthetic node_modules encoding Volto's alias scheme
— `webpack-relative-resolver` rewrites `@plone/volto/X` to `@plone/volto/src/X`
at build time, which plain node resolution cannot do. That alone took the build
from 121 unresolved imports to none.

`build-icons.py` generates a JS twin per SVG carrying the `{attributes, content}`
shape Volto's `Icon` reads, because esbuild loads `.svg` as a data URL and `Icon`
then renders an empty `<svg>`. `build-css.sh` compiles a standalone stylesheet,
since the add-on ships SCSS that webpack injects at runtime.

`src/storybook/` gains the runtime context a standalone bundle lacks: a curated
component surface (the package's only export is `applyConfig`), razzle's
build-time globals, a seeded config registry, and the provider chain the
storybook decorators supply. `createThemeDefinition` moves to `config/blockThemes`
— re-exported from `config/blocks`, so every import is unchanged — because
importing it from there registers every block and drags in the slate editor.

The icons in `ThemeToggle` and `HoverReaderControls` are imported through the
package name rather than a relative path, the form `.storybook/preview.jsx`
already used: a relative import cannot be rerouted by the overlay.

`packages/*/dist` is ignored by prettier and eslint — it is build output, and the
lint globs matched it once something finally emitted there.

Refs #22
npm ignores `.gitignore` entirely once an `.npmignore` exists, and this one
listed a single file. Everything gitignored was therefore being packed —
including the `dist/` declarations and root `index.d.ts` the design-system
export emits, 155 files that exist only to feed the converter.

Stories, tests and their fixture assets go too: they are consumed by Storybook
and by the export, never by a project installing this package.

404 files (1242 KB) -> 192 files (509 KB).

Refs #22
There was no `_documentByLine.scss` anywhere in `src/theme/`, so the block
started flush against the viewport edge on a page while every other block sat in
the centred content column. In isolation it looked fine, because a story gives it
a padded container.

The three fields now render inline at the small type step, separated by a dash,
with author names comma-separated. The byline also reads author first, then
published, then modified, and its rows are paragraphs rather than nested
`Container` elements.

Closes GAPS §2.1.

Refs #22
The fixtures pointed at `picsum.photos`: arbitrary third-party photographs with
no licence trail, fetched over the network every time a story rendered. Fourteen
photographs of Brasilia now ship in `src/storybook/images/`, imported rather than
linked — webpack inlines them for Storybook, esbuild inlines them for the Claude
Design export, so stories render offline and identically every time.

`imageScales()` shapes an imported URL the way Plone's REST API shapes an image
field, splitting it across `base_path` and `download` as Volto's `Image` expects.
It declares an empty `scales` map, so nothing advertises widths that no file
behind it actually has — the previous fixtures claimed 1200x800 with 3:2 scales
while the theme renders every card at 16:9.

`optimize.sh` holds the set under a weight budget: these inline as base64 in the
export, where a megabyte on disk is closer to 1.4 in a preview bundle. 2702 KB ->
1759 KB, with the sRGB profiles preserved.

Story-support modules are excluded from the package alongside the photographs
they import, and the `README.md` ignore rule is scoped to the package root — a
bare rule was hiding `src/storybook/README.md`, which `DESIGN_SYSTEM.md` links
to, from git entirely.

Refs #22

@Humanaice Humanaice left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM

@Humanaice
Humanaice marked this pull request as ready for review September 3, 2026 22:13
@Humanaice
Humanaice merged commit 8c0fbed into main Sep 3, 2026
22 checks passed
@Humanaice
Humanaice deleted the issue-22 branch September 3, 2026 22:13
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants