Frontend
Pages are Jinja templates that mount a Vue 3 app (Options API) with Vuetify. Vue, Vuetify, Axios, and Tabler Icons are vendored into stk/static/, so there is no npm, bundler, or build step. Versions are pinned in stk/static/VERSIONS.txt; to bump one, edit vendor.sh, run ./vendor.sh, then uv run stk smoke.
The fastest way to see the whole pattern is uv run stk new <module>: the generated stk/templates/cms/<module>.html is a working CRUD page that follows every rule below.
Page shape
Section titled “Page shape”{% extends "layout.html" %}{% block css %}{% endblock %} <!-- optional extra CSS -->{% block content %} <!-- Vuetify components -->{% endblock %}{% block js %}<script type="application/json" id="roles-data">{{ roles|tojson|safe }}</script><script>const {createApp, toRaw} = Vue;const {createVuetify} = Vuetify;const vuetify = createVuetify(config.vuetifyConfig); // stk/static/js/config.js
window.app = createApp({ mixins: [layoutMixin], // drawer, nav, WebSocket, notifications delimiters: config.delimiters, // ['${', '}'], so Jinja keeps {{ }} data() { return { roles: JSON.parse(document.querySelector('#roles-data').textContent), }; }, methods: {},});
registerStkComponents(app); // stk/static/js/components/index.jsapp.use(vuetify).mount("#app");</script>{% endblock %}The layout shell (app bar, navigation drawer, v-main) lives in layout.html. Page templates fill {% block content %} and never redefine it. Full-width pages suppress the sidebar with {% block sidebar %}{% endblock %} and {% block layout_classes %} align-center {% endblock %}.
Rules that fail silently
Section titled “Rules that fail silently”-
Delimiters are
${expr}, never{{expr}}, which Jinja renders first. -
Server data goes in
{% block js %}. Vue strips<script>tags inside the#appmount element, so the same JSON tag in{% block content %}reads back as null and the page crashes. A 200 response does not catch this;stk smokedoes. -
Template expressions cannot see page globals. A
const labels = {...}in the page script is undefined inside${...}and directives. Expose shared helpers withapp.config.globalProperties.labels = labelsbefore mount. The production Vue build fails such lookups silently (the cell renders nothing, columns shift), so look at the rendered table. -
Vuetify 4 type classes are MD3. The old MD2 classes apply no styling and log nothing:
Do not use (v3) Use (v4) text-h2text-display-largetext-h4text-headline-largetext-h5text-headline-smalltext-h6text-title-largetext-subtitle-1text-title-mediumtext-subtitle-2text-title-smalltext-body-1text-body-largetext-body-2text-body-mediumtext-captiontext-body-smalltext-overlinetext-label-smallThe font overrides in
stk/static/css/app.cssselect on the MD3 names, so a stray MD2 class also loses the typeface. -
No
<tbody>/<tr>inside<v-table>or another custom element. The browser parser moves the rows out before Vue mounts. Usev-data-table, a native<table>, or flex rows. -
Never ship the Vue development build. Vue 3.5’s production build loops forever on state mutated inside a computed, where the dev build only warns. That is why
filterNavByRoleinstk/static/js/components/index.jsmust stay pure.
Talking to the server
Section titled “Talking to the server”Mutations wrap the payload in {item: {...}}; the view reads json_data.get("item", {}). Axios already sends the CSRF token from the layout.
axios.post('/api/thing/', {item: this.eitem}) // createaxios.post(`/api/thing/${this.eitem.id}`, {item: this.eitem}) // update (POST, not PUT)axios.delete(`/api/thing/${item.id}`) // deleteaxios.get(`/api/things?page=${this.options.page}&per_page=${this.options.itemsPerPage}`)Logout is POST-only: call logout() from layoutMixin. See JSON APIs for the server side.
Navigation
Section titled “Navigation”Sidebar entries live in stk/static/js/navigation.js:
const stkNavigation = [ { heading: 'Section Title' }, { title: 'Dashboard', icon: 'ti ti-home', to: '/dashboard' }, { title: 'Activity', icon: 'ti ti-history', to: '/activities', role: 'admin' }, { title: 'Users', icon: 'ti ti-users-group', role: 'admin', children: [ { title: 'Users', icon: 'ti ti-users', to: '/users' }, { title: 'Roles', icon: 'ti ti-shield', to: '/roles' }, ] }];role is a single string, not an array. Optional link fields: target, badge, badgeClass. Hiding an entry is cosmetic; the view itself must check the role.
layoutMixin
Section titled “layoutMixin”Defined in stk/templates/layout.html. Every page gets:
- data:
drawer,isNavCollapsed,isMobile,navItems,notifications,wsConnected - computed:
filteredNavItems(nav filtered by the user’s roles) - methods:
logout(),toggleNavCollapse()(persisted in localStorage),handleNotificationClick(n),markNotificationRead(id),markAllNotificationsRead(),removeNotification(id) - mounted: restores nav state and, for signed-in users, connects the
/wsWebSocket with backoff. Messages of{type: 'notification'}are prepended tonotifications; extend_onWsMessagefor your own types. See Background Work.
Tabler Icons (ti ti-*), self-hosted. Never Material Design Icons.
<i class="ti ti-pencil" style="font-size: 20px;"></i><v-btn icon="ti ti-x" variant="text" size="small"></v-btn><v-icon class="mr-2">ti ti-pencil</v-icon>Vuetify’s internal icons map to Tabler through config.vuetifyConfig.icons.aliases.
Components
Section titled “Components”Global defaults from config.vuetifyConfig.defaults, so pages rarely repeat them:
| Component | Defaults |
|---|---|
VTextField, VSelect, VTextarea, VCombobox |
variant="outlined" |
VBtn |
variant="elevated", size="small", rounded="0" |
VCard |
elevation="0", rounded="0" |
VChip |
size="small", rounded="sm" |
VDialog |
rounded="0" |
VMenu |
offset="10" |
VToolbar |
elevation="0" |
VDataTableServer |
items-per-page="25", options [25, 50, 100] |
Server-paginated table
Section titled “Server-paginated table”<v-data-table-server :items="items" :items-length="itemsLength" :headers="headers" :page="options.page" :items-per-page="options.itemsPerPage" @update:options="refresh" hover> <template v-slot:top> <v-toolbar color="transparent"> <v-btn class="ml-auto" color="primary" @click="createItem"> <template v-slot:prepend><i class="ti ti-plus"></i></template> Add </v-btn> </v-toolbar> </template> <template v-slot:item.actions="{ item }"> <v-icon class="mr-2" @click="editItem(item)">ti ti-pencil</v-icon> <v-icon @click="deleteItem(item)">ti ti-trash</v-icon> </template></v-data-table-server>headers: [ {title: 'ID', value: 'id'}, {title: 'Name', value: 'name'}, {title: 'Actions', value: 'actions', sortable: false},],
refresh(options) { if (options) { this.options = {...this.options, page: options.page, itemsPerPage: options.itemsPerPage}; } axios.get(`/api/things?page=${this.options.page}&per_page=${this.options.itemsPerPage}`) .then(res => { this.items = res.data.items; this.itemsLength = res.data.total; }) .catch(() => this.showSnack('Failed to load'));},Edit dialog
Section titled “Edit dialog”Put v-if on the inner card so the form remounts each time it opens, and copy the row with toRaw(item) before editing.
<v-dialog v-model="edialog" width="660"> <v-card v-if="edialog"> <v-toolbar> <v-toolbar-title>Editor</v-toolbar-title> <template v-slot:append> <v-btn @click="edialog=false" icon="ti ti-x" variant="text"></v-btn> </template> </v-toolbar> <v-card-text> <v-text-field label="Name" v-model="eitem.name"></v-text-field> <v-select label="Roles" :items="roles" item-title="name" v-model="eitem.roles" multiple chips clearable return-object></v-select> <v-switch color="primary" label="Active" v-model="eitem.active"></v-switch> </v-card-text> <v-card-actions> <v-spacer></v-spacer> <v-btn @click="edialog=false" variant="text">Cancel</v-btn> <v-btn color="primary" @click="saveItem">Save</v-btn> </v-card-actions> </v-card></v-dialog>Snackbar
Section titled “Snackbar”<v-snackbar v-model="snackBar" rounded="pill" elevation="25"> ${snackMessage} <template v-slot:actions> <v-btn @click="snackBar=false" icon="ti ti-x" size="small" variant="text"></v-btn> </template></v-snackbar>stk components
Section titled “stk components”Registered by registerStkComponents(app):
| Tag | Purpose |
|---|---|
<vertical-nav-link>, <vertical-nav-group>, <vertical-nav-section-title> |
Sidebar entries |
<transition-expand> |
Animated height for nav groups |
<notification-dropdown> |
Bell with the notification list; emits click, mark-read, mark-all-read, remove |
<theme-switcher> |
Light/dark toggle, persisted in localStorage |
Notification objects look like {id, title, subtitle, time, icon, img, color, isSeen, to}.

