This document describes the main app frontend stack, component usage, and conventions. It does not cover the client portal or kiosk bases.
- Templates: Jinja2 (Flask)
- Styles: Tailwind CSS (built from
app/static/src/input.css→app/static/dist/output.css) - Design tokens: CSS variables and Tailwind theme in
app/static/src/input.cssandtailwind.config.js - Scripts: esbuild bundles (
scripts/build-js.mjs→app/static/dist/*.min.js), referenced viaasset_url() - Third-party libraries: vendored into
app/static/vendor/— never loaded from a CDN - No React/Vue: The app remains server-rendered with Jinja; use existing macros and minimal JS for behavior.
npm install
npm run build:docker # Tailwind + vendor + JS bundles (what the Dockerfile runs)
# or individually
npm run build:css:once # Tailwind -> dist/output.css
npm run copy:vendor # node_modules -> app/static/vendor/
npm run build:js # esbuild -> dist/*.min.js + manifest.json
# watch during development
npm run build:css # Tailwind, watch mode
npm run watch:js # esbuild, watch modeBoth app/static/dist/ and app/static/vendor/ are build output and are gitignored.
TimeTracker is self-hosted and privacy-focused, so every stylesheet, script, font and icon must be served from the app's own origin. Loading anything from a CDN leaks each user's IP address to a third party and breaks air-gapped deployments outright.
To add a library:
npm install <package>(pin the version).- Add its files to
ASSETSinscripts/copy-vendor.mjs. - Reference it as
{{ url_for('static', filename='vendor/<lib>/<file>') }}.
Files are copied verbatim — never re-minified. Re-minifying a package's own build
output risks correctness for bytes that nginx gzip recovers anyway.
Check the package actually ships a browser build. Some publish a UMD file that lists
its dependencies as webpack externals and require()s them at run time; loaded directly
in a browser those resolve to undefined. @toast-ui/editor is exactly this case — its
dist/toastui-editor.js throws Cannot read properties of undefined (reading 'PluginKey')
because the eight prosemirror-* packages are external. When that happens, add the package
to VENDOR_BUNDLES in scripts/build-js.mjs instead: esbuild bundles the ESM entry with
its real dependencies inlined and exposes the expected global via globalName.
A quick way to tell: open the file and look at the UMD factory. If the header require()s
other packages, it is not self-contained.
tests/test_no_external_assets.py fails the build if a CDN URL, remote @import, or
un-nonced inline <script> reappears, and the frontend-assets CI job enforces it.
Font Awesome must be linked before dist/output.css. Its
.fa-solid { display: var(--fa-display, inline-block) } has the same specificity as
Tailwind's .hidden { display: none }, so whichever loads last wins, and Tailwind is not
configured with the important strategy. Loading Font Awesome last silently makes hidden
a no-op on every icon — which is how the theme switcher ended up showing its light, dark
and system icons simultaneously. test_font_awesome_loads_before_tailwind enforces this.
Scripts loaded on every page belong to a bundle group in scripts/build-js.mjs. Note that
groups exist to preserve execution order relative to the Jinja-rendered inline blocks in
base.html, and that several source files declare globals other files consume — so
order within and between groups is load-bearing. Groups also mirror the Jinja
conditionals around them (e.g. core-ai is emitted only when AI is enabled, core-auth
only for authenticated users); merging across a conditional changes who receives the code.
Reference a bundle with {{ asset_url('core-d') }}. Inline <script> blocks must carry
nonce="{{ csp_nonce() }}".
base.html is split into partials under app/templates/partials/:
| Partial | Contents |
|---|---|
_head.html |
Icons, stylesheets, early inline bootstraps (theme, i18n, date formatting) |
_sidebar.html |
Primary navigation, gated by is_module_enabled() |
_topbar.html |
Global search, theme picker, language switcher, user menu |
_bottom_nav.html |
Mobile bottom navigation |
_command_palette.html |
Ctrl+K palette markup |
Jinja blocks cannot live in a partial. An
{% include %}renders as a separate template, so a child template's{% block title %}or{% block extra_css %}override cannot reach into it — the defaults would silently win. All block declarations stay inbase.html.
References: UI_IMPROVEMENTS_SUMMARY.md, STYLING_CONSISTENCY_SUMMARY.md.
- Main app:
app/templates/base.html— provides<html>, head (meta, CSS, scripts), skip link, sidebar, header,<main id="mainContentAnchor">, footer, and mobile bottom nav. - Blocks:
title,content,extra_css,scripts_extra,head_extra, etc. Page templates extendbase.htmland override these blocks.
Primary source: app/templates/components/ui.html (and app/templates/components/cards.html where used). The legacy _components.html duplicate has been removed.
| Need | Macro / component | Import from |
|---|---|---|
| Page title + subtitle + optional breadcrumbs and actions | page_header(icon_class, title_text, subtitle_text=None, actions_html=None, breadcrumbs=None) |
components/ui.html |
| Breadcrumbs only | breadcrumb_nav(items) |
components/ui.html |
| Summary / stat block | stat_card(title, value, icon_class, color, trend=None, subtitle=None) |
components/ui.html or components/cards.html |
| Empty list / no results | empty_state(...) or empty_state_compact(...) with type='no-data' or type='no-results' |
components/ui.html |
| Buttons | Use classes .btn, .btn-primary, .btn-secondary, .btn-danger, .btn-ghost, .btn-sm, .btn-lg from design system |
app/static/src/input.css |
| Modals | modal(id, title, content_html, footer_html=None, size) |
components/ui.html |
| Confirm (destructive) | confirm_dialog(id, title, message, confirm_text, cancel_text, confirm_class) |
components/ui.html |
| Pagination | pagination_nav(pagination, route_name, url_params=None, aria_label=None) |
components/ui.html |
| Forms | form_group, form_select, form_textarea, etc. |
components/ui.html |
- Use no-data when the list is empty and no filters are applied (e.g. “No time entries yet”).
- Use no-results when filters are applied but nothing matches (e.g. “No time entries match your filters”).
- Prefer the macro over inline empty markup so messaging and CTAs stay consistent.
- Buttons: Use design-system classes (
.btn,.btn-primary,.btn-secondary,.btn-danger,.btn-ghost) so focus states and colors stay consistent. Avoid ad-hocbg-*/px-*for primary actions. - Forms: Use
form_groupand related form macros fromui.htmlso labels,aria-required,aria-invalid, and error blocks are consistent. Shared validation lives inform-validation.js/form-validation.css.
- Use the modal or confirm_dialog macros from
components/ui.htmlfor new and refactored flows. - Custom dialogs must provide:
role="dialog"andaria-modal="true"aria-labelledby(and preferablyaria-describedby) pointing to the title and description- Focus trap when open (keep focus inside the dialog until closed).
- List tables: Prefer
data-table-enhanced(seedata-tables-enhanced.js/.css) for sortable headers and consistent ARIA where applicable. - Responsive: Use
responsive-cardsanddata-labelon cells for small screens. - Pagination: Use the
pagination_navmacro when possible; otherwise wrap pagination in a<nav aria-label="...">(e.g. “Time entries pagination”) for accessibility.
- Landmarks: Main content is in
<main id="mainContentAnchor">; sidebar hasaria-label="{{ _('Main navigation') }}". - Focus: Use
focus:ring-2 focus:ring-primary(or design-system focus classes) on interactive elements; ensure adjust-time, filter toggles, and bulk actions have visible focus. - Modals: Use the shared macros so dialogs have correct ARIA and (where implemented) focus management.
app/templates/_components.htmlis deprecated for new work. Usecomponents/ui.html(andcomponents/cards.html) instead. Existing templates that still import from_components.htmlshould be migrated when touching those pages.