Skip to content

Ship our own behaviors and named themes - #21

Merged
Humanaice merged 23 commits into
mainfrom
issue-20
Aug 24, 2026
Merged

Ship our own behaviors and named themes#21
Humanaice merged 23 commits into
mainfrom
issue-20

Conversation

@ericof

@ericof ericof commented Aug 20, 2026

Copy link
Copy Markdown
Member

Summary

Ships our own header, footer and theme-selector behaviors, replacing the ones
inherited from kitconcept.voltolighttheme, and introduces named themes:
managers define them in a new Themes control panel, editors pick one per site or
per section, and the frontend applies them as CSS custom properties.

Closes #20

Backend

A Color field storable in the registry

  • fields/color.py — a Color schema field validating #rgb / #rrggbb.
  • fields/persistence.pyPersistentColor plus an IPersistentField adapter
    registered on IColor.
    plone.registry resolves persistent fields by class name
    (getattr(plone.registry.field, type(field).__name__, None)), so a custom
    field with no persistent equivalent makes registerInterface raise
    TypeError — without this adapter the add-on does not install at all.

Named themes in the registry

  • interfaces.pyISCVLTThemeDefinition: id, title, plus the theme
    settings. theme_settings is derived by excluding the metadata fields, so
    future non-colour variables need no code change.
  • profiles/default/registry/sc.voltolighttheme.themes.xml — the default theme.
  • utils/themes.py — CRUD over the theme records, including the duplicate
    helper and the guard refusing deletion of the default theme.
  • vocabularies/themes.pysc.voltolighttheme.themes.

@controlpanels/themes

  • controlpanels/themes.py — a collection-shaped control panel.
    plone.restapi already routes sub-paths into add / get / update /
    delete, so no custom service was needed — only its own ISerializeToJson
    (serializers/controlpanels.py), since the default reads a single prefix
    through registry.forInterface.
  • permissions.zcml + rolemap.xml — a dedicated permission for managing themes.

Behaviors

  • behaviors/header.py, behaviors/footer.py — our own site-header,
    intranet-header and footer behaviors.
  • behaviors/theme.pysc.voltolighttheme.themeselector, enabled on
    Plone Site so nothing is ever left without a theme, and available on any type
    for per-section overrides.
  • serializers/fields.py — the theme field serializer resolves the selected
    theme's settings on read, so a consumer gets the resolved values rather than
    just a token.

Note on subclassed behaviors: plone.autoform merges same-named fieldsets by
appending
, so a child that re-lists inherited fields makes z3c.form raise
ValueError("Duplicate name", …) — surfacing as a 500 on @types/<type>.
Child fieldsets list only their own fields and use order_after to restore the
intended order.

Frontend

  • inherit action / reducer / types — the values read from the @inherit
    expander now live in their own store slice. Volto resets content.data before
    re-fetching, which was blanking every inherited value and causing a visible
    flicker in the header and footer when leaving an edit form.
  • Theming.tsx — applies the resolved theme by overriding the colour custom
    properties for the whole page.
  • useLiveData.ts — maps behavior data to live values, so unsaved edits are
    previewed while the form is open.
  • Controlpanels/Themes/Themes.tsx — the control panel UI: add, edit,
    duplicate from an existing theme, delete. The default theme is editable like
    any other; only its deletion is refused.
  • helpers/themeStyles.ts, helpers/themesControlpanel.ts — the resolution
    and control-panel helpers, both unit-tested.
  • types/theme.ts — the block styling themes are typed.
  • _root.scss bridges --header-foreground-color via
    var(--header-foreground-color, var(--primary-foreground-color)), because
    --header-foreground is read by upstream VLT and could not be renamed with
    the field. The bridge is also order-independent, unlike a direct override.

Renames

  • header_foregroundheader_foreground_color (regular naming).
  • theme_colorstheme_settings.

No upgrade step: no site runs this yet.

Tests

  • Backend: 187 passing. New suites for the field, the persistence adapter,
    the control panel, the serializers, the vocabulary and the theme utils.
    The suite was reorganised to mirror the package layout.
  • Frontend: 223 passing. New suites for the inherit reducer, useLiveData,
    themeStyles, themesControlpanel and config/settings.
  • All linters clean.

Not verified

None of this has run against a live stack. The control panel UI also has no
rendering tests — the pencil icon, the duplicate action and the toolbar
formatting are verified against Volto's source and at the helper level, but
nothing exercises the rendered component.

Todo

  • Decide what other colors and options should be on the theme definition
    • Space for custom CSS
  • Update default theme colors
  • Create an intranet profile (That will setup the Intranet header behaviour)
  • Add upgrade steps
    • Uninstall kitconcept.voltolighttheme
    • Replace old behaviors with their equivalent versions
  • Unconfigure kitconcept.voltolighttheme behaviors
  • Guard access to theme control panel
  • Style the theme control panel (spacing from navigation)
  • Stories for theme control panel

Review checklist

  • Test usage in upgrade on existing project

ericof added 10 commits August 19, 2026 23:43
Pin the minimum in the test extra and record the override in mx.ini, so a
checkout resolves the version the suite is written against.
Color validates a hex value and reaches Volto as a colorPicker widget. It also
has to survive a round-trip through the registry, which the upcoming named
themes are built on.

plone.registry resolves the persistent equivalent of a field by looking up its
class name in plone.registry.field (see fieldfactory.persistentFieldAdapter).
Color has no entry there, and neither does its NativeStringLine base, so
registerInterface raises TypeError before writing a single record. Ship a
PersistentColor and register an IPersistentField adapter on the narrower IColor
so it wins over the generic one registered on IField.
A theme is a set of records under sc.voltolighttheme.theme.<id>, described by
ISCVLTThemeDefinition. The layout follows plone.app.querystring's operations:
one records block per theme, discovered by walking the registry and filtering
on the prefix rather than through an index.

utils.themes holds the enumeration and CRUD helpers so the vocabulary, the
control panel and the field serializer share one implementation of the prefix
split. theme_settings() returns only the style-bearing fields, defined by
excluding the metadata ones, so a style variable added to the schema is picked
up without touching the module.

Theme ids may not contain a dot, since the id is a segment of the record name
and a dot would corrupt enumeration. The shipped default theme cannot be
deleted, only edited.
Managers list, create, edit, duplicate and delete themes through
@controlpanels/themes. No dedicated service is needed: plone.restapi already
traverses sub-paths of a panel into add/get/update/delete, so a
RegistryConfigletPanel subclass covers every verb.

The panel is a collection rather than a single set of records, which the
default serializer cannot express -- it reads one prefix through
registry.forInterface. ThemesControlpanelSerializeToJson returns the schema
once and the themes as items instead, so the frontend can drive the existing
form machinery, colorPicker widgets included.

Deleting the default theme is refused; editing it is not. A theme still
selected by content can be deleted, since themes are not catalogued and the
reference cannot be checked cheaply -- the field serializer degrades instead.
Replace the kitconcept.voltolighttheme behaviors with sc.voltolighttheme ones,
split by concern: a site header, an intranet header extending it, and a footer.
former_dotted_names keeps the old header name resolvable.

IIntranetHeaderSettings inherits from ISiteHeaderSettings, so its fieldset must
list only the fields it adds. plone.autoform merges same-named fieldsets across
the inheritance chain by appending, and z3c.form rejects a repeated field name
with ValueError("Duplicate name", ...) -- which surfaces as a 500 on
@types/<type>, breaking the edit form for every type using the behavior.
order_after restores the intended field order that re-listing would have given.
sc.voltolighttheme.themeselector carries a single choice field backed by the
themes vocabulary. It is enabled on Plone Site, and may be added to any other
type: the @inherit expander resolves the closest provider up the acquisition
chain, so a section overrides its parent and content is never left without a
theme.

The field serializes to {token, title, value}, which is not decorative. Reading,
normalizeSingleSelectOption resolves the selected option as
value.token ?? value.value ?? value.UID ?? 'no-value', so a payload without
token renders as "No value" however complete it is. Writing, plone.restapi's
ChoiceFieldDeserializer unwraps value["token"] from a mapping, so an untouched
field survives a PATCH of the whole form. The resolved settings ride under
value without displacing that contract.

A theme deleted while still selected keeps its token and gets an empty value,
so a dangling reference degrades to the stylesheet defaults instead of breaking
the content response.

Allow S105 in tests: "token" is a vocabulary term here, not a secret.
useLiveData maps the kitconcept behavior names onto ours, resolved per call so
a header behavior configured after import is honoured, and passing unmapped
names through so a third-party behavior still resolves.

The values it reads used to come only from content.data, which blanks on every
route change out of the CMS UI: protectLoadStart sets resetBeforeFetch whenever
the departing route isCmsUi, protectLoadEnd then dispatches RESET_CONTENT ahead
of GET_CONTENT_PENDING, and UNLOCK_CONTENT_SUCCESS repopulates content.data
without an @components key. For that window the header and footer render empty.

Hold the expander in its own slice instead, modelled on Volto's navigation
reducer: no GET_CONTENT_PENDING case, and a response carrying no inherit key
leaves the previous values in place. A response that does carry it is
authoritative and replaces the slice, since a behavior missing from it has no
provider up the chain.

vitest could not resolve react-redux and friends from this package -- they are
Volto's dependencies -- so the aliases point at its copies.
Theming replaces the upstream component of the same name through a
customization, so it inherits upstream's own aboveHeader slot registration
rather than adding a second one that would emit the rule twice.

Field names map onto custom properties mechanically -- primary_color becomes
--primary-color -- so a setting added to ISCVLTThemeDefinition needs no change
here. header_foreground_color is the exception in reverse: --header-foreground
is read by upstream VLT as well as by us, so the token keeps its name and
_root.scss resolves it through var(--header-foreground-color, ...), which also
makes the override independent of stylesheet order.

Values are validated before they are written into a style element, since a
value that escapes its declaration injects arbitrary CSS. Fields named *_color
must be a hex colour, the same rule the backend Color field enforces; anything
else has to be free of the constructs that end a declaration, open a rule, or
fetch and evaluate. Unknown settings are allowed through that second check
rather than dropped, so a new kind of setting works as soon as it exists.

SiteThemeSettings described six colours where the backend sends seven; the
types now mirror ISCVLTThemeDefinition, with the old name kept as a deprecated
alias.
A listing with colour swatches, plus Volto's own Form for add and edit, so the
colorPicker widgets and validation come from the served schema rather than
being reimplemented. The four core controlpanel actions already match the API,
updateControlpanel included -- it takes a URL, so it patches an individual
theme.

Volto's generic /controlpanel/:id route renders a single-schema registry form,
which cannot express a collection; addon routes are matched first, so the entry
takes over /controlpanel/themes.

A theme can be duplicated from an existing one: the settings and description
are copied, the identity is not, and the name is suffixed so two themes never
look alike in the listing. The default theme is editable like any other and
only its delete control is hidden.

The id is not part of ISCVLTThemeDefinition -- it lives in the record prefix --
so the add schema grows one, and the edit schema deliberately does not: a theme
cannot be renamed in place.
themes and defaultTheme are a kitconcept convention that Volto's BlockConfigBase
does not declare, so reading them off blocksConfig failed to typecheck. Declare
them through a module augmentation, the same approach settings.ts already uses
for SettingsConfig.

The two block views each redeclared the ThemeDefinition shape; they now import
the one config/blocks.ts exports.
@ericof
ericof requested a review from Humanaice August 20, 2026 12:16
ericof and others added 12 commits August 20, 2026 09:21
The package ships header, theme and footer behaviors whose fields overlap ours
field for field. Leaving both registered means two of everything on the site
root's edit form, and the FTI has to pick a winner per fieldset.

z3c.unconfigure removes them at ZCML time, which is the only point where a
plone:behavior registration can be undone. The overrides are loaded through
includeOverrides under a zcml:condition, so the file is inert when the package
is not installed -- it has to be, since it includes kitconcept's own
configure.zcml to have something to unconfigure.

kitconcept.sticky_menu stays: nothing here replaces it.
An intranet site root wants the intranet header rather than the public one, and
the two behaviors are mutually exclusive -- both provide a header fieldset. A
Plone_Site.xml with purge="true" is the whole difference, so the profile only
carries that one file.

It is registered as an EXTENSION profile and hidden from the add-ons control
panel: it is meant to be applied by a distribution at site creation, not
installed by hand afterwards, since swapping the header behavior on a populated
site drops the fields the other one stored.

The default profile's behavior list gains the theme selector ahead of the
header, and loses the two commented-out entries that outlived the behaviors
they named.
A festive theme with hardcoded reds and greens is a demonstration of what the
control panel can express, not something a plain installation should ship. It
lives in the initial profile now, beside the example content it belongs with,
and the default profile is left with the one theme that is actually a default.
Everything the theme work added to the default profile -- the theme records,
the Plone Site FTI, the control panel entry -- only reaches an existing site
through an upgrade step, and the kitconcept profile it used to depend on has to
be uninstalled rather than merely dropped from metadata.xml: a dependency that
disappears leaves its imported settings in place.

The step reimports the three affected GenericSetup steps and runs
kitconcept.voltolighttheme:uninstall. Removing the dependency is what makes
that safe -- reinstalling our default profile would otherwise pull the package
straight back in.
dummy_type_schema and create_dummy_content were defined identically in
tests/conftest.py and tests/behaviors/conftest.py. The package-level copies
shadowed the root ones and were the only definitions that could run: the root
copies depend on role_request, which existed solely in the file being shadowed.
role_request and editor_request move up with them.

portal_factory grows a container flag. themed_portal used to declare its own
Themed FTI -- a third place building a dummy type -- and the only thing it
needed that portal_factory could not give was a Container klass, so a section
can hold pages. It now builds on portal_factory and registers the corporate
theme before the content that selects it, since the field validates against the
vocabulary.

The theme selector tests take the same shape as the other behavior tests as a
result. Two of them were dead before this: the inherit expander suite
referenced an undefined BEHAVIOR, and the behavior fixture named siteheader.
The panel was one 347-line file holding the store access, the state machine
and every piece of markup. It is now a connected container over a set of
presentational components -- ThemesUI, ThemesList, ThemeForm,
ThemesToolbarActions, ThemeSwatches -- each with a Storybook story, so the
four states the panel can be in can be seen without a running site.

The authorization check moves out of the route and into the component. The
route wrapper only knew whether a token existed, which is true for any logged
in user; an editor without the permission got the panel and then a 401 from
every action it offered. The panel now renders Unauthorized when the fetch
itself is refused. It reads that failure from controlpanels.get.error rather
than from the dispatch promise, since the API middleware returns rather than
rethrows in its rejection handler.

Two things the stories turned up, both invisible until something actually
rendered:

Volto's Form and the Fields inside it are loadable components, so a jsdom test
can only ever assert the absence of a chunk loader. The unit tests pin the
props handed to Form -- which schema, which starting data -- and the stories
are what prove it renders.

A field named id is mapped by Volto to IdWidget, by field name, and that widget
selects state.querystring.indexes. The add form's id field therefore renders
Volto's content-URL widget rather than a text input, which is worth revisiting
separately.

The story fixture declares its colour fields as plain strings: upstream's
colour picker reads config.settings.colorMap[props.id] unguarded, and Storybook
boots no add-on config, so a colorPicker field throws during render and leaves
an empty frame.
The storybook job was already wired into the frontend workflow but never asked
for. It also read its Node version from a job output that does not exist in
that scope, so the input is used directly.
@ericof
ericof marked this pull request as ready for review August 24, 2026 02:53
@Humanaice
Humanaice merged commit ab83620 into main Aug 24, 2026
22 checks passed
@ericof
ericof deleted the issue-20 branch August 24, 2026 15:10
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.

Ship our own behaviors

2 participants