Migrating Templates to the EZ Surface Style
Developer tutorial for the Enezaframework. Part of the Developer documentation. Companion to Frontend Templates & UI Reuse.
Table of Contents
- Who This Is For
- What "New Style" Actually Means
- The Golden Rule: Structure Only
- Step 1 — Audit and Classify Templates
- Step 2 — Convert the Page Shell
- Step 3 — Convert Forms and Detail Panels
- Step 4 — Convert Lists and Tables
- Step 5 — Convert Actions and Badges
- Step 6 — Preserve Behaviour Hooks
- Step 7 — Check Theme, RTL, and Breakpoints
- Worked Example: "New Query" Page
- Accepted Module Shells (Do Not Flatten)
- Out of Scope
- Common Pitfalls
- Verification Checklist (User-Run)
Who This Is For
You are working on an application built on Enezaframework whose templates still use the legacy
Vristo chrome (panel, panel-head, panel-body, table table-*, badge badge-*,
btn btn-*). A page may be functional but visually out of place next to the converted screens.
This tutorial moves those templates onto the framework's current surface language without changing
any backend behaviour.
The typical symptom is a page that renders a bordered card with a small grey header and a plain
table, while neighbouring pages render rounded ez-record sections, an inline toolbar, and
ez-record-btn actions.
What "New Style" Actually Means
The new style is already shipped. It is not a redesign project and does not require new theme work. It is the set of scoped surface primitives defined in:
enezaframework/static/assets/css/ez-record.css(record, form, list, table, query, task primitives)enezaframework/static/assets/css/toolbar.css(ribbon and list toolbar)- module styles such as
rbac-*,es-*,ez-report-*, andec-*
These files are already linked from the base layout
(enezaframework/templates/components/layouts/default.html), so you do not add a stylesheet to
adopt the style. You only change template markup.
The core vocabulary:
| Family | Classes |
|---|---|
| Page shell | ez-record, ez-record-embedded |
| Sections / cards | ez-record-section, ez-record-section-head, ez-record-section-icon, ez-record-section-title, ez-record-section-chevron, ez-record-section-body, ez-record-panel |
| Tabs | ez-record-tabs, ez-record-tab, is-active |
| Form grid | ez-record-grid, ez-record-col, is-full, ez-field, ez-field-label, ez-field-full |
| Lists | ez-list-panel, ez-inline-toolbar, ez-list-heading, ez-inline-search, ez-inline-toolbar-end |
| Tables | ez-table-panel, ez-table-scroll, ez-table, ez-table-empty, ez-table-foot |
| Actions | ez-record-btn, is-primary, is-danger, is-icon; ez-row-actions, ez-record-actions |
| Badges | ez-version-badge, is-latest, is-active, is-neutral, is-warning, is-rejected |
| Query builder | ez-query-breadcrumb, ez-query-editor, ez-query-group, ez-query-rule, ez-query-subcard, ez-query-subhead, ez-query-subtitle, ez-query-hint, ez-query-note |
A rendered reference for every pattern in this guide is available in the repo:
- Form/record:
enezaframework/templates/core/partials/create_partial.html - List/table:
enezaworkflowengine/templates/workflow_instance/error_list.html - Read-only table with pagination:
enezaframework/templates/setups/partials/setup_readonly_list.html
The Golden Rule: Structure Only
A conversion is markup-only. Do not change:
- Python, models, migrations, URLs, permissions, or view context.
- URLs in
hx-get/hx-post/hx-put/hx-delete. hx-target,hx-swap,hx-trigger,hx-include,hx-encoding.- Form
id,method,action,enctype, CSRF token, hidden inputs, submit hooks. - Alpine
x-datanames, model properties, methods,x-show,x-on, or IDs. - Element IDs used by inline scripts (
#main_object_form,#mainsave_button,#detail-container,#setup-detail,#attachment-list,#attachment-detail, and so on). smart_field/label_fieldtemplate tag calls and their arguments.
If a conversion appears to need a behaviour change, stop and document the deviation instead of inventing one.
Step 1 — Audit and Classify Templates
Before touching markup, build a reachability list. A file is only worth converting if a view,
URL, or {% include %} still reaches it.
# Legacy surface markers
rg -n 'class="panel|panel-head|panel-body|panel-foot' --glob '*.html' <app>
rg -n 'class="table|table-responsive|table-hover' --glob '*.html' <app>
rg -n 'badge badge-|class="btn btn-' --glob '*.html' <app>
# Who renders or includes the candidate?
rg -n 'template_name|partial_template_name|render_to_string|include\(' <app>
rg -n "extends|include |hx-get=\"|hx-post=\"" --glob '*.html' <app>
Classify every candidate as:
- Live — reachable and legacy. Convert it.
- Accepted module shell — already on a current design system (
rbac-*,es-*,esb-*,ez-report-*,ec-*, dashboard, calendar,ez-task-*). Leave the layout; only normalise a legacy wrapper if the audit proves one exists inside it. - Dead — no view/URL/include reference. Do not convert and do not delete in this pass. Record it for a separate cleanup.
A file containing btn is not automatically legacy: many accepted module shells intentionally use
the global button utility. Convert the surface wrapper, table/list chrome, and page-level
actions first.
Step 2 — Convert the Page Shell
The outer page wrapper becomes ez-record ez-record-embedded. Content is grouped into
ez-record-section cards, or into ez-record-panel when a single unheaded card is enough.
Before
{% extends 'components/layouts/default.html' %}
{% block content %}
<div class="panel">
<div class="panel-head"><h5 class="text-base font-semibold">Vendor</h5></div>
<div class="panel-body">
<div class="grid grid-cols-2 gap-4">
<div>
<label class="block text-xs font-semibold">Code</label>
<input class="form-input" value="{{ object.code }}">
</div>
</div>
</div>
</div>
{% endblock %}
After
{% extends 'components/layouts/default.html' %}
{% block content %}
<div class="ez-record ez-record-embedded">
<section class="ez-record-section">
<div class="ez-record-section-head">
<span class="ez-record-section-icon" aria-hidden="true">
<svg viewBox="0 0 24 24"><path d="M4 7h16M4 12h16M4 17h10" /></svg>
</span>
<span class="ez-record-section-title">Vendor</span>
</div>
<div class="ez-record-section-body">
<div class="ez-record-grid">
<div class="ez-record-col">
<div class="ez-field">
<label class="ez-field-label">Code</label>
<input class="form-input" value="{{ object.code }}">
</div>
</div>
</div>
</div>
</section>
</div>
{% endblock %}
Notes:
- The collapsible variant is
ez-record-section-headas a<button>with Alpine state, exactly as implemented incore/partials/ez_record_section.html. Do not hand-roll a different accordion. - Tabs become
ez-record-tabswithez-record-tabbuttons andis-activeon the selected tab; the panel body stays anez-record-panel. - Old field grids map to
ez-record-grid→ez-record-col(addis-fullfor full-width fields) →ez-field/ez-field-label.
Step 3 — Convert Forms and Detail Panels
Do not rewrite form logic. Replace the visual container and keep the form element and every hook.
When the view already supplies fieldtabs (and optionally force_readonly), reuse the shipped
partials instead of hand-writing a section:
<div x-data="invoiceEdit" class="ez-record ez-record-embedded">
<form id="main_object_form"
method="post"
action="{% url current_app|add:':'|add:create_url_name %}"
hx-post="{% url current_app|add:':'|add:create_url_name %}"
hx-target="#setup-detail"
hx-trigger="click from:#mainsave_button"
hx-swap="innerHTML"
enctype="multipart/form-data">
{% csrf_token %}
{% for tab in fieldtabs %}
{% include 'core/partials/ez_record_section.html' with tab=tab form=form %}
{% endfor %}
</form>
</div>
The shared partials are:
core/partials/ez_record_section.html— section shell, icon, title, chevron, and Alpine collapse.core/partials/ez_record_fieldgrid.html—ez-record-gridplussmart_field/label_fielddispatch, readonly handling, and non-field errors.
Use ez_record_section.html only when the surrounding context actually provides tab,
form, and the optional fieldtabs / force_readonly / tab.disable_all / tab.disable_fields.
Otherwise write the section manually and keep the existing field-rendering calls unchanged.
For a plain detail page with no fieldtabs loop, convert the container and keep smart_field
calls as they are:
<div class="ez-record">
<section class="ez-record-panel">
<div class="ez-record-grid">
<div class="ez-record-col">
{% smart_field form 'code' full_width=False %}
</div>
<div class="ez-record-col is-full">
{% smart_field form 'description' full_width=True %}
</div>
</div>
</section>
</div>
Preserve main_object_form, #mainsave_button, submit_main_object_form_form(),
initTomSelects(), Quill initialisation, file/image widgets, non_field_errors, and every hidden
field.
Step 4 — Convert Lists and Tables
The list stack is ez-list-panel → optional ez-inline-toolbar → ez-table-panel →
ez-table-scroll → table.ez-table → optional ez-table-foot.
Before
<div class="panel">
<div class="panel-head">
<h5>Open Orders</h5>
<input class="form-input" placeholder="Search">
</div>
<div class="panel-body">
<div class="table-responsive">
<table class="table table-hover">
<thead><tr><th>Number</th><th>Status</th><th></th></tr></thead>
<tbody>
{% for order in object_list %}
<tr>
<td>{{ order.number }}</td>
<td><span class="badge badge-outline-primary">{{ order.status }}</span></td>
<td><a class="btn btn-primary btn-sm" href="{{ order.get_absolute_url }}">Open</a></td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
</div>
After
<div class="ez-record ez-record-embedded">
<div class="ez-list-panel">
<div class="ez-inline-toolbar">
<strong class="ez-list-heading">Open Orders</strong>
<div class="ez-inline-search">
<svg viewBox="0 0 24 24" aria-hidden="true"><circle cx="11" cy="11" r="7" /><path d="m20 20-3.5-3.5" /></svg>
<input class="form-input" placeholder="Search" aria-label="Search orders">
</div>
</div>
<div class="ez-table-panel">
<div class="ez-table-scroll">
<table class="ez-table">
<thead>
<tr><th>Number</th><th>Status</th><th></th></tr>
</thead>
<tbody>
{% for order in object_list %}
<tr>
<td>{{ order.number }}</td>
<td><span class="ez-version-badge is-active">{{ order.status }}</span></td>
<td>
<div class="ez-row-actions">
<a class="ez-record-btn is-primary" href="{{ order.get_absolute_url }}">
<span>Open</span>
</a>
</div>
</td>
</tr>
{% empty %}
<tr>
<td colspan="3">
<div class="ez-table-empty"><p>No orders found.</p></div>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
</div>
</div>
Pagination goes in ez-table-foot with compact ez-record-btn links. Keep every hx-get,
page parameter, hx-target, and list container ID unchanged — see
setups/partials/setup_readonly_list.html for the reference implementation.
For read-only setup layouts, prefer the existing shell (ez-setup-page, ez-setup-page--full)
when the view already provides the surrounding panes. The table contract stays ez-table-panel /
ez-table / ez-table-foot.
Step 5 — Convert Actions and Badges
| Legacy | New |
|---|---|
btn btn-primary |
ez-record-btn is-primary |
btn btn-secondary, btn btn-outline-*, cancel/back |
ez-record-btn |
btn btn-danger |
ez-record-btn is-danger |
| icon-only button | ez-record-btn is-icon |
badge badge-outline-primary |
ez-version-badge is-active |
| neutral meta badge | ez-version-badge is-neutral |
| warning / rejected | ez-version-badge is-warning / is-rejected |
Inline SVG icons stay. ez-record-btn already sizes them; wrap the visible text in a <span>
when pairing icon and label:
<button type="button" class="ez-record-btn is-primary" hx-post="{% url 'app:save' %}">
<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M5 12l5 5L20 7" /></svg>
<span>Save</span>
</button>
Keep action containers (ez-row-actions, ez-record-actions, ez-record-actions-end) around the
buttons rather than styling each button in isolation.
Step 6 — Preserve Behaviour Hooks
Before you save a converted template, diff the behaviour surface. The following must be identical before and after:
- Every
hx-*attribute and its URL. - Every target ID, swap mode, and trigger.
- Every
<form>attribute and submit button. - Every Alpine component name, property, and method.
- Every
{% url %}and query string. - Every
id=used by JavaScript. - Every
{% include %}contract: the included partial still receives the context keys it expects. - Every
smart_field/label_fieldcall, includingfull_width,disabled, and readonly args.
Converted templates must not remove initTomSelects() calls, attachment upload events, Quill
initialisation, or htmx.process(...) refresh logic after a swap.
If a hook lives inside a block you are replacing, move it into the new markup at the same logical position.
Step 7 — Check Theme, RTL, and Breakpoints
- Use
ez-*tokens and logical properties; do not hardcode light-only colours. - Test both light and dark mode. Both are supported and dark is a first-class theme.
- Do not assume LTR: labels, icon tiles, chevrons, and toolbars must survive RTL.
ez-record.cssdefines responsive behaviour around 900px and 640px. Do not remove the wrappers (ez-record-grid,ez-table-scroll) that make those breakpoints work.- Keep
ez-table-scrolleven if the table looks fine without it; it is what prevents horizontal overflow on narrow screens.
Worked Example: "New Query" Page
The reported starting point for this migration was the New Query screen, which rendered the legacy shell while the query list, detail, edit, and delete screens already used the new language. The conversion pattern for that page is worth memorising:
- Wrap the page in
ez-record ez-record-embedded. - Replace the legacy breadcrumb with
ez-query-breadcrumb. - Convert Query Configuration and Query Builder into two
ez-record-sectioncards with icon, title, and chevron heads. - Use
ez-query-editor,ez-query-head-actions,ez-query-group,ez-query-rule,ez-query-subcard,ez-query-subhead,ez-query-subtitle,ez-query-hint,ez-query-note, andez-query-note-itemfor the query UI. - Replace all query
btn btn-*withez-record-btnvariants and allbadge badge-outline-*withez-version-badgevariants. - Keep every handler and Alpine binding:
workflow-name,query-name,model-select,filters_by_model_json,models_list, aggregate methods,saveQuery,goBack, and the CSRF cookie helper.
The converted file is
enezaworkflowengine/templates/saved_query/partials/query_builder_partial.html. Its edit counterpart
is edit_query_builder_partial.html; that file was already ez-record and only needed its btn /
badge chrome normalised. Use both as the canonical query-builder reference.
Accepted Module Shells (Do Not Flatten)
These surfaces already use a current design system. Do not convert them to ez-record:
enezarbac/—rbac-*enezanotification/email/*—es-*/esb-*enezareporting/report builder —ez-report-*enezadashboard/andenezacommunication/— their own current surfaces- calendar, dashboard, and task-specific
ez-task-*structures
Inside an accepted shell, only fix a legacy surface wrapper if the audit proves one exists. Legacy utility classes are acceptable when the surrounding module shell styles them.
Out of Scope
Do not convert, and do not delete:
vristo-html-template/and root*.htmlprototypes / standalone builders.- allauth and auth screens (
account/,socialaccount/,auth/,components/layouts/auth.html). - Print, PDF, email-body, and report-renderer templates.
- Static/marketing/error pages and
enezacommon/templates/designs/*. - Dead templates. Record them in the audit appendix for a separate, explicitly approved cleanup.
- Legacy CSS or JavaScript. This pass does not remove them.
Common Pitfalls
| Symptom | Cause | Fix |
|---|---|---|
| Page renders but the panel looks unstyled | Converted the wrapper but left a nested legacy panel/panel-body |
Remove every legacy surface wrapper inside the ez-record tree |
| Buttons lose their icon sizing | Replaced btn with ez-record-btn but dropped the inline svg |
Keep the svg; ez-record-btn sizes it |
| HTMX stops swapping after conversion | Changed a hx-target, hx-swap, or container ID |
Restore the original attributes and IDs |
| TomSelect stops initialising | Removed initTomSelects() or changed the form/container ID |
Keep the init call and the IDs it queries |
| Dark mode shows light-only colours | Added inline styles or hardcoded hex values | Use ez-* tokens and logical properties |
| Table overflows on mobile | Removed ez-table-scroll |
Keep the scroll wrapper |
| Collapsible sections all start open/closed | Hand-rolled the section head instead of using the shared partial | Use ez_record_section.html or copy its Alpine state exactly |
| Section includes error on render | Used ez_record_section.html without tab/form context |
Write the section manually or supply the required context |
Verification Checklist (User-Run)
The user is solely responsible for all testing and validation of the output. After a conversion, validate at minimum:
- The page renders with the new surface shell; no legacy
panel/table table-*/ page-levelbtn btn-*chrome remains in a reachable in-scope template. - Every form saves, cancels, and shows validation errors as before.
- Every HTMX partial swap still targets the right container and preserves scroll/state as before.
- TomSelect, Quill, file/image uploads, and attachment widgets still initialise.
- Alpine interactions (
x-datacomponents, collapse, tabs, modals) still work. - Pagination, search, filters, and row actions still work.
- Light mode, dark mode, and RTL all render correctly.
- The 900px and 640px breakpoints remain usable on list and detail pages.
- No console errors appear after the swap.
- Dead templates recorded during the audit were reviewed and left untouched.