The design kit
One canonical layout, eight components, one Tailwind entry. It ships inside the Python package at imio/emailkit/kit/, so the buildout pin that governs the runtime governs the design system too.
imio/emailkit/kit/
├── maizzle.config.base.js # shared build config
├── tailwind.css # the CSS entry, including the dark-mode block
├── layouts/
│ └── Main.vue # the single canonical shell
└── components/
├── Button.vue # the call to action
├── ButtonGroup.vue # two actions on one row
├── Card.vue # the rail card: what this message is about
├── DataList.vue # label/value pairs
├── DataRow.vue # one pair inside a DataList
├── DataTable.vue # real tabular data
├── Panel.vue # a tinted callout
└── Pill.vue # the status pill in the header band
Vertical rhythm: blocks space themselves from above
KitCard, KitPanel, KitDataTable, KitButton and KitButtonGroup all carry a 20 px top margin and no bottom margin; the content well supplies the closing padding. Nothing in email can express "except the last one" — :last-child does not survive CSS inlining — so the only way to stop spacing compounding at the end of a mail is for no block to claim any space under itself.
For you that means one rule: a block that wants more air above it overrides with mt-6, and nothing ever needs a bottom margin.
KitMain — the shell
Main.vue owns exactly the document: the <html> namespace declarations, the accessibility defaults, the preheader, the header, the footer and the content well. Anything else is a component.
It provides these slots:
- Name
default- Description
Your authored markup, dropped into the white content well.
- Name
preheader- Description
The build-time fallback for the hidden inbox-preview line. At runtime the
preheadermsgid from the template registration takes precedence.
- Name
body_html- Type
- runtime only
- Description
Filled from the render context with
structure, for a legacy body. Defined for every template, not just the shell — which is what lets theEmailbuilder carry a migrated body. See render_shell().
What it does so you never have to
role="presentation"on every layout table.lang="${lang}"on<html>, so screen readers pick the right pronunciation.i18n:domainon the document, so no author has to remember it. A missingi18n:domainmakes everyi18n:translaterender its msgid's default text — which is indistinguishable from success, because the English default appears either way.- The
color-schemeandsupported-color-schemesmeta tags. - The hidden preheader div, collapsing to nothing when there is no preheader.
It serves two hosts
Your templates render through render(), whose context is flat. Two of the three restyled Plone default mails — password reset and registration — are rendered by a stock Plone view instead, whose kwargs land in options. TAL's | operator lets the shell serve both hosts without the author knowing which one is which.
This is also what makes theme tokens reach the jbot-hosted default mails, which cannot see render()'s context at all: the chain ends at context/@@emailkit_theme, a view returning the three registry-backed tokens as a mapping.
KitButton
The bulletproof call-to-action: a single-cell table holding a block anchor that carries the padding, so the whole coloured rectangle is a click target and not just the glyphs of the label. The cell repeats the same values in mso-padding-alt, because Word's renderer ignores padding on a block anchor.
<KitButton href="${cta_url}" align="center">
<span i18n:translate="">email_cta_view_item</span>
</KitButton>
- Name
href- Type
- String, required
- Description
Destination. A Chameleon placeholder is fine here —
hrefis not CSS.
- Name
align- Type
- String, default 'left'
- Description
Horizontal placement of the button block:
left,centerorright.
- Name
variant- Type
- String, default 'solid'
- Description
solidfills withprimary_colorand takes a white label;outlineis a 2 px rule and a magenta label on no fill, for the second action in a pair.
- Name
inline- Type
- Boolean, default false
- Description
Drop the component's own top margin. Only meaningful inside
KitButtonGroup, which supplies the margin once for the pair.
The primary_color theme token rides on bgcolor, not on style. primary_color is defined by the shell, so this component only works inside it — which is the only place it is meant to be used.
Outlook on Windows renders the button square: it ignores border-radius, and
the VML fix needs a width in pixels that a translated label makes unknowable.
Padding, colour and click target are correct there; only the corners are not.
KitButtonGroup
Two actions on one row. Each KitButton is its own table and two tables do not share a line in mail, so the row has to exist as markup — this is that markup, so you never write the cells yourself.
<KitButtonGroup align="center">
<template #primary>
<KitButton href="${cta_url}" inline>
<span i18n:translate="">email_cta_review</span>
</KitButton>
</template>
<template #secondary>
<KitButton href="${back_url}" variant="outline" inline>
<span i18n:translate="">email_cta_send_back</span>
</KitButton>
</template>
</KitButtonGroup>
- Name
align- Type
- String, default 'center'
- Description
Horizontal placement of the row.
Two named slots rather than one default slot, because a component cannot wrap children it has not been told about — and the 12 px gap between the buttons would then have nowhere to live. Naming them also states the design's rule in the API: the pair is one primary action with an alternative, never two equal choices. #secondary is optional.
The cells stay side by side on a phone. Two short labels are the contract; a
mail whose actions do not fit on one line wants two stacked KitButtons.
KitCard
The rail card: a magenta rail down the left edge and a tinted body, for the thing this message is about — the submitted content, the account, the error. One per mail, or none.
<KitCard>
<template #overline>
<span i18n:translate="">email_field_news_item</span>
</template>
<template #title>${item_title}</template>
<p>${item_description}</p>
</KitCard>
- Name
overline- Description
The small uppercase label above the title: what kind of thing this is.
- Name
title- Description
The thing's name, in the display face.
- Name
default- Description
Anything else — a description, or a
KitDataListof its fields.
KitDataList and KitDataRow
A handful of label/value pairs — who, when, where. Not the same thing as KitDataTable: this is a definition list wearing a table's clothes, with no header row and no repetition, so it is role="presentation" and the labels do the work.
<KitDataList>
<KitDataRow>
<template #label><span i18n:translate="">email_field_author</span></template>
${author}
</KitDataRow>
<KitDataRow>
<template #label><span i18n:translate="">email_field_submitted</span></template>
${python: format_datetime(submitted)}
</KitDataRow>
</KitDataList>
- Name
labelWidth- Type
- String, default '130'
- Description
On
KitDataRow: width of the label column in pixels,'130'or'120'. A string, solabel-width="120"works as written.
The 1 px rule between rows is not a class you write: tailwind.css hangs a tr + tr > td selector off KitDataList's own class, drawing a top border on every row but the first. It has to be that way round — a bottom border would need leaving off the last row, and nothing in an email can say "last".
KitDataRow may be a component where KitDataTable's rows may not, because a
data list is a fixed handful of pairs and never a tal:repeat. If you do need
a repeat, write a plain <tr> with the cell classes spelled out; it still gets
the rule between rows.
KitPill
The status pill in the header band, opposite the logo: one word saying what kind of message this is before the reader has read anything.
The pill is white on every tone. It sits on the head artwork rather than on white, so a tinted fill there is either washed out or a second colour fighting the brand; the tone is carried instead by a coloured disc baked into the icon.
<template #pill>
<KitPill tone="success">
<span i18n:translate="">email_pill_new_account</span>
</KitPill>
</template>
- Name
tone- Type
- String, default 'info'
- Description
info(blue disc),success(green),warning(yellow) ordanger(red). Each resolves to a 14 px PNG icon at build time, so a runtime tone cannot work — a template that needs to say something else writes its own pill.
The icon is skipped when there is no portal_url to build an absolute URL
from (a golden file, a unit test), and a client that blocks remote images
drops it too. A pill stays legible without it — a bold label on white — but it
loses its colour entirely, because since v3 the colour is only in the icon.
Write a label that works on its own.
KitPanel
A tinted callout inside the shell's white content well. The shell already provides the white card, so a panel is for emphasis, not layout.
<KitPanel tone="accent">
<p i18n:translate="">email_deadline_warning</p>
</KitPanel>
- Name
tone- Type
- String, default 'neutral'
- Description
neutralfor supporting detail,accentfor the one thing the reader must not miss.
Two tones only. Magenta is high-signal at iMio and is used sparingly by design.
tone is resolved at build time, so the class strings are literal in the compiled output and both Tailwind's scanner and css.purge see them. This is not a runtime-computed class, which the authoring rules forbid.
KitDataTable
Table chrome for tabular data — and the one table in the kit that is not role="presentation". It holds real data, and marking a data table as presentational hides its structure from screen readers.
<KitDataTable>
<template #head>
<th scope="col" i18n:translate="">col_title</th>
<th scope="col" i18n:translate="">col_state</th>
</template>
<tr tal:repeat="row rows">
<td>${row/title}</td>
<td>${row/state}</td>
</tr>
</KitDataTable>
Rows are yours, not the kit's: tal: attributes on a kit component are forbidden, because attribute fallthrough lands them on an unpredictable root element. So the tal:repeat lives on your own <tr> inside the default slot.
Header cells want scope="col". The kit cannot add it for you, because the
cells come from the slot.
Dark mode
color-scheme hints plus a prefers-color-scheme block keyed on data-dark attributes, which the kit places itself on the surfaces it owns. Six tokens: page (the canvas behind the card), surface (the card, and the data tables nested inside it), raised (tinted blocks within the card — a rail card's body, a callout), body, muted and accent. You never write one, exactly as you never write role="presentation".
The footer is deliberately absent from that list: it is a negative block already, and a hook there would only let a dark client lighten the one part of the mail meant to stay black.
The six values are the greys the v2 mockups' own dark model uses, and they are not charte colours — the iMio palette does not cover dark mode, and communication has not signed those greys off. They are reused rather than invented so the package has one unvalidated dark ramp instead of two.
data-dark is not decoration. css.purge only understands class= and id=, so a class-keyed dark block is deleted silently with a successful build; an attribute selector is outside purge's model and survives untouched.
Web fonts
Quicksand is served from the kit's own resource directory (browser/static/fonts.css, two weights), linked from the shell's head. Apple Mail, iOS, Thunderbird and Samsung honour it; Gmail, Outlook and most webmail strip the <link> and fall back to Trebuchet MS and then Arial, which is why the display stack still names both.
Self-hosted, not Google Fonts, which is what the mockups used: a font fetched from fonts.gstatic.com reports the IP address, the time and the mail client of every citizen who opens a message from a Walloon local authority.
The kit is locked
Consumers compose the layout and components but do not extend the Tailwind config. That is what keeps every iMio product's mails recognisably the same, and it is drastically simpler to maintain.
The escape hatch is not a config option, it is the three theme tokens — see Overrides & theming. If two products genuinely need different shells, a second layout in the kit is the cheap first answer.
Why it is import-free
Every kit file is read from inside a Python egg, and a site-packages directory has no node_modules ancestor for Vite to walk up to. So no kit file imports anything: defineProps is a compiler macro, and useConfig and every built-in component are auto-imported by Maizzle.
Related
- Authoring rules — the eight ways a template breaks with a green build.
- Preview & send-test — the loop for actually looking at what you built.
- Source:
imio/emailkit/kit/