Django Vines UI Style Guide

Django Vines is a production planning tool for potted plant nurseries. Our users are growers and planners who need to schedule production quickly and confidently. Every design decision should serve that goal.

Design Principles

  • Obvious: The interface should be self-explanatory. Growers should not need training to use it.
  • Minimal: Less, but better. Show only what is needed for the task at hand. Avoid decoration.
  • Consistent: The same action should look and behave the same way everywhere.
  • Multilingual: The app is used in Dutch, English, and Polish. All text must be translatable and natural in each language.

Writing & Labels

Good labels reduce confusion more than good layout. Our users think in weeks, plants, and growing phases — use their language.

  • Use domain language: production request (not order), growing phase (not stage), potted plant product (not item)
  • Be brief: prefer "Delete" over "Delete this item", "Copy" over "Copy to clipboard"
  • Be specific: "Create production request" is better than "Create new". Button labels should describe what happens.
  • Sentence case: "Production requests" not "Production Requests". Applies to all languages.
  • No jargon: avoid technical terms the user does not know. When a term might be unfamiliar, explain it on first use.

Translations

  • All visible text must use {% translate %} or {% blocktranslate %}
  • JavaScript strings: pass via a CHART_I18N object using translate in the template
  • Dutch and Polish: use sentence case, not title case
  • Translations are maintained in locale/ for en, nl, and pl

Accessibility

  • Keyboard navigation: all interactive elements must be reachable and operable with keyboard alone
  • Icon-only buttons: always include a title attribute so the purpose is clear on hover and to screen readers
  • Color is not enough: never use color as the only way to communicate meaning. Pair it with text or an icon.
  • Heading hierarchy: use h1 through h6 in order. One h1 per page. Do not skip levels.
  • Hidden actions: table row actions are hidden until hover, but remain accessible via :focus-within

Colors & Styling

  • Use Bootstrap theme variables — never hardcode colors
  • Badges: use plain text for arbitrary data values. Colored badges are reserved for two purposes:
    • Status — use bg-*-subtle variants (e.g. bg-success-subtle text-success for "Released", bg-warning-subtle text-warning-emphasis for "Forecast")
    • Counters — e.g. active-filter count on a filter dropdown trigger (bg-primary is acceptable here)
  • No color coding for dates or information
  • text-muted for secondary information
  • Semantic colors: red means destructive action, not emphasis. Green and orange are not used in the UI.

Icons

  • Use Bootstrap Icons (bi bi-*) exclusively
  • Standard meanings: pencil = edit, trash = delete, gear = admin, files = duplicate, three-dots = actions menu
  • Icons OR text, not both — exception: dropdown menu items can combine icon + text
  • No icons in headings
  • The gear icon (bi-gear) signals navigation to the Django admin backend — a different interface

Links

  • Global text-decoration: none is set in project.css. No need for the text-decoration-none class.
  • Use descriptive link text: "View production request" not "click here"

Buttons

  • Primary actions: btn-primary (Save, Copy, Create)
  • Cancel / secondary: btn-outline-secondary (never solid btn-secondary)
  • Destructive: btn-danger for delete confirmations, btn-outline-danger for toolbar delete buttons
  • Toolbar actions: btn-outline-primary btn-sm with icon only

Button Order & Alignment

  • Primary action first (left), cancel second
  • Left-aligned: use d-flex gap-2 or justify-content-start
  • Applies to forms, modals, and confirmation pages

Tables

  • Wrap in table-responsive with table-hover
  • Column order: identifier or name (clickable, links to detail page), most important data, least important data, actions (rightmost)
  • Actions column: no fixed width, no header text
  • Row actions are hidden by default and appear on hover (with :focus-within fallback for keyboard users)

Reference: schedule_list (list table with card shell, infinite scroll)

Table Hover Effect

This CSS is in project.css:

.table tbody tr .dropdown {
  opacity: 0;
  transition: opacity 0.15s ease-in-out;
}
.table tbody tr:hover .dropdown,
.table tbody tr .dropdown.show,
.table tbody tr .dropdown:focus-within {
  opacity: 1;
}

Forms

  • Order fields by importance. Optional fields go last.
  • Group related fields together. Rarely-used fields go in a collapsible "Advanced options" panel.
  • Field size should reflect expected content length (a week number field should be narrow, a name field wide)
  • Every form ends with a primary action button + cancel link, left-aligned

Full-Page Forms

Centered card with crispy-forms layout. Reference: schedule_form.html.

<div class="row justify-content-center">
  <div class="col-lg-8">
    <div class="card">
      <div class="card-header">
        <h3 class="mb-0">Title</h3>
      </div>
      <div class="card-body">{% crispy form %}</div>
    </div>
  </div>
</div>

Modal Forms

Modal footers use justify-content-start — not the Bootstrap default right-alignment — with the primary action first and Cancel second.

<div class="modal-footer justify-content-start">
  <button type="submit" class="btn btn-primary">Save</button>
  <button type="button"
          class="btn btn-outline-secondary"
          data-bs-dismiss="modal">Cancel</button>
</div>
  • Primary button: btn-primary (Save, Copy, Move, Create). Not btn-outline-primary — that variant is for toolbar/icon buttons.
  • Cancel: btn-outline-secondary with data-bs-dismiss="modal". Never solid btn-secondary.
  • All visible labels go through {% translate %}.

Worked examples: production_request_modal_form.html, schedule_copy_schedule_modal.html, schedule_copy_to_weeks_modal.html, schedule_move_to_schedule_modal.html.

Layout

  • Main content: col-lg-8, Sidebar: col-lg-4
  • Page header: mb-4 spacing
  • Card headers: mb-0 on titles

Reference: schedule_detail (main + sidebar layout, page header, breadcrumbs)

Breadcrumbs

Every page except Home should have breadcrumbs. Pattern: Schedules > Schedule Name > Current Page.

<nav aria-label="breadcrumb" class="mb-4">
  <ol class="breadcrumb">
    <li class="breadcrumb-item"><a href="...">Parent</a></li>
    <li class="breadcrumb-item active" aria-current="page">Current</li>
  </ol>
</nav>

Action Menus

Use the model_actions template tag instead of hand-coded HTML. Actions are registered per model in production_planner/actions.py.

{% load action_tags %}

{% model_actions obj display="dropdown" %}


{% model_actions obj display="dropdown" exclude="view" %}


{% model_actions obj display="icons" only="edit,delete" %}

Detail Page Header

Detail pages use a three-dots dropdown inline after the item name. This keeps the header clean and consistent with table rows.

<h1>{{ obj.name }} {% model_actions obj display="dropdown" exclude="view" %}</h1>
  • Use display="dropdown" (not "text")
  • Always exclude="view" since we are already on the detail page
  • No separate button group or flex container — the dropdown sits inline after the name

Reference: schedule_detail, production_request_detail, potted_plant_grid

Page Patterns

List Pages (Model Index)

Standard layout: title row, search form, table container. The search form is a reusable partial that posts to the same view via HTMX, swaps the table container, and pushes URL state. References: article_list.html, potted_plant_list.html, product_definition_list.html. Partial: production_planner/partials/list_search_form.html.

{% url 'app:thing_list' as list_url %}
{% translate "Search things..." as search_placeholder %}
{% include "production_planner/partials/list_search_form.html"
  with list_url=list_url
       target_id="thing-table-container"
       placeholder=search_placeholder
       q_value=filter.form.q.value %}
<div id="thing-table-container">
  {% include "app/thing_list_partial.html" %}
</div>
  • The partial inside the container wraps {% render_table table %} in a Bootstrap card with hx-boost="true" so pagination and sort links keep the HTMX behaviour
  • The view must accept a q query parameter and return the partial for HTMX requests (full page for direct loads)
  • Use this pattern when the list has only a free-text filter. For multi-control filters (date range, multi-select), see Filtering Lists below.

Filtering Lists

Two patterns. Pick by the number of controls, not by personal taste.

  • Inline search bar — one free-text q input with a search icon submit. Use this when the list has exactly one filter. Reusable partial: production_planner/partials/list_search_form.html. See List Pages (Model Index) above for usage.
  • Filter dropdown with badge — a funnel-icon button that opens a panel containing multiple form controls (text, multi-select, date range, etc.). The button shows a bg-primary count badge for the number of active filters. A "Clear filters" link sits inside the panel. Used on schedule overview and schedule tracking. Reference: production_planner/partials/schedule_filter_dropdown.html.
  • The dropdown's panel uses data-bs-auto-close="outside" so a flatpickr or Select2 popover does not close the panel
  • The badge count is the only place a colored bg-primary badge is acceptable in the UI (see Colors & Styling)
  • Both patterns post via HTMX, swap a single table container, and push URL state so filters survive navigation and reloads

Delete Confirmation Pages

Simple layout: breadcrumbs, heading, confirmation question, buttons. No red card headers, warning alerts, or detail lists. Concrete pages extend the shared base production_planner/partials/_confirm_delete_base.html and override only the blocks they need.

{% extends "production_planner/partials/_confirm_delete_base.html" %}
{% load i18n %}

{% block delete_breadcrumbs %}
  <li class="breadcrumb-item">
    <a href="...">{% translate "Schedules" %}</a>
  </li>
{% endblock %}
{% block delete_question %}
  <p>Are you sure you want to delete <strong>"name"</strong>?</p>
{% endblock %}

The view adds cancel_url to the context. The base renders it directly as the Cancel link's href; concrete templates do not override it.

  • delete_breadcrumbs (required): parent breadcrumb <li> items. The active "Delete" item is rendered by the base.
  • delete_question (required): the <p>…</p> confirmation question (use blocktranslate for interpolated names).
  • delete_extras (optional): content between the question and the form. Bulk delete uses this to render the list of items.
  • delete_form_hidden (optional): hidden <input> elements inside the form (e.g. request_ids for bulk delete).

The view is responsible for placing cancel_url in the template context (see ScheduleDeleteView.get_context_data for the canonical example).

  • Delete button: btn-danger (not outline) — fixed by the base.
  • Bulk delete: lists items as links to their detail pages. Above 5 items, the first 5 stay visible and the rest fold into a native <details>/<summary> element (no JS, accessible by default).

Worked examples: schedule_confirm_delete.html, production_request_confirm_delete.html, production_request_confirm_bulk_delete.html.

Chart Pages

Simple card with chart, loading/empty/error states. No filters, no summary stat cards. Always weekly, always stacked bar. Concrete chart pages extend the shared base production_planner/partials/_chart_page_base.html and override the chart_script block with the ECharts setup.

{% extends "production_planner/partials/_chart_page_base.html" %}
{% load i18n %}

{% block chart_script %}
  <script>
    const CHART_I18N = {
      title: '{% translate "Chart Title" %}',
      yAxisName: '{% translate "Y Axis" %}',
      errorTitle: '{% translate "Error Loading Data" %}',
    };
    // ... echarts init + fetch + render ...
  </script>
{% endblock %}

The view supplies chart_heading (translatable label used in the page title, breadcrumb, and h1) and chart_id (the HTML id of the chart container). See YieldForecastChartView and UtilizationChartView.

Worked examples: yield_forecast_chart.html, utilization_chart.html.

Empty States

Card-level (inside cards and tab panes): use components/card_empty_state.html.

{% translate "No items" as empty_title %}
{% translate "Description." as empty_subtitle %}
{% include "components/card_empty_state.html" with icon="inbox" title=empty_title subtitle=empty_subtitle %}

Full-page (list views with no data): use components/empty_state.html with optional action button.

{% include "components/empty_state.html" with icon="calendar-x" title=empty_title subtitle=empty_subtitle action_url=create_url action_label="Create" %}

Reference: production_request_detail

Sticky Tab Bar

Tabbed pages use nav-tabs-sticky to keep tabs visible while scrolling:

<ul class="nav nav-tabs nav-tabs-sticky" role="tablist">
  ...
</ul>

Floating Bulk Actions Bar

When rows are selected via checkboxes, a floating bar appears at the bottom of the viewport with bulk action buttons. Buttons follow the standard button rules. Reference: schedule_detail

Inline Editing

Click-to-edit fields use paired view/edit templates in partials/inline_fields/. Each field has a view template (displays the value) and an edit template (shows controls). HTMX swaps between them.

  • Per-row refresh: after an inline edit, the entire table row refreshes via hx-get + hx-trigger with a pk filter. This keeps all fields in the row consistent.
  • Event pattern: inline field views dispatch production-request-field-changed with {"pk": pk}. Each row listens for this event filtered to its own pk.
  • Swap targets: edit controls should always target their own container (closest .date-inline-edit or similar) — never closest tr. The row-level hx-trigger handles full-row updates.
  • OOB updates: when editing one date (start/end), the sibling date is updated out-of-band via hx-swap-oob. This gives instant feedback before the row refresh completes.

Available field types: date_view/date_edit, quantity_view/quantity_edit, product_view/product_edit.

Reference: schedule_detail overview page , schedule_detail tracking page

<tr id="overview-row-{{ production_request.pk }}"
    hx-get="{% url '...:overview_row' ... %}"
    hx-trigger="production-request-field-changed[detail.pk=={{ production_request.pk }}] from:body"
    hx-target="this"
    hx-swap="outerHTML">
  <td class="inline-edit-cell">
    {% include ".../date_view.html" with ... %}
  </td>
</tr>

Week Navigator

The schedule detail page includes a fixed week navigator in the left margin, visible only at the xxl breakpoint. Reference: schedule_detail

Scripts

base.html exposes two script blocks. The choice between them is not stylistic — they live in different positions in the HTML and have different ordering semantics.

  • {% block javascript %} sits in <head>. Use it ONLY for external <script src="..."> library or asset loads and for Django {{ form.media }}. Always start the override with {{ block.super }} so the project-wide libraries stay loaded.
  • {% block inline_javascript %} sits at the end of <body>. Put all inline <script> code here — chart setup, HTMX-related glue, page-specific initialisation. Wrap the body in document.addEventListener('DOMContentLoaded', ...) or equivalent.

Rationale: keeping inline code at end-of-body means it sees a fully parsed document and every external library has already loaded. Mixing inline scripts into the head block was the most common cause of subtle ordering bugs before this rule existed.

Loading & Feedback

  • Any action that takes time must show visual feedback (spinner, loading indicator, or progress text)
  • Charts: show a loading state while data is fetched, an empty state when there is no data, and an error state on failure
  • After a successful action (create, delete, copy), show a flash message confirming what happened

Don'ts

  • ❌ Icons in headers: <h5><i class="bi bi-info"></i> Title</h5>
  • ❌ Mixed colors: <span class="text-success">Start</span> <span class="text-danger">End</span>
  • ❌ Colored badges for arbitrary data: <span class="badge bg-primary">42 plants</span> — use plain text. Colored badges are reserved for status and counters (see Colors & Styling).
  • ❌ Icon + text buttons: <button><i class="bi bi-plus"></i> Add</button>
  • ❌ Disabled buttons without explanation: if a button cannot be used, hide it or explain why
  • ❌ Hardcoded English: all visible text must go through translate / blocktranslate
  • ❌ Color as the only indicator: always pair color with text or an icon

When starting a new page, find the most similar existing page and use it as your starting template. Use Bootstrap theme variables for all colors. Consult this guide for consistency.