Theme System — Lvntr Starter Kit

This document covers:

  • The AppShell / AdminLayout composition model and how to build a new layout
  • The two-layer theme model: runtime kit themes (main, aura) and build-time custom slot-overrides
  • The build-time resolver and full-replacement + fallback model for custom themes
  • VITE_SK_THEME activation for custom themes
  • Step-by-step recipe for adding a custom theme or overriding a single component's styles

Layout architecture

AppShell — structural shell

resources/js/layouts/AppShell.vue is the structural backbone of the admin panel. It owns:

  • The .admin-layout root and .admin-main / .admin-content regions
  • Sidebar collapse and mobile-open state via useSidebar (single owner — do not import useSidebar again in child components)
  • Responsive class modifiers (admin-main--expanded, admin-main--collapsed, admin-main--mobile) on .admin-main

AppShell exposes five named slots:

Slot Receives (scoped) Purpose
#sidebar { collapsed, mobileOpen, isMobile, closeMobile } Sidebar region
#header { collapsed, isMobile, toggle } Top header bar
default Page content (.admin-content wrapper)
#footer Footer strip
#overlays Global overlays (dialogs, toasts, confirm)

AppShell carries no page props, no flash-to-toast bridge, and no <Head>. It is a pure structural wrapper.

AdminLayout — thin composition

resources/js/layouts/AdminLayout.vue wraps AppShell and fills every region with the standard admin components:

  • #sidebarAdminSidebar
  • #headerAdminHeader (forwards dark-mode toggle)
  • default → AdminPageHeader + <slot /> (page content) + <slot name="page-actions" />
  • #footerAdminFooter
  • #overlaysConfirmDialogComponent, ToastComponent, AppDialog, ImageLightbox

It also handles the flash-to-toast bridge (router.on('finish', …)) and <Head :title="title" />.

External contract (unchanged):

<AdminLayout title="Users" subtitle="Manage users" :back-url="route('admin.users.index')">
    <template #page-actions>
        <Button label="New User" />
    </template>

    <!-- page content -->
</AdminLayout>

Props: title?: string, subtitle?: string, backUrl?: string | boolean. Slots: default (page body), page-actions (rendered inside AdminPageHeader's #actions slot).

All existing pages continue to import @/layouts/AdminLayout.vue — the public contract is identical.

Building a new layout

To create a layout that shares the shell structure but arranges regions differently, compose AppShell directly:

<!-- resources/js/layouts/MinimalLayout.vue -->
<script setup lang="ts">
    import AppShell from '@/layouts/AppShell.vue';
    import MinimalSidebar from '@/layouts/components/MinimalSidebar.vue';
    import MinimalHeader from '@/layouts/components/MinimalHeader.vue';
</script>

<template>
    <AppShell>
        <template #sidebar="{ collapsed, mobileOpen, isMobile, closeMobile }">
            <MinimalSidebar
                :collapsed="collapsed"
                :mobile-open="mobileOpen"
                :is-mobile="isMobile"
                @close-mobile="closeMobile"
            />
        </template>
        <template #header="{ collapsed, isMobile, toggle }">
            <MinimalHeader :collapsed="collapsed" :is-mobile="isMobile" @toggle-sidebar="toggle" />
        </template>
        <slot />
    </AppShell>
</template>

Pages using the new layout import it by path — AdminLayout pages are unaffected.


Two style layers

The theme system has two independent, complementary layers.

Layer Themes How it activates Resolver Artifact
Runtime kit themes main, aura appearance.theme DB setting (Settings → Appearance) — takes effect instantly, no rebuild useTheme composable sets <html data-sk-theme> at runtime None — CSS always bundled
Build-time custom themes Consumer-created theme/<custom>/ directories VITE_SK_THEME=custom in .env + npm run build sk-theme-build.mjs (vendor-resident) writes _active.css Generated file (_active.css), gitignored

Additionally, the PrimeVue preset layer is keyed off VITE_SK_THEME at build time:

Layer What it covers Resolver Artifact
PrimeVue preset Primary palette, surface colours, border radius, component tokens (--p-* variables) alias customResolver in vite.config.ts (resolveActivePreset) None — pure JS module resolution

Runtime kit themes — main and aura

Both main and aura are always bundled in every build. Switching between them requires no rebuild — the useTheme composable reads appearance.theme from the shared Inertia props and writes <html data-sk-theme="aura"> (or removes the attribute for main) on mount and on every prop change.

  • main — the default. No data-sk-theme attribute on <html>; all base rules apply.
  • aura — activates when data-sk-theme="aura" is set on <html>. All aura rules are scoped to html[data-sk-theme='aura'], so they are inert until the attribute is present.

Switch from the admin Settings → Appearance panel; the change takes effect immediately and persists admin-wide. There is no per-user override.

Build-time custom themes

Custom themes live in resources/css/theme/<name>/ directories and use the full-replacement + fallback slot model — see CSS theme system below. They require VITE_SK_THEME=<name> in .env and a npm run build / npm run dev to become active. When a custom theme is selected in Settings → Appearance, a "rebuild required" hint is shown in the UI.

The two layers are independent: you can have VITE_SK_THEME=custom (build-time custom base) while the runtime attribute switches between main and aura visual styles. A tokens.css override typically reads the --p-* tokens the preset emits — see Dependency chain below.


CSS theme system

Directory structure

resources/css/
├── theme/
│   ├── theme.css              # Entry: imports _active.css then theme-runtime/aura/aura.css
│   ├── _active.css            # GENERATED — do not edit; gitignored
│   ├── main/                  # Built-in base theme (source of truth for all slots)
│   │   ├── tokens.css         # CSS custom properties: layout dims, colours, shadows
│   │   ├── fonts.css          # Web font declarations
│   │   ├── _base.scss         # Base reset / typography
│   │   ├── layout/
│   │   │   ├── shell.css        # .admin-layout, .admin-main, Vue transitions
│   │   │   ├── sidebar.css      # .admin-sidebar*, .admin-overlay
│   │   │   ├── header.css       # .admin-header*
│   │   │   ├── page-header.css  # .admin-page-header*
│   │   │   └── footer.css       # .admin-footer*
│   │   ├── components/
│   │   │   ├── card.css
│   │   │   ├── button.css
│   │   │   ├── confirm.css
│   │   │   ├── datatable.css
│   │   │   ├── dialog.css
│   │   │   ├── editor.css
│   │   │   ├── formbuilder.css
│   │   │   ├── menus.css
│   │   │   ├── message.css
│   │   │   ├── navigation.css
│   │   │   ├── primevue.css
│   │   │   ├── tabs.css
│   │   │   ├── tag.css
│   │   │   └── toast.css
│   │   ├── _auth.scss         # Auth layout styles
│   │   └── utilities.css      # Tailwind utility overrides
│   └── custom/                # Your override theme — not shipped; you create it
│       └── (create as needed — see recipe below)
└── theme-runtime/
    └── aura/                  # Runtime kit theme — always bundled, scoped to html[data-sk-theme='aura']
        ├── aura.css           # Index: imports the four files below in order
        ├── tokens.css         # Frame/panel/sidebar tokens (scoped + dark + sidebar variants)
        └── layout/
            ├── shell.css        # Brand-colored frame + inset rounded panel
            ├── sidebar.css      # Static sidebar on the frame, version chip, mobile drawer
            └── header.css       # 70px panel header, solid dev/debug tags

The kit no longer ships an empty custom/ directory. If you want to override any slot, create the directory yourself first (see recipes below).

theme-runtime/aura/ is not a build-time slot directory. It is not read by the resolver (sk-theme-build.mjs) and setting VITE_SK_THEME=aura has no effect — aura activates at runtime via the data-sk-theme attribute only.

Built-in aura theme

aura is the kit's second built-in theme. It restyles the admin shell as an inset, rounded panel inside a brand-colored frame: the sidebar sits directly on the frame (white active pill in the colored style), and the header/content/footer share the panel surface. It overrides only four visual areas — tokens.css, layout/shell.css, layout/sidebar.css, layout/header.css — everything else (navigation, footer, page-header, all components) falls back to main and is repainted purely by the tokens.

The frame color follows the active PrimeVue primary (--p-primary-800), so the existing accent picker recolors the whole frame; the sidebar style switch (colored / light) and dark mode are fully supported.

Activating aura: open admin Settings → Appearance and select the Aura theme card. The change applies instantly — no build step required. The setting is admin-wide (stored in DB as appearance.theme).

aura's CSS lives in resources/css/theme-runtime/aura/ and is always bundled alongside main. All its rules are scoped to html[data-sk-theme='aura'] so they are completely inert until the runtime attribute is present. The useTheme composable (called from AdminLayout) writes and removes this attribute in response to the Inertia shared appearance.theme prop.

Do not set VITE_SK_THEME=aura — that variable controls the build-time custom-theme resolver only, and aura is not a slot-based theme. Setting it has no effect on aura's runtime behavior and may produce unexpected results from the resolver.

How the resolver works

sk-theme-build.mjs is a kit-managed build script shipped inside the package (vendor-resident at vendor/lvntr/laravel-starter-kit/resources/js/theme/sk-theme-build.mjs). You do not own or edit this file — only theme/slot/preset files in your resources/ directory are yours to customise. The script is invoked by the skTheme() Vite plugin automatically during dev and build, and it writes theme/_active.css. The model is full-replacement + fallback:

  1. main/ is the canonical slot list and import order.
  2. For each slot, the resolver loads <active>/<slot> if the file exists; otherwise it loads main/<slot>.
  3. The result is a single _active.css with one @import per slot in the correct cascade order.

If custom/ does not exist at all, the build still completes — every slot falls through to main.

Import order (preserved from the original cascade):

tokens → fonts → _base → layout/* → components/* → _auth → utilities

Every layer is a slot. A custom theme that ships only components/datatable.css overrides just the datatable. Everything else falls through to main. A file in custom/ whose path does not match any main/ slot is ignored — the resolver iterates main's slot list, not the custom directory.

Note: _base.scss and _auth.scss are .scss files — their extension is preserved. The resolver is extension-aware; Sass compilation is handled by the same pipeline as before.

VITE_SK_THEME activation

VITE_SK_THEME controls the build-time custom theme resolver only. It has no effect on the runtime kit themes (main, aura) — those are always bundled and activated via the data-sk-theme attribute at runtime.

Set the active custom theme in .env:

VITE_SK_THEME=custom

The default is main. If the variable is absent or blank, main is used — i.e. every slot resolves to main/ and no custom files are loaded. Run npm run dev or npm run build — the skTheme() Vite plugin guarantees the resolver runs and regenerates _active.css before Vite processes any assets. This approach is safe under ignore-scripts=true because it does not rely on npm lifecycle hooks.

To preview the resolved manifest without a full build, use the theme:build npm script (which runs the vendor script directly):

npm run theme:build
# [sk-theme-build] → resources/css/theme/_active.css (25 slots, 1 override)

Or invoke the vendor script directly:

node vendor/lvntr/laravel-starter-kit/resources/js/theme/sk-theme-build.mjs

Open resources/css/theme/_active.css to see exactly which file each slot resolved to. Overridden slots are annotated with /* override */.

_active.css — generated artifact

_active.css is a build artifact:

  • Listed in .gitignore — never commit it.
  • Not hash-tracked by sk:update — it is regenerated on every npm run dev / npm run build.
  • Always present before Vite starts because the skTheme() plugin invokes the resolver before Vite processes assets.

Custom theme recipe

Override a single component

To restyle the datatable without touching anything else:

  1. Create the custom directory and copy the slot you want to override:

    mkdir -p resources/css/theme/custom/components
    cp resources/css/theme/main/components/datatable.css \
       resources/css/theme/custom/components/datatable.css
    
  2. Edit custom/components/datatable.css. Keep the same class names — the Vue components target them.

  3. Set VITE_SK_THEME=custom in .env.

  4. Run npm run dev. The skTheme() plugin regenerates _active.css before Vite starts; custom/components/datatable.css replaces main/components/datatable.css in the import list. All other slots remain from main.

Verify the resolved manifest:

@import './main/tokens.css';
@import './main/fonts.css';
@import './main/_base.scss';
@import './main/layout/footer.css';
…
@import './custom/components/datatable.css'; /* override */
@import './main/components/dialog.css';
…
@import './main/_auth.scss';
@import './main/utilities.css';

Override the token set

To change layout dimensions, colours, or shadows globally, override tokens.css:

mkdir -p resources/css/theme/custom
cp resources/css/theme/main/tokens.css \
   resources/css/theme/custom/tokens.css

Edit the custom properties. All layout regions and components read from these tokens, so a token change propagates everywhere without touching individual component files.

Override a layout region

mkdir -p resources/css/theme/custom/layout
cp resources/css/theme/main/layout/sidebar.css \
   resources/css/theme/custom/layout/sidebar.css

Edit as needed. Only the sidebar slot is replaced; the rest of the layout comes from main.

Override fonts or utilities

The font declarations and Tailwind utility overrides are now slots like any other. To swap in your own fonts without touching layout or components:

mkdir -p resources/css/theme/custom
cp resources/css/theme/main/fonts.css \
   resources/css/theme/custom/fonts.css

Edit the @font-face declarations in custom/fonts.css. On the next build, _active.css will use your font file instead of the main version; all other slots remain from main.

Similarly, to override Tailwind utility overrides:

mkdir -p resources/css/theme/custom
cp resources/css/theme/main/utilities.css \
   resources/css/theme/custom/utilities.css

Because utilities.css is unlayered and emitted last in the cascade, it wins over every layered rule — the same precedence behaviour as before. Edit freely.

Override auth styles

mkdir -p resources/css/theme/custom
cp resources/css/theme/main/_auth.scss \
   resources/css/theme/custom/_auth.scss

Edit custom/_auth.scss. The .scss extension is required — the resolver and Sass compiler are extension-aware.

Complete slot reference

Slot File Notes
tokens tokens.css CSS custom properties (light + dark)
fonts fonts.css @font-face declarations
base _base.scss Reset / typography; .scss extension required
layout/footer layout/footer.css .admin-footer*
layout/header layout/header.css .admin-header*
layout/page-header layout/page-header.css .admin-page-header*
layout/shell layout/shell.css .admin-layout, .admin-main, Vue transitions
layout/sidebar layout/sidebar.css .admin-sidebar*, .admin-overlay
components/button components/button.css Button severity palette (Tailwind colors)
components/card components/card.css
components/confirm components/confirm.css
components/datatable components/datatable.css
components/dialog components/dialog.css
components/editor components/editor.css
components/formbuilder components/formbuilder.css
components/menus components/menus.css
components/navigation components/navigation.css
components/page-loader components/page-loader.css Full-screen page-switch loading overlay
components/primevue components/primevue.css
components/tabs components/tabs.css
components/tag components/tag.css
components/toast components/toast.css
auth _auth.scss Auth layout styles; .scss extension required
utilities utilities.css Tailwind utility overrides; unlayered, emitted last

Notes

  • A custom theme replaces slots whole-file — there is no cascading diff. Copy the main file as a starting point.
  • Custom themes cannot add new slots. A file in custom/ that has no matching path in main/ is never imported.
  • VITE_SK_THEME=main (the default) produces a build byte-identical to the stock panel — no custom files are loaded.
  • The kit does not ship a custom/ directory. Create it yourself before adding overrides; if it does not exist, every slot resolves to main.
  • Remove custom/ files you no longer need; the resolver falls back to main automatically.
  • Slots with a .scss extension (_base.scss, _auth.scss) must keep that extension in custom/ too.

PrimeVue preset layer

How it works

resources/js/theme/preset.ts is the base PrimeVue styled-mode preset — it defines the primary palette, surface colours, border radius, and per-component tokens. app.ts imports it as @/theme/preset. The import path never changes.

The alias customResolver in vite.config.ts — the resolveActivePreset helper exported from the kit's vendor-resident vite-plugin-sk-theme.mjs — intercepts the @/theme/preset specifier at build time:

  • If VITE_SK_THEME is not main and resources/js/theme/<active>/preset.ts exists → resolves to that override file.
  • Otherwise → resolves to the base resources/js/theme/preset.ts.

The base file stays where it has always been — the kit never moves it because it is the most commonly customised file in a consumer project.

Directory layout

resources/js/theme/
├── preset.ts              # Base preset (consumer-customizable; stays here)
└── custom/                # Not shipped — you create this when needed
    └── preset.ts          # (you create this — see recipe below)

The custom/ directory is not shipped by the kit. The custom theme's preset is absent by default, so the base preset is used and the PrimeVue look is byte-identical to the stock panel. Create the directory only when you need a theme-specific preset override.

Give a custom theme its own PrimeVue palette

  1. Create resources/js/theme/custom/preset.ts (create the custom/ directory first if it does not exist). The simplest approach is to import the base preset and override only the palette:

    mkdir -p resources/js/theme/custom
    
    import { definePreset } from '@primevue/themes';
    import Material from '@primevue/themes/material';
    import AppPreset from '../preset';
    
    export default definePreset(Material, {
        ...AppPreset,
        semantic: {
            ...AppPreset.semantic,
            primary: {
                50:  '{emerald.50}',
                100: '{emerald.100}',
                200: '{emerald.200}',
                300: '{emerald.300}',
                400: '{emerald.400}',
                500: '{emerald.500}',
                600: '{emerald.600}',
                700: '{emerald.700}',
                800: '{emerald.800}',
                900: '{emerald.900}',
                950: '{emerald.950}',
            },
        },
    });
    
  2. Set VITE_SK_THEME=custom in .env.

  3. Run npm run dev or npm run build. The plugin resolves @/theme/preset to your override; with no override file present it falls back to the base.

Notes

  • Export the preset object as the default export — this is what app.ts passes to PrimeVue's preset option.
  • The override applies only when VITE_SK_THEME=custom and resources/js/theme/custom/preset.ts exists. Any other value falls back to the base.
  • The kit does not ship a custom/ directory under resources/js/theme/. Create it yourself when you need a theme-specific preset.
  • There is no generated artifact. This is pure module resolution — no _active file to gitignore, no npm chain to maintain.

Dependency chain — tokens and preset

resources/css/theme/main/tokens.css (and any custom/tokens.css override) defines the --admin-* custom properties the layout and components use. These are live references to the --p-* variables the PrimeVue preset emits at runtime:

/* tokens.css — admin roles point at PrimeVue's preset output */
--admin-sidebar-bg: var(--p-surface-900);
--admin-sidebar-item-active-bg: var(--p-primary-color);

Because they are live var() references, changing the primary/surface palette in your preset override flows through automatically — the --admin-* roles re-resolve to the new --p-* values; there is nothing to keep "in sync".

Override tokens.css only if you want to re-map which PrimeVue token an admin role points at (e.g. make the sidebar read a different surface step):

mkdir -p resources/css/theme/custom
cp resources/css/theme/main/tokens.css \
   resources/css/theme/custom/tokens.css

Then edit the --admin-* properties to reference your chosen --p-* tokens.


Accent color system

The admin panel supports a runtime accent color that repaints the PrimeVue primary palette and the sidebar surface without any rebuild. The system has two layers: an admin global default (set in Settings → Appearance) and an optional per-user override (set from the header popover). When a user has no personal override, the admin global default applies; when the admin global default is also 'default', the kit primary color is used (blue under main, indigo under aura).

How it works

useAccentColor (vendor-resident composable, resources/js/composables/useAccentColor.ts) drives the accent. On onMounted and on watch it calls:

  1. updatePrimaryPalette(palette) — PrimeVue's runtime palette swap. Replaces the active --p-primary-* CSS custom properties with the chosen Tailwind v4 oklch scale; buttons, links, focus rings, active states, and every --p-primary-color reference update instantly with no rebuild.
  2. data-sk-accent on <html> — a marker used by tokens.css to apply the deep accent tint to the sidebar surface in light mode. In dark mode, the sidebar always stays the neutral dark surface; only active items and buttons carry the accent color. The marker is absent when the accent is 'default'.

The sidebar surface treatment is independent of the accent: the sidebarStyle value ('colored' | 'light') controls a data-sk-sidebar marker on <html>. When data-sk-sidebar="light" is present, the sidebar shows a white/light surface with dark text; when absent (the 'colored' default), the deep accent tint (or neutral dark in dark mode) applies.

Available colors

ACCENT_COLORS lists all 26 selectable names: the 22 standard Tailwind v4 palettes (slate, gray, zinc, neutral, stone, red, orange, amber, yellow, lime, green, emerald, teal, cyan, sky, blue, indigo, violet, purple, fuchsia, pink, rose) plus four custom muted tones (taupe, mauve, mist, olive). The special value 'default' means "use the admin global default" (or fall back to the kit primary if the global is also 'default').

All palette values are Tailwind v4 oklch scales inlined in the composable — they are not read from Tailwind's CSS variables at runtime, because Tailwind tree-shakes unused --color-* variables.

Persistence

Both accent choice and sidebar style are persisted in localStorage:

Key Default Meaning
admin-accent-color 'default' Per-user accent choice; writeDefaults: false (seed value is not written until the user makes an explicit pick)
admin-sidebar-style 'colored' Per-user sidebar surface treatment

The writeDefaults: false option means localStorage is empty for a first-time user, so the admin global default is always live until the user explicitly picks a color. One-time legacy storage cleanup (migrateLegacyAppearanceStorage) removes any stale 'default' / false / 'colored' seeds written by older builds that used eager writeDefaults: true.

Admin global default vs per-user override

The appearance shared prop (provided by HandleInertiaRequests on every Inertia response) carries:

Field Type Description
accent_color string Admin global default accent name, or 'default'
sidebar_style 'colored' | 'light' Admin global default sidebar style
dark_mode_default boolean Admin global dark mode default
theme string Active runtime theme ('main' or 'aura')

useAppearanceDefaults reads this prop. useAccentColor consumes defaultAccent and defaultSidebarStyle from it to seed initial values when the user has made no personal choice.

Configure the global default from Settings → Appearance → Default Color. The admin-side picker shows a live preview: selecting a swatch calls applyAccent(color, { followGlobal: false }), which treats 'default' as the kit primary (because the admin is defining the default and must see what kit-blue looks like). On leaving the tab without saving, the user's own session accent is reinstated.

The header popover available to every admin user shows the same color grid plus a "Default" swatch. Picking a color calls setAccent(color) which persists to localStorage; the watch on accent then calls applyAccent automatically. Choosing "Default" clears the personal override so the admin global default applies again.

Theme interaction

  • main default accent: blue ({blue.x} token references, resolved against the Material preset).
  • aura default accent: indigo (Tailwind v4 oklch scale). When the runtime theme switches between main and aura, useAccentColor re-applies the palette automatically (a watch on appearance.theme triggers applyAccent) so a 'default' accent picks the correct signature color for the active theme.
  • The aura frame color (--p-primary-800) follows the active primary, so the accent picker recolors the entire frame under aura.

Page-loader accent

SkPageLoader — the full-screen animated overlay shown during Inertia page switches — uses --p-primary-color for brand elements (rays, blobs, bouncing dots, wave-peak letter color). Because updatePrimaryPalette updates --p-primary-color at runtime, the page-loader automatically uses whatever accent is active. The overlay background is theme-driven:

  • main: --admin-sidebar-bg (the dark sidebar surface).
  • aura: --p-surface-900 (light) / --p-surface-950 (dark) — a neutral dark surface, because aura's --admin-sidebar-bg is transparent.

Styles live in the components/page-loader.css theme slot (overrideable via a custom theme). The overlay is SSR-safe and honors prefers-reduced-motion. It is opt-in — mount <SkPageLoader/> in your AdminLayout overlays slot to enable it (see ui-components.md — SkPageLoader).


Dark mode

CSS custom properties for dark mode are declared inside .dark { … } blocks in main/tokens.css (and in the per-region layout files where layout-specific dark overrides exist). If you override tokens.css, copy those .dark blocks too.

Dark mode is toggled by useDarkMode — it adds / removes the dark class on <html>. No build step or separate CSS file is needed.

The aura theme's dark-mode rules are declared as html.dark[data-sk-theme='aura'] { … } blocks inside theme-runtime/aura/tokens.css. Both attributes are independent — dark mode and the theme switch compose correctly in all four combinations (light/dark × main/aura).


  • docs/install.md — first-time setup
  • docs/update.mdsk:update and hash-tracked stubs
  • docs/UPGRADE.md — migration guide for existing projects