Skip to content

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.

{% 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.js
app.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 %}.

  • Delimiters are ${expr}, never {{expr}}, which Jinja renders first.

  • Server data goes in {% block js %}. Vue strips <script> tags inside the #app mount 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 smoke does.

  • Template expressions cannot see page globals. A const labels = {...} in the page script is undefined inside ${...} and directives. Expose shared helpers with app.config.globalProperties.labels = labels before 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-h2 text-display-large
    text-h4 text-headline-large
    text-h5 text-headline-small
    text-h6 text-title-large
    text-subtitle-1 text-title-medium
    text-subtitle-2 text-title-small
    text-body-1 text-body-large
    text-body-2 text-body-medium
    text-caption text-body-small
    text-overline text-label-small

    The font overrides in stk/static/css/app.css select 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. Use v-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 filterNavByRole in stk/static/js/components/index.js must stay pure.

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}) // create
axios.post(`/api/thing/${this.eitem.id}`, {item: this.eitem}) // update (POST, not PUT)
axios.delete(`/api/thing/${item.id}`) // delete
axios.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.

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.

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 /ws WebSocket with backoff. Messages of {type: 'notification'} are prepended to notifications; extend _onWsMessage for 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.

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]
<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'));
},

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>
<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>

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}.