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 preheader msgid 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 the Email builder 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:domain on the document, so no author has to remember it. A missing i18n:domain makes every i18n:translate render its msgid's default text — which is indistinguishable from success, because the English default appears either way.
  • The color-scheme and supported-color-schemes meta 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 — href is not CSS.

  • Name
    align
    Type
    String, default 'left'
    Description

    Horizontal placement of the button block: left, center or right.

  • Name
    variant
    Type
    String, default 'solid'
    Description

    solid fills with primary_color and takes a white label; outline is 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.

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.

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 KitDataList of 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, so label-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".

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) or danger (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.

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

    neutral for supporting detail, accent for 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.

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.