diff --git a/.gitignore b/.gitignore
index 906dc226..b3006f90 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,5 +1,8 @@
+*~
+*.pyc
 .coverage
 .tox/
+dist/
 docs/_build/
 htmlcov/
 Tailbone.egg-info/
diff --git a/CHANGELOG.md b/CHANGELOG.md
new file mode 100644
index 00000000..c974b3a6
--- /dev/null
+++ b/CHANGELOG.md
@@ -0,0 +1,683 @@
+
+# Changelog
+All notable changes to Tailbone will be documented in this file.
+
+The format is based on [Keep a Changelog](http://keepachangelog.com/en/1.0.0/)
+and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0.html).
+
+## v0.22.7 (2025-02-19)
+
+### Fix
+
+- stop using old config for logo image url on login page
+- fix warning msg for deprecated Grid param
+
+## v0.22.6 (2025-02-01)
+
+### Fix
+
+- register vue3 form component for products -> make batch
+
+## v0.22.5 (2024-12-16)
+
+### Fix
+
+- whoops this is latest rattail
+- require newer rattail lib
+- require newer wuttaweb
+- let caller request safe HTML literal for rendered grid table
+
+## v0.22.4 (2024-11-22)
+
+### Fix
+
+- avoid error in product search for duplicated key
+- use vmodel for confirm password widget input
+
+## v0.22.3 (2024-11-19)
+
+### Fix
+
+- avoid error for trainwreck query when not a customer
+
+## v0.22.2 (2024-11-18)
+
+### Fix
+
+- use local/custom enum for continuum operations
+- add basic master view for Product Costs
+- show continuum operation type when viewing version history
+- always define `app` attr for ViewSupplement
+- avoid deprecated import
+
+## v0.22.1 (2024-11-02)
+
+### Fix
+
+- fix submit button for running problem report
+- avoid deprecated grid method
+
+## v0.22.0 (2024-10-22)
+
+### Feat
+
+- add support for new ordering batch from parsed file
+
+### Fix
+
+- avoid deprecated method to suggest username
+
+## v0.21.11 (2024-10-03)
+
+### Fix
+
+- custom method for adding grid action
+- become/stop root should redirect to previous url
+
+## v0.21.10 (2024-09-15)
+
+### Fix
+
+- update project repo links, kallithea -> forgejo
+- use better icon for submit button on login page
+- wrap notes text for batch view
+- expose datasync consumer batch size via configure page
+
+## v0.21.9 (2024-08-28)
+
+### Fix
+
+- render custom attrs in form component tag
+
+## v0.21.8 (2024-08-28)
+
+### Fix
+
+- ignore session kwarg for `MasterView.make_row_grid()`
+
+## v0.21.7 (2024-08-28)
+
+### Fix
+
+- avoid error when form value cannot be obtained
+
+## v0.21.6 (2024-08-28)
+
+### Fix
+
+- avoid error when grid value cannot be obtained
+
+## v0.21.5 (2024-08-28)
+
+### Fix
+
+- set empty string for "-new-" file configure option
+
+## v0.21.4 (2024-08-26)
+
+### Fix
+
+- handle differing email profile keys for appinfo/configure
+
+## v0.21.3 (2024-08-26)
+
+### Fix
+
+- show non-standard config values for app info configure email
+
+## v0.21.2 (2024-08-26)
+
+### Fix
+
+- refactor waterpark base template to use wutta feedback component
+- fix input/output file upload feature for configure pages, per oruga
+- tweak how grid data translates to Vue template context
+- merge filters into main grid template
+- add basic wutta view for users
+- some fixes for wutta people view
+- various fixes for waterpark theme
+- avoid deprecated `component` form kwarg
+
+## v0.21.1 (2024-08-22)
+
+### Fix
+
+- misc. bugfixes per recent changes
+
+## v0.21.0 (2024-08-22)
+
+### Feat
+
+- move "most" filtering logic for grid class to wuttaweb
+- inherit from wuttaweb templates for home, login pages
+- inherit from wuttaweb for AppInfoView, appinfo/configure template
+- add "has output file templates" config option for master view
+
+### Fix
+
+- change grid reset-view param name to match wuttaweb
+- move "searchable columns" grid feature to wuttaweb
+- use wuttaweb to get/render csrf token
+- inherit from wuttaweb for appinfo/index template
+- prefer wuttaweb config for "home redirect to login" feature
+- fix master/index template rendering for waterpark theme
+- fix spacing for navbar logo/title in waterpark theme
+
+## v0.20.1 (2024-08-20)
+
+### Fix
+
+- fix default filter verbs logic for workorder status
+
+## v0.20.0 (2024-08-20)
+
+### Feat
+
+- add new 'waterpark' theme, based on wuttaweb w/ vue2 + buefy
+- refactor templates to simplify base/page/form structure
+
+### Fix
+
+- avoid deprecated reference to app db engine
+
+## v0.19.3 (2024-08-19)
+
+### Fix
+
+- add pager stats to all grid vue data (fixes view history)
+
+## v0.19.2 (2024-08-19)
+
+### Fix
+
+- sort on frontend for appinfo package listing grid
+- prefer attr over key lookup when getting model values
+- replace all occurrences of `component_studly` => `vue_component`
+
+## v0.19.1 (2024-08-19)
+
+### Fix
+
+- fix broken user auth for web API app
+
+## v0.19.0 (2024-08-18)
+
+### Feat
+
+- move multi-column grid sorting logic to wuttaweb
+- move single-column grid sorting logic to wuttaweb
+
+### Fix
+
+- fix misc. errors in grid template per wuttaweb
+- fix broken permission directives in web api startup
+
+## v0.18.0 (2024-08-16)
+
+### Feat
+
+- move "basic" grid pagination logic to wuttaweb
+- inherit from wutta base class for Grid
+- inherit most logic from wuttaweb, for GridAction
+
+### Fix
+
+- avoid route error in user view, when using wutta people view
+- fix some more wutta compat for base template
+
+## v0.17.0 (2024-08-15)
+
+### Feat
+
+- use wuttaweb for `get_liburl()` logic
+
+## v0.16.1 (2024-08-15)
+
+### Fix
+
+- improve wutta People view a bit
+- update references to `get_class_hierarchy()`
+- tweak template for `people/view_profile` per wutta compat
+
+## v0.16.0 (2024-08-15)
+
+### Feat
+
+- add first wutta-based master, for PersonView
+- refactor forms/grids/views/templates per wuttaweb compat
+
+## v0.15.6 (2024-08-13)
+
+### Fix
+
+- avoid `before_render` subscriber hook for web API
+- simplify verbiage for batch execution panel
+
+## v0.15.5 (2024-08-09)
+
+### Fix
+
+- assign convenience attrs for all views (config, app, enum, model)
+
+## v0.15.4 (2024-08-09)
+
+### Fix
+
+- avoid bug when checking current theme
+
+## v0.15.3 (2024-08-08)
+
+### Fix
+
+- fix timepicker `parseTime()` when value is null
+
+## v0.15.2 (2024-08-06)
+
+### Fix
+
+- use auth handler, avoid legacy calls for role/perm checks
+
+## v0.15.1 (2024-08-05)
+
+### Fix
+
+- move magic `b` template context var to wuttaweb
+
+## v0.15.0 (2024-08-05)
+
+### Feat
+
+- move more subscriber logic to wuttaweb
+
+### Fix
+
+- use wuttaweb logic for `util.get_form_data()`
+
+## v0.14.5 (2024-08-03)
+
+### Fix
+
+- use auth handler instead of deprecated auth functions
+- avoid duplicate `partial` param when grid reloads data
+
+## v0.14.4 (2024-07-18)
+
+### Fix
+
+- fix more settings persistence bug(s) for datasync/configure
+- fix modals for luigi tasks page, per oruga
+
+## v0.14.3 (2024-07-17)
+
+### Fix
+
+- fix auto-collapse title for viewing trainwreck txn
+- allow auto-collapse of header when viewing trainwreck txn
+
+## v0.14.2 (2024-07-15)
+
+### Fix
+
+- add null menu handler, for use with API apps
+
+## v0.14.1 (2024-07-14)
+
+### Fix
+
+- update usage of auth handler, per rattail changes
+- fix model reference in menu handler
+- fix bug when making "integration" menus
+
+## v0.14.0 (2024-07-14)
+
+### Feat
+
+- move core menu logic to wuttaweb
+
+## v0.13.2 (2024-07-13)
+
+### Fix
+
+- fix logic bug for datasync/config settings save
+
+## v0.13.1 (2024-07-13)
+
+### Fix
+
+- fix settings persistence bug(s) for datasync/configure page
+
+## v0.13.0 (2024-07-12)
+
+### Feat
+
+- begin integrating WuttaWeb as upstream dependency
+
+### Fix
+
+- cast enum as list to satisfy deform widget
+
+## v0.12.1 (2024-07-11)
+
+### Fix
+
+- refactor `config.get_model()` => `app.model`
+
+## v0.12.0 (2024-07-09)
+
+### Feat
+
+- drop python 3.6 support, use pyproject.toml (again)
+
+## v0.11.10 (2024-07-05)
+
+### Fix
+
+- make the Members tab optional, for profile view
+
+## v0.11.9 (2024-07-05)
+
+### Fix
+
+- do not show flash message when changing app theme
+
+- improve collapse panels for butterball theme
+
+- expand input for butterball theme
+
+- add xref button to customer profile, for trainwreck txn view
+
+- add optional Transactions tab for profile view
+
+## v0.11.8 (2024-07-04)
+
+### Fix
+
+- fix grid action icons for datasync/configure, per oruga
+
+- allow view supplements to add extra links for profile employee tab
+
+- leverage import handler method to determine command/subcommand
+
+- add tool to make user account from profile view
+
+## v0.11.7 (2024-07-04)
+
+### Fix
+
+- add stacklevel to deprecation warnings
+
+- require zope.sqlalchemy >= 1.5
+
+- include edit profile email/phone dialogs only if user has perms
+
+- allow view supplements to add to profile member context
+
+- cast enum as list to satisfy deform widget
+
+- expand POD image URL setting input
+
+## v0.11.6 (2024-07-01)
+
+### Fix
+
+- set explicit referrer when changing dbkey
+
+- remove references, dependency for `six` package
+
+## v0.11.5 (2024-06-30)
+
+### Fix
+
+- allow comma in numeric filter input
+
+- add custom url prefix if needed, for fanstatic
+
+- use vue 3.4.31 and oruga 0.8.12 by default
+
+## v0.11.4 (2024-06-30)
+
+### Fix
+
+- start/stop being root should submit POST instead of GET
+
+- require vendor when making new ordering batch via api
+
+- don't escape each address for email attempts grid
+
+## v0.11.3 (2024-06-28)
+
+### Fix
+
+- add link to "resolved by" user for pending products
+
+- handle error when merging 2 records fails
+
+## v0.11.2 (2024-06-18)
+
+### Fix
+
+- hide certain custorder settings if not applicable
+
+- use different logic for buefy/oruga for product lookup keydown
+
+- product records should be touchable
+
+- show flash error message if resolve pending product fails
+
+## v0.11.1 (2024-06-14)
+
+### Fix
+
+- revert back to setup.py + setup.cfg
+
+## v0.11.0 (2024-06-10)
+
+### Feat
+
+- switch from setup.cfg to pyproject.toml + hatchling
+
+## v0.10.16 (2024-06-10)
+
+### Feat
+
+- standardize how app, package versions are determined
+
+### Fix
+
+- avoid deprecated config methods for app/node title
+
+## v0.10.15 (2024-06-07)
+
+### Fix
+
+- do *not* Use `pkg_resources` to determine package versions
+
+## v0.10.14 (2024-06-06)
+
+### Fix
+
+- use `pkg_resources` to determine package versions
+
+## v0.10.13 (2024-06-06)
+
+### Feat
+
+- remove old/unused scaffold for use with `pcreate`
+
+- add 'fanstatic' support for sake of libcache assets
+
+## v0.10.12 (2024-06-04)
+
+### Feat
+
+- require pyramid 2.x; remove 1.x-style auth policies
+
+- remove version cap for deform
+
+- set explicit referrer when changing app theme
+
+- add `<b-tooltip>` component shim
+
+- include extra styles from `base_meta` template for butterball
+
+- include butterball theme by default for new apps
+
+### Fix
+
+- fix product lookup component, per butterball
+
+## v0.10.11 (2024-06-03)
+
+### Feat
+
+- fix vue3 refresh bugs for various views
+
+- fix grid bug for tempmon appliance view, per oruga
+
+- fix ordering worksheet generator, per butterball
+
+- fix inventory worksheet generator, per butterball
+
+## v0.10.10 (2024-06-03)
+
+### Feat
+
+- more butterball fixes for "view profile" template
+
+### Fix
+
+- fix focus for `<b-select>` shim component
+
+## v0.10.9 (2024-06-03)
+
+### Feat
+
+- let master view control context menu items for page
+
+- fix the "new custorder" page for butterball
+
+### Fix
+
+- fix panel style for PO vs. Invoice breakdown in receiving batch
+
+## v0.10.8 (2024-06-02)
+
+### Feat
+
+- add styling for checked grid rows, per oruga/butterball
+
+- fix product view template for oruga/butterball
+
+- allow per-user custom styles for butterball
+
+- use oruga 0.8.9 by default
+
+## v0.10.7 (2024-06-01)
+
+### Feat
+
+- add setting to allow decimal quantities for receiving
+
+- log error if registry has no rattail config
+
+- add column filters for import/export main grid
+
+- escape all unsafe html for grid data
+
+- add speedbumps for delete, set preferred email/phone in profile view
+
+- fix file upload widget for oruga
+
+### Fix
+
+- fix overflow when instance header title is too long (butterball)
+
+## v0.10.6 (2024-05-29)
+
+### Feat
+
+- add way to flag organic products within lookup dialog
+
+- expose db picker for butterball theme
+
+- expose quickie lookup for butterball theme
+
+- fix basic problems with people profile view, per butterball
+
+## v0.10.5 (2024-05-29)
+
+### Feat
+
+- add `<tailbone-timepicker>` component for oruga
+
+## v0.10.4 (2024-05-12)
+
+### Fix
+
+- fix styles for grid actions, per butterball
+
+## v0.10.3 (2024-05-10)
+
+### Fix
+
+- fix bug with grid date filters
+
+## v0.10.2 (2024-05-08)
+
+### Feat
+
+- remove version restriction for pyramid_beaker dependency
+
+- rename some attrs etc. for buefy components used with oruga
+
+- fix "tools" helper for receiving batch view, per oruga
+
+- more data type fixes for ``<tailbone-datepicker>``
+
+- fix "view receiving row" page, per oruga
+
+- tweak styles for grid action links, per butterball
+
+### Fix
+
+- fix employees grid when viewing department (per oruga)
+
+- fix login "enter" key behavior, per oruga
+
+- fix button text for autocomplete
+
+## v0.10.1 (2024-04-28)
+
+### Feat
+
+- sort list of available themes
+
+- update various icon names for oruga compatibility
+
+- show "View This" button when cloning a record
+
+- stop including 'falafel' as available theme
+
+### Fix
+
+- fix vertical alignment in main menu bar, for butterball
+
+- fix upgrade execution logic/UI per oruga
+
+## v0.10.0 (2024-04-28)
+
+This version bump is to reflect adding support for Vue 3 + Oruga via
+the 'butterball' theme.  There is likely more work to be done for that
+yet, but it mostly works at this point.
+
+### Feat
+
+- misc. template and view logic tweaks (applicable to all themes) for
+  better patterns, consistency etc.
+
+- add initial support for Vue 3 + Oruga, via "butterball" theme
+
+
+## Older Releases
+
+Please see `docs/OLDCHANGES.rst` for older release notes.
diff --git a/README.rst b/README.md
similarity index 56%
rename from README.rst
rename to README.md
index 0cffc62d..74c007f6 100644
--- a/README.rst
+++ b/README.md
@@ -1,10 +1,8 @@
 
-Tailbone
-========
+# Tailbone
 
 Tailbone is an extensible web application based on Rattail.  It provides a
 "back-office network environment" (BONE) for use in managing retail data.
 
-Please see Rattail's `home page`_ for more information.
-
-.. _home page: http://rattailproject.org/
+Please see Rattail's [home page](http://rattailproject.org/) for more
+information.
diff --git a/CHANGES.rst b/docs/OLDCHANGES.rst
similarity index 97%
rename from CHANGES.rst
rename to docs/OLDCHANGES.rst
index c6809592..0a802f40 100644
--- a/CHANGES.rst
+++ b/docs/OLDCHANGES.rst
@@ -2,166 +2,8 @@
 CHANGELOG
 =========
 
-Unreleased
-----------
-
-0.10.12 (2024-06-04)
---------------------
-
-* Require pyramid 2.x; remove 1.x-style auth policies.
-
-* Remove version cap for deform.
-
-* Set explicit referrer when changing app theme.
-
-* Add ``<b-tooltip>`` component shim.
-
-* Include extra styles from ``base_meta`` template for butterball.
-
-* Fix product lookup component, per butterball.
-
-* Include butterball theme by default for new apps.
-
-
-0.10.11 (2024-06-03)
---------------------
-
-* Fix vue3 refresh bugs for various views.
-
-* Fix grid bug for tempmon appliance view, per oruga.
-
-* Fix ordering worksheet generator, per butterball.
-
-* Fix inventory worksheet generator, per butterball.
-
-
-0.10.10 (2024-06-03)
---------------------
-
-* Fix focus for ``<b-select>`` shim component.
-
-* More butterball fixes for "view profile" template.
-
-
-0.10.9 (2024-06-03)
--------------------
-
-* Let master view control context menu items for page.
-
-* Fix panel style for PO vs. Invoice breakdown in receiving batch.
-
-* Fix the "new custorder" page for butterball.
-
-
-0.10.8 (2024-06-02)
--------------------
-
-* Add styling for checked grid rows, per oruga/butterball.
-
-* Fix product view template for oruga/butterball.
-
-* Allow per-user custom styles for butterball.
-
-* Use oruga 0.8.9 by default.
-
-
-0.10.7 (2024-06-01)
--------------------
-
-* Add setting to allow decimal quantities for receiving.
-
-* Log error if registry has no rattail config.
-
-* Add column filters for import/export main grid.
-
-* Fix overflow when instance header title is too long (butterball).
-
-* Escape all unsafe html for grid data.
-
-* Add speedbumps for delete, set preferred email/phone in profile view.
-
-* Fix file upload widget for oruga.
-
-
-0.10.6 (2024-05-29)
--------------------
-
-* Add way to flag organic products within lookup dialog.
-
-* Expose db picker for butterball theme.
-
-* Expose quickie lookup for butterball theme.
-
-* Fix basic problems with people profile view, per butterball.
-
-
-0.10.5 (2024-05-29)
--------------------
-
-* Add ``<tailbone-timepicker>`` component for oruga.
-
-
-0.10.4 (2024-05-12)
--------------------
-
-* Fix styles for grid actions, per butterball.
-
-
-0.10.3 (2024-05-10)
--------------------
-
-* Fix bug with grid date filters.
-
-
-0.10.2 (2024-05-08)
--------------------
-
-* Fix employees grid when viewing department (per oruga).
-
-* Remove version restriction for pyramid_beaker dependency.
-
-* Fix login "enter" key behavior, per oruga.
-
-* Rename some attrs etc. for buefy components used with oruga.
-
-* Fix "tools" helper for receiving batch view, per oruga.
-
-* Fix button text for autocomplete.
-
-* More data type fixes for ``<tailbone-datepicker>``.
-
-* Fix "view receiving row" page, per oruga.
-
-* Tweak styles for grid action links, per butterball.
-
-
-0.10.1 (2024-04-28)
--------------------
-
-* Sort list of available themes.
-
-* Update various icon names for oruga compatibility.
-
-* Fix vertical alignment in main menu bar, for butterball.
-
-* Fix upgrade execution logic/UI per oruga.
-
-* Show "View This" button when cloning a record.
-
-* Stop including 'falafel' as available theme.
-
-
-0.10.0 (2024-04-28)
--------------------
-
-This version bump is to reflect adding support for Vue 3 + Oruga via
-the 'butterball' theme.  There is likely more work to be done for that
-yet, but it mostly works at this point.
-
-* Misc. template and view logic tweaks (applicable to all themes) for
-  better patterns, consistency etc.
-
-* Add initial support for Vue 3 + Oruga, via "butterball" theme.
+NB. this file contains "old" release notes only.  for newer releases
+see the `CHANGELOG.md` file in the source root folder.
 
 
 0.9.96 (2024-04-25)
@@ -5150,7 +4992,7 @@ and related technologies.
 0.6.47 (2017-11-08)
 -------------------
 
-* Fix manifest to include *.pt deform templates
+* Fix manifest to include ``*.pt`` deform templates
 
 
 0.6.46 (2017-11-08)
@@ -5483,13 +5325,13 @@ and related technologies.
 
 
 0.6.13 (2017-07-26)
-------------------
+-------------------
 
 * Allow master view to decide whether each grid checkbox is checked
 
 
 0.6.12 (2017-07-26)
-------------------
+-------------------
 
 * Add basic support for product inventory and status
 
@@ -5497,7 +5339,7 @@ and related technologies.
 
 
 0.6.11 (2017-07-18)
-------------------
+-------------------
 
 * Tweak some basic styles for forms/grids
 
@@ -5505,7 +5347,7 @@ and related technologies.
 
 
 0.6.10 (2017-07-18)
-------------------
+-------------------
 
 * Fix grid bug if "current page" becomes invalid
 
diff --git a/docs/api/db.rst b/docs/api/db.rst
new file mode 100644
index 00000000..ace21b68
--- /dev/null
+++ b/docs/api/db.rst
@@ -0,0 +1,6 @@
+
+``tailbone.db``
+===============
+
+.. automodule:: tailbone.db
+  :members:
diff --git a/docs/api/subscribers.rst b/docs/api/subscribers.rst
index 8b25c994..d28a1b15 100644
--- a/docs/api/subscribers.rst
+++ b/docs/api/subscribers.rst
@@ -3,5 +3,4 @@
 ========================
 
 .. automodule:: tailbone.subscribers
-
-.. autofunction:: new_request
+   :members:
diff --git a/docs/api/util.rst b/docs/api/util.rst
new file mode 100644
index 00000000..35e66ed3
--- /dev/null
+++ b/docs/api/util.rst
@@ -0,0 +1,6 @@
+
+``tailbone.util``
+=================
+
+.. automodule:: tailbone.util
+   :members:
diff --git a/docs/changelog.rst b/docs/changelog.rst
new file mode 100644
index 00000000..bbf94f4b
--- /dev/null
+++ b/docs/changelog.rst
@@ -0,0 +1,8 @@
+
+Changelog Archive
+=================
+
+.. toctree::
+   :maxdepth: 1
+
+   OLDCHANGES
diff --git a/docs/conf.py b/docs/conf.py
index 505396ed..ade4c92a 100644
--- a/docs/conf.py
+++ b/docs/conf.py
@@ -1,38 +1,21 @@
-# -*- coding: utf-8; -*-
+# Configuration file for the Sphinx documentation builder.
 #
-# Tailbone documentation build configuration file, created by
-# sphinx-quickstart on Sat Feb 15 23:15:27 2014.
-#
-# This file is exec()d with the current directory set to its
-# containing dir.
-#
-# Note that not all possible configuration values are present in this
-# autogenerated file.
-#
-# All configuration values have a default; values that are commented out
-# serve to show the default.
+# For the full list of built-in configuration values, see the documentation:
+# https://www.sphinx-doc.org/en/master/usage/configuration.html
 
-import sys
-import os
+# -- Project information -----------------------------------------------------
+# https://www.sphinx-doc.org/en/master/usage/configuration.html#project-information
 
-import sphinx_rtd_theme
+from importlib.metadata import version as get_version
 
-exec(open(os.path.join(os.pardir, 'tailbone', '_version.py')).read())
+project = 'Tailbone'
+copyright = '2010 - 2024, Lance Edgar'
+author = 'Lance Edgar'
+release = get_version('Tailbone')
 
+# -- General configuration ---------------------------------------------------
+# https://www.sphinx-doc.org/en/master/usage/configuration.html#general-configuration
 
-# If extensions (or modules to document with autodoc) are in another directory,
-# add these directories to sys.path here. If the directory is relative to the
-# documentation root, use os.path.abspath to make it absolute, like shown here.
-#sys.path.insert(0, os.path.abspath('.'))
-
-# -- General configuration ------------------------------------------------
-
-# If your documentation needs a minimal Sphinx version, state it here.
-#needs_sphinx = '1.0'
-
-# Add any Sphinx extension module names here, as strings. They can be
-# extensions coming with Sphinx (named 'sphinx.ext.*') or your custom
-# ones.
 extensions = [
     'sphinx.ext.autodoc',
     'sphinx.ext.todo',
@@ -40,241 +23,30 @@ extensions = [
     'sphinx.ext.viewcode',
 ]
 
+templates_path = ['_templates']
+exclude_patterns = ['_build', 'Thumbs.db', '.DS_Store']
+
 intersphinx_mapping = {
-    'rattail': ('https://rattailproject.org/docs/rattail/', None),
+    'rattail': ('https://docs.wuttaproject.org/rattail/', None),
     'webhelpers2': ('https://webhelpers2.readthedocs.io/en/latest/', None),
+    'wuttaweb': ('https://docs.wuttaproject.org/wuttaweb/', None),
+    'wuttjamaican': ('https://docs.wuttaproject.org/wuttjamaican/', None),
 }
 
-# Add any paths that contain templates here, relative to this directory.
-templates_path = ['_templates']
-
-# The suffix of source filenames.
-source_suffix = '.rst'
-
-# The encoding of source files.
-#source_encoding = 'utf-8-sig'
-
-# The master toctree document.
-master_doc = 'index'
-
-# General information about the project.
-project = u'Tailbone'
-copyright = u'2010 - 2020, Lance Edgar'
-
-# The version info for the project you're documenting, acts as replacement for
-# |version| and |release|, also used in various other places throughout the
-# built documents.
-#
-# The short X.Y version.
-# version = '0.3'
-version = '.'.join(__version__.split('.')[:2])
-# The full version, including alpha/beta/rc tags.
-release = __version__
-
-# The language for content autogenerated by Sphinx. Refer to documentation
-# for a list of supported languages.
-#language = None
-
-# There are two options for replacing |today|: either, you set today to some
-# non-false value, then it is used:
-#today = ''
-# Else, today_fmt is used as the format for a strftime call.
-#today_fmt = '%B %d, %Y'
-
-# List of patterns, relative to source directory, that match files and
-# directories to ignore when looking for source files.
-exclude_patterns = ['_build']
-
-# The reST default role (used for this markup: `text`) to use for all
-# documents.
-#default_role = None
-
-# If true, '()' will be appended to :func: etc. cross-reference text.
-#add_function_parentheses = True
-
-# If true, the current module name will be prepended to all description
-# unit titles (such as .. function::).
-#add_module_names = True
-
-# If true, sectionauthor and moduleauthor directives will be shown in the
-# output. They are ignored by default.
-#show_authors = False
-
-# The name of the Pygments (syntax highlighting) style to use.
-pygments_style = 'sphinx'
-
-# A list of ignored prefixes for module index sorting.
-#modindex_common_prefix = []
-
-# If true, keep warnings as "system message" paragraphs in the built documents.
-#keep_warnings = False
-
-# Allow todo entries to show up.
+# allow todo entries to show up
 todo_include_todos = True
 
 
-# -- Options for HTML output ----------------------------------------------
+# -- Options for HTML output -------------------------------------------------
+# https://www.sphinx-doc.org/en/master/usage/configuration.html#options-for-html-output
 
-# The theme to use for HTML and HTML Help pages.  See the documentation for
-# a list of builtin themes.
-# html_theme = 'classic'
-html_theme = 'sphinx_rtd_theme'
-
-# Theme options are theme-specific and customize the look and feel of a theme
-# further.  For a list of options available for each theme, see the
-# documentation.
-#html_theme_options = {}
-
-# Add any paths that contain custom themes here, relative to this directory.
-#html_theme_path = []
-html_theme_path = [sphinx_rtd_theme.get_html_theme_path()]
-
-# The name for this set of Sphinx documents.  If None, it defaults to
-# "<project> v<release> documentation".
-#html_title = None
-
-# A shorter title for the navigation bar.  Default is the same as html_title.
-#html_short_title = None
+html_theme = 'furo'
+html_static_path = ['_static']
 
 # The name of an image file (relative to this directory) to place at the top
 # of the sidebar.
 #html_logo = None
-html_logo = 'images/rattail_avatar.png'
-
-# The name of an image file (within the static path) to use as favicon of the
-# docs.  This file should be a Windows icon file (.ico) being 16x16 or 32x32
-# pixels large.
-#html_favicon = None
-
-# Add any paths that contain custom static files (such as style sheets) here,
-# relative to this directory. They are copied after the builtin static files,
-# so a file named "default.css" will overwrite the builtin "default.css".
-html_static_path = ['_static']
-
-# Add any extra paths that contain custom files (such as robots.txt or
-# .htaccess) here, relative to this directory. These files are copied
-# directly to the root of the documentation.
-#html_extra_path = []
-
-# If not '', a 'Last updated on:' timestamp is inserted at every page bottom,
-# using the given strftime format.
-#html_last_updated_fmt = '%b %d, %Y'
-
-# If true, SmartyPants will be used to convert quotes and dashes to
-# typographically correct entities.
-#html_use_smartypants = True
-
-# Custom sidebar templates, maps document names to template names.
-#html_sidebars = {}
-
-# Additional templates that should be rendered to pages, maps page names to
-# template names.
-#html_additional_pages = {}
-
-# If false, no module index is generated.
-#html_domain_indices = True
-
-# If false, no index is generated.
-#html_use_index = True
-
-# If true, the index is split into individual pages for each letter.
-#html_split_index = False
-
-# If true, links to the reST sources are added to the pages.
-#html_show_sourcelink = True
-
-# If true, "Created using Sphinx" is shown in the HTML footer. Default is True.
-#html_show_sphinx = True
-
-# If true, "(C) Copyright ..." is shown in the HTML footer. Default is True.
-#html_show_copyright = True
-
-# If true, an OpenSearch description file will be output, and all pages will
-# contain a <link> tag referring to it.  The value of this option must be the
-# base URL from which the finished HTML is served.
-#html_use_opensearch = ''
-
-# This is the file name suffix for HTML files (e.g. ".xhtml").
-#html_file_suffix = None
+#html_logo = 'images/rattail_avatar.png'
 
 # Output file base name for HTML help builder.
-htmlhelp_basename = 'Tailbonedoc'
-
-
-# -- Options for LaTeX output ---------------------------------------------
-
-latex_elements = {
-# The paper size ('letterpaper' or 'a4paper').
-#'papersize': 'letterpaper',
-
-# The font size ('10pt', '11pt' or '12pt').
-#'pointsize': '10pt',
-
-# Additional stuff for the LaTeX preamble.
-#'preamble': '',
-}
-
-# Grouping the document tree into LaTeX files. List of tuples
-# (source start file, target name, title,
-#  author, documentclass [howto, manual, or own class]).
-latex_documents = [
-  ('index', 'Tailbone.tex', u'Tailbone Documentation',
-   u'Lance Edgar', 'manual'),
-]
-
-# The name of an image file (relative to this directory) to place at the top of
-# the title page.
-#latex_logo = None
-
-# For "manual" documents, if this is true, then toplevel headings are parts,
-# not chapters.
-#latex_use_parts = False
-
-# If true, show page references after internal links.
-#latex_show_pagerefs = False
-
-# If true, show URL addresses after external links.
-#latex_show_urls = False
-
-# Documents to append as an appendix to all manuals.
-#latex_appendices = []
-
-# If false, no module index is generated.
-#latex_domain_indices = True
-
-
-# -- Options for manual page output ---------------------------------------
-
-# One entry per manual page. List of tuples
-# (source start file, name, description, authors, manual section).
-man_pages = [
-    ('index', 'tailbone', u'Tailbone Documentation',
-     [u'Lance Edgar'], 1)
-]
-
-# If true, show URL addresses after external links.
-#man_show_urls = False
-
-
-# -- Options for Texinfo output -------------------------------------------
-
-# Grouping the document tree into Texinfo files. List of tuples
-# (source start file, target name, title, author,
-#  dir menu entry, description, category)
-texinfo_documents = [
-  ('index', 'Tailbone', u'Tailbone Documentation',
-   u'Lance Edgar', 'Tailbone', 'One line description of project.',
-   'Miscellaneous'),
-]
-
-# Documents to append as an appendix to all manuals.
-#texinfo_appendices = []
-
-# If false, no module index is generated.
-#texinfo_domain_indices = True
-
-# How to display URL addresses: 'footnote', 'no', or 'inline'.
-#texinfo_show_urls = 'footnote'
-
-# If true, do not generate a @detailmenu in the "Top" node's menu.
-#texinfo_no_detailmenu = False
+#htmlhelp_basename = 'Tailbonedoc'
diff --git a/docs/index.rst b/docs/index.rst
index 351e910d..d964086f 100644
--- a/docs/index.rst
+++ b/docs/index.rst
@@ -44,6 +44,7 @@ Package API:
 
    api/api/batch/core
    api/api/batch/ordering
+   api/db
    api/diffs
    api/forms
    api/forms.widgets
@@ -51,6 +52,7 @@ Package API:
    api/grids.core
    api/progress
    api/subscribers
+   api/util
    api/views/batch
    api/views/batch.vendorcatalog
    api/views/core
@@ -60,6 +62,14 @@ Package API:
    api/views/purchasing.ordering
 
 
+Changelog:
+
+.. toctree::
+   :maxdepth: 1
+
+   changelog
+
+
 Documentation To-Do
 ===================
 
diff --git a/pyproject.toml b/pyproject.toml
new file mode 100644
index 00000000..a7214a8e
--- /dev/null
+++ b/pyproject.toml
@@ -0,0 +1,103 @@
+
+[build-system]
+requires = ["hatchling"]
+build-backend = "hatchling.build"
+
+
+[project]
+name = "Tailbone"
+version = "0.22.7"
+description = "Backoffice Web Application for Rattail"
+readme = "README.md"
+authors = [{name = "Lance Edgar", email = "lance@edbob.org"}]
+license = {text = "GNU GPL v3+"}
+classifiers = [
+        "Development Status :: 4 - Beta",
+        "Environment :: Web Environment",
+        "Framework :: Pyramid",
+        "Intended Audience :: Developers",
+        "License :: OSI Approved :: GNU General Public License v3 or later (GPLv3+)",
+        "Natural Language :: English",
+        "Operating System :: OS Independent",
+        "Programming Language :: Python",
+        "Programming Language :: Python :: 3",
+        "Programming Language :: Python :: 3.8",
+        "Programming Language :: Python :: 3.9",
+        "Programming Language :: Python :: 3.10",
+        "Programming Language :: Python :: 3.11",
+        "Topic :: Internet :: WWW/HTTP",
+        "Topic :: Office/Business",
+        "Topic :: Software Development :: Libraries :: Python Modules",
+]
+requires-python = ">= 3.8"
+dependencies = [
+        "asgiref",
+        "colander",
+        "ColanderAlchemy",
+        "cornice",
+        "cornice-swagger",
+        "deform",
+        "humanize",
+        "Mako",
+        "markdown",
+        "openpyxl",
+        "paginate",
+        "paginate_sqlalchemy",
+        "passlib",
+        "Pillow",
+        "pyramid>=2",
+        "pyramid_beaker",
+        "pyramid_deform",
+        "pyramid_exclog",
+        "pyramid_fanstatic",
+        "pyramid_mako",
+        "pyramid_retry",
+        "pyramid_tm",
+        "rattail[db,bouncer]>=0.20.1",
+        "sa-filters",
+        "simplejson",
+        "transaction",
+        "waitress",
+        "WebHelpers2",
+        "WuttaWeb>=0.21.0",
+        "zope.sqlalchemy>=1.5",
+]
+
+
+[project.optional-dependencies]
+docs = ["Sphinx", "furo"]
+tests = ["coverage", "mock", "pytest", "pytest-cov"]
+
+
+[project.entry-points."paste.app_factory"]
+main = "tailbone.app:main"
+webapi = "tailbone.webapi:main"
+
+
+[project.entry-points."rattail.cleaners"]
+beaker = "tailbone.cleanup:BeakerCleaner"
+
+
+[project.entry-points."rattail.config.extensions"]
+tailbone = "tailbone.config:ConfigExtension"
+
+
+[project.urls]
+Homepage = "https://rattailproject.org"
+Repository = "https://forgejo.wuttaproject.org/rattail/tailbone"
+Issues = "https://forgejo.wuttaproject.org/rattail/tailbone/issues"
+Changelog = "https://forgejo.wuttaproject.org/rattail/tailbone/src/branch/master/CHANGELOG.md"
+
+
+[tool.commitizen]
+version_provider = "pep621"
+tag_format = "v$version"
+update_changelog_on_bump = true
+
+
+[tool.nosetests]
+nocapture = 1
+cover-package = "tailbone"
+cover-erase = 1
+cover-html = 1
+cover-html-dir = "htmlcov"
diff --git a/setup.cfg b/setup.cfg
deleted file mode 100644
index 5f46bc5c..00000000
--- a/setup.cfg
+++ /dev/null
@@ -1,103 +0,0 @@
-# -*- coding: utf-8; -*-
-
-[nosetests]
-nocapture = 1
-cover-package = tailbone
-cover-erase = 1
-cover-html = 1
-cover-html-dir = htmlcov
-
-[metadata]
-name = Tailbone
-version = attr: tailbone.__version__
-author = Lance Edgar
-author_email = lance@edbob.org
-url = http://rattailproject.org/
-license = GNU GPL v3
-description = Backoffice Web Application for Rattail
-long_description = file: README.rst
-classifiers =
-        Development Status :: 4 - Beta
-        Environment :: Web Environment
-        Framework :: Pyramid
-        Intended Audience :: Developers
-        License :: OSI Approved :: GNU General Public License v3 or later (GPLv3+)
-        Natural Language :: English
-        Operating System :: OS Independent
-        Programming Language :: Python
-        Programming Language :: Python :: 3
-        Programming Language :: Python :: 3.6
-        Programming Language :: Python :: 3.7
-        Programming Language :: Python :: 3.8
-        Programming Language :: Python :: 3.9
-        Programming Language :: Python :: 3.10
-        Programming Language :: Python :: 3.11
-        Topic :: Internet :: WWW/HTTP
-        Topic :: Office/Business
-        Topic :: Software Development :: Libraries :: Python Modules
-
-
-[options]
-install_requires =
-        asgiref
-        colander
-        ColanderAlchemy
-        cornice
-        cornice-swagger
-        deform
-        humanize
-        Mako
-        markdown
-        openpyxl
-        paginate
-        paginate_sqlalchemy
-        passlib
-        Pillow
-        pyramid>=2
-        pyramid_beaker
-        pyramid_deform
-        pyramid_exclog
-        pyramid_mako
-        pyramid_retry
-        pyramid_tm
-        rattail[db,bouncer]
-        six
-        sa-filters
-        simplejson
-        transaction
-        waitress
-        WebHelpers2
-        zope.sqlalchemy
-
-tests_require = Tailbone[tests]
-test_suite = tests
-packages = find:
-include_package_data = True
-zip_safe = False
-
-
-[options.packages.find]
-exclude =
-        tests.*
-        tests
-
-
-[options.extras_require]
-docs = Sphinx; sphinx-rtd-theme
-tests = coverage; mock; pytest; pytest-cov
-
-
-[options.entry_points]
-
-paste.app_factory =
-        main = tailbone.app:main
-        webapi = tailbone.webapi:main
-
-rattail.cleaners =
-        beaker = tailbone.cleanup:BeakerCleaner
-
-rattail.config.extensions =
-        tailbone = tailbone.config:ConfigExtension
-
-pyramid.scaffold =
-        rattail = tailbone.scaffolds:RattailTemplate
diff --git a/tailbone/_version.py b/tailbone/_version.py
index 2af82b6d..7095f6c8 100644
--- a/tailbone/_version.py
+++ b/tailbone/_version.py
@@ -1,3 +1,9 @@
 # -*- coding: utf-8; -*-
 
-__version__ = '0.10.12'
+try:
+    from importlib.metadata import version
+except ImportError:
+    from importlib_metadata import version
+
+
+__version__ = version('Tailbone')
diff --git a/tailbone/api/auth.py b/tailbone/api/auth.py
index 1b347b21..a710e30d 100644
--- a/tailbone/api/auth.py
+++ b/tailbone/api/auth.py
@@ -2,7 +2,7 @@
 ################################################################################
 #
 #  Rattail -- Retail Software Framework
-#  Copyright © 2010-2023 Lance Edgar
+#  Copyright © 2010-2024 Lance Edgar
 #
 #  This file is part of Rattail.
 #
@@ -24,8 +24,6 @@
 Tailbone Web API - Auth Views
 """
 
-from rattail.db.auth import set_user_password
-
 from cornice import Service
 
 from tailbone.api import APIView, api
@@ -42,11 +40,10 @@ class AuthenticationView(APIView):
         This will establish a server-side web session for the user if none
         exists.  Note that this also resets the user's session timer.
         """
-        data = {'ok': True}
+        data = {'ok': True, 'permissions': []}
         if self.request.user:
             data['user'] = self.get_user_info(self.request.user)
-
-        data['permissions'] = list(self.request.tailbone_cached_permissions)
+            data['permissions'] = list(self.request.user_permissions)
 
         # background color may be set per-request, by some apps
         if hasattr(self.request, 'background_color') and self.request.background_color:
@@ -176,7 +173,8 @@ class AuthenticationView(APIView):
             return {'error': "The current/old password you provided is incorrect"}
 
         # okay then, set new password
-        set_user_password(self.request.user, data['new_password'])
+        auth = self.app.get_auth_handler()
+        auth.set_user_password(self.request.user, data['new_password'])
         return {
             'ok': True,
             'user': self.get_user_info(self.request.user),
diff --git a/tailbone/api/batch/labels.py b/tailbone/api/batch/labels.py
index 4787aeb9..4f154b21 100644
--- a/tailbone/api/batch/labels.py
+++ b/tailbone/api/batch/labels.py
@@ -2,7 +2,7 @@
 ################################################################################
 #
 #  Rattail -- Retail Software Framework
-#  Copyright © 2010-2022 Lance Edgar
+#  Copyright © 2010-2024 Lance Edgar
 #
 #  This file is part of Rattail.
 #
@@ -24,10 +24,6 @@
 Tailbone Web API - Label Batches
 """
 
-from __future__ import unicode_literals, absolute_import
-
-import six
-
 from rattail.db import model
 
 from tailbone.api.batch import APIBatchView, APIBatchRowView
@@ -56,10 +52,10 @@ class LabelBatchRowViews(APIBatchRowView):
 
     def normalize(self, row):
         batch = row.batch
-        data = super(LabelBatchRowViews, self).normalize(row)
+        data = super().normalize(row)
 
         data['item_id'] = row.item_id
-        data['upc'] = six.text_type(row.upc)
+        data['upc'] = str(row.upc)
         data['upc_pretty'] = row.upc.pretty() if row.upc else None
         data['brand_name'] = row.brand_name
         data['description'] = row.description
diff --git a/tailbone/api/batch/ordering.py b/tailbone/api/batch/ordering.py
index 1b11194e..204be8ad 100644
--- a/tailbone/api/batch/ordering.py
+++ b/tailbone/api/batch/ordering.py
@@ -86,6 +86,8 @@ class OrderingBatchViews(APIBatchView):
         Sets the mode to "ordering" for the new batch.
         """
         data = dict(data)
+        if not data.get('vendor_uuid'):
+            raise ValueError("You must specify the vendor")
         data['mode'] = self.enum.PURCHASE_BATCH_MODE_ORDERING
         batch = super().create_object(data)
         return batch
diff --git a/tailbone/api/batch/receiving.py b/tailbone/api/batch/receiving.py
index daa4290f..b23bff55 100644
--- a/tailbone/api/batch/receiving.py
+++ b/tailbone/api/batch/receiving.py
@@ -29,8 +29,7 @@ import logging
 import humanize
 import sqlalchemy as sa
 
-from rattail.db import model
-from rattail.util import pretty_quantity
+from rattail.db.model import PurchaseBatch, PurchaseBatchRow
 
 from cornice import Service
 from deform import widget as dfwidget
@@ -45,7 +44,7 @@ log = logging.getLogger(__name__)
 
 class ReceivingBatchViews(APIBatchView):
 
-    model_class = model.PurchaseBatch
+    model_class = PurchaseBatch
     default_handler_spec = 'rattail.batch.purchase:PurchaseBatchHandler'
     route_prefix = 'receivingbatchviews'
     permission_prefix = 'receiving'
@@ -55,7 +54,8 @@ class ReceivingBatchViews(APIBatchView):
     supports_execute = True
 
     def base_query(self):
-        query = super(ReceivingBatchViews, self).base_query()
+        model = self.app.model
+        query = super().base_query()
         query = query.filter(model.PurchaseBatch.mode == self.enum.PURCHASE_BATCH_MODE_RECEIVING)
         return query
 
@@ -85,7 +85,7 @@ class ReceivingBatchViews(APIBatchView):
 
         # assume "receive from PO" if given a PO key
         if data.get('purchase_key'):
-            data['receiving_workflow'] = 'from_po'
+            data['workflow'] = 'from_po'
 
         return super().create_object(data)
 
@@ -120,6 +120,7 @@ class ReceivingBatchViews(APIBatchView):
         return self._get(obj=batch)
 
     def eligible_purchases(self):
+        model = self.app.model
         uuid = self.request.params.get('vendor_uuid')
         vendor = self.Session.get(model.Vendor, uuid) if uuid else None
         if not vendor:
@@ -176,7 +177,7 @@ class ReceivingBatchViews(APIBatchView):
 
 class ReceivingBatchRowViews(APIBatchRowView):
 
-    model_class = model.PurchaseBatchRow
+    model_class = PurchaseBatchRow
     default_handler_spec = 'rattail.batch.purchase:PurchaseBatchHandler'
     route_prefix = 'receiving.rows'
     permission_prefix = 'receiving'
@@ -185,7 +186,8 @@ class ReceivingBatchRowViews(APIBatchRowView):
     supports_quick_entry = True
 
     def make_filter_spec(self):
-        filters = super(ReceivingBatchRowViews, self).make_filter_spec()
+        model = self.app.model
+        filters = super().make_filter_spec()
         if filters:
 
             # must translate certain convenience filters
@@ -296,11 +298,11 @@ class ReceivingBatchRowViews(APIBatchRowView):
         return filters
 
     def normalize(self, row):
-        data = super(ReceivingBatchRowViews, self).normalize(row)
+        data = super().normalize(row)
+        model = self.app.model
 
         batch = row.batch
-        app = self.get_rattail_app()
-        prodder = app.get_products_handler()
+        prodder = self.app.get_products_handler()
 
         data['product_uuid'] = row.product_uuid
         data['item_id'] = row.item_id
@@ -375,7 +377,7 @@ class ReceivingBatchRowViews(APIBatchRowView):
                 if accounted_for:
                     # some product accounted for; button should receive "remainder" only
                     if remainder:
-                        remainder = pretty_quantity(remainder)
+                        remainder = self.app.render_quantity(remainder)
                         data['quick_receive_quantity'] = remainder
                         data['quick_receive_text'] = "Receive Remainder ({} {})".format(
                             remainder, data['unit_uom'])
@@ -386,7 +388,7 @@ class ReceivingBatchRowViews(APIBatchRowView):
                 else: # nothing yet accounted for, button should receive "all"
                     if not remainder:
                         log.warning("quick receive remainder is empty for row %s", row.uuid)
-                    remainder = pretty_quantity(remainder)
+                    remainder = self.app.render_quantity(remainder)
                     data['quick_receive_quantity'] = remainder
                     data['quick_receive_text'] = "Receive ALL ({} {})".format(
                         remainder, data['unit_uom'])
@@ -414,7 +416,7 @@ class ReceivingBatchRowViews(APIBatchRowView):
             data['received_alert'] = None
             if self.batch_handler.get_units_confirmed(row):
                 msg = "You have already received some of this product; last update was {}.".format(
-                    humanize.naturaltime(app.make_utc() - row.modified))
+                    humanize.naturaltime(self.app.make_utc() - row.modified))
                 data['received_alert'] = msg
 
         return data
@@ -423,6 +425,8 @@ class ReceivingBatchRowViews(APIBatchRowView):
         """
         View which handles "receiving" against a particular batch row.
         """
+        model = self.app.model
+
         # first do basic input validation
         schema = ReceiveRow().bind(session=self.Session())
         form = forms.Form(schema=schema, request=self.request)
diff --git a/tailbone/api/common.py b/tailbone/api/common.py
index 30dfeab1..6cacfb06 100644
--- a/tailbone/api/common.py
+++ b/tailbone/api/common.py
@@ -2,7 +2,7 @@
 ################################################################################
 #
 #  Rattail -- Retail Software Framework
-#  Copyright © 2010-2023 Lance Edgar
+#  Copyright © 2010-2024 Lance Edgar
 #
 #  This file is part of Rattail.
 #
@@ -26,15 +26,12 @@ Tailbone Web API - "Common" Views
 
 from collections import OrderedDict
 
-import rattail
-from rattail.db import model
-from rattail.mail import send_email
+from rattail.util import get_pkg_version
 
 from cornice import Service
 from cornice.service import get_services
 from cornice_swagger import CorniceSwagger
 
-import tailbone
 from tailbone import forms
 from tailbone.forms.common import Feedback
 from tailbone.api import APIView, api
@@ -66,11 +63,12 @@ class CommonView(APIView):
         }
 
     def get_project_title(self):
-        return self.rattail_config.app_title(default="Tailbone")
+        app = self.get_rattail_app()
+        return app.get_title()
 
     def get_project_version(self):
-        import tailbone
-        return tailbone.__version__
+        app = self.get_rattail_app()
+        return app.get_version()
 
     def get_packages(self):
         """
@@ -78,8 +76,8 @@ class CommonView(APIView):
         'about' page.
         """
         return OrderedDict([
-            ('rattail', rattail.__version__),
-            ('Tailbone', tailbone.__version__),
+            ('rattail', get_pkg_version('rattail')),
+            ('Tailbone', get_pkg_version('Tailbone')),
         ])
 
     @api
@@ -87,6 +85,8 @@ class CommonView(APIView):
         """
         View to handle user feedback form submits.
         """
+        app = self.get_rattail_app()
+        model = self.model
         # TODO: this logic was copied from tailbone.views.common and is largely
         # identical; perhaps should merge somehow?
         schema = Feedback().bind(session=Session())
@@ -106,7 +106,7 @@ class CommonView(APIView):
 
             data['client_ip'] = self.request.client_addr
             email_key = data['email_key'] or self.feedback_email_key
-            send_email(self.rattail_config, email_key, data=data)
+            app.send_email(email_key, data=data)
             return {'ok': True}
 
         return {'error': "Form did not validate!"}
diff --git a/tailbone/api/core.py b/tailbone/api/core.py
index b278d4af..0d8eec32 100644
--- a/tailbone/api/core.py
+++ b/tailbone/api/core.py
@@ -2,7 +2,7 @@
 ################################################################################
 #
 #  Rattail -- Retail Software Framework
-#  Copyright © 2010-2023 Lance Edgar
+#  Copyright © 2010-2024 Lance Edgar
 #
 #  This file is part of Rattail.
 #
@@ -102,7 +102,7 @@ class APIView(View):
         auth = app.get_auth_handler()
 
         # basic / default info
-        is_admin = user.is_admin()
+        is_admin = auth.user_is_admin(user)
         employee = app.get_employee(user)
         info = {
             'uuid': user.uuid,
diff --git a/tailbone/api/customers.py b/tailbone/api/customers.py
index e9953572..85d28c24 100644
--- a/tailbone/api/customers.py
+++ b/tailbone/api/customers.py
@@ -2,7 +2,7 @@
 ################################################################################
 #
 #  Rattail -- Retail Software Framework
-#  Copyright © 2010-2022 Lance Edgar
+#  Copyright © 2010-2024 Lance Edgar
 #
 #  This file is part of Rattail.
 #
@@ -24,10 +24,6 @@
 Tailbone Web API - Customer Views
 """
 
-from __future__ import unicode_literals, absolute_import
-
-import six
-
 from rattail.db import model
 
 from tailbone.api import APIMasterView
@@ -46,7 +42,7 @@ class CustomerView(APIMasterView):
     def normalize(self, customer):
         return {
             'uuid': customer.uuid,
-            '_str': six.text_type(customer),
+            '_str': str(customer),
             'id': customer.id,
             'number': customer.number,
             'name': customer.name,
diff --git a/tailbone/api/master.py b/tailbone/api/master.py
index 70616484..551d6428 100644
--- a/tailbone/api/master.py
+++ b/tailbone/api/master.py
@@ -2,7 +2,7 @@
 ################################################################################
 #
 #  Rattail -- Retail Software Framework
-#  Copyright © 2010-2023 Lance Edgar
+#  Copyright © 2010-2024 Lance Edgar
 #
 #  This file is part of Rattail.
 #
@@ -26,12 +26,11 @@ Tailbone Web API - Master View
 
 import json
 
-from rattail.config import parse_bool
 from rattail.db.util import get_fieldnames
 
 from cornice import resource, Service
 
-from tailbone.api import APIView, api
+from tailbone.api import APIView
 from tailbone.db import Session
 from tailbone.util import SortColumn
 
@@ -185,7 +184,7 @@ class APIMasterView(APIView):
             if sortcol:
                 spec = {
                     'field': sortcol.field_name,
-                    'direction': 'asc' if parse_bool(self.request.params['ascending']) else 'desc',
+                    'direction': 'asc' if self.config.parse_bool(self.request.params['ascending']) else 'desc',
                 }
                 if sortcol.model_name:
                     spec['model'] = sortcol.model_name
@@ -355,9 +354,13 @@ class APIMasterView(APIView):
         data = self.request.json_body
 
         # add instance to session, and return data for it
-        obj = self.create_object(data)
-        self.Session.flush()
-        return self._get(obj)
+        try:
+            obj = self.create_object(data)
+        except Exception as error:
+            return self.json_response({'error': str(error)})
+        else:
+            self.Session.flush()
+            return self._get(obj)
 
     def create_object(self, data):
         """
diff --git a/tailbone/api/people.py b/tailbone/api/people.py
index 7e06e969..f7c08dfa 100644
--- a/tailbone/api/people.py
+++ b/tailbone/api/people.py
@@ -2,7 +2,7 @@
 ################################################################################
 #
 #  Rattail -- Retail Software Framework
-#  Copyright © 2010-2022 Lance Edgar
+#  Copyright © 2010-2024 Lance Edgar
 #
 #  This file is part of Rattail.
 #
@@ -24,10 +24,6 @@
 Tailbone Web API - Person Views
 """
 
-from __future__ import unicode_literals, absolute_import
-
-import six
-
 from rattail.db import model
 
 from tailbone.api import APIMasterView
@@ -45,7 +41,7 @@ class PersonView(APIMasterView):
     def normalize(self, person):
         return {
             'uuid': person.uuid,
-            '_str': six.text_type(person),
+            '_str': str(person),
             'first_name': person.first_name,
             'last_name': person.last_name,
             'display_name': person.display_name,
diff --git a/tailbone/api/upgrades.py b/tailbone/api/upgrades.py
index 6ce5f778..467c8a0d 100644
--- a/tailbone/api/upgrades.py
+++ b/tailbone/api/upgrades.py
@@ -2,7 +2,7 @@
 ################################################################################
 #
 #  Rattail -- Retail Software Framework
-#  Copyright © 2010-2022 Lance Edgar
+#  Copyright © 2010-2024 Lance Edgar
 #
 #  This file is part of Rattail.
 #
@@ -24,10 +24,6 @@
 Tailbone Web API - Upgrade Views
 """
 
-from __future__ import unicode_literals, absolute_import
-
-import six
-
 from rattail.db import model
 
 from tailbone.api import APIMasterView
@@ -53,7 +49,7 @@ class UpgradeView(APIMasterView):
             data['status_code'] = None
         else:
             data['status_code'] = self.enum.UPGRADE_STATUS.get(upgrade.status_code,
-                                                               six.text_type(upgrade.status_code))
+                                                               str(upgrade.status_code))
         return data
 
 
diff --git a/tailbone/api/vendors.py b/tailbone/api/vendors.py
index 7fa61590..64311b1b 100644
--- a/tailbone/api/vendors.py
+++ b/tailbone/api/vendors.py
@@ -2,7 +2,7 @@
 ################################################################################
 #
 #  Rattail -- Retail Software Framework
-#  Copyright © 2010-2022 Lance Edgar
+#  Copyright © 2010-2024 Lance Edgar
 #
 #  This file is part of Rattail.
 #
@@ -24,10 +24,6 @@
 Tailbone Web API - Vendor Views
 """
 
-from __future__ import unicode_literals, absolute_import
-
-import six
-
 from rattail.db import model
 
 from tailbone.api import APIMasterView
@@ -44,7 +40,7 @@ class VendorView(APIMasterView):
     def normalize(self, vendor):
         return {
             'uuid': vendor.uuid,
-            '_str': six.text_type(vendor),
+            '_str': str(vendor),
             'id': vendor.id,
             'name': vendor.name,
         }
diff --git a/tailbone/api/workorders.py b/tailbone/api/workorders.py
index eabe4cdb..19def6c4 100644
--- a/tailbone/api/workorders.py
+++ b/tailbone/api/workorders.py
@@ -2,7 +2,7 @@
 ################################################################################
 #
 #  Rattail -- Retail Software Framework
-#  Copyright © 2010-2022 Lance Edgar
+#  Copyright © 2010-2024 Lance Edgar
 #
 #  This file is part of Rattail.
 #
@@ -24,12 +24,8 @@
 Tailbone Web API - Work Order Views
 """
 
-from __future__ import unicode_literals, absolute_import
-
 import datetime
 
-import six
-
 from rattail.db.model import WorkOrder
 
 from cornice import Service
@@ -44,19 +40,19 @@ class WorkOrderView(APIMasterView):
     object_url_prefix = '/workorder'
 
     def __init__(self, *args, **kwargs):
-        super(WorkOrderView, self).__init__(*args, **kwargs)
+        super().__init__(*args, **kwargs)
         app = self.get_rattail_app()
         self.workorder_handler = app.get_workorder_handler()
 
     def normalize(self, workorder):
-        data = super(WorkOrderView, self).normalize(workorder)
+        data = super().normalize(workorder)
         data.update({
             'customer_name': workorder.customer.name,
             'status_label': self.enum.WORKORDER_STATUS[workorder.status_code],
-            'date_submitted': six.text_type(workorder.date_submitted or ''),
-            'date_received': six.text_type(workorder.date_received or ''),
-            'date_released': six.text_type(workorder.date_released or ''),
-            'date_delivered': six.text_type(workorder.date_delivered or ''),
+            'date_submitted': str(workorder.date_submitted or ''),
+            'date_received': str(workorder.date_received or ''),
+            'date_released': str(workorder.date_released or ''),
+            'date_delivered': str(workorder.date_delivered or ''),
         })
         return data
 
@@ -87,7 +83,7 @@ class WorkOrderView(APIMasterView):
         if 'status_code' in data:
             data['status_code'] = int(data['status_code'])
 
-        return super(WorkOrderView, self).update_object(workorder, data)
+        return super().update_object(workorder, data)
 
     def status_codes(self):
         """
diff --git a/tailbone/app.py b/tailbone/app.py
index 5ca4c5c9..d2d0c5ef 100644
--- a/tailbone/app.py
+++ b/tailbone/app.py
@@ -25,17 +25,15 @@ Application Entry Point
 """
 
 import os
-import warnings
 
-import sqlalchemy as sa
 from sqlalchemy.orm import sessionmaker, scoped_session
 
-from rattail.config import make_config, parse_list
+from wuttjamaican.util import parse_list
+
+from rattail.config import make_config
 from rattail.exceptions import ConfigurationError
-from rattail.db.types import GPCType
 
 from pyramid.config import Configurator
-from pyramid.authentication import SessionAuthenticationPolicy
 from zope.sqlalchemy import register
 
 import tailbone.db
@@ -61,9 +59,23 @@ def make_rattail_config(settings):
         rattail_config = make_config(path)
         settings['rattail_config'] = rattail_config
 
+    # nb. this is for compaibility with wuttaweb
+    settings['wutta_config'] = rattail_config
+
+    # must import all sqlalchemy models before things get rolling,
+    # otherwise can have errors about continuum TransactionMeta class
+    # not yet mapped, when relevant pages are first requested...
+    # cf. https://docs.pylonsproject.org/projects/pyramid_cookbook/en/latest/database/sqlalchemy.html#importing-all-sqlalchemy-models
+    # hat tip to https://stackoverflow.com/a/59241485
+    if getattr(rattail_config, 'tempmon_engine', None):
+        from rattail_tempmon.db import model as tempmon_model, Session as TempmonSession
+        tempmon_session = TempmonSession()
+        tempmon_session.query(tempmon_model.Appliance).first()
+        tempmon_session.close()
+
     # configure database sessions
-    if hasattr(rattail_config, 'rattail_engine'):
-        tailbone.db.Session.configure(bind=rattail_config.rattail_engine)
+    if hasattr(rattail_config, 'appdb_engine'):
+        tailbone.db.Session.configure(bind=rattail_config.appdb_engine)
     if hasattr(rattail_config, 'trainwreck_engine'):
         tailbone.db.TrainwreckSession.configure(bind=rattail_config.trainwreck_engine)
     if hasattr(rattail_config, 'tempmon_engine'):
@@ -129,6 +141,7 @@ def make_pyramid_config(settings, configure_csrf=True):
         # we want the new themes feature!
         establish_theme(settings)
 
+        settings.setdefault('fanstatic.versioning', 'true')
         settings.setdefault('pyramid_deform.template_search_path', 'tailbone:templates/deform')
         config = Configurator(settings=settings, root_factory=Root)
 
@@ -147,6 +160,7 @@ def make_pyramid_config(settings, configure_csrf=True):
     # Bring in some Pyramid goodies.
     config.include('tailbone.beaker')
     config.include('pyramid_deform')
+    config.include('pyramid_fanstatic')
     config.include('pyramid_mako')
     config.include('pyramid_tm')
 
@@ -182,9 +196,16 @@ def make_pyramid_config(settings, configure_csrf=True):
             for spec in includes:
                 config.include(spec)
 
-    # Add some permissions magic.
-    config.add_directive('add_tailbone_permission_group', 'tailbone.auth.add_permission_group')
-    config.add_directive('add_tailbone_permission', 'tailbone.auth.add_permission')
+    # add some permissions magic
+    config.add_directive('add_wutta_permission_group',
+                         'wuttaweb.auth.add_permission_group')
+    config.add_directive('add_wutta_permission',
+                         'wuttaweb.auth.add_permission')
+    # TODO: deprecate / remove these
+    config.add_directive('add_tailbone_permission_group',
+                         'wuttaweb.auth.add_permission_group')
+    config.add_directive('add_tailbone_permission',
+                         'wuttaweb.auth.add_permission')
 
     # and some similar magic for certain master views
     config.add_directive('add_tailbone_index_page', 'tailbone.app.add_index_page')
@@ -311,7 +332,8 @@ def main(global_config, **settings):
     """
     This function returns a Pyramid WSGI application.
     """
-    settings.setdefault('mako.directories', ['tailbone:templates'])
+    settings.setdefault('mako.directories', ['tailbone:templates',
+                                             'wuttaweb:templates'])
     rattail_config = make_rattail_config(settings)
     pyramid_config = make_pyramid_config(settings)
     pyramid_config.include('tailbone')
diff --git a/tailbone/auth.py b/tailbone/auth.py
index 5a35caa6..95bf90ba 100644
--- a/tailbone/auth.py
+++ b/tailbone/auth.py
@@ -27,29 +27,28 @@ Authentication & Authorization
 import logging
 import re
 
-from rattail.util import prettify, NOTSET
+from wuttjamaican.util import UNSPECIFIED
 
-from zope.interface import implementer
-from pyramid.authentication import SessionAuthenticationHelper
-from pyramid.request import RequestLocalCache
 from pyramid.security import remember, forget
 
+from wuttaweb.auth import WuttaSecurityPolicy
 from tailbone.db import Session
 
 
 log = logging.getLogger(__name__)
 
 
-def login_user(request, user, timeout=NOTSET):
+def login_user(request, user, timeout=UNSPECIFIED):
     """
     Perform the steps necessary to login the given user.  Note that this
     returns a ``headers`` dict which you should pass to the redirect.
     """
-    app = request.rattail_config.get_app()
+    config = request.rattail_config
+    app = config.get_app()
     user.record_event(app.enum.USER_EVENT_LOGIN)
     headers = remember(request, user.uuid)
-    if timeout is NOTSET:
-        timeout = session_timeout_for_user(user)
+    if timeout is UNSPECIFIED:
+        timeout = session_timeout_for_user(config, user)
     log.debug("setting session timeout for '{}' to {}".format(user.username, timeout))
     set_session_timeout(request, timeout)
     return headers
@@ -70,15 +69,18 @@ def logout_user(request):
     return headers
 
 
-def session_timeout_for_user(user):
+def session_timeout_for_user(config, user):
     """
     Returns the "max" session timeout for the user, according to roles
     """
-    from rattail.db.auth import authenticated_role
+    app = config.get_app()
+    auth = app.get_auth_handler()
 
-    roles = user.roles + [authenticated_role(Session())]
+    authenticated = auth.get_role_authenticated(Session())
+    roles = user.roles + [authenticated]
     timeouts = [role.session_timeout for role in roles
                 if role.session_timeout is not None]
+
     if timeouts and 0 not in timeouts:
         return max(timeouts)
 
@@ -90,12 +92,12 @@ def set_session_timeout(request, timeout):
     request.session['_timeout'] = timeout or None
 
 
-class TailboneSecurityPolicy:
+class TailboneSecurityPolicy(WuttaSecurityPolicy):
 
-    def __init__(self, api_mode=False):
+    def __init__(self, db_session=None, api_mode=False, **kwargs):
+        kwargs['db_session'] = db_session or Session()
+        super().__init__(**kwargs)
         self.api_mode = api_mode
-        self.session_helper = SessionAuthenticationHelper()
-        self.identity_cache = RequestLocalCache(self.load_identity)
 
     def load_identity(self, request):
         config = request.registry.settings.get('rattail_config')
@@ -111,7 +113,7 @@ class TailboneSecurityPolicy:
                 if match:
                     token = match.group(1)
                     auth = app.get_auth_handler()
-                    user = auth.authenticate_user_token(Session(), token)
+                    user = auth.authenticate_user_token(self.db_session, token)
 
         if not user:
 
@@ -122,63 +124,10 @@ class TailboneSecurityPolicy:
 
             # fetch user object from db
             model = app.model
-            user = Session.get(model.User, uuid)
+            user = self.db_session.get(model.User, uuid)
             if not user:
                 return
 
         # this user is responsible for data changes in current request
-        Session().set_continuum_user(user)
+        self.db_session.set_continuum_user(user)
         return user
-
-    def identity(self, request):
-        return self.identity_cache.get_or_create(request)
-
-    def authenticated_userid(self, request):
-        user = self.identity(request)
-        if user is not None:
-            return user.uuid
-
-    def remember(self, request, userid, **kw):
-        return self.session_helper.remember(request, userid, **kw)
-
-    def forget(self, request, **kw):
-        return self.session_helper.forget(request, **kw)
-
-    def permits(self, request, context, permission):
-        # nb. root user can do anything
-        if request.is_root:
-            return True
-
-        config = request.registry.settings.get('rattail_config')
-        app = config.get_app()
-        auth = app.get_auth_handler()
-
-        user = self.identity(request)
-        return auth.has_permission(Session(), user, permission)
-
-
-def add_permission_group(config, key, label=None, overwrite=True):
-    """
-    Add a permission group to the app configuration.
-    """
-    def action():
-        perms = config.get_settings().get('tailbone_permissions', {})
-        if key not in perms or overwrite:
-            group = perms.setdefault(key, {'key': key})
-            group['label'] = label or prettify(key)
-        config.add_settings({'tailbone_permissions': perms})
-    config.action(None, action)
-
-
-def add_permission(config, groupkey, key, label=None):
-    """
-    Add a permission to the app configuration.
-    """
-    def action():
-        perms = config.get_settings().get('tailbone_permissions', {})
-        group = perms.setdefault(groupkey, {'key': groupkey})
-        group.setdefault('label', prettify(groupkey))
-        perm = group.setdefault('perms', {}).setdefault(key, {'key': key})
-        perm['label'] = label or prettify(key)
-        config.add_settings({'tailbone_permissions': perms})
-    config.action(None, action)
diff --git a/tailbone/beaker.py b/tailbone/beaker.py
index b5d592f1..25a450df 100644
--- a/tailbone/beaker.py
+++ b/tailbone/beaker.py
@@ -2,7 +2,7 @@
 ################################################################################
 #
 #  Rattail -- Retail Software Framework
-#  Copyright © 2010-2022 Lance Edgar
+#  Copyright © 2010-2024 Lance Edgar
 #
 #  This file is part of Rattail.
 #
@@ -27,11 +27,11 @@ Note that most of the code for this module was copied from the beaker and
 pyramid_beaker projects.
 """
 
-from __future__ import unicode_literals, absolute_import
-
 import time
 from pkg_resources import parse_version
 
+from rattail.util import get_pkg_version
+
 import beaker
 from beaker.session import Session
 from beaker.util import coerce_session_params
@@ -49,7 +49,7 @@ class TailboneSession(Session):
         "Loads the data from this session from persistent storage"
 
         # are we using older version of beaker?
-        old_beaker = parse_version(beaker.__version__) < parse_version('1.12')
+        old_beaker = parse_version(get_pkg_version('beaker')) < parse_version('1.12')
 
         self.namespace = self.namespace_class(self.id,
             data_dir=self.data_dir,
diff --git a/tailbone/config.py b/tailbone/config.py
index ee906149..8392ba0a 100644
--- a/tailbone/config.py
+++ b/tailbone/config.py
@@ -26,13 +26,14 @@ Rattail config extension for Tailbone
 
 import warnings
 
-from rattail.config import ConfigExtension as BaseExtension
+from wuttjamaican.conf import WuttaConfigExtension
+
 from rattail.db.config import configure_session
 
 from tailbone.db import Session
 
 
-class ConfigExtension(BaseExtension):
+class ConfigExtension(WuttaConfigExtension):
     """
     Rattail config extension for Tailbone.  Does the following:
 
@@ -52,6 +53,9 @@ class ConfigExtension(BaseExtension):
         config.setdefault('tailbone', 'themes.keys', 'default, butterball')
         config.setdefault('tailbone', 'themes.expose_picker', 'true')
 
+        # override oruga detection
+        config.setdefault('wuttaweb.oruga_detector.spec', 'tailbone.util:should_use_oruga')
+
 
 def csrf_token_name(config):
     return config.get('tailbone', 'csrf_token_name', default='_csrf')
diff --git a/tailbone/db.py b/tailbone/db.py
index 4a6821f9..8b37f399 100644
--- a/tailbone/db.py
+++ b/tailbone/db.py
@@ -2,7 +2,7 @@
 ################################################################################
 #
 #  Rattail -- Retail Software Framework
-#  Copyright © 2010-2023 Lance Edgar
+#  Copyright © 2010-2024 Lance Edgar
 #
 #  This file is part of Rattail.
 #
@@ -21,14 +21,13 @@
 #
 ################################################################################
 """
-Database Stuff
+Database sessions etc.
 """
 
 import sqlalchemy as sa
 from zope.sqlalchemy import datamanager
 import sqlalchemy_continuum as continuum
 from sqlalchemy.orm import sessionmaker, scoped_session
-from pkg_resources import get_distribution, parse_version
 
 from rattail.db import SessionBase
 from rattail.db.continuum import versioning_manager
@@ -43,23 +42,28 @@ TrainwreckSession = scoped_session(sessionmaker())
 # empty dict for now, this must populated on app startup (if needed)
 ExtraTrainwreckSessions = {}
 
-# some of the logic below may need to vary somewhat, based on which version of
-# zope.sqlalchemy we have installed
-zope_sqlalchemy_version = get_distribution('zope.sqlalchemy').version
-zope_sqlalchemy_version_parsed = parse_version(zope_sqlalchemy_version)
-
 
 class TailboneSessionDataManager(datamanager.SessionDataManager):
-    """Integrate a top level sqlalchemy session transaction into a zope transaction
+    """
+    Integrate a top level sqlalchemy session transaction into a zope
+    transaction
 
     One phase variant.
 
     .. note::
-       This class appears to be necessary in order for the Continuum
-       integration to work alongside the Zope transaction integration.
+
+       This class appears to be necessary in order for the
+       SQLAlchemy-Continuum integration to work alongside the Zope
+       transaction integration.
+
+       It subclasses
+       ``zope.sqlalchemy.datamanager.SessionDataManager`` but injects
+       some SQLAlchemy-Continuum logic within :meth:`tpc_vote()`, and
+       is sort of monkey-patched into the mix.
     """
 
     def tpc_vote(self, trans):
+        """ """
         # for a one phase data manager commit last in tpc_vote
         if self.tx is not None:  # there may have been no work to do
 
@@ -71,126 +75,120 @@ class TailboneSessionDataManager(datamanager.SessionDataManager):
             self._finish('committed')
 
 
-def join_transaction(session, initial_state=datamanager.STATUS_ACTIVE, transaction_manager=datamanager.zope_transaction.manager, keep_session=False):
-    """Join a session to a transaction using the appropriate datamanager.
+def join_transaction(
+        session,
+        initial_state=datamanager.STATUS_ACTIVE,
+        transaction_manager=datamanager.zope_transaction.manager,
+        keep_session=False,
+):
+    """
+    Join a session to a transaction using the appropriate datamanager.
 
-    It is safe to call this multiple times, if the session is already joined
-    then it just returns.
+    It is safe to call this multiple times, if the session is already
+    joined then it just returns.
 
-    `initial_state` is either STATUS_ACTIVE, STATUS_INVALIDATED or STATUS_READONLY
+    `initial_state` is either STATUS_ACTIVE, STATUS_INVALIDATED or
+    STATUS_READONLY
 
-    If using the default initial status of STATUS_ACTIVE, you must ensure that
-    mark_changed(session) is called when data is written to the database.
+    If using the default initial status of STATUS_ACTIVE, you must
+    ensure that mark_changed(session) is called when data is written
+    to the database.
 
-    The ZopeTransactionExtesion SessionExtension can be used to ensure that this is
-    called automatically after session write operations.
+    The ZopeTransactionExtesion SessionExtension can be used to ensure
+    that this is called automatically after session write operations.
 
     .. note::
-       This function is copied from upstream, and tweaked so that our custom
-       :class:`TailboneSessionDataManager` will be used.
+
+       This function appears to be necessary in order for the
+       SQLAlchemy-Continuum integration to work alongside the Zope
+       transaction integration.
+
+       It overrides ``zope.sqlalchemy.datamanager.join_transaction()``
+       to ensure the custom :class:`TailboneSessionDataManager` is
+       used, and is sort of monkey-patched into the mix.
     """
     # the upstream internals of this function has changed a little over time.
     # unfortunately for us, that means we must include each variant here.
 
-    if zope_sqlalchemy_version_parsed >= parse_version('1.1'): # 1.1+
-        if datamanager._SESSION_STATE.get(session, None) is None:
-            if session.twophase:
-                DataManager = datamanager.TwoPhaseSessionDataManager
-            else:
-                DataManager = TailboneSessionDataManager
-            DataManager(session, initial_state, transaction_manager, keep_session=keep_session)
-
-    else: # pre-1.1
-        if datamanager._SESSION_STATE.get(id(session), None) is None:
-            if session.twophase:
-                DataManager = datamanager.TwoPhaseSessionDataManager
-            else:
-                DataManager = TailboneSessionDataManager
-            DataManager(session, initial_state, transaction_manager, keep_session=keep_session)
+    if datamanager._SESSION_STATE.get(session, None) is None:
+        if session.twophase:
+            DataManager = datamanager.TwoPhaseSessionDataManager
+        else:
+            DataManager = TailboneSessionDataManager
+        DataManager(session, initial_state, transaction_manager, keep_session=keep_session)
 
 
-if zope_sqlalchemy_version_parsed >= parse_version('1.2'): # 1.2+
-
-    class ZopeTransactionEvents(datamanager.ZopeTransactionEvents):
-        """
-        Record that a flush has occurred on a session's
-        connection. This allows the DataManager to rollback rather
-        than commit on read only transactions.
-
-        .. note::
-           This class is copied from upstream, and tweaked so that our
-           custom :func:`join_transaction()` will be used.
-        """
-
-        def after_begin(self, session, transaction, connection):
-            join_transaction(session, self.initial_state,
-                             self.transaction_manager, self.keep_session)
-
-        def after_attach(self, session, instance):
-            join_transaction(session, self.initial_state,
-                             self.transaction_manager, self.keep_session)
-
-        def join_transaction(self, session):
-            join_transaction(session, self.initial_state,
-                             self.transaction_manager, self.keep_session)
-
-else: # pre-1.2
-
-    class ZopeTransactionExtension(datamanager.ZopeTransactionExtension):
-        """
-        Record that a flush has occurred on a session's
-        connection. This allows the DataManager to rollback rather
-        than commit on read only transactions.
-
-        .. note::
-           This class is copied from upstream, and tweaked so that our
-           custom :func:`join_transaction()` will be used.
-        """
-
-        def after_begin(self, session, transaction, connection):
-            join_transaction(session, self.initial_state,
-                             self.transaction_manager, self.keep_session)
-
-        def after_attach(self, session, instance):
-            join_transaction(session, self.initial_state,
-                             self.transaction_manager, self.keep_session)
-
-
-def register(session, initial_state=datamanager.STATUS_ACTIVE,
-             transaction_manager=datamanager.zope_transaction.manager, keep_session=False):
-    """Register ZopeTransaction listener events on the
-    given Session or Session factory/class.
-
-    This function requires at least SQLAlchemy 0.7 and makes use
-    of the newer sqlalchemy.event package in order to register event listeners
-    on the given Session.
-
-    The session argument here may be a Session class or subclass, a
-    sessionmaker or scoped_session instance, or a specific Session instance.
-    Event listening will be specific to the scope of the type of argument
-    passed, including specificity to its subclass as well as its identity.
+class ZopeTransactionEvents(datamanager.ZopeTransactionEvents):
+    """
+    Record that a flush has occurred on a session's connection. This
+    allows the DataManager to rollback rather than commit on read only
+    transactions.
 
     .. note::
-       This function is copied from upstream, and tweaked so that our custom
-       :class:`ZopeTransactionExtension` will be used.
+
+       This class appears to be necessary in order for the
+       SQLAlchemy-Continuum integration to work alongside the Zope
+       transaction integration.
+
+       It subclasses
+       ``zope.sqlalchemy.datamanager.ZopeTransactionEvents`` but
+       overrides various methods to ensure the custom
+       :func:`join_transaction()` is called, and is sort of
+       monkey-patched into the mix.
+    """
+
+    def after_begin(self, session, transaction, connection):
+        """ """
+        join_transaction(session, self.initial_state,
+                         self.transaction_manager, self.keep_session)
+
+    def after_attach(self, session, instance):
+        """ """
+        join_transaction(session, self.initial_state,
+                         self.transaction_manager, self.keep_session)
+
+    def join_transaction(self, session):
+        """ """
+        join_transaction(session, self.initial_state,
+                         self.transaction_manager, self.keep_session)
+
+
+def register(
+        session,
+        initial_state=datamanager.STATUS_ACTIVE,
+        transaction_manager=datamanager.zope_transaction.manager,
+        keep_session=False,
+):
+    """
+    Register ZopeTransaction listener events on the given Session or
+    Session factory/class.
+
+    This function requires at least SQLAlchemy 0.7 and makes use of
+    the newer sqlalchemy.event package in order to register event
+    listeners on the given Session.
+
+    The session argument here may be a Session class or subclass, a
+    sessionmaker or scoped_session instance, or a specific Session
+    instance.  Event listening will be specific to the scope of the
+    type of argument passed, including specificity to its subclass as
+    well as its identity.
+
+    .. note::
+
+       This function appears to be necessary in order for the
+       SQLAlchemy-Continuum integration to work alongside the Zope
+       transaction integration.
+
+       It overrides ``zope.sqlalchemy.datamanager.regsiter()`` to
+       ensure the custom :class:`ZopeTransactionEvents` is used.
     """
     from sqlalchemy import event
 
-    if zope_sqlalchemy_version_parsed >= parse_version('1.2'): # 1.2+
-
-        ext = ZopeTransactionEvents(
-            initial_state=initial_state,
-            transaction_manager=transaction_manager,
-            keep_session=keep_session,
-        )
-
-    else: # pre-1.2
-
-        ext = ZopeTransactionExtension(
-            initial_state=initial_state,
-            transaction_manager=transaction_manager,
-            keep_session=keep_session,
-        )
+    ext = ZopeTransactionEvents(
+        initial_state=initial_state,
+        transaction_manager=transaction_manager,
+        keep_session=keep_session,
+    )
 
     event.listen(session, "after_begin", ext.after_begin)
     event.listen(session, "after_attach", ext.after_attach)
@@ -199,9 +197,8 @@ def register(session, initial_state=datamanager.STATUS_ACTIVE,
     event.listen(session, "after_bulk_delete", ext.after_bulk_delete)
     event.listen(session, "before_commit", ext.before_commit)
 
-    if zope_sqlalchemy_version_parsed >= parse_version('1.5'): # 1.5+
-        if datamanager.SA_GE_14:
-            event.listen(session, "do_orm_execute", ext.do_orm_execute)
+    if datamanager.SA_GE_14:
+        event.listen(session, "do_orm_execute", ext.do_orm_execute)
 
 
 register(Session)
diff --git a/tailbone/diffs.py b/tailbone/diffs.py
index 98253c57..2e582b15 100644
--- a/tailbone/diffs.py
+++ b/tailbone/diffs.py
@@ -2,7 +2,7 @@
 ################################################################################
 #
 #  Rattail -- Retail Software Framework
-#  Copyright © 2010-2023 Lance Edgar
+#  Copyright © 2010-2024 Lance Edgar
 #
 #  This file is part of Rattail.
 #
@@ -270,9 +270,21 @@ class VersionDiff(Diff):
         for field in self.fields:
             values[field] = {'before': self.render_old_value(field),
                              'after': self.render_new_value(field)}
+
+        operation = None
+        if self.version.operation_type == continuum.Operation.INSERT:
+            operation = 'INSERT'
+        elif self.version.operation_type == continuum.Operation.UPDATE:
+            operation = 'UPDATE'
+        elif self.version.operation_type == continuum.Operation.DELETE:
+            operation = 'DELETE'
+        else:
+            operation = self.version.operation_type
+
         return {
             'key': id(self.version),
             'model_title': self.title,
+            'operation': operation,
             'diff_class': self.nature,
             'fields': self.fields,
             'values': values,
diff --git a/tailbone/exceptions.py b/tailbone/exceptions.py
index beea1366..3468562a 100644
--- a/tailbone/exceptions.py
+++ b/tailbone/exceptions.py
@@ -2,7 +2,7 @@
 ################################################################################
 #
 #  Rattail -- Retail Software Framework
-#  Copyright © 2010-2020 Lance Edgar
+#  Copyright © 2010-2024 Lance Edgar
 #
 #  This file is part of Rattail.
 #
@@ -24,10 +24,6 @@
 Tailbone Exceptions
 """
 
-from __future__ import unicode_literals, absolute_import
-
-import six
-
 from rattail.exceptions import RattailError
 
 
@@ -37,7 +33,6 @@ class TailboneError(RattailError):
     """
 
 
-@six.python_2_unicode_compatible
 class TailboneJSONFieldError(TailboneError):
     """
     Error raised when JSON serialization of a form field results in an error.
diff --git a/tailbone/forms/core.py b/tailbone/forms/core.py
index d6303bb1..4024557b 100644
--- a/tailbone/forms/core.py
+++ b/tailbone/forms/core.py
@@ -35,7 +35,7 @@ from sqlalchemy import orm
 from sqlalchemy.ext.associationproxy import AssociationProxy, ASSOCIATION_PROXY
 from wuttjamaican.util import UNSPECIFIED
 
-from rattail.util import prettify, pretty_boolean
+from rattail.util import pretty_boolean
 from rattail.db.util import get_fieldnames
 
 import colander
@@ -47,8 +47,10 @@ from pyramid_deform import SessionFileUploadTempStore
 from pyramid.renderers import render
 from webhelpers2.html import tags, HTML
 
+from wuttaweb.util import FieldList, get_form_data, make_json_safe
+
 from tailbone.db import Session
-from tailbone.util import raw_datetime, get_form_data, render_markdown
+from tailbone.util import raw_datetime, render_markdown
 from tailbone.forms import types
 from tailbone.forms.widgets import (ReadonlyWidget, PlainDateWidget,
                                     JQueryDateWidget, JQueryTimeWidget,
@@ -326,7 +328,7 @@ class Form(object):
     """
     Base class for all forms.
     """
-    save_label = "Save"
+    save_label = "Submit"
     update_label = "Save"
     show_cancel = True
     auto_disable = True
@@ -337,10 +339,12 @@ class Form(object):
                  model_instance=None, model_class=None, appstruct=UNSPECIFIED, nodes={}, enums={}, labels={},
                  assume_local_times=False, renderers=None, renderer_kwargs={},
                  hidden={}, widgets={}, defaults={}, validators={}, required={}, helptext={}, focus_spec=None,
-                 action_url=None, cancel_url=None, component='tailbone-form',
+                 action_url=None, cancel_url=None,
+                 vue_tagname=None,
                  vuejs_component_kwargs=None, vuejs_field_converters={}, json_data={}, included_templates={},
                  # TODO: ugh this is getting out hand!
                  can_edit_help=False, edit_help_url=None, route_prefix=None,
+                 **kwargs
     ):
         self.fields = None
         if fields is not None:
@@ -378,7 +382,17 @@ class Form(object):
         self.focus_spec = focus_spec
         self.action_url = action_url
         self.cancel_url = cancel_url
-        self.component = component
+
+        # vue_tagname
+        self.vue_tagname = vue_tagname
+        if not self.vue_tagname and kwargs.get('component'):
+            warnings.warn("component kwarg is deprecated for Form(); "
+                          "please use vue_tagname param instead",
+                          DeprecationWarning, stacklevel=2)
+            self.vue_tagname = kwargs['component']
+        if not self.vue_tagname:
+            self.vue_tagname = 'tailbone-form'
+
         self.vuejs_component_kwargs = vuejs_component_kwargs or {}
         self.vuejs_field_converters = vuejs_field_converters or {}
         self.json_data = json_data or {}
@@ -387,14 +401,60 @@ class Form(object):
         self.edit_help_url = edit_help_url
         self.route_prefix = route_prefix
 
+        self.button_icon_submit = kwargs.get('button_icon_submit', 'save')
+
     def __iter__(self):
         return iter(self.fields)
 
     @property
-    def component_studly(self):
-        words = self.component.split('-')
+    def vue_component(self):
+        """
+        String name for the Vue component, e.g. ``'TailboneGrid'``.
+
+        This is a generated value based on :attr:`vue_tagname`.
+        """
+        words = self.vue_tagname.split('-')
         return ''.join([word.capitalize() for word in words])
 
+    @property
+    def component(self):
+        """
+        DEPRECATED - use :attr:`vue_tagname` instead.
+        """
+        warnings.warn("Form.component is deprecated; "
+                      "please use vue_tagname instead",
+                      DeprecationWarning, stacklevel=2)
+        return self.vue_tagname
+
+    @property
+    def component_studly(self):
+        """
+        DEPRECATED - use :attr:`vue_component` instead.
+        """
+        warnings.warn("Form.component_studly is deprecated; "
+                      "please use vue_component instead",
+                      DeprecationWarning, stacklevel=2)
+        return self.vue_component
+
+    def get_button_label_submit(self):
+        """ """
+        if hasattr(self, '_button_label_submit'):
+            return self._button_label_submit
+
+        label = getattr(self, 'submit_label', None)
+        if label:
+            return label
+
+        return self.save_label
+
+    def set_button_label_submit(self, value):
+        """ """
+        self._button_label_submit = value
+
+    # wutta compat
+    button_label_submit = property(get_button_label_submit,
+                                   set_button_label_submit)
+
     def __contains__(self, item):
         return item in self.fields
 
@@ -570,7 +630,9 @@ class Form(object):
             self.schema[key].title = label
 
     def get_label(self, key):
-        return self.labels.get(key, prettify(key))
+        config = self.request.rattail_config
+        app = config.get_app()
+        return self.labels.get(key, app.make_title(key))
 
     def set_readonly(self, key, readonly=True):
         if readonly:
@@ -801,6 +863,10 @@ class Form(object):
                       DeprecationWarning, stacklevel=2)
         return self.render_deform(**kwargs)
 
+    def get_deform(self):
+        """ """
+        return self.make_deform_form()
+
     def make_deform_form(self):
         if not hasattr(self, 'deform_form'):
 
@@ -839,6 +905,11 @@ class Form(object):
 
         return self.deform_form
 
+    def render_vue_template(self, template='/forms/deform.mako', **context):
+        """ """
+        output = self.render_deform(template=template, **context)
+        return HTML.literal(output)
+
     def render_deform(self, dform=None, template=None, **kwargs):
         if not template:
             template = '/forms/deform.mako'
@@ -861,8 +932,8 @@ class Form(object):
         context.setdefault('form_kwargs', {})
         # TODO: deprecate / remove the latter option here
         if self.auto_disable_save or self.auto_disable:
-            context['form_kwargs'].setdefault('ref', self.component_studly)
-            context['form_kwargs']['@submit'] = 'submit{}'.format(self.component_studly)
+            context['form_kwargs'].setdefault('ref', self.vue_component)
+            context['form_kwargs']['@submit'] = 'submit{}'.format(self.vue_component)
         if self.focus_spec:
             context['form_kwargs']['data-focus'] = self.focus_spec
         context['request'] = self.request
@@ -874,11 +945,13 @@ class Form(object):
         return dict([(field, self.get_label(field))
                      for field in self])
 
-    def get_field_markdowns(self):
-        model = self.request.rattail_config.get_model()
+    def get_field_markdowns(self, session=None):
+        app = self.request.rattail_config.get_app()
+        model = app.model
+        session = session or Session()
 
         if not hasattr(self, 'field_markdowns'):
-            infos = Session.query(model.TailboneFieldInfo)\
+            infos = session.query(model.TailboneFieldInfo)\
                            .filter(model.TailboneFieldInfo.route_prefix == self.route_prefix)\
                            .all()
             self.field_markdowns = dict([(info.field_name, info.markdown_text)
@@ -886,6 +959,18 @@ class Form(object):
 
         return self.field_markdowns
 
+    def get_vue_field_value(self, key):
+        """ """
+        if key not in self.fields:
+            return
+
+        dform = self.get_deform()
+        if key not in dform:
+            return
+
+        field = dform[key]
+        return make_json_safe(field.cstruct)
+
     def get_vuejs_model_value(self, field):
         """
         This method must return "raw" JS which will be assigned as the initial
@@ -952,7 +1037,11 @@ class Form(object):
     def set_vuejs_component_kwargs(self, **kwargs):
         self.vuejs_component_kwargs.update(kwargs)
 
-    def render_vuejs_component(self):
+    def render_vue_tag(self, **kwargs):
+        """ """
+        return self.render_vuejs_component(**kwargs)
+
+    def render_vuejs_component(self, **kwargs):
         """
         Render the Vue.js component HTML for the form.
 
@@ -963,10 +1052,11 @@ class Form(object):
            <tailbone-form :configure-fields-help="configureFieldsHelp">
            </tailbone-form>
         """
-        kwargs = dict(self.vuejs_component_kwargs)
+        kw = dict(self.vuejs_component_kwargs)
+        kw.update(kwargs)
         if self.can_edit_help:
-            kwargs.setdefault(':configure-fields-help', 'configureFieldsHelp')
-        return HTML.tag(self.component, **kwargs)
+            kw.setdefault(':configure-fields-help', 'configureFieldsHelp')
+        return HTML.tag(self.vue_tagname, **kw)
 
     def set_json_data(self, key, value):
         """
@@ -992,7 +1082,12 @@ class Form(object):
             templates.append(HTML.literal(render(template, context)))
         return HTML.literal('\n').join(templates)
 
-    def render_field_complete(self, fieldname, bfield_attrs={}):
+    def render_vue_field(self, fieldname, **kwargs):
+        """ """
+        return self.render_field_complete(fieldname, **kwargs)
+
+    def render_field_complete(self, fieldname, bfield_attrs={},
+                              session=None):
         """
         Render the given field completely, i.e. with ``<b-field>``
         wrapper.  Note that this is meant to render *editable* fields,
@@ -1010,7 +1105,7 @@ class Form(object):
 
         if self.field_visible(fieldname):
             label = self.get_label(fieldname)
-            markdowns = self.get_field_markdowns()
+            markdowns = self.get_field_markdowns(session=session)
 
             # these attrs will be for the <b-field> (*not* the widget)
             attrs = {
@@ -1129,6 +1224,18 @@ class Form(object):
             # TODO: again, why does serialize() not return literal?
             return HTML.literal(field.serialize())
 
+    # TODO: this was copied from wuttaweb; can remove when we align
+    # Form class structure
+    def render_vue_finalize(self):
+        """ """
+        set_data = f"{self.vue_component}.data = function() {{ return {self.vue_component}Data }}"
+        make_component = f"Vue.component('{self.vue_tagname}', {self.vue_component})"
+        return HTML.tag('script', c=['\n',
+                                     HTML.literal(set_data),
+                                     '\n',
+                                     HTML.literal(make_component),
+                                     '\n'])
+
     def render_field_readonly(self, field_name, **kwargs):
         """
         Render the given field completely, but in read-only fashion.
@@ -1268,12 +1375,19 @@ class Form(object):
 
     def obtain_value(self, record, field_name):
         if record:
+
+            if isinstance(record, dict):
+                return record[field_name]
+
+            try:
+                return getattr(record, field_name)
+            except AttributeError:
+                pass
+
             try:
                 return record[field_name]
-            except KeyError:
-                return None
             except TypeError:
-                return getattr(record, field_name, None)
+                pass
 
         # TODO: is this always safe to do?
         elif self.defaults and field_name in self.defaults:
@@ -1327,30 +1441,6 @@ class Form(object):
             return False
 
 
-class FieldList(list):
-    """
-    Convenience wrapper for a form's field list.
-    """
-
-    def insert_before(self, field, newfield):
-        if field in self:
-            i = self.index(field)
-            self.insert(i, newfield)
-        else:
-            log.warning("field '%s' not found, will append new field: %s",
-                        field, newfield)
-            self.append(newfield)
-
-    def insert_after(self, field, newfield):
-        if field in self:
-            i = self.index(field)
-            self.insert(i + 1, newfield)
-        else:
-            log.warning("field '%s' not found, will append new field: %s",
-                        field, newfield)
-            self.append(newfield)
-
-
 @colander.deferred
 def upload_widget(node, kw):
     request = kw['request']
diff --git a/tailbone/forms/widgets.py b/tailbone/forms/widgets.py
index 2923b7ec..8c16726d 100644
--- a/tailbone/forms/widgets.py
+++ b/tailbone/forms/widgets.py
@@ -477,7 +477,8 @@ class CustomerAutocompleteWidget(JQueryAutocompleteWidget):
     def __init__(self, request, *args, **kwargs):
         super().__init__(*args, **kwargs)
         self.request = request
-        model = self.request.rattail_config.get_model()
+        app = self.request.rattail_config.get_app()
+        model = app.model
 
         # must figure out URL providing autocomplete service
         if 'service_url' not in kwargs:
@@ -498,7 +499,8 @@ class CustomerAutocompleteWidget(JQueryAutocompleteWidget):
         """ """
         # fetch customer to provide button label, if we have a value
         if cstruct:
-            model = self.request.rattail_config.get_model()
+            app = self.request.rattail_config.get_app()
+            model = app.model
             customer = Session.get(model.Customer, cstruct)
             if customer:
                 self.field_display = str(customer)
@@ -552,7 +554,8 @@ class DepartmentWidget(dfwidget.SelectWidget):
     def __init__(self, request, **kwargs):
 
         if 'values' not in kwargs:
-            model = request.rattail_config.get_model()
+            app = request.rattail_config.get_app()
+            model = app.model
             departments = Session.query(model.Department)\
                                  .order_by(model.Department.number)
             values = [(dept.uuid, str(dept))
@@ -594,7 +597,8 @@ class VendorAutocompleteWidget(JQueryAutocompleteWidget):
     def __init__(self, request, *args, **kwargs):
         super().__init__(*args, **kwargs)
         self.request = request
-        model = self.request.rattail_config.get_model()
+        app = self.request.rattail_config.get_app()
+        model = app.model
 
         # must figure out URL providing autocomplete service
         if 'service_url' not in kwargs:
@@ -615,7 +619,8 @@ class VendorAutocompleteWidget(JQueryAutocompleteWidget):
         """ """
         # fetch vendor to provide button label, if we have a value
         if cstruct:
-            model = self.request.rattail_config.get_model()
+            app = self.request.rattail_config.get_app()
+            model = app.model
             vendor = Session.get(model.Vendor, cstruct)
             if vendor:
                 self.field_display = str(vendor)
@@ -643,7 +648,8 @@ class VendorDropdownWidget(dfwidget.SelectWidget):
                     vendors = vendors()
 
             else: # default vendor list
-                model = self.request.rattail_config.get_model()
+                app = self.request.rattail_config.get_app()
+                model = app.model
                 vendors = Session.query(model.Vendor)\
                                    .order_by(model.Vendor.name)\
                                    .all()
diff --git a/tailbone/grids/core.py b/tailbone/grids/core.py
index 91c3d1f5..56b97b86 100644
--- a/tailbone/grids/core.py
+++ b/tailbone/grids/core.py
@@ -24,20 +24,24 @@
 Core Grid Classes
 """
 
-from urllib.parse import urlencode
-import warnings
+import inspect
 import logging
+import warnings
+from urllib.parse import urlencode
 
 import sqlalchemy as sa
 from sqlalchemy import orm
 
+from wuttjamaican.util import UNSPECIFIED
 from rattail.db.types import GPCType
-from rattail.util import prettify, pretty_boolean, pretty_quantity
+from rattail.util import prettify, pretty_boolean
 
 from pyramid.renderers import render
 from webhelpers2.html import HTML, tags
 from paginate_sqlalchemy import SqlalchemyOrmPage
 
+from wuttaweb.grids import Grid as WuttaGrid, GridAction as WuttaGridAction, SortInfo
+from wuttaweb.util import FieldList
 from . import filters as gridfilters
 from tailbone.db import Session
 from tailbone.util import raw_datetime
@@ -46,23 +50,17 @@ from tailbone.util import raw_datetime
 log = logging.getLogger(__name__)
 
 
-class FieldList(list):
-    """
-    Convenience wrapper for a field list.
+class Grid(WuttaGrid):
     """
+    Base class for all grids.
 
-    def insert_before(self, field, newfield):
-        i = self.index(field)
-        self.insert(i, newfield)
+    This is now a subclass of
+    :class:`wuttaweb:wuttaweb.grids.base.Grid`, and exists to add
+    customizations which have traditionally been part of Tailbone.
 
-    def insert_after(self, field, newfield):
-        i = self.index(field)
-        self.insert(i + 1, newfield)
-
-
-class Grid(object):
-    """
-    Core grid class.  In sore need of documentation.
+    Some of these customizations are still undocumented.  Some will
+    eventually be moved to the upstream/parent class, and possibly
+    some will be removed outright.  What docs we have, are shown here.
 
     .. _Buefy docs: https://buefy.org/documentation/table/
 
@@ -185,31 +183,92 @@ class Grid(object):
           grid.row_uuid_getter = fake_uuid
     """
 
-    def __init__(self, key, data, columns=None, width='auto', request=None,
-                 model_class=None, model_title=None, model_title_plural=None,
-                 enums={}, labels={}, assume_local_times=False, renderers={}, invisible=[],
-                 raw_renderers={},
-                 extra_row_class=None, linked_columns=[], url='#',
-                 joiners={}, filterable=False, filters={}, use_byte_string_filters=False,
-                 searchable={},
-                 sortable=False, sorters={}, default_sortkey=None, default_sortdir='asc',
-                 pageable=False, default_pagesize=None, default_page=1,
-                 checkboxes=False, checked=None, check_handler=None, check_all_handler=None,
-                 checkable=None, row_uuid_getter=None,
-                 clicking_row_checks_box=False, click_handlers=None,
-                 main_actions=[], more_actions=[], delete_speedbump=False,
-                 ajax_data_url=None, component='tailbone-grid',
-                 expose_direct_link=False,
-                 **kwargs):
+    def __init__(
+            self,
+            request,
+            key=None,
+            data=None,
+            width='auto',
+            model_title=None,
+            model_title_plural=None,
+            enums={},
+            assume_local_times=False,
+            invisible=[],
+            raw_renderers={},
+            extra_row_class=None,
+            url='#',
+            use_byte_string_filters=False,
+            checkboxes=False,
+            checked=None,
+            check_handler=None,
+            check_all_handler=None,
+            checkable=None,
+            row_uuid_getter=None,
+            clicking_row_checks_box=False,
+            click_handlers=None,
+            main_actions=[],
+            more_actions=[],
+            delete_speedbump=False,
+            ajax_data_url=None,
+            expose_direct_link=False,
+            **kwargs,
+    ):
+        if 'component' in kwargs:
+            warnings.warn("component param is deprecated for Grid(); "
+                          "please use vue_tagname param instead",
+                          DeprecationWarning, stacklevel=2)
+            kwargs.setdefault('vue_tagname', kwargs.pop('component'))
 
-        self.key = key
-        self.data = data
-        self.columns = FieldList(columns) if columns is not None else None
-        self.width = width
-        self.request = request
-        self.model_class = model_class
-        if self.model_class and self.columns is None:
-            self.columns = self.make_columns()
+        if 'default_sortkey' in kwargs:
+            warnings.warn("default_sortkey param is deprecated for Grid(); "
+                          "please use sort_defaults param instead",
+                          DeprecationWarning, stacklevel=2)
+        if 'default_sortdir' in kwargs:
+            warnings.warn("default_sortdir param is deprecated for Grid(); "
+                          "please use sort_defaults param instead",
+                          DeprecationWarning, stacklevel=2)
+        if 'default_sortkey' in kwargs or 'default_sortdir' in kwargs:
+            sortkey = kwargs.pop('default_sortkey', None)
+            sortdir = kwargs.pop('default_sortdir', 'asc')
+            if sortkey:
+                kwargs.setdefault('sort_defaults', [(sortkey, sortdir)])
+
+        if 'pageable' in kwargs:
+            warnings.warn("pageable param is deprecated for Grid(); "
+                          "please use paginated param instead",
+                          DeprecationWarning, stacklevel=2)
+            kwargs.setdefault('paginated', kwargs.pop('pageable'))
+
+        if 'default_pagesize' in kwargs:
+            warnings.warn("default_pagesize param is deprecated for Grid(); "
+                          "please use pagesize param instead",
+                          DeprecationWarning, stacklevel=2)
+            kwargs.setdefault('pagesize', kwargs.pop('default_pagesize'))
+
+        if 'default_page' in kwargs:
+            warnings.warn("default_page param is deprecated for Grid(); "
+                          "please use page param instead",
+                          DeprecationWarning, stacklevel=2)
+            kwargs.setdefault('page', kwargs.pop('default_page'))
+
+        if 'searchable' in kwargs:
+            warnings.warn("searchable param is deprecated for Grid(); "
+                          "please use searchable_columns param instead",
+                          DeprecationWarning, stacklevel=2)
+            kwargs.setdefault('searchable_columns', kwargs.pop('searchable'))
+
+        # TODO: this should not be needed once all templates correctly
+        # reference grid.vue_component etc.
+        kwargs.setdefault('vue_tagname', 'tailbone-grid')
+
+        # nb. these must be set before super init, as they are
+        # referenced when constructing filters
+        self.assume_local_times = assume_local_times
+        self.use_byte_string_filters = use_byte_string_filters
+
+        kwargs['key'] = key
+        kwargs['data'] = data
+        super().__init__(request, **kwargs)
 
         self.model_title = model_title
         if not self.model_title and self.model_class and hasattr(self.model_class, 'get_model_title'):
@@ -222,32 +281,13 @@ class Grid(object):
             if not self.model_title_plural:
                 self.model_title_plural = '{}s'.format(self.model_title)
 
+        self.width = width
         self.enums = enums or {}
-
-        self.labels = labels or {}
-        self.assume_local_times = assume_local_times
-        self.renderers = self.make_default_renderers(renderers or {})
+        self.renderers = self.make_default_renderers(self.renderers)
         self.raw_renderers = raw_renderers or {}
         self.invisible = invisible or []
         self.extra_row_class = extra_row_class
-        self.linked_columns = linked_columns or []
         self.url = url
-        self.joiners = joiners or {}
-
-        self.filterable = filterable
-        self.use_byte_string_filters = use_byte_string_filters
-        self.filters = self.make_filters(filters)
-
-        self.searchable = searchable or {}
-
-        self.sortable = sortable
-        self.sorters = self.make_sorters(sorters)
-        self.default_sortkey = default_sortkey
-        self.default_sortdir = default_sortdir
-
-        self.pageable = pageable
-        self.default_pagesize = default_pagesize
-        self.default_page = default_page
 
         self.checkboxes = checkboxes
         self.checked = checked
@@ -261,43 +301,104 @@ class Grid(object):
 
         self.click_handlers = click_handlers or {}
 
-        self.main_actions = main_actions or []
-        self.more_actions = more_actions or []
         self.delete_speedbump = delete_speedbump
 
         if ajax_data_url:
             self.ajax_data_url = ajax_data_url
         elif self.request:
-            self.ajax_data_url = self.request.current_route_url(_query=None)
+            self.ajax_data_url = self.request.path_url
         else:
             self.ajax_data_url = ''
 
-        self.component = component
+        self.main_actions = main_actions or []
+        if self.main_actions:
+            warnings.warn("main_actions param is deprecated for Grdi(); "
+                          "please use actions param instead",
+                          DeprecationWarning, stacklevel=2)
+            self.actions.extend(self.main_actions)
+        self.more_actions = more_actions or []
+        if self.more_actions:
+            warnings.warn("more_actions param is deprecated for Grdi(); "
+                          "please use actions param instead",
+                          DeprecationWarning, stacklevel=2)
+            self.actions.extend(self.more_actions)
+
         self.expose_direct_link = expose_direct_link
         self._whgrid_kwargs = kwargs
 
+    @property
+    def component(self):
+        """ """
+        warnings.warn("Grid.component is deprecated; "
+                      "please use vue_tagname instead",
+                      DeprecationWarning, stacklevel=2)
+        return self.vue_tagname
+
     @property
     def component_studly(self):
-        words = self.component.split('-')
-        return ''.join([word.capitalize() for word in words])
+        """ """
+        warnings.warn("Grid.component_studly is deprecated; "
+                      "please use vue_component instead",
+                      DeprecationWarning, stacklevel=2)
+        return self.vue_component
 
-    def make_columns(self):
-        """
-        Return a default list of columns, based on :attr:`model_class`.
-        """
-        if not self.model_class:
-            raise ValueError("Must define model_class to use make_columns()")
+    def get_default_sortkey(self):
+        """ """
+        warnings.warn("Grid.default_sortkey is deprecated; "
+                      "please use Grid.sort_defaults instead",
+                      DeprecationWarning, stacklevel=2)
+        if self.sort_defaults:
+            return self.sort_defaults[0].sortkey
 
-        mapper = orm.class_mapper(self.model_class)
-        return [prop.key for prop in mapper.iterate_properties]
+    def set_default_sortkey(self, value):
+        """ """
+        warnings.warn("Grid.default_sortkey is deprecated; "
+                      "please use Grid.sort_defaults instead",
+                      DeprecationWarning, stacklevel=2)
+        if self.sort_defaults:
+            info = self.sort_defaults[0]
+            self.sort_defaults[0] = SortInfo(value, info.sortdir)
+        else:
+            self.sort_defaults = [SortInfo(value, 'asc')]
 
-    def remove(self, *keys):
-        """
-        This *removes* some column(s) from the grid, altogether.
-        """
-        for key in keys:
-            if key in self.columns:
-                self.columns.remove(key)
+    default_sortkey = property(get_default_sortkey, set_default_sortkey)
+
+    def get_default_sortdir(self):
+        """ """
+        warnings.warn("Grid.default_sortdir is deprecated; "
+                      "please use Grid.sort_defaults instead",
+                      DeprecationWarning, stacklevel=2)
+        if self.sort_defaults:
+            return self.sort_defaults[0].sortdir
+
+    def set_default_sortdir(self, value):
+        """ """
+        warnings.warn("Grid.default_sortdir is deprecated; "
+                      "please use Grid.sort_defaults instead",
+                      DeprecationWarning, stacklevel=2)
+        if self.sort_defaults:
+            info = self.sort_defaults[0]
+            self.sort_defaults[0] = SortInfo(info.sortkey, value)
+        else:
+            raise ValueError("cannot set default_sortdir without default_sortkey")
+
+    default_sortdir = property(get_default_sortdir, set_default_sortdir)
+
+    def get_pageable(self):
+        """ """
+        warnings.warn("Grid.pageable is deprecated; "
+                      "please use Grid.paginated instead",
+                      DeprecationWarning, stacklevel=2)
+        return self.paginated
+
+    def set_pageable(self, value):
+        """ """
+        warnings.warn("Grid.pageable is deprecated; "
+                      "please use Grid.paginated instead",
+                      DeprecationWarning, stacklevel=2)
+        self.paginated = value
+
+    pageable = property(get_pageable, set_pageable)
 
     def hide_column(self, key):
         """
@@ -331,9 +432,6 @@ class Grid(object):
             if key in self.invisible:
                 self.invisible.remove(key)
 
-    def append(self, field):
-        self.columns.append(field)
-
     def insert_before(self, field, newfield):
         self.columns.insert_before(field, newfield)
 
@@ -345,62 +443,54 @@ class Grid(object):
         self.remove(oldfield)
 
     def set_joiner(self, key, joiner):
+        """ """
         if joiner is None:
-            self.joiners.pop(key, None)
+            warnings.warn("specifying None is deprecated for Grid.set_joiner(); "
+                          "please use Grid.remove_joiner() instead",
+                          DeprecationWarning, stacklevel=2)
+            self.remove_joiner(key)
         else:
-            self.joiners[key] = joiner
+            super().set_joiner(key, joiner)
 
     def set_sorter(self, key, *args, **kwargs):
-        if len(args) == 1 and args[0] is None:
-            self.remove_sorter(key)
+        """ """
+
+        if len(args) == 1:
+            if kwargs:
+                warnings.warn("kwargs are ignored for Grid.set_sorter(); "
+                              "please refactor your code accordingly",
+                              DeprecationWarning, stacklevel=2)
+            if args[0] is None:
+                warnings.warn("specifying None is deprecated for Grid.set_sorter(); "
+                              "please use Grid.remove_sorter() instead",
+                              DeprecationWarning, stacklevel=2)
+                self.remove_sorter(key)
+            else:
+                super().set_sorter(key, args[0])
+
+        elif len(args) == 0:
+            super().set_sorter(key)
+
         else:
+            warnings.warn("multiple args are deprecated for Grid.set_sorter(); "
+                          "please refactor your code accordingly",
+                          DeprecationWarning, stacklevel=2)
             self.sorters[key] = self.make_sorter(*args, **kwargs)
 
-    def remove_sorter(self, key):
-        self.sorters.pop(key, None)
-
-    def set_sort_defaults(self, sortkey, sortdir='asc'):
-        self.default_sortkey = sortkey
-        self.default_sortdir = sortdir
-
     def set_filter(self, key, *args, **kwargs):
-        if len(args) == 1 and args[0] is None:
-            self.remove_filter(key)
-        else:
-            if 'label' not in kwargs and key in self.labels:
-                kwargs['label'] = self.labels[key]
-            self.filters[key] = self.make_filter(key, *args, **kwargs)
+        """ """
+        if len(args) == 1:
+            if args[0] is None:
+                warnings.warn("specifying None is deprecated for Grid.set_filter(); "
+                              "please use Grid.remove_filter() instead",
+                              DeprecationWarning, stacklevel=2)
+                self.remove_filter(key)
+                return
 
-    def set_searchable(self, key, searchable=True):
-        if searchable:
-            self.searchable[key] = True
-        else:
-            self.searchable.pop(key, None)
-
-    def is_searchable(self, key):
-        return self.searchable.get(key, False)
-
-    def remove_filter(self, key):
-        self.filters.pop(key, None)
-
-    def set_label(self, key, label, column_only=False):
-        self.labels[key] = label
-        if not column_only and key in self.filters:
-            self.filters[key].label = label
-
-    def get_label(self, key):
-        """
-        Returns the label text for given field key.
-        """
-        return self.labels.get(key, prettify(key))
-
-    def set_link(self, key, link=True):
-        if link:
-            if key not in self.linked_columns:
-                self.linked_columns.append(key)
-        else: # unlink
-            if self.linked_columns and key in self.linked_columns:
-                self.linked_columns.remove(key)
+        # TODO: our make_filter() signature differs from upstream,
+        # so must call it explicitly instead of delegating to super
+        kwargs.setdefault('label', self.get_label(key))
+        self.filters[key] = self.make_filter(key, *args, **kwargs)
 
     def set_click_handler(self, key, handler):
         if handler:
@@ -411,9 +501,6 @@ class Grid(object):
     def has_click_handler(self, key):
         return key in self.click_handlers
 
-    def set_renderer(self, key, renderer):
-        self.renderers[key] = renderer
-
     def set_raw_renderer(self, key, renderer):
         """
         Set or remove the "raw" renderer for the given field.
@@ -481,12 +568,18 @@ class Grid(object):
         if isinstance(obj, sa.engine.Row):
             return obj._mapping[column_name]
 
+        if isinstance(obj, dict):
+            return obj[column_name]
+
+        try:
+            return getattr(obj, column_name)
+        except AttributeError:
+            pass
+
         try:
             return obj[column_name]
-        except KeyError:
-            pass
         except TypeError:
-            return getattr(obj, column_name, None)
+            pass
 
     def render_currency(self, obj, column_name):
         value = self.obtain_value(obj, column_name)
@@ -532,7 +625,8 @@ class Grid(object):
 
     def render_quantity(self, obj, column_name):
         value = self.obtain_value(obj, column_name)
-        return pretty_quantity(value)
+        app = self.request.rattail_config.get_app()
+        return app.render_quantity(value)
 
     def render_duration(self, obj, column_name):
         seconds = self.obtain_value(obj, column_name)
@@ -600,6 +694,14 @@ class Grid(object):
     def actions_column_format(self, column_number, row_number, item):
         return HTML.td(self.render_actions(item, row_number), class_='actions')
 
+    # TODO: upstream should handle this..
+    def make_backend_filters(self, filters=None):
+        """ """
+        final = self.get_default_filters()
+        if filters:
+            final.update(filters)
+        return final
+
     def get_default_filters(self):
         """
         Returns the default set of filters provided by the grid.
@@ -624,16 +726,6 @@ class Grid(object):
                 filters[prop.key] = self.make_filter(prop.key, column)
         return filters
 
-    def make_filters(self, filters=None):
-        """
-        Returns an initial set of filters which will be available to the grid.
-        The grid itself may or may not provide some default filters, and the
-        ``filters`` kwarg may contain additions and/or overrides.
-        """
-        if filters:
-            return filters
-        return self.get_default_filters()
-
     def make_filter(self, key, column, **kwargs):
         """
         Make a filter suitable for use with the given column.
@@ -681,95 +773,103 @@ class Grid(object):
             if filtr.active:
                 yield filtr
 
-    def make_sorters(self, sorters=None):
-        """
-        Returns an initial set of sorters which will be available to the grid.
-        The grid itself may or may not provide some default sorters, and the
-        ``sorters`` kwarg may contain additions and/or overrides.
-        """
-        sorters, updates = {}, sorters
-        if self.model_class:
-            mapper = orm.class_mapper(self.model_class)
-            for prop in mapper.iterate_properties:
-                if isinstance(prop, orm.ColumnProperty) and not prop.key.endswith('uuid'):
-                    sorters[prop.key] = self.make_sorter(prop)
-        if updates:
-            sorters.update(updates)
-        return sorters
-
-    def make_sorter(self, model_property):
-        """
-        Returns a function suitable for a sort map callable, with typical logic
-        built in for sorting applied to ``field``.
-        """
-        class_ = getattr(model_property, 'class_', self.model_class)
-        column = getattr(class_, model_property.key)
-
-        def sorter(query, direction):
-            # TODO: this seems hacky..normally we expect a true query
-            # of course, but in some cases it may be a list instead.
-            # if so then we can't actually sort
-            if isinstance(query, list):
-                return query
-            return query.order_by(getattr(column, direction)())
-
-        sorter._class = class_
-        sorter._column = column
-
-        return sorter
-
     def make_simple_sorter(self, key, foldcase=False):
-        """
-        Returns a function suitable for a sort map callable, with typical logic
-        built in for sorting a data set comprised of dicts, on the given key.
-        """
-        if foldcase:
-            keyfunc = lambda v: v[key].lower()
-        else:
-            keyfunc = lambda v: v[key]
-        return lambda q, d: sorted(q, key=keyfunc, reverse=d == 'desc')
+        """ """
+        warnings.warn("Grid.make_simple_sorter() is deprecated; "
+                      "please use Grid.make_sorter() instead",
+                      DeprecationWarning, stacklevel=2)
+        return self.make_sorter(key, foldcase=foldcase)
+
+    def get_pagesize_options(self, default=None):
+        """ """
+        # let upstream check config
+        options = super().get_pagesize_options(default=UNSPECIFIED)
+        if options is not UNSPECIFIED:
+            return options
+
+        # fallback to legacy config
+        options = self.config.get_list('tailbone.grid.pagesize_options')
+        if options:
+            warnings.warn("tailbone.grid.pagesize_options setting is deprecated; "
+                          "please set wuttaweb.grids.default_pagesize_options instead",
+                          DeprecationWarning)
+            options = [int(size) for size in options
+                       if size.isdigit()]
+            if options:
+                return options
+
+        if default:
+            return default
+
+        # use upstream default
+        return super().get_pagesize_options()
+
+    def get_pagesize(self, default=None):
+        """ """
+        # let upstream check config
+        pagesize = super().get_pagesize(default=UNSPECIFIED)
+        if pagesize is not UNSPECIFIED:
+            return pagesize
+
+        # fallback to legacy config
+        pagesize = self.config.get_int('tailbone.grid.default_pagesize')
+        if pagesize:
+            warnings.warn("tailbone.grid.default_pagesize setting is deprecated; "
+                          "please use wuttaweb.grids.default_pagesize instead",
+                          DeprecationWarning)
+            return pagesize
+
+        if default:
+            return default
+
+        # use upstream default
+        return super().get_pagesize()
+
+    def get_default_pagesize(self): # pragma: no cover
+        """ """
+        warnings.warn("Grid.get_default_pagesize() method is deprecated; "
+                      "please use Grid.get_pagesize() of Grid.page instead",
+                      DeprecationWarning, stacklevel=2)
 
-    def get_default_pagesize(self):
         if self.default_pagesize:
             return self.default_pagesize
 
-        pagesize = self.request.rattail_config.getint('tailbone',
-                                                      'grid.default_pagesize',
-                                                      default=0)
-        if pagesize:
-            return pagesize
+        return self.get_pagesize()
 
-        options = self.get_pagesize_options()
-        return options[0]
+    def load_settings(self, **kwargs):
+        """ """
+        if 'store' in kwargs:
+            warnings.warn("the 'store' param is deprecated for load_settings(); "
+                          "please use the 'persist' param instead",
+                          DeprecationWarning, stacklevel=2)
+            kwargs.setdefault('persist', kwargs.pop('store'))
 
-    def load_settings(self, store=True):
-        """
-        Load current/effective settings for the grid, from the request query
-        string and/or session storage.  If ``store`` is true, then once
-        settings have been fully read, they are stored in current session for
-        next time.  Finally, various instance attributes of the grid and its
-        filters are updated in-place to reflect the settings; this is so code
-        needn't access the settings dict directly, but the more Pythonic
-        instance attributes.
-        """
+        persist = kwargs.get('persist', True)
 
         # initial default settings
         settings = {}
         if self.sortable:
-            if self.default_sortkey:
+            if self.sort_defaults:
+                # nb. as of writing neither Buefy nor Oruga support a
+                # multi-column *default* sort; so just use first sorter
+                sortinfo = self.sort_defaults[0]
                 settings['sorters.length'] = 1
-                settings['sorters.1.key'] = self.default_sortkey
-                settings['sorters.1.dir'] = self.default_sortdir
+                settings['sorters.1.key'] = sortinfo.sortkey
+                settings['sorters.1.dir'] = sortinfo.sortdir
             else:
                 settings['sorters.length'] = 0
-        if self.pageable:
-            settings['pagesize'] = self.get_default_pagesize()
-            settings['page'] = self.default_page
+        if self.paginated:
+            settings['pagesize'] = self.pagesize
+            settings['page'] = self.page
         if self.filterable:
             for filtr in self.iter_filters():
-                settings['filter.{}.active'.format(filtr.key)] = filtr.default_active
-                settings['filter.{}.verb'.format(filtr.key)] = filtr.default_verb
-                settings['filter.{}.value'.format(filtr.key)] = filtr.default_value
+                defaults = self.filter_defaults.get(filtr.key, {})
+                settings[f'filter.{filtr.key}.active'] = defaults.get('active',
+                                                                      filtr.default_active)
+                settings[f'filter.{filtr.key}.verb'] = defaults.get('verb',
+                                                                    filtr.default_verb)
+                settings[f'filter.{filtr.key}.value'] = defaults.get('value',
+                                                                     filtr.default_value)
 
         # If user has default settings on file, apply those first.
         if self.user_has_defaults():
@@ -777,25 +877,25 @@ class Grid(object):
 
         # If request contains instruction to reset to default filters, then we
         # can skip the rest of the request/session checks.
-        if self.request.GET.get('reset-to-default-filters') == 'true':
+        if self.request.GET.get('reset-view'):
             pass
 
         # If request has filter settings, grab those, then grab sort/pager
         # settings from request or session.
-        elif self.filterable and self.request_has_settings('filter'):
-            self.update_filter_settings(settings, 'request')
+        elif self.request_has_settings('filter'):
+            self.update_filter_settings(settings, src='request')
             if self.request_has_settings('sort'):
-                self.update_sort_settings(settings, 'request')
+                self.update_sort_settings(settings, src='request')
             else:
-                self.update_sort_settings(settings, 'session')
+                self.update_sort_settings(settings, src='session')
             self.update_page_settings(settings)
 
         # If request has no filter settings but does have sort settings, grab
         # those, then grab filter settings from session, then grab pager
         # settings from request or session.
         elif self.request_has_settings('sort'):
-            self.update_sort_settings(settings, 'request')
-            self.update_filter_settings(settings, 'session')
+            self.update_sort_settings(settings, src='request')
+            self.update_filter_settings(settings, src='session')
             self.update_page_settings(settings)
 
         # NOTE: These next two are functionally equivalent, but are kept
@@ -805,27 +905,27 @@ class Grid(object):
         # grab those, then grab filter/sort settings from session.
         elif self.request_has_settings('page'):
             self.update_page_settings(settings)
-            self.update_filter_settings(settings, 'session')
-            self.update_sort_settings(settings, 'session')
+            self.update_filter_settings(settings, src='session')
+            self.update_sort_settings(settings, src='session')
 
         # If request has no settings, grab all from session.
         elif self.session_has_settings():
-            self.update_filter_settings(settings, 'session')
-            self.update_sort_settings(settings, 'session')
+            self.update_filter_settings(settings, src='session')
+            self.update_sort_settings(settings, src='session')
             self.update_page_settings(settings)
 
         # If no settings were found in request or session, don't store result.
         else:
-            store = False
+            persist = False
             
         # Maybe store settings for next time.
-        if store:
-            self.persist_settings(settings, 'session')
+        if persist:
+            self.persist_settings(settings, dest='session')
 
         # If request contained instruction to save current settings as defaults
         # for the current user, then do that.
         if self.request.GET.get('save-current-filters-as-defaults') == 'true':
-            self.persist_settings(settings, 'defaults')
+            self.persist_settings(settings, dest='defaults')
 
         # update ourself to reflect settings
         if self.filterable:
@@ -834,13 +934,14 @@ class Grid(object):
                 filtr.verb = settings['filter.{}.verb'.format(filtr.key)]
                 filtr.value = settings['filter.{}.value'.format(filtr.key)]
         if self.sortable:
+            # and self.sort_on_backend:
             self.active_sorters = []
             for i in range(1, settings['sorters.length'] + 1):
                 self.active_sorters.append({
-                    'field': settings[f'sorters.{i}.key'],
-                    'order': settings[f'sorters.{i}.dir'],
+                    'key': settings[f'sorters.{i}.key'],
+                    'dir': settings[f'sorters.{i}.dir'],
                 })
-        if self.pageable:
+        if self.paginated:
             self.pagesize = settings['pagesize']
             self.page = settings['page']
 
@@ -944,23 +1045,16 @@ class Grid(object):
                     merge(f'sorters.{i}.key')
                     merge(f'sorters.{i}.dir')
 
-        if self.pageable:
+        if self.paginated:
             merge('pagesize', int)
             merge('page', int)
 
     def request_has_settings(self, type_):
-        """
-        Determine if the current request (GET query string) contains any
-        filter/sort settings for the grid.
-        """
-        if type_ == 'filter':
-            for filtr in self.iter_filters():
-                if filtr.key in self.request.GET:
-                    return True
-            if 'filter' in self.request.GET: # user may be applying empty filters
-                return True
+        """ """
+        if super().request_has_settings(type_):
+            return True
 
-        elif type_ == 'sort':
+        if type_ == 'sort':
 
             # TODO: remove this eventually, but some links in the wild
             # may still include these params, so leave it for now
@@ -968,14 +1062,6 @@ class Grid(object):
                 if key in self.request.GET:
                     return True
 
-            if 'sort1key' in self.request.GET:
-                return True
-
-        elif type_ == 'page':
-            for key in ['pagesize', 'page']:
-                if key in self.request.GET:
-                    return True
-
         return False
 
     def session_has_settings(self):
@@ -991,173 +1077,19 @@ class Grid(object):
         return any([key.startswith(f'{prefix}.filter')
                     for key in self.request.session])
 
-    def get_setting(self, source, settings, key, normalize=lambda v: v, default=None):
-        """
-        Get the effective value for a particular setting, preferring ``source``
-        but falling back to existing ``settings`` and finally the ``default``.
-        """
-        if source not in ('request', 'session'):
-            raise ValueError("Invalid source identifier: {}".format(source))
+    def persist_settings(self, settings, dest='session'):
+        """ """
+        if dest not in ('defaults', 'session'):
+            raise ValueError(f"invalid dest identifier: {dest}")
 
-        # If source is query string, try that first.
-        if source == 'request':
-            value = self.request.GET.get(key)
-            if value is not None:
-                try:
-                    value = normalize(value)
-                except ValueError:
-                    pass
-                else:
-                    return value
+        app = self.request.rattail_config.get_app()
+        model = app.model
 
-        # Or, if source is session, try that first.
-        else:
-            value = self.request.session.get('grid.{}.{}'.format(self.key, key))
-            if value is not None:
-                return normalize(value)
-
-        # If source had nothing, try default/existing settings.
-        value = settings.get(key)
-        if value is not None:
-            try:
-                value = normalize(value)
-            except ValueError:
-                pass
-            else:
-                return value
-
-        # Okay then, default it is.
-        return default
-
-    def update_filter_settings(self, settings, source):
-        """
-        Updates a settings dictionary according to filter settings data found
-        in either the GET query string, or session storage.
-
-        :param settings: Dictionary of initial settings, which is to be updated.
-
-        :param source: String identifying the source to consult for settings
-           data.  Must be one of: ``('request', 'session')``.
-        """
-        if not self.filterable:
-            return
-
-        for filtr in self.iter_filters():
-            prefix = 'filter.{}'.format(filtr.key)
-
-            if source == 'request':
-                # consider filter active if query string contains a value for it
-                settings['{}.active'.format(prefix)] = filtr.key in self.request.GET
-                settings['{}.verb'.format(prefix)] = self.get_setting(
-                    source, settings, '{}.verb'.format(filtr.key), default='')
-                settings['{}.value'.format(prefix)] = self.get_setting(
-                    source, settings, filtr.key, default='')
-
-            else: # source = session
-                settings['{}.active'.format(prefix)] = self.get_setting(
-                    source, settings, '{}.active'.format(prefix),
-                    normalize=lambda v: str(v).lower() == 'true', default=False)
-                settings['{}.verb'.format(prefix)] = self.get_setting(
-                    source, settings, '{}.verb'.format(prefix), default='')
-                settings['{}.value'.format(prefix)] = self.get_setting(
-                    source, settings, '{}.value'.format(prefix), default='')
-
-    def update_sort_settings(self, settings, source):
-        """
-        Updates a settings dictionary according to sort settings data found in
-        either the GET query string, or session storage.
-
-        :param settings: Dictionary of initial settings, which is to be updated.
-
-        :param source: String identifying the source to consult for settings
-           data.  Must be one of: ``('request', 'session')``.
-        """
-        if not self.sortable:
-            return
-
-        if source == 'request':
-
-            # TODO: remove this eventually, but some links in the wild
-            # may still include these params, so leave it for now
-            if 'sortkey' in self.request.GET:
-                settings['sorters.length'] = 1
-                settings['sorters.1.key'] = self.get_setting(source, settings, 'sortkey')
-                settings['sorters.1.dir'] = self.get_setting(source, settings, 'sortdir')
-
-            else: # the future
-                i = 1
-                while True:
-                    skey = f'sort{i}key'
-                    if skey in self.request.GET:
-                        settings[f'sorters.{i}.key'] = self.get_setting(source, settings, skey)
-                        settings[f'sorters.{i}.dir'] = self.get_setting(source, settings, f'sort{i}dir')
-                    else:
-                        break
-                    i += 1
-                settings['sorters.length'] = i - 1
-
-        else: # session
-
-            # TODO: definitely will remove this, but leave it for now
-            # so it doesn't monkey with current user sessions when
-            # next upgrade happens.  so, remove after all are upgraded
-            sortkey = self.get_setting(source, settings, 'sortkey')
-            if sortkey:
-                settings['sorters.length'] = 1
-                settings['sorters.1.key'] = sortkey
-                settings['sorters.1.dir'] = self.get_setting(source, settings, 'sortdir')
-
-            else: # the future
-                settings['sorters.length'] = self.get_setting(source, settings,
-                                                              'sorters.length', int)
-                for i in range(1, settings['sorters.length'] + 1):
-                    for key in ('key', 'dir'):
-                        skey = f'sorters.{i}.{key}'
-                        settings[skey] = self.get_setting(source, settings, skey)
-
-    def update_page_settings(self, settings):
-        """
-        Updates a settings dictionary according to pager settings data found in
-        either the GET query string, or session storage.
-
-        Note that due to how the actual pager functions, the effective settings
-        will often come from *both* the request and session.  This is so that
-        e.g. the page size will remain constant (coming from the session) while
-        the user jumps between pages (which only provides the single setting).
-
-        :param settings: Dictionary of initial settings, which is to be updated.
-        """
-        if not self.pageable:
-            return
-
-        pagesize = self.request.GET.get('pagesize')
-        if pagesize is not None:
-            if pagesize.isdigit():
-                settings['pagesize'] = int(pagesize)
-        else:
-            pagesize = self.request.session.get('grid.{}.pagesize'.format(self.key))
-            if pagesize is not None:
-                settings['pagesize'] = pagesize
-
-        page = self.request.GET.get('page')
-        if page is not None:
-            if page.isdigit():
-                settings['page'] = int(page)
-        else:
-            page = self.request.session.get('grid.{}.page'.format(self.key))
-            if page is not None:
-                settings['page'] = int(page)
-
-    def persist_settings(self, settings, to='session'):
-        """
-        Persist the given settings in some way, as defined by ``func``.
-        """
-        def persist(key, value=lambda k: settings[k]):
-            if to == 'defaults':
+        def persist(key, value=lambda k: settings.get(k)):
+            if dest == 'defaults':
                 skey = 'tailbone.{}.grid.{}.{}'.format(self.request.user.uuid, self.key, key)
-                app = self.request.rattail_config.get_app()
                 app.save_setting(Session(), skey, value(key))
-            else: # to == session
+            else: # dest == session
                 skey = 'grid.{}.{}'.format(self.key, key)
                 self.request.session[skey] = value(key)
 
@@ -1169,10 +1101,11 @@ class Grid(object):
 
         if self.sortable:
 
-            # first clear existing settings for *sorting* only
-            # nb. this is because number of sort settings will vary
-            if to == 'defaults':
-                model = self.request.rattail_config.get_model()
+            # first must clear all sort settings from dest. this is
+            # because number of sort settings will vary, so we delete
+            # all and then write all
+
+            if dest == 'defaults':
                 prefix = f'tailbone.{self.request.user.uuid}.grid.{self.key}'
                 query = Session.query(model.Setting)\
                                .filter(sa.or_(
@@ -1186,7 +1119,9 @@ class Grid(object):
                 for setting in query.all():
                     Session.delete(setting)
                 Session.flush()
+
             else: # session
+                # remove sort settings from user session
                 prefix = f'grid.{self.key}'
                 for key in list(self.request.session):
                     if key.startswith(f'{prefix}.sorters.'):
@@ -1198,12 +1133,14 @@ class Grid(object):
                 self.request.session.pop(f'{prefix}.sortkey', None)
                 self.request.session.pop(f'{prefix}.sortdir', None)
 
-            persist('sorters.length')
-            for i in range(1, settings['sorters.length'] + 1):
-                persist(f'sorters.{i}.key')
-                persist(f'sorters.{i}.dir')
+            # now save sort settings to dest
+            if 'sorters.length' in settings:
+                persist('sorters.length')
+                for i in range(1, settings['sorters.length'] + 1):
+                    persist(f'sorters.{i}.key')
+                    persist(f'sorters.{i}.dir')
 
-        if self.pageable:
+        if self.paginated:
             persist('pagesize')
             persist('page')
 
@@ -1227,110 +1164,27 @@ class Grid(object):
 
         return data
 
-    def sort_data(self, data):
-        """
-        Sort the given query according to current settings, and return the result.
-        """
-        # bail if no sort settings
-        if not self.active_sorters:
-            return data
-
-        # TODO: is there a better way to check for SA sorting?
-        if self.model_class:
-
-            # collect actual column sorters for order_by clause
-            sorters = []
-            for sorter in self.active_sorters:
-                sortkey = sorter['field']
-                sortfunc = self.sorters.get(sortkey)
-                if not sortfunc:
-                    log.warning("unknown sorter: %s", sorter)
-                    continue
-
-                # join appropriate model if needed
-                if sortkey in self.joiners and sortkey not in self.joined:
-                    data = self.joiners[sortkey](data)
-                    self.joined.add(sortkey)
-
-                # add column/dir to collection
-                sortdir = sorter['order']
-                sorters.append(getattr(sortfunc._column, sortdir)())
-
-            # apply sorting to query
-            if sorters:
-                data = data.order_by(*sorters)
-
-            return data
-
-        else:
-            # not a SQLAlchemy grid, custom sorter
-
-            assert len(self.active_sorters) < 2
-
-            sortkey = self.active_sorters[0]['field']
-            sortdir = self.active_sorters[0]['order'] or 'asc'
-
-            # Cannot sort unless we have a sort function.
-            sortfunc = self.sorters.get(sortkey)
-            if not sortfunc:
-                return data
-
-            # apply joins needed for this sorter
-            if sortkey in self.joiners and sortkey not in self.joined:
-                data = self.joiners[sortkey](data)
-                self.joined.add(sortkey)
-
-            return sortfunc(data, sortdir)
-
-    def paginate_data(self, data):
-        """
-        Paginate the given data set according to current settings, and return
-        the result.
-        """
-        # we of course assume our current page is correct, at first
-        pager = self.make_pager(data)
-
-        # if pager has detected that our current page is outside the valid
-        # range, we must re-orient ourself around the "new" (valid) page
-        if pager.page != self.page:
-            self.page = pager.page
-            self.request.session['grid.{}.page'.format(self.key)] = self.page
-            pager = self.make_pager(data)
-
-        return pager
-
-    def make_pager(self, data):
-
-        # TODO: this seems hacky..normally we expect `data` to be a
-        # query of course, but in some cases it may be a list instead.
-        # if so then we can't use ORM pager
-        if isinstance(data, list):
-            import paginate
-            return paginate.Page(data,
-                                 items_per_page=self.pagesize,
-                                 page=self.page)
-
-        return SqlalchemyOrmPage(data,
-                                 items_per_page=self.pagesize,
-                                 page=self.page,
-                                 url_maker=URLMaker(self.request))
-
     def make_visible_data(self):
-        """
-        Apply various settings to the raw data set, to produce a final data
-        set.  This will page / sort / filter as necessary, according to the
-        grid's defaults and the current request etc.
-        """
-        self.joined = set()
-        data = self.data
-        if self.filterable:
-            data = self.filter_data(data)
-        if self.sortable:
-            data = self.sort_data(data)
-        if self.pageable:
-            self.pager = self.paginate_data(data)
-            data = self.pager
-        return data
+        """ """
+        warnings.warn("grid.make_visible_data() method is deprecated; "
+                      "please use grid.get_visible_data() instead",
+                      DeprecationWarning, stacklevel=2)
+        return self.get_visible_data()
+
+    def render_vue_tag(self, master=None, **kwargs):
+        """ """
+        kwargs.setdefault('ref', 'grid')
+        kwargs.setdefault(':csrftoken', 'csrftoken')
+
+        if (master and master.deletable and master.has_perm('delete')
+            and master.delete_confirm == 'simple'):
+            kwargs.setdefault('@deleteActionClicked', 'deleteObject')
+
+        return HTML.tag(self.vue_tagname, **kwargs)
+
+    def render_vue_template(self, template='/grids/complete.mako', **context):
+        """ """
+        return self.render_complete(template=template, **context)
 
     def render_complete(self, template='/grids/complete.mako', **kwargs):
         """
@@ -1338,7 +1192,7 @@ class Grid(object):
         includes the context menu items and grid tools.
         """
         if 'grid_columns' not in kwargs:
-            kwargs['grid_columns'] = self.get_table_columns()
+            kwargs['grid_columns'] = self.get_vue_columns()
 
         if 'grid_data' not in kwargs:
             kwargs['grid_data'] = self.get_table_data()
@@ -1357,9 +1211,11 @@ class Grid(object):
         context['request'] = self.request
         context.setdefault('allow_save_defaults', True)
         context.setdefault('view_click_handler', self.get_view_click_handler())
-        return render(template, context)
+        html = render(template, context)
+        return HTML.literal(html)
 
     def render_buefy(self, **kwargs):
+        """ """
         warnings.warn("Grid.render_buefy() is deprecated; "
                       "please use Grid.render_complete() instead",
                       DeprecationWarning, stacklevel=2)
@@ -1367,6 +1223,7 @@ class Grid(object):
 
     def render_table_element(self, template='/grids/b-table.mako',
                              data_prop='gridData', empty_labels=False,
+                             literal=False,
                              **kwargs):
         """
         This is intended for ad-hoc "small" grids with static data.  Renders
@@ -1378,30 +1235,24 @@ class Grid(object):
         context['data_prop'] = data_prop
         context['empty_labels'] = empty_labels
         if 'grid_columns' not in context:
-            context['grid_columns'] = self.get_table_columns()
+            context['grid_columns'] = self.get_vue_columns()
         context.setdefault('paginated', False)
         if context['paginated']:
             context.setdefault('per_page', 20)
         context['view_click_handler'] = self.get_view_click_handler()
-        return render(template, context)
+        result = render(template, context)
+        if literal:
+            result = HTML.literal(result)
+        return result
 
     def get_view_click_handler(self):
-
+        """ """
         # locate the 'view' action
         # TODO: this should be easier, and/or moved elsewhere?
         view = None
-        for action in self.main_actions:
+        for action in self.actions:
             if action.key == 'view':
-                view = action
-                break
-        if not view:
-            for action in self.more_actions:
-                if action.key == 'view':
-                    view = action
-                    break
-
-        if view:
-            return view.click_handler
+                return getattr(action, 'click_handler', None)
 
     def set_filters_sequence(self, filters, only=False):
         """
@@ -1475,48 +1326,21 @@ class Grid(object):
 
         return data
 
-    def render_filters(self, template='/grids/filters.mako', **kwargs):
-        """
-        Render the filters to a Unicode string, using the specified template.
-        Additional kwargs are passed along as context to the template.
-        """
-        # Provide default data to filters form, so renderer can do some of the
-        # work for us.
-        data = {}
-        for filtr in self.iter_active_filters():
-            data['{}.active'.format(filtr.key)] = filtr.active
-            data['{}.verb'.format(filtr.key)] = filtr.verb
-            data[filtr.key] = filtr.value
+    def render_actions(self, row, i): # pragma: no cover
+        """ """
+        warnings.warn("grid.render_actions() is deprecated!",
+                      DeprecationWarning, stacklevel=2)
 
-        form = gridfilters.GridFiltersForm(self.filters,
-                                           request=self.request,
-                                           defaults=data)
+        actions = [self.render_action(a, row, i)
+                   for a in self.actions]
+        actions = [a for a in actions if a]
+        return HTML.literal('').join(actions)
 
-        kwargs['request'] = self.request
-        kwargs['grid'] = self
-        kwargs['form'] = form
-        return render(template, kwargs)
+    def render_action(self, action, row, i): # pragma: no cover
+        """ """
+        warnings.warn("grid.render_action() is deprecated!",
+                      DeprecationWarning, stacklevel=2)
 
-    def render_actions(self, row, i):
-        """
-        Returns the rendered contents of the 'actions' column for a given row.
-        """
-        main_actions = [self.render_action(a, row, i)
-                        for a in self.main_actions]
-        main_actions = [a for a in main_actions if a]
-        more_actions = [self.render_action(a, row, i)
-                        for a in self.more_actions]
-        more_actions = [a for a in more_actions if a]
-        if more_actions:
-            icon = HTML.tag('span', class_='ui-icon ui-icon-carat-1-e')
-            link = tags.link_to("More" + icon, '#', class_='more')
-            main_actions.append(HTML.literal('&nbsp; ') + link + HTML.tag('div', class_='more', c=more_actions))
-        return HTML.literal('').join(main_actions)
-
-    def render_action(self, action, row, i):
-        """
-        Renders an action menu item (link) for the given row.
-        """
         url = action.get_url(row, i)
         if url:
             kwargs = {'class_': action.key, 'target': action.target}
@@ -1550,18 +1374,6 @@ class Grid(object):
         return tags.checkbox('checkbox-{}-{}'.format(self.key, self.get_row_key(item)),
                              checked=self.checked(item))
 
-    def get_pagesize_options(self):
-
-        # use values from config, if defined
-        options = self.request.rattail_config.getlist('tailbone', 'grid.pagesize_options')
-        if options:
-            options = [int(size) for size in options
-                       if size.isdigit()]
-            if options:
-                return options
-
-        return [5, 10, 20, 50, 100, 200]
-
     def has_static_data(self):
         """
         Should return ``True`` if the grid data can be considered "static"
@@ -1573,21 +1385,22 @@ class Grid(object):
             return True
         return False
 
-    def get_table_columns(self):
-        """
-        Return a list of dicts representing all grid columns.  Meant
-        for use with the client-side JS table.
-        """
-        columns = []
-        for name in self.columns:
-            columns.append({
-                'field': name,
-                'label': self.get_label(name),
-                'sortable': self.sortable and name in self.sorters,
-                'visible': name not in self.invisible,
-            })
+    def get_vue_columns(self):
+        """ """
+        columns = super().get_vue_columns()
+
+        for column in columns:
+            column['visible'] = column['field'] not in self.invisible
+
         return columns
 
+    def get_table_columns(self):
+        """ """
+        warnings.warn("grid.get_table_columns() method is deprecated; "
+                      "please use grid.get_vue_columns() instead",
+                      DeprecationWarning, stacklevel=2)
+        return self.get_vue_columns()
+
     def get_uuid_for_row(self, rowobj):
 
         # use custom getter if set
@@ -1598,13 +1411,25 @@ class Grid(object):
         if hasattr(rowobj, 'uuid'):
             return rowobj.uuid
 
+    def get_vue_context(self):
+        """ """
+        return self.get_table_data()
+
+    def get_vue_data(self):
+        """ """
+        table_data = self.get_table_data()
+        return table_data['data']
+
     def get_table_data(self):
         """
         Returns a list of data rows for the grid, for use with
         client-side JS table.
         """
+        if hasattr(self, '_table_data'):
+            return self._table_data
+
         # filter / sort / paginate to get "visible" data
-        raw_data = self.make_visible_data()
+        raw_data = self.get_visible_data()
         data = []
         status_map = {}
         checked = []
@@ -1645,10 +1470,22 @@ class Grid(object):
 
                 # leverage configured rendering logic where applicable;
                 # otherwise use "raw" data value as string
+                value = self.obtain_value(rowobj, name)
                 if self.renderers and name in self.renderers:
-                    value = self.renderers[name](rowobj, name)
-                else:
-                    value = self.obtain_value(rowobj, name)
+                    renderer = self.renderers[name]
+
+                    # TODO: legacy renderer callables require 2 args,
+                    # but wuttaweb callables require 3 args
+                    sig = inspect.signature(renderer)
+                    required = [param for param in sig.parameters.values()
+                                if param.default == param.empty]
+
+                    if len(required) == 2:
+                        # TODO: legacy renderer
+                        value = renderer(rowobj, name)
+                    else: # the future
+                        value = renderer(rowobj, name, value)
+
                 if value is None:
                     value = ""
 
@@ -1681,6 +1518,8 @@ class Grid(object):
 
         results = {
             'data': data,
+            'row_classes': status_map,
+            # TODO: deprecate / remove this
             'row_status_map': status_map,
         }
 
@@ -1688,11 +1527,15 @@ class Grid(object):
             results['checked_rows'] = checked
             # TODO: this seems a bit hacky, but is required for now to
             # initialize things on the client side...
-            var = '{}CurrentData'.format(self.component_studly)
+            var = '{}CurrentData'.format(self.vue_component)
             results['checked_rows_code'] = '[{}]'.format(
                 ', '.join(['{}[{}]'.format(var, i) for i in checked]))
 
-        if self.pageable and self.pager is not None:
+        if self.paginated and self.paginate_on_backend:
+            results['pager_stats'] = self.get_vue_pager_stats()
+
+        # TODO: is this actually needed now that we have pager_stats?
+        if self.paginated and self.pager is not None:
             results['total_items'] = self.pager.item_count
             results['per_page'] = self.pager.items_per_page
             results['page'] = self.pager.page
@@ -1702,41 +1545,38 @@ class Grid(object):
         else:
             results['total_items'] = count
 
-        return results
+        self._table_data = results
+        return self._table_data
+
+    # TODO: remove this when we use upstream GridAction
+    def add_action(self, key, **kwargs):
+        """ """
+        self.actions.append(GridAction(self.request, key, **kwargs))
 
     def set_action_urls(self, row, rowobj, i):
         """
         Pre-generate all action URLs for the given data row.  Meant for use
         with client-side table, since we can't generate URLs from JS.
         """
-        for action in (self.main_actions + self.more_actions):
+        for action in self.actions:
             url = action.get_url(rowobj, i)
             row['_action_url_{}'.format(action.key)] = url
 
-    def is_linked(self, name):
-        """
-        Should return ``True`` if the given column name is configured to be
-        "linked" (i.e. table cell should contain a link to "view object"),
-        otherwise ``False``.
-        """
-        if self.linked_columns:
-            if name in self.linked_columns:
-                return True
-        return False
 
-
-class GridAction(object):
+class GridAction(WuttaGridAction):
     """
-    Represents an action available to a grid.  This is used to construct the
-    'actions' column when rendering the grid.
+    Represents a "row action" hyperlink within a grid context.
 
-    :param key: Key for the action (e.g. ``'edit'``), unique within
-       the grid.
+    This is a subclass of
+    :class:`wuttaweb:wuttaweb.grids.base.GridAction`.
 
-    :param label: Label to be displayed for the action.  If not set,
-       will be a capitalized version of ``key``.
+    .. warning::
 
-    :param icon: Icon name for the action.
+       This class remains for now, to retain compatibility with
+       existing code.  But at some point the WuttaWeb class will
+       supersede this one entirely.
+
+    :param target: HTML "target" attribute for the ``<a>`` tag.
 
     :param click_handler: Optional JS click handler for the action.
        This value will be rendered as-is within the final grid
@@ -1748,41 +1588,23 @@ class GridAction(object):
        * ``$emit('do-something', props.row)``
     """
 
-    def __init__(self, key, label=None, url='#', icon=None, target=None,
-                 link_class=None, click_handler=None):
-        self.key = key
-        self.label = label or prettify(key)
-        self.icon = icon
-        self.url = url
+    def __init__(
+            self,
+            request,
+            key,
+            target=None,
+            click_handler=None,
+            **kwargs,
+    ):
+        # TODO: previously url default was '#' - but i don't think we
+        # need that anymore?  guess we'll see..
+        #kwargs.setdefault('url', '#')
+
+        super().__init__(request, key, **kwargs)
+
         self.target = target
-        self.link_class = link_class
         self.click_handler = click_handler
 
-    def get_url(self, row, i):
-        """
-        Returns an action URL for the given row.
-        """
-        if callable(self.url):
-            return self.url(row, i)
-        return self.url
-
-    def render_icon(self):
-        """
-        Render the HTML snippet for the action link icon.
-        """
-        return HTML.tag('i', class_='fas fa-{}'.format(self.icon))
-
-    def render_label(self):
-        """
-        Render the label "text" within the actions column of a grid
-        row.  Most actions have a static label that never varies, but
-        you can override this to add e.g. HTML content.  Note that the
-        return value will be treated / rendered as HTML whether or not
-        it contains any, so perhaps be careful that it is trusted
-        content.
-        """
-        return self.label
-
 
 class URLMaker(object):
     """
diff --git a/tailbone/grids/filters.py b/tailbone/grids/filters.py
index 3b198614..7e52bb8d 100644
--- a/tailbone/grids/filters.py
+++ b/tailbone/grids/filters.py
@@ -26,6 +26,7 @@ Grid Filters
 
 import re
 import datetime
+import decimal
 import logging
 from collections import OrderedDict
 
@@ -647,12 +648,22 @@ class AlchemyNumericFilter(AlchemyGridFilter):
 
         # first just make sure it's somewhat numeric
         try:
-            float(value)
-        except ValueError:
+            self.parse_decimal(value)
+        except decimal.InvalidOperation:
             return True
 
         return bool(value and len(str(value)) > 8)
 
+    def parse_decimal(self, value):
+        if value:
+            value = value.replace(',', '')
+            return decimal.Decimal(value)
+
+    def encode_value(self, value):
+        if value:
+            value = str(self.parse_decimal(value))
+        return super().encode_value(value)
+
     def filter_equal(self, query, value):
         if self.value_invalid(value):
             return query
diff --git a/tailbone/handler.py b/tailbone/handler.py
index db95bc71..00f41bc9 100644
--- a/tailbone/handler.py
+++ b/tailbone/handler.py
@@ -2,7 +2,7 @@
 ################################################################################
 #
 #  Rattail -- Retail Software Framework
-#  Copyright © 2010-2023 Lance Edgar
+#  Copyright © 2010-2024 Lance Edgar
 #
 #  This file is part of Rattail.
 #
@@ -24,9 +24,8 @@
 Tailbone Handler
 """
 
-from __future__ import unicode_literals, absolute_import
+import warnings
 
-import six
 from mako.lookup import TemplateLookup
 
 from rattail.app import GenericHandler
@@ -41,7 +40,7 @@ class TailboneHandler(GenericHandler):
     """
 
     def __init__(self, *args, **kwargs):
-        super(TailboneHandler, self).__init__(*args, **kwargs)
+        super().__init__(*args, **kwargs)
 
         # TODO: make templates dir configurable?
         templates = [resource_path('rattail:templates/web')]
@@ -49,11 +48,14 @@ class TailboneHandler(GenericHandler):
 
     def get_menu_handler(self, **kwargs):
         """
-        Get the configured "menu" handler.
-
-        :returns: The :class:`~tailbone.menus.MenuHandler` instance
-           for the app.
+        DEPRECATED; use
+        :meth:`wuttaweb.handler.WebHandler.get_menu_handler()`
+        instead.
         """
+        warnings.warn("TailboneHandler.get_menu_handler() is deprecated; "
+                      "please use WebHandler.get_menu_handler() instead",
+                      DeprecationWarning, stacklevel=2)
+
         if not hasattr(self, 'menu_handler'):
             spec = self.config.get('tailbone.menus', 'handler',
                                    default='tailbone.menus:MenuHandler')
@@ -67,7 +69,7 @@ class TailboneHandler(GenericHandler):
         Returns an iterator over all registered Tailbone providers.
         """
         providers = get_all_providers(self.config)
-        return six.itervalues(providers)
+        return providers.values()
 
     def write_model_view(self, data, path, **kwargs):
         """
diff --git a/tailbone/helpers.py b/tailbone/helpers.py
index d4065cc5..50b38c30 100644
--- a/tailbone/helpers.py
+++ b/tailbone/helpers.py
@@ -2,7 +2,7 @@
 ################################################################################
 #
 #  Rattail -- Retail Software Framework
-#  Copyright © 2010-2023 Lance Edgar
+#  Copyright © 2010-2024 Lance Edgar
 #
 #  This file is part of Rattail.
 #
@@ -24,6 +24,9 @@
 Template Context Helpers
 """
 
+# start off with all from wuttaweb
+from wuttaweb.helpers import *
+
 import os
 import datetime
 from decimal import Decimal
@@ -33,14 +36,9 @@ from rattail.time import localtime, make_utc
 from rattail.util import pretty_quantity, pretty_hours, hours_as_decimal
 from rattail.db.util import maxlen
 
-from webhelpers2.html import *
-from webhelpers2.html.tags import *
-
-from tailbone.util import (csrf_token, get_csrf_token,
-                           pretty_datetime, raw_datetime,
+from tailbone.util import (pretty_datetime, raw_datetime,
                            render_markdown,
-                           route_exists,
-                           get_liburl)
+                           route_exists)
 
 
 def pretty_date(date):
diff --git a/tailbone/menus.py b/tailbone/menus.py
index 50dd3f4a..09d6f3f0 100644
--- a/tailbone/menus.py
+++ b/tailbone/menus.py
@@ -2,7 +2,7 @@
 ################################################################################
 #
 #  Rattail -- Retail Software Framework
-#  Copyright © 2010-2023 Lance Edgar
+#  Copyright © 2010-2024 Lance Edgar
 #
 #  This file is part of Rattail.
 #
@@ -24,37 +24,48 @@
 App Menus
 """
 
-import re
 import logging
 import warnings
 
-from rattail.app import GenericHandler
 from rattail.util import prettify, simple_error
 
 from webhelpers2.html import tags, HTML
 
+from wuttaweb.menus import MenuHandler as WuttaMenuHandler
+
 from tailbone.db import Session
 
 
 log = logging.getLogger(__name__)
 
 
-class MenuHandler(GenericHandler):
+class TailboneMenuHandler(WuttaMenuHandler):
     """
     Base class and default implementation for menu handler.
     """
 
-    def make_raw_menus(self, request, **kwargs):
-        """
-        Generate a full set of "raw" menus for the app.
+    ##############################
+    # internal methods
+    ##############################
 
-        The "raw" menus are basically just a set of dicts to represent
-        the final menus.
+    def _is_allowed(self, request, item):
+        """
+        TODO: must override this until wuttaweb has proper user auth checks
+        """
+        perm = item.get('perm')
+        if perm:
+            return request.has_perm(perm)
+        return True
+
+    def _make_raw_menus(self, request, **kwargs):
+        """
+        We are overriding this to allow for making dynamic menus from
+        config/settings.  Which may or may not be a good idea..
         """
         # first try to make menus from config, but this is highly
         # susceptible to failure, so try to warn user of problems
         try:
-            menus = self.make_menus_from_config(request)
+            menus = self._make_menus_from_config(request)
             if menus:
                 return menus
         except Exception as error:
@@ -71,9 +82,9 @@ class MenuHandler(GenericHandler):
             request.session.flash(msg, 'warning')
 
         # okay, no config, so menus will be built from code
-        return self.make_menus(request)
+        return self.make_menus(request, **kwargs)
 
-    def make_menus_from_config(self, request, **kwargs):
+    def _make_menus_from_config(self, request, **kwargs):
         """
         Try to build a complete menu set from config/settings.
 
@@ -85,7 +96,7 @@ class MenuHandler(GenericHandler):
         if not main_keys:
             return
 
-        model = self.model
+        model = self.app.model
         menus = []
 
         # menu definition can come either from config file or db
@@ -101,16 +112,15 @@ class MenuHandler(GenericHandler):
                                             query=query, key='name',
                                             normalizer=lambda s: s.value)
             for key in main_keys:
-                menus.append(self.make_single_menu_from_settings(request, key,
-                                                                 settings))
+                menus.append(self._make_single_menu_from_settings(request, key, settings))
 
         else: # read from config file only
             for key in main_keys:
-                menus.append(self.make_single_menu_from_config(request, key))
+                menus.append(self._make_single_menu_from_config(request, key))
 
         return menus
 
-    def make_single_menu_from_config(self, request, key, **kwargs):
+    def _make_single_menu_from_config(self, request, key, **kwargs):
         """
         Makes a single top-level menu dict from config file.  Note
         that this will read from config file(s) *only* and avoids
@@ -178,7 +188,7 @@ class MenuHandler(GenericHandler):
 
         return menu
 
-    def make_single_menu_from_settings(self, request, key, settings, **kwargs):
+    def _make_single_menu_from_settings(self, request, key, settings, **kwargs):
         """
         Makes a single top-level menu dict from DB settings.
         """
@@ -237,6 +247,10 @@ class MenuHandler(GenericHandler):
 
         return menu
 
+    ##############################
+    # menu defaults
+    ##############################
+
     def make_menus(self, request, **kwargs):
         """
         Make the full set of menus for the app.
@@ -267,8 +281,9 @@ class MenuHandler(GenericHandler):
         """
         Make a set of menus for all registered system integrations.
         """
+        tb = self.app.get_tailbone_handler()
         menus = []
-        for provider in self.tb.iter_providers():
+        for provider in tb.iter_providers():
             menu = provider.make_integration_menu(request)
             if menu:
                 menus.append(menu)
@@ -379,6 +394,11 @@ class MenuHandler(GenericHandler):
                     'route': 'products',
                     'perm': 'products.list',
                 },
+                {
+                    'title': "Product Costs",
+                    'route': 'product_costs',
+                    'perm': 'product_costs.list',
+                },
                 {
                     'title': "Departments",
                     'route': 'departments',
@@ -436,6 +456,11 @@ class MenuHandler(GenericHandler):
                     'route': 'vendors',
                     'perm': 'vendors.list',
                 },
+                {
+                    'title': "Product Costs",
+                    'route': 'product_costs',
+                    'perm': 'product_costs.list',
+                },
                 {'type': 'sep'},
                 {
                     'title': "Ordering",
@@ -688,7 +713,7 @@ class MenuHandler(GenericHandler):
             },
             {'type': 'sep'},
             {
-                'title': "App Details",
+                'title': "App Info",
                 'route': 'appinfo',
                 'perm': 'appinfo.list',
             },
@@ -723,182 +748,25 @@ class MenuHandler(GenericHandler):
         }
 
 
-def make_simple_menus(request):
+class MenuHandler(TailboneMenuHandler):
+
+    def __init__(self, *args, **kwargs):
+        warnings.warn("tailbone.menus.MenuHandler is deprecated; "
+                      "please use tailbone.menus.TailboneMenuHandler instead",
+                      DeprecationWarning, stacklevel=2)
+        super().__init__(*args, **kwargs)
+
+
+class NullMenuHandler(WuttaMenuHandler):
     """
-    Build the main menu list for the app.
+    Null menu handler which uses an empty menu set.
+
+    .. note:
+
+       This class shouldn't even exist, but for the moment, it is
+       useful to configure non-traditional (e.g. API) web apps to use
+       this, in order to avoid most of the overhead.
     """
-    app = request.rattail_config.get_app()
-    tailbone_handler = app.get_tailbone_handler()
-    menu_handler = tailbone_handler.get_menu_handler()
 
-    raw_menus = menu_handler.make_raw_menus(request)
-
-    # now we have "simple" (raw) menus definition, but must refine
-    # that somewhat to produce our final menus
-    mark_allowed(request, raw_menus)
-    final_menus = []
-    for topitem in raw_menus:
-
-        if topitem['allowed']:
-
-            if topitem.get('type') == 'link':
-                final_menus.append(make_menu_entry(request, topitem))
-
-            else: # assuming 'menu' type
-
-                menu_items = []
-                for item in topitem['items']:
-                    if not item['allowed']:
-                        continue
-
-                    # nested submenu
-                    if item.get('type') == 'menu':
-                        submenu_items = []
-                        for subitem in item['items']:
-                            if subitem['allowed']:
-                                submenu_items.append(make_menu_entry(request, subitem))
-                        menu_items.append({
-                            'type': 'submenu',
-                            'title': item['title'],
-                            'items': submenu_items,
-                            'is_menu': True,
-                            'is_sep': False,
-                        })
-
-                    elif item.get('type') == 'sep':
-                        # we only want to add a sep, *if* we already have some
-                        # menu items (i.e. there is something to separate)
-                        # *and* the last menu item is not a sep (avoid doubles)
-                        if menu_items and not menu_items[-1]['is_sep']:
-                            menu_items.append(make_menu_entry(request, item))
-
-                    else: # standard menu item
-                        menu_items.append(make_menu_entry(request, item))
-
-                # remove final separator if present
-                if menu_items and menu_items[-1]['is_sep']:
-                    menu_items.pop()
-
-                # only add if we wound up with something
-                assert menu_items
-                if menu_items:
-                    group = {
-                        'type': 'menu',
-                        'key': topitem.get('key'),
-                        'title': topitem['title'],
-                        'items': menu_items,
-                        'is_menu': True,
-                        'is_link':  False,
-                    }
-
-                    # topitem w/ no key likely means it did not come
-                    # from config but rather explicit definition in
-                    # code.  so we are free to "invent" a (safe) key
-                    # for it, since that is only for editing config
-                    if not group['key']:
-                        group['key'] = make_menu_key(request.rattail_config,
-                                                     topitem['title'])
-
-                    final_menus.append(group)
-
-    return final_menus
-
-
-def make_menu_key(config, value):
-    """
-    Generate a normalized menu key for the given value.
-    """
-    return re.sub(r'\W', '', value.lower())
-
-
-def make_menu_entry(request, item):
-    """
-    Convert a simple menu entry dict, into a proper menu-related object, for
-    use in constructing final menu.
-    """
-    # separator
-    if item.get('type') == 'sep':
-        return {
-            'type': 'sep',
-            'is_menu': False,
-            'is_sep': True,
-        }
-
-    # standard menu item
-    entry = {
-        'type': 'item',
-        'title': item['title'],
-        'perm': item.get('perm'),
-        'target': item.get('target'),
-        'is_link': True,
-        'is_menu': False,
-        'is_sep': False,
-    }
-    if item.get('route'):
-        entry['route'] = item['route']
-        try:
-            entry['url'] = request.route_url(entry['route'])
-        except KeyError:        # happens if no such route
-            log.warning("invalid route name for menu entry: %s", entry)
-            entry['url'] = entry['route']
-        entry['key'] = entry['route']
-    else:
-        if item.get('url'):
-            entry['url'] = item['url']
-        entry['key'] = make_menu_key(request.rattail_config, entry['title'])
-    return entry
-
-
-def is_allowed(request, item):
-    """
-    Logic to determine if a given menu item is "allowed" for current user.
-    """
-    perm = item.get('perm')
-    if perm:
-        return request.has_perm(perm)
-    return True
-
-
-def mark_allowed(request, menus):
-    """
-    Traverse the menu set, and mark each item as "allowed" (or not) based on
-    current user permissions.
-    """
-    for topitem in menus:
-
-        if topitem.get('type', 'menu') == 'menu':
-            topitem['allowed'] = False
-
-            for item in topitem['items']:
-
-                if item.get('type') == 'menu':
-                    for subitem in item['items']:
-                        subitem['allowed'] = is_allowed(request, subitem)
-
-                    item['allowed'] = False
-                    for subitem in item['items']:
-                        if subitem['allowed'] and subitem.get('type') != 'sep':
-                            item['allowed'] = True
-                            break
-
-                else:
-                    item['allowed'] = is_allowed(request, item)
-
-            for item in topitem['items']:
-                if item['allowed'] and item.get('type') != 'sep':
-                    topitem['allowed'] = True
-                    break
-
-
-def make_admin_menu(request, **kwargs):
-    """
-    Generate a typical Admin menu
-    """
-    warnings.warn("make_admin_menu() function is deprecated; please use "
-                  "MenuHandler.make_admin_menu() instead",
-                  DeprecationWarning, stacklevel=2)
-
-    app = request.rattail_config.get_app()
-    tailbone_handler = app.get_tailbone_handler()
-    menu_handler = tailbone_handler.get_menu_handler()
-    return menu_handler.make_admin_menu(request, **kwargs)
+    def make_menus(self, request, **kwargs):
+        return []
diff --git a/tailbone/scaffolds.py b/tailbone/scaffolds.py
deleted file mode 100644
index 10bf9640..00000000
--- a/tailbone/scaffolds.py
+++ /dev/null
@@ -1,45 +0,0 @@
-# -*- coding: utf-8 -*-
-################################################################################
-#
-#  Rattail -- Retail Software Framework
-#  Copyright © 2010-2017 Lance Edgar
-#
-#  This file is part of Rattail.
-#
-#  Rattail is free software: you can redistribute it and/or modify it under the
-#  terms of the GNU General Public License as published by the Free Software
-#  Foundation, either version 3 of the License, or (at your option) any later
-#  version.
-#
-#  Rattail is distributed in the hope that it will be useful, but WITHOUT ANY
-#  WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
-#  FOR A PARTICULAR PURPOSE.  See the GNU General Public License for more
-#  details.
-#
-#  You should have received a copy of the GNU General Public License along with
-#  Rattail.  If not, see <http://www.gnu.org/licenses/>.
-#
-################################################################################
-"""
-Pyramid scaffold templates
-"""
-
-from __future__ import unicode_literals, absolute_import
-
-from rattail.files import resource_path
-from rattail.util import prettify
-
-from pyramid.scaffolds import PyramidTemplate
-
-
-class RattailTemplate(PyramidTemplate):
-    _template_dir = resource_path('rattail:data/project')
-    summary = "Starter project based on Rattail / Tailbone"
-
-    def pre(self, command, output_dir, vars):
-        """
-        Adds some more variables to the template context.
-        """
-        vars['project_title'] = prettify(vars['project'])
-        vars['package_title'] = vars['package'].capitalize()
-        return super(RattailTemplate, self).pre(command, output_dir, vars)
diff --git a/tailbone/static/__init__.py b/tailbone/static/__init__.py
index 2ad5161a..57700b80 100644
--- a/tailbone/static/__init__.py
+++ b/tailbone/static/__init__.py
@@ -2,7 +2,7 @@
 ################################################################################
 #
 #  Rattail -- Retail Software Framework
-#  Copyright © 2010-2017 Lance Edgar
+#  Copyright © 2010-2024 Lance Edgar
 #
 #  This file is part of Rattail.
 #
@@ -24,9 +24,8 @@
 Static Assets
 """
 
-from __future__ import unicode_literals, absolute_import
-
 
 def includeme(config):
+    config.include('wuttaweb.static')
     config.add_static_view('tailbone', 'tailbone:static')
     config.add_static_view('deform', 'deform:static')
diff --git a/tailbone/subscribers.py b/tailbone/subscribers.py
index 42d3cab7..268d4818 100644
--- a/tailbone/subscribers.py
+++ b/tailbone/subscribers.py
@@ -24,8 +24,6 @@
 Event Subscribers
 """
 
-import six
-import json
 import datetime
 import logging
 import warnings
@@ -38,191 +36,158 @@ import deform
 from pyramid import threadlocal
 from webhelpers2.html import tags
 
+from wuttaweb import subscribers as base
+
 import tailbone
 from tailbone import helpers
 from tailbone.db import Session
 from tailbone.config import csrf_header_name, should_expose_websockets
-from tailbone.menus import make_simple_menus
-from tailbone.util import (get_available_themes, get_global_search_options,
-                           should_use_oruga)
+from tailbone.util import get_available_themes, get_global_search_options
 
 
 log = logging.getLogger(__name__)
 
 
-def new_request(event):
+def new_request(event, session=None):
     """
-    Identify the current user, and cache their current permissions.  Also adds
-    the ``rattail_config`` attribute to the request.
+    Event hook called when processing a new request.
 
-    A global Rattail ``config`` should already be present within the Pyramid
-    application registry's settings, which would normally be accessed via::
-    
-       request.registry.settings['rattail_config']
+    This first invokes the upstream hooks:
 
-    This function merely "promotes" that config object so that it is more
-    directly accessible, a la::
+    * :func:`wuttaweb:wuttaweb.subscribers.new_request()`
+    * :func:`wuttaweb:wuttaweb.subscribers.new_request_set_user()`
 
-       request.rattail_config
+    It then adds more things to the request object; among them:
 
-    .. note::
-       This of course assumes that a Rattail ``config`` object *has* in fact
-       already been placed in the application registry settings.  If this is
-       not the case, this function will do nothing.
+    .. attribute:: request.rattail_config
 
-    Also, attach some goodies to the request object:
+       Reference to the app :term:`config object`.  Note that this
+       will be the same as :attr:`wuttaweb:request.wutta_config`.
 
-    * The currently logged-in user instance (if any), as ``user``.
+    .. method:: request.register_component(tagname, classname)
 
-    * ``is_admin`` flag indicating whether user has the Administrator role.
+       Function to register a Vue component for use with the app.
 
-    * ``is_root`` flag indicating whether user is currently elevated to root.
-
-    * A shortcut method for permission checking, as ``has_perm()``.
+       This can be called from wherever a component is defined, and
+       then in the base template all registered components will be
+       properly loaded.
     """
-    log.debug("new request: %s", event)
     request = event.request
-    rattail_config = request.registry.settings.get('rattail_config')
-    # TODO: why would this ever be null?
-    if rattail_config:
-        request.rattail_config = rattail_config
-    else:
-        log.error("registry has no rattail_config ?!")
 
-    def user(request):
-        user = None
-        uuid = request.authenticated_userid
-        if uuid:
-            model = request.rattail_config.get_model()
-            user = Session.get(model.User, uuid)
-            if user:
-                Session().set_continuum_user(user)
-        return user
+    # invoke main upstream logic
+    # nb. this sets request.wutta_config
+    base.new_request(event)
 
-    request.set_property(user, reify=True)
+    config = request.wutta_config
+    app = config.get_app()
+    auth = app.get_auth_handler()
+    session = session or Session()
 
-    # nb. only add oruga check for "classic" web app
-    classic = rattail_config.parse_bool(request.registry.settings.get('tailbone.classic'))
-    if classic:
+    # compatibility
+    rattail_config = config
+    request.rattail_config = rattail_config
 
-        def use_oruga(request):
-            return should_use_oruga(request)
+    def user_getter(request, db_session=None):
+        user = base.default_user_getter(request, db_session=db_session)
+        if user:
+            # nb. we also assign continuum user to session
+            session = db_session or Session()
+            session.set_continuum_user(user)
+            return user
 
-        request.set_property(use_oruga, reify=True)
+    # invoke upstream hook to set user
+    base.new_request_set_user(event, user_getter=user_getter, db_session=session)
 
     # assign client IP address to the session, for sake of versioning
-    Session().continuum_remote_addr = request.client_addr
+    if hasattr(request, 'client_addr'):
+        session.continuum_remote_addr = request.client_addr
 
-    request.is_admin = bool(request.user) and request.user.is_admin()
-    request.is_root = request.is_admin and request.session.get('is_root', False)
+    # request.register_component()
+    def register_component(tagname, classname):
+        """
+        Register a Vue 3 component, so the base template knows to
+        declare it for use within the app (page).
+        """
+        if not hasattr(request, '_tailbone_registered_components'):
+            request._tailbone_registered_components = OrderedDict()
 
-    # TODO: why would this ever be null?
-    if rattail_config:
+        if tagname in request._tailbone_registered_components:
+            log.warning("component with tagname '%s' already registered "
+                        "with class '%s' but we are replacing that with "
+                        "class '%s'",
+                        tagname,
+                        request._tailbone_registered_components[tagname],
+                        classname)
 
-        app = rattail_config.get_app()
-        auth = app.get_auth_handler()
-        request.tailbone_cached_permissions = auth.get_permissions(
-            Session(), request.user)
-
-        def has_perm(name):
-            if name in request.tailbone_cached_permissions:
-                return True
-            return request.is_root
-        request.has_perm = has_perm
-
-        def has_any_perm(*names):
-            for name in names:
-                if has_perm(name):
-                    return True
-            return False
-        request.has_any_perm = has_any_perm
-
-        def register_component(tagname, classname):
-            """
-            Register a Vue 3 component, so the base template knows to
-            declare it for use within the app (page).
-            """
-            if not hasattr(request, '_tailbone_registered_components'):
-                request._tailbone_registered_components = OrderedDict()
-
-            if tagname in request._tailbone_registered_components:
-                log.warning("component with tagname '%s' already registered "
-                            "with class '%s' but we are replacing that with "
-                            "class '%s'",
-                            tagname,
-                            request._tailbone_registered_components[tagname],
-                            classname)
-
-            request._tailbone_registered_components[tagname] = classname
-        request.register_component = register_component
+        request._tailbone_registered_components[tagname] = classname
+    request.register_component = register_component
 
 
 def before_render(event):
     """
     Adds goodies to the global template renderer context.
     """
-    log.debug("before_render: %s", event)
+    # log.debug("before_render: %s", event)
+
+    # invoke upstream logic
+    base.before_render(event)
 
     request = event.get('request') or threadlocal.get_current_request()
-    rattail_config = request.rattail_config
+    config = request.wutta_config
+    app = config.get_app()
 
     renderer_globals = event
-    renderer_globals['rattail_app'] = request.rattail_config.get_app()
-    renderer_globals['app_title'] = request.rattail_config.app_title()
+
+    # overrides
     renderer_globals['h'] = helpers
-    renderer_globals['url'] = request.route_url
-    renderer_globals['rattail'] = rattail
-    renderer_globals['tailbone'] = tailbone
-    renderer_globals['model'] = request.rattail_config.get_model()
-    renderer_globals['enum'] = request.rattail_config.get_enum()
-    renderer_globals['six'] = six
-    renderer_globals['json'] = json
+
+    # misc.
     renderer_globals['datetime'] = datetime
     renderer_globals['colander'] = colander
     renderer_globals['deform'] = deform
-    renderer_globals['csrf_header_name'] = csrf_header_name(request.rattail_config)
+    renderer_globals['csrf_header_name'] = csrf_header_name(config)
+
+    # TODO: deprecate / remove these
+    renderer_globals['rattail_app'] = app
+    renderer_globals['app_title'] = app.get_title()
+    renderer_globals['app_version'] = app.get_version()
+    renderer_globals['rattail'] = rattail
+    renderer_globals['tailbone'] = tailbone
+    renderer_globals['model'] = app.model
+    renderer_globals['enum'] = app.enum
 
     # theme  - we only want do this for classic web app, *not* API
     # TODO: so, clearly we need a better way to distinguish the two
     if 'tailbone.theme' in request.registry.settings:
-        renderer_globals['b'] = 'o' if request.use_oruga else 'b' # for buefy
         renderer_globals['theme'] = request.registry.settings['tailbone.theme']
         # note, this is just a global flag; user still needs permission to see picker
-        expose_picker = request.rattail_config.getbool('tailbone', 'themes.expose_picker',
-                                                       default=False)
+        expose_picker = config.get_bool('tailbone.themes.expose_picker',
+                                        default=False)
         renderer_globals['expose_theme_picker'] = expose_picker
         if expose_picker:
 
             # TODO: should remove 'falafel' option altogether
-            available = get_available_themes(request.rattail_config)
+            available = get_available_themes(config)
 
             options = [tags.Option(theme, value=theme) for theme in available]
             renderer_globals['theme_picker_options'] = options
 
-        # heck while we're assuming the classic web app here...
-        # (we don't want this to happen for the API either!)
-        # TODO: just..awful *shrug*
-        # note that we assume "simple" menus nowadays
-        if request.rattail_config.getbool('tailbone', 'menus.simple', default=True):
-            renderer_globals['menus'] = make_simple_menus(request)
-
         # TODO: ugh, same deal here
-        renderer_globals['messaging_enabled'] = request.rattail_config.getbool(
-            'tailbone', 'messaging.enabled', default=False)
+        renderer_globals['messaging_enabled'] = config.get_bool('tailbone.messaging.enabled',
+                                                                default=False)
 
         # background color may be set per-request, by some apps
         if hasattr(request, 'background_color') and request.background_color:
             renderer_globals['background_color'] = request.background_color
         else: # otherwise we use the one from config
-            renderer_globals['background_color'] = request.rattail_config.get(
-                'tailbone', 'background_color')
+            renderer_globals['background_color'] = config.get('tailbone.background_color')
 
         # maybe set custom stylesheet
         css = None
         if request.user:
-            css = rattail_config.get(f'tailbone.{request.user.uuid}', 'user_css')
+            css = config.get(f'tailbone.{request.user.uuid}', 'user_css')
             if not css:
-                css = rattail_config.get(f'tailbone.{request.user.uuid}', 'buefy_css')
+                css = config.get(f'tailbone.{request.user.uuid}', 'buefy_css')
                 if css:
                     warnings.warn(f"setting 'tailbone.{request.user.uuid}.buefy_css' should be"
                                   f"changed to 'tailbone.{request.user.uuid}.user_css'",
@@ -233,7 +198,7 @@ def before_render(event):
         renderer_globals['global_search_data'] = get_global_search_options(request)
 
         # here we globally declare widths for grid filter pseudo-columns
-        widths = request.rattail_config.get('tailbone', 'grids.filters.column_widths')
+        widths = config.get('tailbone.grids.filters.column_widths')
         if widths:
             widths = widths.split(';')
             if len(widths) < 2:
@@ -244,7 +209,7 @@ def before_render(event):
         renderer_globals['filter_verb_width'] = widths[1]
 
         # declare global support for websockets, or lack thereof
-        renderer_globals['expose_websockets'] = should_expose_websockets(rattail_config)
+        renderer_globals['expose_websockets'] = should_expose_websockets(config)
 
 
 def add_inbox_count(event):
@@ -258,8 +223,9 @@ def add_inbox_count(event):
     request = event.get('request') or threadlocal.get_current_request()
     if request.user:
         renderer_globals = event
+        app = request.rattail_config.get_app()
+        model = app.model
         enum = request.rattail_config.get_enum()
-        model = request.rattail_config.get_model()
         renderer_globals['inbox_count'] = Session.query(model.Message)\
                                                  .outerjoin(model.MessageRecipient)\
                                                  .filter(model.MessageRecipient.recipient == Session.merge(request.user))\
@@ -273,27 +239,10 @@ def context_found(event):
 
     The following is attached to the request:
 
-    * ``get_referrer()`` function
-
     * ``get_session_timeout()`` function
     """
     request = event.request
 
-    def get_referrer(default=None, **kwargs):
-        if request.params.get('referrer'):
-            return request.params['referrer']
-        if request.session.get('referrer'):
-            return request.session.pop('referrer')
-        referrer = request.referrer
-        if (not referrer or referrer == request.current_route_url()
-            or not referrer.startswith(request.host_url)):
-            if default:
-                referrer = default
-            else:
-                referrer = request.route_url('home')
-        return referrer
-    request.get_referrer = get_referrer
-
     def get_session_timeout():
         """
         Returns the timeout in effect for the current session
diff --git a/tailbone/templates/appinfo/configure.mako b/tailbone/templates/appinfo/configure.mako
index 280b5cb9..9d866cea 100644
--- a/tailbone/templates/appinfo/configure.mako
+++ b/tailbone/templates/appinfo/configure.mako
@@ -1,250 +1,2 @@
 ## -*- coding: utf-8; -*-
-<%inherit file="/configure.mako" />
-
-<%def name="form_content()">
-
-  <h3 class="block is-size-3">Basics</h3>
-  <div class="block" style="padding-left: 2rem;">
-
-    <b-field grouped>
-
-      <b-field label="App Title">
-        <b-input name="rattail.app_title"
-                 v-model="simpleSettings['rattail.app_title']"
-                 @input="settingsNeedSaved = true">
-        </b-input>
-      </b-field>
-
-      <b-field label="Node Type">
-        ## TODO: should be a dropdown, app handler defines choices
-        <b-input name="rattail.node_type"
-                 v-model="simpleSettings['rattail.node_type']"
-                 @input="settingsNeedSaved = true">
-        </b-input>
-      </b-field>
-
-      <b-field label="Node Title">
-        <b-input name="rattail.node_title"
-                 v-model="simpleSettings['rattail.node_title']"
-                 @input="settingsNeedSaved = true">
-        </b-input>
-      </b-field>
-
-    </b-field>
-
-    <b-field>
-      <b-checkbox name="rattail.production"
-                  v-model="simpleSettings['rattail.production']"
-                  native-value="true"
-                  @input="settingsNeedSaved = true">
-        Production Mode
-      </b-checkbox>
-    </b-field>
-
-    <div class="level-left">
-      <div class="level-item">
-        <b-field>
-          <b-checkbox name="rattail.running_from_source"
-                      v-model="simpleSettings['rattail.running_from_source']"
-                      native-value="true"
-                      @input="settingsNeedSaved = true">
-            Running from Source
-          </b-checkbox>
-        </b-field>
-      </div>
-      <div class="level-item">
-        <b-field label="Top-Level Package" horizontal
-                 v-if="simpleSettings['rattail.running_from_source']">
-          <b-input name="rattail.running_from_source.rootpkg"
-                   v-model="simpleSettings['rattail.running_from_source.rootpkg']"
-                   @input="settingsNeedSaved = true">
-          </b-input>
-        </b-field>
-      </div>
-    </div>
-
-  </div>
-
-  <h3 class="block is-size-3">Display</h3>
-  <div class="block" style="padding-left: 2rem;">
-
-    <b-field grouped>
-
-      <b-field label="Background Color">
-        <b-input name="tailbone.background_color"
-                 v-model="simpleSettings['tailbone.background_color']"
-                 @input="settingsNeedSaved = true">
-        </b-input>
-      </b-field>
-
-    </b-field>
-
-  </div>
-
-  <h3 class="block is-size-3">Grids</h3>
-  <div class="block" style="padding-left: 2rem;">
-
-    <b-field grouped>
-
-      <b-field label="Default Page Size">
-        <b-input name="tailbone.grid.default_pagesize"
-                 v-model="simpleSettings['tailbone.grid.default_pagesize']"
-                 @input="settingsNeedSaved = true">
-        </b-input>
-      </b-field>
-
-    </b-field>
-
-  </div>
-
-  <h3 class="block is-size-3">Web Libraries</h3>
-  <div class="block" style="padding-left: 2rem;">
-
-    <${b}-table :data="weblibs">
-
-      <${b}-table-column field="title"
-                      label="Name"
-                      v-slot="props">
-        {{ props.row.title }}
-      </${b}-table-column>
-
-      <${b}-table-column field="configured_version"
-                      label="Version"
-                      v-slot="props">
-        {{ props.row.configured_version || props.row.default_version }}
-      </${b}-table-column>
-
-      <${b}-table-column field="configured_url"
-                      label="URL Override"
-                      v-slot="props">
-        {{ props.row.configured_url }}
-      </${b}-table-column>
-
-      <${b}-table-column field="live_url"
-                      label="Effective (Live) URL"
-                      v-slot="props">
-        <span v-if="props.row.modified"
-              class="has-text-warning">
-          save settings and refresh page to see new URL
-        </span>
-        <span v-if="!props.row.modified">
-          {{ props.row.live_url }}
-        </span>
-      </${b}-table-column>
-
-      <${b}-table-column field="actions"
-                      label="Actions"
-                      v-slot="props">
-        <a href="#"
-           @click.prevent="editWebLibraryInit(props.row)">
-          % if request.use_oruga:
-              <o-icon icon="edit" />
-          % else:
-              <i class="fas fa-edit"></i>
-          % endif
-          Edit
-        </a>
-      </${b}-table-column>
-
-    </${b}-table>
-
-    % for weblib in weblibs:
-        ${h.hidden('tailbone.libver.{}'.format(weblib['key']), **{':value': "simpleSettings['tailbone.libver.{}']".format(weblib['key'])})}
-        ${h.hidden('tailbone.liburl.{}'.format(weblib['key']), **{':value': "simpleSettings['tailbone.liburl.{}']".format(weblib['key'])})}
-    % endfor
-
-    <${b}-modal has-modal-card
-                % if request.use_oruga:
-                    v-model:active="editWebLibraryShowDialog"
-                % else:
-                    :active.sync="editWebLibraryShowDialog"
-                % endif
-                >
-      <div class="modal-card">
-
-        <header class="modal-card-head">
-          <p class="modal-card-title">Web Library: {{ editWebLibraryRecord.title }}</p>
-        </header>
-
-        <section class="modal-card-body">
-
-          <b-field grouped>
-            
-            <b-field label="Default Version">
-              <b-input v-model="editWebLibraryRecord.default_version"
-                       disabled>
-              </b-input>
-            </b-field>
-
-            <b-field label="Override Version">
-              <b-input v-model="editWebLibraryVersion">
-              </b-input>
-            </b-field>
-
-          </b-field>
-
-          <b-field label="Override URL">
-            <b-input v-model="editWebLibraryURL"
-                     expanded />
-          </b-field>
-
-          <b-field label="Effective URL (as of last page load)">
-            <b-input v-model="editWebLibraryRecord.live_url"
-                     disabled
-                     expanded />
-          </b-field>
-
-        </section>
-
-        <footer class="modal-card-foot">
-          <b-button type="is-primary"
-                    @click="editWebLibrarySave()"
-                    icon-pack="fas"
-                    icon-left="save">
-            Save
-          </b-button>
-          <b-button @click="editWebLibraryShowDialog = false">
-            Cancel
-          </b-button>
-        </footer>
-      </div>
-    </${b}-modal>
-
-  </div>
-</%def>
-
-<%def name="modify_this_page_vars()">
-  ${parent.modify_this_page_vars()}
-  <script type="text/javascript">
-
-    ThisPageData.weblibs = ${json.dumps(weblibs)|n}
-
-    ThisPageData.editWebLibraryShowDialog = false
-    ThisPageData.editWebLibraryRecord = {}
-    ThisPageData.editWebLibraryVersion = null
-    ThisPageData.editWebLibraryURL = null
-
-    ThisPage.methods.editWebLibraryInit = function(row) {
-        this.editWebLibraryRecord = row
-        this.editWebLibraryVersion = row.configured_version
-        this.editWebLibraryURL = row.configured_url
-        this.editWebLibraryShowDialog = true
-    }
-
-    ThisPage.methods.editWebLibrarySave = function() {
-        this.editWebLibraryRecord.configured_version = this.editWebLibraryVersion
-        this.editWebLibraryRecord.configured_url = this.editWebLibraryURL
-        this.editWebLibraryRecord.modified = true
-
-        this.simpleSettings[`tailbone.libver.${'$'}{this.editWebLibraryRecord.key}`] = this.editWebLibraryVersion
-        this.simpleSettings[`tailbone.liburl.${'$'}{this.editWebLibraryRecord.key}`] = this.editWebLibraryURL
-
-        this.settingsNeedSaved = true
-        this.editWebLibraryShowDialog = false
-    }
-
-  </script>
-</%def>
-
-
-${parent.body()}
+<%inherit file="wuttaweb:templates/appinfo/configure.mako" />
diff --git a/tailbone/templates/appinfo/index.mako b/tailbone/templates/appinfo/index.mako
index 73f53920..faaea935 100644
--- a/tailbone/templates/appinfo/index.mako
+++ b/tailbone/templates/appinfo/index.mako
@@ -1,8 +1,7 @@
 ## -*- coding: utf-8; -*-
-<%inherit file="/master/index.mako" />
-
-<%def name="render_grid_component()">
+<%inherit file="wuttaweb:templates/appinfo/index.mako" />
 
+<%def name="page_content()">
   <div class="buttons">
 
     <once-button type="is-primary"
@@ -28,100 +27,5 @@
 
   </div>
 
-  <${b}-collapse class="panel" open>
-
-    <template #trigger="props">
-      <div class="panel-heading"
-           style="cursor: pointer;"
-           role="button">
-
-        ## TODO: for some reason buefy will "reuse" the icon
-        ## element in such a way that its display does not
-        ## refresh.  so to work around that, we use different
-        ## structure for the two icons, so buefy is forced to
-        ## re-draw
-
-        <b-icon v-if="props.open"
-                pack="fas"
-                icon="angle-down">
-        </b-icon>
-
-        <span v-if="!props.open">
-          <b-icon pack="fas"
-                  icon="angle-right">
-          </b-icon>
-        </span>
-
-        <span>Configuration Files</span>
-      </div>
-    </template>
-
-    <div class="panel-block">
-      <div style="width: 100%;">
-        <${b}-table :data="configFiles">
-          
-          <${b}-table-column field="priority"
-                          label="Priority"
-                          v-slot="props">
-            {{ props.row.priority }}
-          </${b}-table-column>
-
-          <${b}-table-column field="path"
-                          label="File Path"
-                          v-slot="props">
-            {{ props.row.path }}
-          </${b}-table-column>
-
-        </${b}-table>
-      </div>
-    </div>
-  </${b}-collapse>
-
-  <${b}-collapse class="panel"
-              :open="false">
-
-    <template #trigger="props">
-      <div class="panel-heading"
-           style="cursor: pointer;"
-           role="button">
-
-        ## TODO: for some reason buefy will "reuse" the icon
-        ## element in such a way that its display does not
-        ## refresh.  so to work around that, we use different
-        ## structure for the two icons, so buefy is forced to
-        ## re-draw
-
-        <b-icon v-if="props.open"
-                pack="fas"
-                icon="angle-down">
-        </b-icon>
-
-        <span v-if="!props.open">
-          <b-icon pack="fas"
-                  icon="angle-right">
-          </b-icon>
-        </span>
-
-        <strong>Installed Packages</strong>
-      </div>
-    </template>
-
-    <div class="panel-block">
-      <div style="width: 100%;">
-        ${parent.render_grid_component()}
-      </div>
-    </div>
-  </${b}-collapse>
+  ${parent.page_content()}
 </%def>
-
-<%def name="modify_this_page_vars()">
-  ${parent.modify_this_page_vars()}
-  <script type="text/javascript">
-
-    ThisPageData.configFiles = ${json.dumps([dict(path=p, priority=i) for i, p in enumerate(request.rattail_config.prioritized_files, 1)])|n}
-
-  </script>
-</%def>
-
-
-${parent.body()}
diff --git a/tailbone/templates/appsettings.mako b/tailbone/templates/appsettings.mako
index 4f935956..ba667e0e 100644
--- a/tailbone/templates/appsettings.mako
+++ b/tailbone/templates/appsettings.mako
@@ -15,8 +15,8 @@
   <app-settings :groups="groups" :showing-group="showingGroup"></app-settings>
 </%def>
 
-<%def name="render_this_page_template()">
-  ${parent.render_this_page_template()}
+<%def name="render_vue_templates()">
+  ${parent.render_vue_templates()}
   <script type="text/x-template" id="app-settings-template">
 
     <div class="form">
@@ -150,19 +150,18 @@
   </script>
 </%def>
 
-<%def name="modify_this_page_vars()">
-  ${parent.modify_this_page_vars()}
-  <script type="text/javascript">
 
+<%def name="modify_vue_vars()">
+  ${parent.modify_vue_vars()}
+  <script>
     ThisPageData.groups = ${json.dumps(settings_data)|n}
     ThisPageData.showingGroup = ${json.dumps(current_group or '')|n}
-
   </script>
 </%def>
 
-<%def name="make_this_page_component()">
-  ${parent.make_this_page_component()}
-  <script type="text/javascript">
+<%def name="make_vue_components()">
+  ${parent.make_vue_components()}
+  <script>
 
     Vue.component('app-settings', {
         template: '#app-settings-template',
@@ -193,6 +192,3 @@
 
   </script>
 </%def>
-
-
-${parent.body()}
diff --git a/tailbone/templates/base.mako b/tailbone/templates/base.mako
index f576473d..8228f823 100644
--- a/tailbone/templates/base.mako
+++ b/tailbone/templates/base.mako
@@ -1,4 +1,5 @@
 ## -*- coding: utf-8; -*-
+<%namespace file="/wutta-components.mako" import="make_wutta_components" />
 <%namespace file="/grids/nav.mako" import="grid_index_nav" />
 <%namespace file="/autocomplete.mako" import="tailbone_autocomplete_template" />
 <%namespace name="base_meta" file="/base_meta.mako" />
@@ -34,17 +35,21 @@
   </head>
 
   <body>
-    ${declare_formposter_mixin()}
-
-    ${self.body()}
-
-    <div id="whole-page-app">
+    <div id="app" style="height: 100%;">
       <whole-page></whole-page>
     </div>
 
-    ${self.render_whole_page_template()}
-    ${self.make_whole_page_component()}
-    ${self.make_whole_page_app()}
+    ## TODO: this must come before the self.body() call..but why?
+    ${declare_formposter_mixin()}
+
+    ## content body from derived/child template
+    ${self.body()}
+
+    ## Vue app
+    ${self.render_vue_templates()}
+    ${self.modify_vue_vars()}
+    ${self.make_vue_components()}
+    ${self.make_vue_app()}
   </body>
 </html>
 
@@ -122,16 +127,16 @@
 </%def>
 
 <%def name="vuejs()">
-  ${h.javascript_link(h.get_liburl(request, 'vue'))}
-  ${h.javascript_link(h.get_liburl(request, 'vue_resource'))}
+  ${h.javascript_link(h.get_liburl(request, 'vue', prefix='tailbone'))}
+  ${h.javascript_link(h.get_liburl(request, 'vue_resource', prefix='tailbone'))}
 </%def>
 
 <%def name="buefy()">
-  ${h.javascript_link(h.get_liburl(request, 'buefy'))}
+  ${h.javascript_link(h.get_liburl(request, 'buefy', prefix='tailbone'))}
 </%def>
 
 <%def name="fontawesome()">
-  <script defer src="${h.get_liburl(request, 'fontawesome')}"></script>
+  <script defer src="${h.get_liburl(request, 'fontawesome', prefix='tailbone')}"></script>
 </%def>
 
 <%def name="extra_javascript()"></%def>
@@ -153,12 +158,16 @@
   <style type="text/css">
     .filters .filter-fieldname,
     .filters .filter-fieldname .button {
+        % if filter_fieldname_width is not Undefined:
         min-width: ${filter_fieldname_width};
+        % endif
         justify-content: left;
     }
+    % if filter_fieldname_width is not Undefined:
     .filters .filter-verb {
         min-width: ${filter_verb_width};
     }
+    % endif
   </style>
 </%def>
 
@@ -167,7 +176,7 @@
       ${h.stylesheet_link(user_css)}
   % else:
       ## upstream Buefy CSS
-      ${h.stylesheet_link(h.get_liburl(request, 'buefy.css'))}
+      ${h.stylesheet_link(h.get_liburl(request, 'buefy.css', prefix='tailbone'))}
   % endif
 </%def>
 
@@ -177,7 +186,7 @@
 
 <%def name="head_tags()"></%def>
 
-<%def name="render_whole_page_template()">
+<%def name="render_vue_template_whole_page()">
   <script type="text/x-template" id="whole-page-template">
     <div>
       <header>
@@ -276,7 +285,7 @@
                       <span class="header-text">
                         ${index_title}
                       </span>
-                      % if master.creatable and master.show_create_link and master.has_perm('create'):
+                      % if master.creatable and getattr(master, 'show_create_link', True) and master.has_perm('create'):
                           <once-button type="is-primary"
                                        tag="a" href="${url('{}.create'.format(route_prefix))}"
                                        icon-left="plus"
@@ -302,7 +311,7 @@
                           <span class="header-text">
                             ${h.link_to(instance_title, instance_url)}
                           </span>
-                      % elif master.creatable and master.show_create_link and master.has_perm('create'):
+                      % elif master.creatable and getattr(master, 'show_create_link', True) and master.has_perm('create'):
                           % if not request.matched_route.name.endswith('.create'):
                               <once-button type="is-primary"
                                            tag="a" href="${url('{}.create'.format(route_prefix))}"
@@ -623,9 +632,23 @@
         % endif
         <div class="navbar-dropdown">
           % if request.is_root:
-              ${h.link_to("Stop being root", url('stop_root'), class_='navbar-item root-user')}
+              ${h.form(url('stop_root'), ref='stopBeingRootForm')}
+              ${h.csrf_token(request)}
+              <input type="hidden" name="referrer" value="${request.current_route_url()}" />
+              <a @click="$refs.stopBeingRootForm.submit()"
+                 class="navbar-item root-user">
+                Stop being root
+              </a>
+              ${h.end_form()}
           % elif request.is_admin:
-              ${h.link_to("Become root", url('become_root'), class_='navbar-item root-user')}
+              ${h.form(url('become_root'), ref='startBeingRootForm')}
+              ${h.csrf_token(request)}
+              <input type="hidden" name="referrer" value="${request.current_route_url()}" />
+              <a @click="$refs.startBeingRootForm.submit()"
+                 class="navbar-item root-user">
+                Become root
+              </a>
+              ${h.end_form()}
           % endif
           % if messaging_enabled:
               ${h.link_to("Messages{}".format(" ({})".format(inbox_count) if inbox_count else ''), url('messages.inbox'), class_='navbar-item')}
@@ -633,7 +656,11 @@
           % if request.is_root or not request.user.prevent_password_change:
               ${h.link_to("Change Password", url('change_password'), class_='navbar-item')}
           % endif
-          ${h.link_to("Edit Preferences", url('my.preferences'), class_='navbar-item')}
+          % try:
+              ## nb. does not exist yet for wuttaweb
+              ${h.link_to("Edit Preferences", url('my.preferences'), class_='navbar-item')}
+          % except:
+          % endtry
           ${h.link_to("Logout", url('logout'), class_='navbar-item')}
         </div>
       </div>
@@ -654,19 +681,19 @@
       ## TODO: is there a better way to check if viewing parent?
       % if parent_instance is Undefined:
           % if master.editable and instance_editable and master.has_perm('edit'):
-              <once-button tag="a" href="${action_url('edit', instance)}"
+              <once-button tag="a" href="${master.get_action_url('edit', instance)}"
                            icon-left="edit"
                            text="Edit This">
               </once-button>
           % endif
-          % if master.cloneable and master.has_perm('clone'):
-              <once-button tag="a" href="${action_url('clone', instance)}"
+          % if getattr(master, 'cloneable', False) and not master.cloning and master.has_perm('clone'):
+              <once-button tag="a" href="${master.get_action_url('clone', instance)}"
                            icon-left="object-ungroup"
                            text="Clone This">
               </once-button>
           % endif
           % if master.deletable and instance_deletable and master.has_perm('delete'):
-              <once-button tag="a" href="${action_url('delete', instance)}"
+              <once-button tag="a" href="${master.get_action_url('delete', instance)}"
                            type="is-danger"
                            icon-left="trash"
                            text="Delete This">
@@ -675,7 +702,7 @@
       % else:
           ## viewing row
           % if instance_deletable and master.has_perm('delete_row'):
-              <once-button tag="a" href="${action_url('delete', instance)}"
+              <once-button tag="a" href="${master.get_action_url('delete', instance)}"
                            type="is-danger"
                            icon-left="trash"
                            text="Delete This">
@@ -684,13 +711,13 @@
       % endif
   % elif master and master.editing:
       % if master.viewable and master.has_perm('view'):
-          <once-button tag="a" href="${action_url('view', instance)}"
+          <once-button tag="a" href="${master.get_action_url('view', instance)}"
                        icon-left="eye"
                        text="View This">
           </once-button>
       % endif
       % if master.deletable and instance_deletable and master.has_perm('delete'):
-          <once-button tag="a" href="${action_url('delete', instance)}"
+          <once-button tag="a" href="${master.get_action_url('delete', instance)}"
                        type="is-danger"
                        icon-left="trash"
                        text="Delete This">
@@ -698,13 +725,13 @@
       % endif
   % elif master and master.deleting:
       % if master.viewable and master.has_perm('view'):
-          <once-button tag="a" href="${action_url('view', instance)}"
+          <once-button tag="a" href="${master.get_action_url('view', instance)}"
                        icon-left="eye"
                        text="View This">
           </once-button>
       % endif
       % if master.editable and instance_editable and master.has_perm('edit'):
-          <once-button tag="a" href="${action_url('edit', instance)}"
+          <once-button tag="a" href="${master.get_action_url('edit', instance)}"
                        icon-left="edit"
                        text="Edit This">
           </once-button>
@@ -745,11 +772,8 @@
   % endif
 </%def>
 
-<%def name="declare_whole_page_vars()">
-  ${page_help.declare_vars()}
-  ${multi_file_upload.declare_vars()}
-  ${h.javascript_link(request.static_url('tailbone:static/js/tailbone.feedback.js') + '?ver={}'.format(tailbone.__version__))}
-  <script type="text/javascript">
+<%def name="render_vue_script_whole_page()">
+  <script>
 
     let WholePage = {
         template: '#whole-page-template',
@@ -856,7 +880,7 @@
         feedbackMessage: "",
 
         % if expose_theme_picker and request.has_perm('common.change_app_theme'):
-            globalTheme: ${json.dumps(theme)|n},
+            globalTheme: ${json.dumps(theme or None)|n},
             referrer: location.href,
         % endif
 
@@ -866,7 +890,7 @@
 
         globalSearchActive: false,
         globalSearchTerm: '',
-        globalSearchData: ${json.dumps(global_search_data)|n},
+        globalSearchData: ${json.dumps(global_search_data or [])|n},
 
         mountedHooks: [],
     }
@@ -885,57 +909,6 @@
   </script>
 </%def>
 
-<%def name="modify_whole_page_vars()">
-  <script type="text/javascript">
-
-    % if request.user:
-    FeedbackFormData.userUUID = ${json.dumps(request.user.uuid)|n}
-    FeedbackFormData.userName = ${json.dumps(six.text_type(request.user))|n}
-    % endif
-
-  </script>
-</%def>
-
-<%def name="finalize_whole_page_vars()">
-  ## NOTE: if you override this, must use <script> tags
-</%def>
-
-<%def name="make_whole_page_component()">
-
-  ${make_grid_filter_components()}
-
-  ${self.declare_whole_page_vars()}
-  ${self.modify_whole_page_vars()}
-  ${self.finalize_whole_page_vars()}
-
-  ${h.javascript_link(request.static_url('tailbone:static/js/tailbone.buefy.autocomplete.js') + '?ver={}'.format(tailbone.__version__))}
-
-  ${page_help.make_component()}
-  ${multi_file_upload.make_component()}
-
-  <script type="text/javascript">
-
-    FeedbackForm.data = function() { return FeedbackFormData }
-
-    Vue.component('feedback-form', FeedbackForm)
-
-    WholePage.data = function() { return WholePageData }
-
-    Vue.component('whole-page', WholePage)
-
-  </script>
-</%def>
-
-<%def name="make_whole_page_app()">
-  <script type="text/javascript">
-
-    new Vue({
-        el: '#whole-page-app'
-    })
-
-  </script>
-</%def>
-
 <%def name="wtfield(form, name, **kwargs)">
   <div class="field-wrapper${' error' if form[name].errors else ''}">
     <label for="${name}">${form[name].label}</label>
@@ -957,3 +930,88 @@
     </div>
   </div>
 </%def>
+
+##############################
+## vue components + app
+##############################
+
+<%def name="render_vue_templates()">
+  ${page_help.declare_vars()}
+  ${multi_file_upload.declare_vars()}
+  ${h.javascript_link(request.static_url('tailbone:static/js/tailbone.feedback.js') + '?ver={}'.format(tailbone.__version__))}
+  ${h.javascript_link(request.static_url('tailbone:static/js/tailbone.buefy.autocomplete.js') + '?ver={}'.format(tailbone.__version__))}
+
+  ## DEPRECATED; called for back-compat
+  ${self.render_whole_page_template()}
+</%def>
+
+## DEPRECATED; remains for back-compat
+<%def name="render_whole_page_template()">
+  ${self.render_vue_template_whole_page()}
+  ${self.declare_whole_page_vars()}
+</%def>
+
+## DEPRECATED; remains for back-compat
+<%def name="declare_whole_page_vars()">
+  ${self.render_vue_script_whole_page()}
+</%def>
+
+<%def name="modify_vue_vars()">
+  ## DEPRECATED; called for back-compat
+  ${self.modify_whole_page_vars()}
+</%def>
+
+## DEPRECATED; remains for back-compat
+<%def name="modify_whole_page_vars()">
+  <script>
+
+    % if request.user:
+    FeedbackFormData.userUUID = ${json.dumps(request.user.uuid)|n}
+    FeedbackFormData.userName = ${json.dumps(str(request.user))|n}
+    % endif
+
+  </script>
+</%def>
+
+<%def name="make_vue_components()">
+  ${make_wutta_components()}
+  ${make_grid_filter_components()}
+  ${page_help.make_component()}
+  ${multi_file_upload.make_component()}
+  <script>
+    FeedbackForm.data = function() { return FeedbackFormData }
+    Vue.component('feedback-form', FeedbackForm)
+  </script>
+
+  ## DEPRECATED; called for back-compat
+  ${self.finalize_whole_page_vars()}
+  ${self.make_whole_page_component()}
+</%def>
+
+## DEPRECATED; remains for back-compat
+<%def name="make_whole_page_component()">
+  <script>
+    WholePage.data = function() { return WholePageData }
+    Vue.component('whole-page', WholePage)
+  </script>
+</%def>
+
+<%def name="make_vue_app()">
+  ## DEPRECATED; called for back-compat
+  ${self.make_whole_page_app()}
+</%def>
+
+## DEPRECATED; remains for back-compat
+<%def name="make_whole_page_app()">
+  <script>
+    new Vue({
+        el: '#app'
+    })
+  </script>
+</%def>
+
+##############################
+## DEPRECATED
+##############################
+
+<%def name="finalize_whole_page_vars()"></%def>
diff --git a/tailbone/templates/base_meta.mako b/tailbone/templates/base_meta.mako
index 07b13e61..b6376448 100644
--- a/tailbone/templates/base_meta.mako
+++ b/tailbone/templates/base_meta.mako
@@ -1,10 +1,7 @@
 ## -*- coding: utf-8; -*-
+<%inherit file="wuttaweb:templates/base_meta.mako" />
 
-<%def name="app_title()">${request.rattail_config.node_title(default="Rattail")}</%def>
-
-<%def name="global_title()">${"[STAGE] " if not request.rattail_config.production() else ''}${self.app_title()}</%def>
-
-<%def name="extra_styles()"></%def>
+<%def name="app_title()">${app.get_node_title()}</%def>
 
 <%def name="favicon()">
   <link rel="icon" type="image/x-icon" href="${request.rattail_config.get('tailbone', 'favicon_url', default=request.static_url('tailbone:static/img/rattail.ico'))}" />
@@ -13,9 +10,3 @@
 <%def name="header_logo()">
   ${h.image(request.rattail_config.get('tailbone', 'header_image_url', default=request.static_url('tailbone:static/img/rattail.ico')), "Header Logo", style="height: 49px;")}
 </%def>
-
-<%def name="footer()">
-  <p class="has-text-centered">
-    powered by ${h.link_to("Rattail", url('about'))}
-  </p>
-</%def>
diff --git a/tailbone/templates/batch/index.mako b/tailbone/templates/batch/index.mako
index 209fbb0c..bea10a97 100644
--- a/tailbone/templates/batch/index.mako
+++ b/tailbone/templates/batch/index.mako
@@ -43,7 +43,7 @@
             <br />
             <div class="form-wrapper">
               <div class="form">
-                <${execute_form.component} ref="executeResultsForm"></${execute_form.component}>
+                ${execute_form.render_vue_tag(ref='executeResultsForm')}
               </div>
             </div>
           </section>
@@ -64,10 +64,17 @@
   % endif
 </%def>
 
-<%def name="modify_this_page_vars()">
-  ${parent.modify_this_page_vars()}
+<%def name="render_vue_templates()">
+  ${parent.render_vue_templates()}
+  % if master.results_executable and master.has_perm('execute_multiple'):
+      ${execute_form.render_vue_template(form_kwargs={'ref': 'actualExecuteForm'}, buttons=False)}
+  % endif
+</%def>
+
+<%def name="modify_vue_vars()">
+  ${parent.modify_vue_vars()}
   % if master.results_refreshable and master.has_perm('refresh'):
-      <script type="text/javascript">
+      <script>
 
         TailboneGridData.refreshResultsButtonText = "Refresh Results"
         TailboneGridData.refreshResultsButtonDisabled = false
@@ -81,9 +88,9 @@
       </script>
   % endif
   % if master.results_executable and master.has_perm('execute_multiple'):
-      <script type="text/javascript">
+      <script>
 
-        ${execute_form.component_studly}.methods.submit = function() {
+        ${execute_form.vue_component}.methods.submit = function() {
             this.$refs.actualExecuteForm.submit()
         }
 
@@ -118,25 +125,9 @@
   % endif
 </%def>
 
-<%def name="make_this_page_component()">
-  ${parent.make_this_page_component()}
+<%def name="make_vue_components()">
+  ${parent.make_vue_components()}
   % if master.results_executable and master.has_perm('execute_multiple'):
-      <script type="text/javascript">
-
-        ${execute_form.component_studly}.data = function() { return ${execute_form.component_studly}Data }
-
-        Vue.component('${execute_form.component}', ${execute_form.component_studly})
-
-      </script>
+      ${execute_form.render_vue_finalize()}
   % endif
 </%def>
-
-<%def name="render_this_page_template()">
-  ${parent.render_this_page_template()}
-  % if master.results_executable and master.has_perm('execute_multiple'):
-      ${execute_form.render_deform(form_kwargs={'ref': 'actualExecuteForm'}, buttons=False)|n}
-  % endif
-</%def>
-
-
-${parent.body()}
diff --git a/tailbone/templates/batch/inventory/desktop_form.mako b/tailbone/templates/batch/inventory/desktop_form.mako
index 7e4795a8..cddaa2c5 100644
--- a/tailbone/templates/batch/inventory/desktop_form.mako
+++ b/tailbone/templates/batch/inventory/desktop_form.mako
@@ -147,7 +147,7 @@
 
   <script type="text/javascript">
 
-    let ${form.component_studly} = {
+    let ${form.vue_component} = {
         template: '#${form.component}-template',
         mixins: [SimpleRequestMixin],
 
@@ -278,7 +278,7 @@
         },
     }
 
-    let ${form.component_studly}Data = {
+    let ${form.vue_component}Data = {
         submitting: false,
 
         productUPC: null,
@@ -297,14 +297,9 @@
   </script>
 </%def>
 
-<%def name="modify_this_page_vars()">
-  ${parent.modify_this_page_vars()}
-  <script type="text/javascript">
-
+<%def name="modify_vue_vars()">
+  ${parent.modify_vue_vars()}
+  <script>
     ThisPageData.toggleCompleteSubmitting = false
-
   </script>
 </%def>
-
-
-${parent.body()}
diff --git a/tailbone/templates/batch/pos/view.mako b/tailbone/templates/batch/pos/view.mako
index 0da755aa..5ecabd4d 100644
--- a/tailbone/templates/batch/pos/view.mako
+++ b/tailbone/templates/batch/pos/view.mako
@@ -1,13 +1,9 @@
 ## -*- coding: utf-8; -*-
 <%inherit file="/batch/view.mako" />
 
-<%def name="modify_this_page_vars()">
-  ${parent.modify_this_page_vars()}
-  <script type="text/javascript">
-
-    ${form.component_studly}Data.taxesData = ${json.dumps(taxes_data)|n}
-
+<%def name="modify_vue_vars()">
+  ${parent.modify_vue_vars()}
+  <script>
+    ${form.vue_component}Data.taxesData = ${json.dumps(taxes_data)|n}
   </script>
 </%def>
-
-${parent.body()}
diff --git a/tailbone/templates/batch/vendorcatalog/configure.mako b/tailbone/templates/batch/vendorcatalog/configure.mako
index 0d57053e..4f91cb02 100644
--- a/tailbone/templates/batch/vendorcatalog/configure.mako
+++ b/tailbone/templates/batch/vendorcatalog/configure.mako
@@ -39,14 +39,9 @@
   </div>
 </%def>
 
-<%def name="modify_this_page_vars()">
-  ${parent.modify_this_page_vars()}
-  <script type="text/javascript">
-
+<%def name="modify_vue_vars()">
+  ${parent.modify_vue_vars()}
+  <script>
     ThisPageData.catalogParsers = ${json.dumps(catalog_parsers_data)|n}
-
   </script>
 </%def>
-
-
-${parent.body()}
diff --git a/tailbone/templates/batch/vendorcatalog/create.mako b/tailbone/templates/batch/vendorcatalog/create.mako
index d25c8f16..d9d62bd1 100644
--- a/tailbone/templates/batch/vendorcatalog/create.mako
+++ b/tailbone/templates/batch/vendorcatalog/create.mako
@@ -1,16 +1,16 @@
 ## -*- coding: utf-8; -*-
 <%inherit file="/batch/create.mako" />
 
-<%def name="modify_this_page_vars()">
-  ${parent.modify_this_page_vars()}
-  <script type="text/javascript">
+<%def name="modify_vue_vars()">
+  ${parent.modify_vue_vars()}
+  <script>
 
-    ${form.component_studly}Data.parsers = ${json.dumps(parsers_data)|n}
+    ${form.vue_component}Data.parsers = ${json.dumps(parsers_data)|n}
 
-    ${form.component_studly}Data.vendorName = null
-    ${form.component_studly}Data.vendorNameReplacement = null
+    ${form.vue_component}Data.vendorName = null
+    ${form.vue_component}Data.vendorNameReplacement = null
 
-    ${form.component_studly}.watch.field_model_parser_key = function(val) {
+    ${form.vue_component}.watch.field_model_parser_key = function(val) {
         let parser = this.parsers[val]
         if (parser.vendor_uuid) {
             if (this.field_model_vendor_uuid != parser.vendor_uuid) {
@@ -24,11 +24,11 @@
         }
     }
 
-    ${form.component_studly}.methods.vendorLabelChanging = function(label) {
+    ${form.vue_component}.methods.vendorLabelChanging = function(label) {
         this.vendorNameReplacement = label
     }
 
-    ${form.component_studly}.methods.vendorChanged = function(uuid) {
+    ${form.vue_component}.methods.vendorChanged = function(uuid) {
         if (uuid) {
             this.vendorName = this.vendorNameReplacement
             this.vendorNameReplacement = null
@@ -37,6 +37,3 @@
 
   </script>
 </%def>
-
-
-${parent.body()}
diff --git a/tailbone/templates/batch/view.mako b/tailbone/templates/batch/view.mako
index 5e3328d9..7c81ab0e 100644
--- a/tailbone/templates/batch/view.mako
+++ b/tailbone/templates/batch/view.mako
@@ -85,13 +85,11 @@
       <div style="display: flex; flex-direction: column; gap: 0.5rem;">
       % if batch.executed:
           <p>
-            Batch was executed
             ${h.pretty_datetime(request.rattail_config, batch.executed)}
             by ${batch.executed_by}
           </p>
       % elif master.handler.executable(batch):
           % if master.has_perm('execute'):
-              <p>Batch has not yet been executed.</p>
               <b-button type="is-primary"
                         % if not execute_enabled:
                         disabled
@@ -121,8 +119,7 @@
                         <div class="markdown">
                           ${execution_described|n}
                         </div>
-                        <${execute_form.component} ref="executeBatchForm">
-                        </${execute_form.component}>
+                        ${execute_form.render_vue_tag(ref='executeBatchForm')}
                       </section>
 
                       <footer class="modal-card-foot">
@@ -151,12 +148,6 @@
   </nav>
 </%def>
 
-<%def name="render_form_template()">
-  ## TODO: should use self.render_form_buttons()
-  ## ${form.render_deform(form_id='batch-form', buttons=capture(self.render_form_buttons))|n}
-  ${form.render_deform(form_id='batch-form', buttons=capture(buttons))|n}
-</%def>
-
 <%def name="render_this_page()">
   ${parent.render_this_page()}
 
@@ -176,8 +167,7 @@
               Please be certain to use the right one!
             </p>
             <br />
-            <${upload_worksheet_form.component} ref="uploadForm">
-            </${upload_worksheet_form.component}>
+            ${upload_worksheet_form.render_vue_tag(ref='uploadForm')}
           </section>
 
           <footer class="modal-card-foot">
@@ -199,16 +189,6 @@
 
 </%def>
 
-<%def name="render_this_page_template()">
-  ${parent.render_this_page_template()}
-  % if master.has_worksheet_file and master.allow_worksheet(batch) and master.has_perm('worksheet'):
-      ${upload_worksheet_form.render_deform(buttons=False, form_kwargs={'ref': 'actualUploadForm'})|n}
-  % endif
-  % if master.handler.executable(batch) and master.has_perm('execute'):
-      ${execute_form.render_deform(form_kwargs={'ref': 'actualExecuteForm'}, buttons=False)|n}
-  % endif
-</%def>
-
 <%def name="render_form()">
   <div class="form">
     <${form.component} @show-upload="showUploadDialog = true">
@@ -269,9 +249,27 @@
   % endif
 </%def>
 
-<%def name="modify_this_page_vars()">
-  ${parent.modify_this_page_vars()}
-  <script type="text/javascript">
+<%def name="render_vue_templates()">
+  ${parent.render_vue_templates()}
+  % if master.has_worksheet_file and master.allow_worksheet(batch) and master.has_perm('worksheet'):
+      ${upload_worksheet_form.render_vue_template(buttons=False, form_kwargs={'ref': 'actualUploadForm'})}
+  % endif
+  % if master.handler.executable(batch) and master.has_perm('execute'):
+      ${execute_form.render_vue_template(form_kwargs={'ref': 'actualExecuteForm'}, buttons=False)}
+  % endif
+</%def>
+
+## DEPRECATED; remains for back-compat
+## nb. this is called by parent template, /form.mako
+<%def name="render_form_template()">
+  ## TODO: should use self.render_form_buttons()
+  ## ${form.render_deform(form_id='batch-form', buttons=capture(self.render_form_buttons))|n}
+  ${form.render_deform(form_id='batch-form', buttons=capture(buttons))|n}
+</%def>
+
+<%def name="modify_vue_vars()">
+  ${parent.modify_vue_vars()}
+  <script>
 
     ThisPageData.statusBreakdownData = ${json.dumps(status_breakdown_data)|n}
 
@@ -287,7 +285,7 @@
     }
 
     % if not batch.executed and master.has_perm('edit'):
-        ${form.component_studly}Data.togglingBatchComplete = false
+        ${form.vue_component}Data.togglingBatchComplete = false
     % endif
 
     % if master.has_worksheet_file and master.allow_worksheet(batch) and master.has_perm('worksheet'):
@@ -308,7 +306,7 @@
             form.submit()
         }
 
-        ${upload_worksheet_form.component_studly}.methods.submit = function() {
+        ${upload_worksheet_form.vue_component}.methods.submit = function() {
             this.$refs.actualUploadForm.submit()
         }
 
@@ -323,7 +321,7 @@
             this.$refs.executeBatchForm.submit()
         }
 
-        ${execute_form.component_studly}.methods.submit = function() {
+        ${execute_form.vue_component}.methods.submit = function() {
             this.$refs.actualExecuteForm.submit()
         }
 
@@ -331,9 +329,9 @@
 
     % if master.rows_bulk_deletable and not batch.executed and master.has_perm('delete_rows'):
 
-        ${rows_grid.component_studly}Data.deleteResultsShowDialog = false
+        ${rows_grid.vue_component}Data.deleteResultsShowDialog = false
 
-        ${rows_grid.component_studly}.methods.deleteResultsInit = function() {
+        ${rows_grid.vue_component}.methods.deleteResultsInit = function() {
             this.deleteResultsShowDialog = true
         }
 
@@ -342,28 +340,12 @@
   </script>
 </%def>
 
-<%def name="make_this_page_component()">
-  ${parent.make_this_page_component()}
+<%def name="make_vue_components()">
+  ${parent.make_vue_components()}
   % if master.has_worksheet_file and master.allow_worksheet(batch) and master.has_perm('worksheet'):
-      <script type="text/javascript">
-
-        ## UploadForm
-        ${upload_worksheet_form.component_studly}.data = function() { return ${upload_worksheet_form.component_studly}Data }
-        Vue.component('${upload_worksheet_form.component}', ${upload_worksheet_form.component_studly})
-
-      </script>
+      ${upload_worksheet_form.render_vue_finalize()}
   % endif
-
   % if execute_enabled and master.has_perm('execute'):
-      <script type="text/javascript">
-
-        ## ExecuteForm
-        ${execute_form.component_studly}.data = function() { return ${execute_form.component_studly}Data }
-        Vue.component('${execute_form.component}', ${execute_form.component_studly})
-
-      </script>
+      ${execute_form.render_vue_finalize()}
   % endif
 </%def>
-
-
-${parent.body()}
diff --git a/tailbone/templates/configure-menus.mako b/tailbone/templates/configure-menus.mako
index c0200912..c7f46d21 100644
--- a/tailbone/templates/configure-menus.mako
+++ b/tailbone/templates/configure-menus.mako
@@ -208,9 +208,9 @@
   % endif
 </%def>
 
-<%def name="modify_this_page_vars()">
-  ${parent.modify_this_page_vars()}
-  <script type="text/javascript">
+<%def name="modify_vue_vars()">
+  ${parent.modify_vue_vars()}
+  <script>
 
     ThisPageData.menuSequence = ${json.dumps([m['key'] for m in menus])|n}
 
@@ -443,6 +443,3 @@
 
   </script>
 </%def>
-
-
-${parent.body()}
diff --git a/tailbone/templates/configure.mako b/tailbone/templates/configure.mako
index 3aa60f31..e6b128fc 100644
--- a/tailbone/templates/configure.mako
+++ b/tailbone/templates/configure.mako
@@ -92,7 +92,7 @@
         <b-select name="${tmpl['setting_file']}"
                   v-model="inputFileTemplateSettings['${tmpl['setting_file']}']"
                   @input="settingsNeedSaved = true">
-          <option :value="null">-new-</option>
+          <option value="">-new-</option>
           <option v-for="option in inputFileTemplateFileOptions['${tmpl['key']}']"
                   :key="option"
                   :value="option">
@@ -104,22 +104,40 @@
       <b-field label="Upload"
                v-show="inputFileTemplateSettings['${tmpl['setting_mode']}'] == 'hosted' && !inputFileTemplateSettings['${tmpl['setting_file']}']">
 
-        <b-field class="file is-primary"
-                 :class="{'has-name': !!inputFileTemplateSettings['${tmpl['setting_file']}']}">
-          <b-upload name="${tmpl['setting_file']}.upload"
-                    v-model="inputFileTemplateUploads['${tmpl['key']}']"
-                    class="file-label"
-                    @input="settingsNeedSaved = true">
-            <span class="file-cta">
-              <b-icon class="file-icon" pack="fas" icon="upload"></b-icon>
-              <span class="file-label">Click to upload</span>
-            </span>
-          </b-upload>
-          <span v-if="inputFileTemplateUploads['${tmpl['key']}']"
-                class="file-name">
-            {{ inputFileTemplateUploads['${tmpl['key']}'].name }}
-          </span>
-        </b-field>
+        % if request.use_oruga:
+            <o-field class="file">
+              <o-upload name="${tmpl['setting_file']}.upload"
+                        v-model="inputFileTemplateUploads['${tmpl['key']}']"
+                        v-slot="{ onclick }"
+                        @input="settingsNeedSaved = true">
+                <o-button variant="primary"
+                          @click="onclick">
+                  <o-icon icon="upload" />
+                  <span>Click to upload</span>
+                </o-button>
+                <span class="file-name" v-if="inputFileTemplateUploads['${tmpl['key']}']">
+                  {{ inputFileTemplateUploads['${tmpl['key']}'].name }}
+                </span>
+              </o-upload>
+            </o-field>
+        % else:
+            <b-field class="file is-primary"
+                     :class="{'has-name': !!inputFileTemplateSettings['${tmpl['setting_file']}']}">
+              <b-upload name="${tmpl['setting_file']}.upload"
+                        v-model="inputFileTemplateUploads['${tmpl['key']}']"
+                        class="file-label"
+                        @input="settingsNeedSaved = true">
+                <span class="file-cta">
+                  <b-icon class="file-icon" pack="fas" icon="upload"></b-icon>
+                  <span class="file-label">Click to upload</span>
+                </span>
+              </b-upload>
+              <span v-if="inputFileTemplateUploads['${tmpl['key']}']"
+                    class="file-name">
+                {{ inputFileTemplateUploads['${tmpl['key']}'].name }}
+              </span>
+            </b-field>
+        % endif
 
       </b-field>
 
@@ -143,6 +161,85 @@
   </div>
 </%def>
 
+<%def name="output_file_template_field(key)">
+    <% tmpl = output_file_templates[key] %>
+    <b-field grouped>
+
+      <b-field label="${tmpl['label']}">
+        <b-select name="${tmpl['setting_mode']}"
+                  v-model="outputFileTemplateSettings['${tmpl['setting_mode']}']"
+                  @input="settingsNeedSaved = true">
+          <option value="default">use default</option>
+          <option value="hosted">use uploaded file</option>
+        </b-select>
+      </b-field>
+
+      <b-field label="File"
+               v-show="outputFileTemplateSettings['${tmpl['setting_mode']}'] == 'hosted'"
+               :message="outputFileTemplateSettings['${tmpl['setting_file']}'] ? 'This file lives on disk at: ${output_file_option_dirs[tmpl['key']]}' : null">
+        <b-select name="${tmpl['setting_file']}"
+                  v-model="outputFileTemplateSettings['${tmpl['setting_file']}']"
+                  @input="settingsNeedSaved = true">
+          <option value="">-new-</option>
+          <option v-for="option in outputFileTemplateFileOptions['${tmpl['key']}']"
+                  :key="option"
+                  :value="option">
+            {{ option }}
+          </option>
+        </b-select>
+      </b-field>
+
+      <b-field label="Upload"
+               v-show="outputFileTemplateSettings['${tmpl['setting_mode']}'] == 'hosted' && !outputFileTemplateSettings['${tmpl['setting_file']}']">
+
+        % if request.use_oruga:
+            <o-field class="file">
+              <o-upload name="${tmpl['setting_file']}.upload"
+                        v-model="outputFileTemplateUploads['${tmpl['key']}']"
+                        v-slot="{ onclick }"
+                        @input="settingsNeedSaved = true">
+                <o-button variant="primary"
+                          @click="onclick">
+                  <o-icon icon="upload" />
+                  <span>Click to upload</span>
+                </o-button>
+                <span class="file-name" v-if="outputFileTemplateUploads['${tmpl['key']}']">
+                  {{ outputFileTemplateUploads['${tmpl['key']}'].name }}
+                </span>
+              </o-upload>
+            </o-field>
+        % else:
+            <b-field class="file is-primary"
+                     :class="{'has-name': !!outputFileTemplateSettings['${tmpl['setting_file']}']}">
+              <b-upload name="${tmpl['setting_file']}.upload"
+                        v-model="outputFileTemplateUploads['${tmpl['key']}']"
+                        class="file-label"
+                        @input="settingsNeedSaved = true">
+                <span class="file-cta">
+                  <b-icon class="file-icon" pack="fas" icon="upload"></b-icon>
+                  <span class="file-label">Click to upload</span>
+                </span>
+              </b-upload>
+              <span v-if="outputFileTemplateUploads['${tmpl['key']}']"
+                    class="file-name">
+                {{ outputFileTemplateUploads['${tmpl['key']}'].name }}
+              </span>
+            </b-field>
+        % endif
+      </b-field>
+
+    </b-field>
+</%def>
+
+<%def name="output_file_templates_section()">
+  <h3 class="block is-size-3">Output File Templates</h3>
+  <div class="block" style="padding-left: 2rem;">
+    % for key in output_file_templates:
+        ${self.output_file_template_field(key)}
+    % endfor
+  </div>
+</%def>
+
 <%def name="form_content()"></%def>
 
 <%def name="page_content()">
@@ -183,15 +280,14 @@
         <b-button @click="purgeSettingsShowDialog = false">
           Cancel
         </b-button>
-        ${h.form(request.current_route_url())}
+        ${h.form(request.current_route_url(), **{'@submit': 'purgingSettings = true'})}
         ${h.csrf_token(request)}
         ${h.hidden('remove_settings', 'true')}
         <b-button type="is-danger"
                   native-type="submit"
                   :disabled="purgingSettings"
                   icon-pack="fas"
-                  icon-left="trash"
-                  @click="purgingSettings = true">
+                  icon-left="trash">
           {{ purgingSettings ? "Working, please wait..." : "Remove All Settings" }}
         </b-button>
         ${h.end_form()}
@@ -205,62 +301,42 @@
   ${h.end_form()}
 </%def>
 
-<%def name="modify_this_page_vars()">
-  ${parent.modify_this_page_vars()}
-  <script type="text/javascript">
+<%def name="modify_vue_vars()">
+  ${parent.modify_vue_vars()}
+  <script>
 
     % if simple_settings is not Undefined:
         ThisPageData.simpleSettings = ${json.dumps(simple_settings)|n}
     % endif
 
-    % if input_file_template_settings is not Undefined:
-        ThisPageData.inputFileTemplateSettings = ${json.dumps(input_file_template_settings)|n}
-        ThisPageData.inputFileTemplateFileOptions = ${json.dumps(input_file_options)|n}
-        ThisPageData.inputFileTemplateUploads = {
-            % for key in input_file_templates:
-                '${key}': null,
-            % endfor
-        }
-    % endif
-
     ThisPageData.purgeSettingsShowDialog = false
     ThisPageData.purgingSettings = false
 
     ThisPageData.settingsNeedSaved = false
     ThisPageData.undoChanges = false
     ThisPageData.savingSettings = false
+    ThisPageData.validators = []
 
     ThisPage.methods.purgeSettingsInit = function() {
         this.purgeSettingsShowDialog = true
     }
 
-    % if input_file_template_settings is not Undefined:
-        ThisPage.methods.validateInputFileTemplateSettings = function() {
-            % for tmpl in six.itervalues(input_file_templates):
-                if (this.inputFileTemplateSettings['${tmpl['setting_mode']}'] == 'hosted') {
-                    if (!this.inputFileTemplateSettings['${tmpl['setting_file']}']) {
-                        if (!this.inputFileTemplateUploads['${tmpl['key']}']) {
-                            return "You must provide a file to upload for the ${tmpl['label']} template."
-                        }
-                    }
-                }
-            % endfor
-        }
-    % endif
-
-    ThisPage.methods.validateSettings = function() {
-        let msg
-
-        % if input_file_template_settings is not Undefined:
-            msg = this.validateInputFileTemplateSettings()
-            if (msg) {
-                return msg
-            }
-        % endif
-    }
+    ThisPage.methods.validateSettings = function() {}
 
     ThisPage.methods.saveSettings = function() {
-        let msg = this.validateSettings()
+        let msg
+
+        // nb. this is the future
+        for (let validator of this.validators) {
+            msg = validator.call(this)
+            if (msg) {
+                alert(msg)
+                return
+            }
+        }
+
+        // nb. legacy method
+        msg = this.validateSettings()
         if (msg) {
             alert(msg)
             return
@@ -291,8 +367,65 @@
         window.addEventListener('beforeunload', this.beforeWindowUnload)
     }
 
+    ##############################
+    ## input file templates
+    ##############################
+
+    % if input_file_template_settings is not Undefined:
+
+        ThisPageData.inputFileTemplateSettings = ${json.dumps(input_file_template_settings)|n}
+        ThisPageData.inputFileTemplateFileOptions = ${json.dumps(input_file_options)|n}
+        ThisPageData.inputFileTemplateUploads = {
+            % for key in input_file_templates:
+                '${key}': null,
+            % endfor
+        }
+
+        ThisPage.methods.validateInputFileTemplateSettings = function() {
+            % for tmpl in input_file_templates.values():
+                if (this.inputFileTemplateSettings['${tmpl['setting_mode']}'] == 'hosted') {
+                    if (!this.inputFileTemplateSettings['${tmpl['setting_file']}']) {
+                        if (!this.inputFileTemplateUploads['${tmpl['key']}']) {
+                            return "You must provide a file to upload for the ${tmpl['label']} template."
+                        }
+                    }
+                }
+            % endfor
+        }
+
+        ThisPageData.validators.push(ThisPage.methods.validateInputFileTemplateSettings)
+
+    % endif
+
+    ##############################
+    ## output file templates
+    ##############################
+
+    % if output_file_template_settings is not Undefined:
+
+        ThisPageData.outputFileTemplateSettings = ${json.dumps(output_file_template_settings)|n}
+        ThisPageData.outputFileTemplateFileOptions = ${json.dumps(output_file_options)|n}
+        ThisPageData.outputFileTemplateUploads = {
+            % for key in output_file_templates:
+                '${key}': null,
+            % endfor
+        }
+
+        ThisPage.methods.validateOutputFileTemplateSettings = function() {
+            % for tmpl in output_file_templates.values():
+                if (this.outputFileTemplateSettings['${tmpl['setting_mode']}'] == 'hosted') {
+                    if (!this.outputFileTemplateSettings['${tmpl['setting_file']}']) {
+                        if (!this.outputFileTemplateUploads['${tmpl['key']}']) {
+                            return "You must provide a file to upload for the ${tmpl['label']} template."
+                        }
+                    }
+                }
+            % endfor
+        }
+
+        ThisPageData.validators.push(ThisPage.methods.validateOutputFileTemplateSettings)
+
+    % endif
+
   </script>
 </%def>
-
-
-${parent.body()}
diff --git a/tailbone/templates/customers/configure.mako b/tailbone/templates/customers/configure.mako
index e68f4543..1a6dca8b 100644
--- a/tailbone/templates/customers/configure.mako
+++ b/tailbone/templates/customers/configure.mako
@@ -88,9 +88,9 @@
 
 </%def>
 
-<%def name="modify_this_page_vars()">
-  ${parent.modify_this_page_vars()}
-  <script type="text/javascript">
+<%def name="modify_vue_vars()">
+  ${parent.modify_vue_vars()}
+  <script>
 
     ThisPage.methods.getLabelForKey = function(key) {
         switch (key) {
@@ -111,6 +111,3 @@
 
   </script>
 </%def>
-
-
-${parent.body()}
diff --git a/tailbone/templates/customers/pending/view.mako b/tailbone/templates/customers/pending/view.mako
index e9e54c99..1cea9d1f 100644
--- a/tailbone/templates/customers/pending/view.mako
+++ b/tailbone/templates/customers/pending/view.mako
@@ -106,9 +106,9 @@
   % endif
 </%def>
 
-<%def name="modify_this_page_vars()">
-  ${parent.modify_this_page_vars()}
-  <script type="text/javascript">
+<%def name="modify_vue_vars()">
+  ${parent.modify_vue_vars()}
+  <script>
 
     ThisPageData.resolvePersonShowDialog = false
     ThisPageData.resolvePersonUUID = null
@@ -139,5 +139,3 @@
 
   </script>
 </%def>
-
-${parent.body()}
diff --git a/tailbone/templates/customers/view.mako b/tailbone/templates/customers/view.mako
index 8b07bdb3..490e4757 100644
--- a/tailbone/templates/customers/view.mako
+++ b/tailbone/templates/customers/view.mako
@@ -16,15 +16,15 @@
   </div>
 </%def>
 
-<%def name="modify_this_page_vars()">
-  ${parent.modify_this_page_vars()}
-  <script type="text/javascript">
+<%def name="modify_vue_vars()">
+  ${parent.modify_vue_vars()}
+  <script>
 
     % if expose_shoppers:
-    ${form.component_studly}Data.shoppers = ${json.dumps(shoppers_data)|n}
+    ${form.vue_component}Data.shoppers = ${json.dumps(shoppers_data)|n}
     % endif
     % if expose_people:
-    ${form.component_studly}Data.peopleData = ${json.dumps(people_data)|n}
+    ${form.vue_component}Data.peopleData = ${json.dumps(people_data)|n}
     % endif
 
     ThisPage.methods.detachPerson = function(url) {
@@ -36,5 +36,3 @@
 
   </script>
 </%def>
-
-${parent.body()}
diff --git a/tailbone/templates/custorders/configure.mako b/tailbone/templates/custorders/configure.mako
index d2f6610d..16d26d21 100644
--- a/tailbone/templates/custorders/configure.mako
+++ b/tailbone/templates/custorders/configure.mako
@@ -24,29 +24,38 @@
       </b-checkbox>
     </b-field>
 
-    <b-field message="Only applies if user is allowed to choose contact info.">
-      <b-checkbox name="rattail.custorders.new_orders.allow_contact_info_create"
-                  v-model="simpleSettings['rattail.custorders.new_orders.allow_contact_info_create']"
-                  native-value="true"
-                  @input="settingsNeedSaved = true">
-        Allow user to enter new contact info
-      </b-checkbox>
-    </b-field>
+    <div v-show="simpleSettings['rattail.custorders.new_orders.allow_contact_info_choice']"
+         style="padding-left: 2rem;">
 
-    <p class="block">
-      If you allow users to enter new contact info, the default action
-      when the order is submitted, is to send email with details of
-      the new contact info.&nbsp; Settings for these are at:
-    </p>
+      <b-field message="Only applies if user is allowed to choose contact info.">
+        <b-checkbox name="rattail.custorders.new_orders.allow_contact_info_create"
+                    v-model="simpleSettings['rattail.custorders.new_orders.allow_contact_info_create']"
+                    native-value="true"
+                    @input="settingsNeedSaved = true">
+          Allow user to enter new contact info
+        </b-checkbox>
+      </b-field>
 
-    <ul class="list">
-      <li class="list-item">
-        ${h.link_to("New Phone Request", url('emailprofiles.view', key='new_phone_requested'))}
-      </li>
-      <li class="list-item">
-        ${h.link_to("New Email Request", url('emailprofiles.view', key='new_email_requested'))}
-      </li>
-    </ul>
+      <div v-show="simpleSettings['rattail.custorders.new_orders.allow_contact_info_create']"
+           style="padding-left: 2rem;">
+
+        <p class="block">
+          If you allow users to enter new contact info, the default action
+          when the order is submitted, is to send email with details of
+          the new contact info.&nbsp; Settings for these are at:
+        </p>
+
+        <ul class="list">
+          <li class="list-item">
+            ${h.link_to("New Phone Request", url('emailprofiles.view', key='new_phone_requested'))}
+          </li>
+          <li class="list-item">
+            ${h.link_to("New Email Request", url('emailprofiles.view', key='new_email_requested'))}
+          </li>
+        </ul>
+
+      </div>
+    </div>
   </div>
 
   <h3 class="block is-size-3">Product Handling</h3>
diff --git a/tailbone/templates/custorders/create.mako b/tailbone/templates/custorders/create.mako
index 9a3a2d57..382a121f 100644
--- a/tailbone/templates/custorders/create.mako
+++ b/tailbone/templates/custorders/create.mako
@@ -47,10 +47,9 @@
   </div>
 </%def>
 
-<%def name="render_this_page_template()">
-  ${parent.render_this_page_template()}
+<%def name="render_vue_templates()">
+  ${parent.render_vue_templates()}
   ${product_lookup.tailbone_product_lookup_template()}
-
   <script type="text/x-template" id="customer-order-creator-template">
     <div>
 
@@ -78,15 +77,16 @@
 
             <b-icon v-if="props.open"
                     pack="fas"
-                    icon="angle-down">
+                    icon="caret-down">
             </b-icon>
 
             <span v-if="!props.open">
               <b-icon pack="fas"
-                      icon="angle-right">
+                      icon="caret-right">
               </b-icon>
             </span>
 
+            &nbsp;
             <strong v-html="customerPanelHeader"></strong>
           </div>
         </template>
@@ -525,15 +525,16 @@
 
             <b-icon v-if="props.open"
                     pack="fas"
-                    icon="angle-down">
+                    icon="caret-down">
             </b-icon>
 
             <span v-if="!props.open">
               <b-icon pack="fas"
-                      icon="angle-right">
+                      icon="caret-right">
               </b-icon>
             </span>
 
+            &nbsp;
             <strong v-html="itemsPanelHeader"></strong>
           </div>
         </template>
@@ -1263,12 +1264,7 @@
 
     </div>
   </script>
-</%def>
-
-<%def name="make_this_page_component()">
-  ${parent.make_this_page_component()}
-  ${product_lookup.tailbone_product_lookup_component()}
-  <script type="text/javascript">
+  <script>
 
     const CustomerOrderCreator = {
         template: '#customer-order-creator-template',
@@ -2404,5 +2400,7 @@
   </script>
 </%def>
 
-
-${parent.body()}
+<%def name="make_vue_components()">
+  ${parent.make_vue_components()}
+  ${product_lookup.tailbone_product_lookup_component()}
+</%def>
diff --git a/tailbone/templates/custorders/items/view.mako b/tailbone/templates/custorders/items/view.mako
index 41567d41..4cc92bbf 100644
--- a/tailbone/templates/custorders/items/view.mako
+++ b/tailbone/templates/custorders/items/view.mako
@@ -291,11 +291,11 @@
   % endif
 </%def>
 
-<%def name="modify_this_page_vars()">
-  ${parent.modify_this_page_vars()}
-  <script type="text/javascript">
+<%def name="modify_vue_vars()">
+  ${parent.modify_vue_vars()}
+  <script>
 
-    ${form.component_studly}Data.eventsData = ${json.dumps(events_data)|n}
+    ${form.vue_component}Data.eventsData = ${json.dumps(events_data)|n}
 
     % if master.has_perm('confirm_price'):
 
@@ -347,7 +347,7 @@
         }
 
         ThisPageData.orderItemStatuses = ${json.dumps(enum.CUSTORDER_ITEM_STATUS)|n}
-        ThisPageData.orderItemStatusOptions = ${json.dumps([dict(key=k, label=v) for k, v in six.iteritems(enum.CUSTORDER_ITEM_STATUS)])|n}
+        ThisPageData.orderItemStatusOptions = ${json.dumps([dict(key=k, label=v) for k, v in enum.CUSTORDER_ITEM_STATUS.items()])|n}
 
         ThisPageData.oldStatusCode = ${instance.status_code}
 
@@ -392,9 +392,9 @@
             this.$refs.changeStatusForm.submit()
         }
 
-        ${form.component_studly}Data.changeFlaggedSubmitting = false
+        ${form.vue_component}Data.changeFlaggedSubmitting = false
 
-        ${form.component_studly}.methods.changeFlaggedSubmit = function() {
+        ${form.vue_component}.methods.changeFlaggedSubmit = function() {
             this.changeFlaggedSubmitting = true
         }
 
@@ -448,5 +448,3 @@
 
   </script>
 </%def>
-
-${parent.body()}
diff --git a/tailbone/templates/datasync/changes/index.mako b/tailbone/templates/datasync/changes/index.mako
index 6d171619..86f5c121 100644
--- a/tailbone/templates/datasync/changes/index.mako
+++ b/tailbone/templates/datasync/changes/index.mako
@@ -26,9 +26,9 @@
 
 </%def>
 
-<%def name="modify_this_page_vars()">
-  ${parent.modify_this_page_vars()}
-  <script type="text/javascript">
+<%def name="modify_vue_vars()">
+  ${parent.modify_vue_vars()}
+  <script>
 
     % if request.has_perm('datasync.restart'):
         TailboneGridData.restartDatasyncFormSubmitting = false
@@ -50,6 +50,3 @@
 
   </script>
 </%def>
-
-
-${parent.body()}
diff --git a/tailbone/templates/datasync/configure.mako b/tailbone/templates/datasync/configure.mako
index 8b0f5e51..2e444fb5 100644
--- a/tailbone/templates/datasync/configure.mako
+++ b/tailbone/templates/datasync/configure.mako
@@ -1,6 +1,15 @@
 ## -*- coding: utf-8; -*-
 <%inherit file="/configure.mako" />
 
+<%def name="extra_styles()">
+  ${parent.extra_styles()}
+  <style>
+    .invisible-watcher {
+        display: none;
+    }
+  </style>
+</%def>
+
 <%def name="buttons_row()">
   <div class="level">
     <div class="level-left">
@@ -74,8 +83,8 @@
   </b-notification>
 
   <b-field>
-    <b-checkbox name="use_profile_settings"
-                v-model="useProfileSettings"
+    <b-checkbox name="rattail.datasync.use_profile_settings"
+                v-model="simpleSettings['rattail.datasync.use_profile_settings']"
                 native-value="true"
                 @input="settingsNeedSaved = true">
       Use these Settings to configure watchers and consumers
@@ -90,7 +99,7 @@
     </div>
     <div class="level-right">
       <div class="level-item"
-           v-show="useProfileSettings">
+           v-show="simpleSettings['rattail.datasync.use_profile_settings']">
         <b-button type="is-primary"
                   @click="newProfile()"
                   icon-pack="fas"
@@ -106,8 +115,8 @@
     </div>
   </div>
 
-  <${b}-table :data="filteredProfilesData"
-           :row-class="(row, i) => row.enabled ? null : 'has-background-warning'">
+  <${b}-table :data="profilesData"
+              :row-class="getWatcherRowClass">
       <${b}-table-column field="key"
                       label="Watcher Key"
                       v-slot="props">
@@ -153,19 +162,33 @@
       </${b}-table-column>
       <${b}-table-column label="Actions"
                       v-slot="props"
-                      v-if="useProfileSettings">
+                      v-if="simpleSettings['rattail.datasync.use_profile_settings']">
         <a href="#"
            class="grid-action"
            @click.prevent="editProfile(props.row)">
-          <i class="fas fa-edit"></i>
-          Edit
+          % if request.use_oruga:
+              <span class="icon-text">
+                <o-icon icon="edit" />
+                <span>Edit</span>
+              </span>
+          % else:
+              <i class="fas fa-edit"></i>
+              Edit
+          % endif
         </a>
         &nbsp;
         <a href="#"
            class="grid-action has-text-danger"
            @click.prevent="deleteProfile(props.row)">
-          <i class="fas fa-trash"></i>
-          Delete
+          % if request.use_oruga:
+              <span class="icon-text">
+                <o-icon icon="trash" />
+                <span>Delete</span>
+              </span>
+          % else:
+              <i class="fas fa-trash"></i>
+              Delete
+          % endif
         </a>
       </${b}-table-column>
       <template #empty>
@@ -314,15 +337,29 @@
                             v-slot="props">
               <a href="#"
                  @click.prevent="editProfileWatcherKwarg(props.row)">
-                <i class="fas fa-edit"></i>
-                Edit
+                % if request.use_oruga:
+                    <span class="icon-text">
+                      <o-icon icon="edit" />
+                      <span>Edit</span>
+                    </span>
+                % else:
+                    <i class="fas fa-edit"></i>
+                    Edit
+                % endif
               </a>
               &nbsp;
               <a href="#"
                  class="has-text-danger"
                  @click.prevent="deleteProfileWatcherKwarg(props.row)">
-                <i class="fas fa-trash"></i>
-                Delete
+                % if request.use_oruga:
+                    <span class="icon-text">
+                      <o-icon icon="trash" />
+                      <span>Delete</span>
+                    </span>
+                % else:
+                    <i class="fas fa-trash"></i>
+                    Delete
+                % endif
               </a>
             </${b}-table-column>
             <template #empty>
@@ -372,15 +409,29 @@
                 <a href="#"
                    class="grid-action"
                    @click.prevent="editProfileConsumer(props.row)">
-                  <i class="fas fa-edit"></i>
-                  Edit
+                  % if request.use_oruga:
+                      <span class="icon-text">
+                        <o-icon icon="edit" />
+                        <span>Edit</span>
+                      </span>
+                  % else:
+                      <i class="fas fa-edit"></i>
+                      Edit
+                  % endif
                 </a>
                 &nbsp;
                 <a href="#"
                    class="grid-action has-text-danger"
                    @click.prevent="deleteProfileConsumer(props.row)">
-                  <i class="fas fa-trash"></i>
-                  Delete
+                  % if request.use_oruga:
+                      <span class="icon-text">
+                        <o-icon icon="trash" />
+                        <span>Delete</span>
+                      </span>
+                  % else:
+                      <i class="fas fa-trash"></i>
+                      Delete
+                  % endif
                 </a>
               </${b}-table-column>
               <template #empty>
@@ -529,18 +580,27 @@
   <b-field label="Supervisor Process Name"
            message="This should be the complete name, including group - e.g. poser:poser_datasync"
            expanded>
-    <b-input name="supervisor_process_name"
-             v-model="supervisorProcessName"
+    <b-input name="rattail.datasync.supervisor_process_name"
+             v-model="simpleSettings['rattail.datasync.supervisor_process_name']"
              @input="settingsNeedSaved = true"
              expanded>
     </b-input>
   </b-field>
 
+  <b-field label="Consumer Batch Size"
+           message="Max number of changes to be consumed at once."
+           expanded>
+    <numeric-input name="rattail.datasync.batch_size_limit"
+                   v-model="simpleSettings['rattail.datasync.batch_size_limit']"
+                   @input="settingsNeedSaved = true" />
+  </b-field>
+
+  <h3 class="is-size-3">Legacy</h3>
   <b-field label="Restart Command"
            message="This will run as '${system_user}' system user - please configure sudoers as needed.  Typical command is like:  sudo supervisorctl restart poser:poser_datasync"
            expanded>
-    <b-input name="restart_command"
-             v-model="restartCommand"
+    <b-input name="tailbone.datasync.restart"
+             v-model="simpleSettings['tailbone.datasync.restart']"
              @input="settingsNeedSaved = true"
              expanded>
     </b-input>
@@ -548,14 +608,13 @@
 
 </%def>
 
-<%def name="modify_this_page_vars()">
-  ${parent.modify_this_page_vars()}
-  <script type="text/javascript">
+<%def name="modify_vue_vars()">
+  ${parent.modify_vue_vars()}
+  <script>
 
     ThisPageData.showConfigFilesNote = false
     ThisPageData.profilesData = ${json.dumps(profiles_data)|n}
     ThisPageData.showDisabledProfiles = false
-    ThisPageData.useProfileSettings = ${json.dumps(use_profile_settings)|n}
 
     ThisPageData.editProfileShowDialog = false
     ThisPageData.editingProfile = null
@@ -580,22 +639,6 @@
     ThisPageData.editingConsumerRunas = null
     ThisPageData.editingConsumerEnabled = true
 
-    ThisPageData.supervisorProcessName = ${json.dumps(supervisor_process_name)|n}
-    ThisPageData.restartCommand = ${json.dumps(restart_command)|n}
-
-    ThisPage.computed.filteredProfilesData = function() {
-        if (this.showDisabledProfiles) {
-            return this.profilesData
-        }
-        let data = []
-        for (let row of this.profilesData) {
-            if (row.enabled) {
-                data.push(row)
-            }
-        }
-        return data
-    }
-
     ThisPage.computed.updateConsumerDisabled = function() {
         if (!this.editingConsumerKey) {
             return true
@@ -623,6 +666,15 @@
         this.showDisabledProfiles = !this.showDisabledProfiles
     }
 
+    ThisPage.methods.getWatcherRowClass = function(row, i) {
+        if (!row.enabled) {
+            if (!this.showDisabledProfiles) {
+                return 'invisible-watcher'
+            }
+            return 'has-background-warning'
+        }
+    }
+
     ThisPage.methods.consumerShortList = function(row) {
         let keys = []
         if (row.watcher_consumes_self) {
@@ -687,16 +739,9 @@
 
         this.editingProfilePendingConsumers = []
         for (let consumer of row.consumers_data) {
-            let pending = {
+            const pending = {
+                ...consumer,
                 original_key: consumer.key,
-                key: consumer.key,
-                consumer_spec: consumer.consumer_spec,
-                consumer_dbkey: consumer.consumer_dbkey,
-                consumer_delay: consumer.consumer_delay,
-                consumer_retry_attempts: consumer.consumer_retry_attempts,
-                consumer_retry_delay: consumer.consumer_retry_delay,
-                consumer_runas: consumer.consumer_runas,
-                enabled: consumer.enabled,
             }
             this.editingProfilePendingConsumers.push(pending)
         }
@@ -744,8 +789,8 @@
         this.editingProfilePendingWatcherKwargs.splice(i, 1)
     }
 
-    ThisPage.methods.findOriginalConsumer = function(key) {
-        for (let consumer of this.editingProfile.consumers_data) {
+    ThisPage.methods.findConsumer = function(profileConsumers, key) {
+        for (const consumer of profileConsumers) {
             if (consumer.key == key) {
                 return consumer
             }
@@ -753,11 +798,15 @@
     }
 
     ThisPage.methods.updateProfile = function() {
-        let row = this.editingProfile
+        const row = this.editingProfile
 
-        if (!row.key) {
+        const newRow = !row.key
+        let originalProfile = null
+        if (newRow) {
             row.consumers_data = []
             this.profilesData.push(row)
+        } else {
+            originalProfile = this.findProfile(row)
         }
 
         row.key = this.editingProfileKey
@@ -805,7 +854,8 @@
         for (let pending of this.editingProfilePendingConsumers) {
             persistentConsumers.push(pending.key)
             if (pending.original_key) {
-                let consumer = this.findOriginalConsumer(pending.original_key)
+                const consumer = this.findConsumer(originalProfile.consumers_data,
+                                                   pending.original_key)
                 consumer.key = pending.key
                 consumer.consumer_spec = pending.consumer_spec
                 consumer.consumer_dbkey = pending.consumer_dbkey
@@ -832,10 +882,31 @@
             row.consumers_data.splice(i, 1)
         }
 
+        if (!newRow) {
+
+            // nb. must explicitly update the original data row;
+            // otherwise (with vue3) it will remain stale and
+            // submitting the form will keep same settings!
+            // TODO: this probably means i am doing something
+            // sloppy, but at least this hack fixes for now.
+            const profile = this.findProfile(row)
+            for (const key of Object.keys(row)) {
+                profile[key] = row[key]
+            }
+        }
+
         this.settingsNeedSaved = true
         this.editProfileShowDialog = false
     }
 
+    ThisPage.methods.findProfile = function(row) {
+        for (const profile of this.profilesData) {
+            if (profile.key == row.key) {
+                return profile
+            }
+        }
+    }
+
     ThisPage.methods.deleteProfile = function(row) {
         if (confirm("Are you sure you want to delete the '" + row.key + "' profile?")) {
             let i = this.profilesData.indexOf(row)
@@ -872,8 +943,10 @@
     }
 
     ThisPage.methods.updateConsumer = function() {
-        let pending = this.editingConsumer
-        let isNew = !pending.key
+        const pending = this.findConsumer(
+            this.editingProfilePendingConsumers,
+            this.editingConsumer.key)
+        const isNew = !pending.key
 
         pending.key = this.editingConsumerKey
         pending.consumer_spec = this.editingConsumerSpec
@@ -914,6 +987,3 @@
 
   </script>
 </%def>
-
-
-${parent.body()}
diff --git a/tailbone/templates/datasync/status.mako b/tailbone/templates/datasync/status.mako
index c782dec6..e14686f8 100644
--- a/tailbone/templates/datasync/status.mako
+++ b/tailbone/templates/datasync/status.mako
@@ -115,8 +115,9 @@
     </${b}-table>
 </%def>
 
-<%def name="modify_this_page_vars()">
-  <script type="text/javascript">
+<%def name="modify_vue_vars()">
+  ${parent.modify_vue_vars()}
+  <script>
 
     ThisPageData.processInfo = ${json.dumps(process_info)|n}
 
@@ -171,6 +172,3 @@
 
   </script>
 </%def>
-
-
-${parent.body()}
diff --git a/tailbone/templates/deform/checked_password.pt b/tailbone/templates/deform/checked_password.pt
index f78c0b85..2121f01d 100644
--- a/tailbone/templates/deform/checked_password.pt
+++ b/tailbone/templates/deform/checked_password.pt
@@ -1,6 +1,7 @@
 <div i18n:domain="deform" tal:omit-tag=""
       tal:define="oid oid|field.oid;
                   name name|field.name;
+                  vmodel vmodel|'field_model_' + name;
                   css_class css_class|field.widget.css_class;
                   style style|field.widget.style;">
 
@@ -8,7 +9,7 @@
     ${field.start_mapping()}
     <b-input type="password"
              name="${name}"
-             value="${field.widget.redisplay and cstruct or ''}"
+             v-model="${vmodel}"
              tal:attributes="class string: form-control ${css_class or ''};
                              style style;
                              attributes|field.widget.attributes|{};"
@@ -18,7 +19,6 @@
     </b-input>
     <b-input type="password"
              name="${name}-confirm"
-             value="${field.widget.redisplay and confirm or ''}"
              tal:attributes="class string: form-control ${css_class or ''};
                              style style;
                              confirm_attributes|field.widget.confirm_attributes|{};"
diff --git a/tailbone/templates/departments/view.mako b/tailbone/templates/departments/view.mako
index 442f045f..c5c39cbb 100644
--- a/tailbone/templates/departments/view.mako
+++ b/tailbone/templates/departments/view.mako
@@ -1,13 +1,9 @@
 ## -*- coding: utf-8; -*-
 <%inherit file="/master/view.mako" />
 
-<%def name="modify_this_page_vars()">
-  ${parent.modify_this_page_vars()}
-  <script type="text/javascript">
-
-    ${form.component_studly}Data.employeesData = ${json.dumps(employees_data)|n}
-
+<%def name="modify_vue_vars()">
+  ${parent.modify_vue_vars()}
+  <script>
+    ${form.vue_component}Data.employeesData = ${json.dumps(employees_data)|n}
   </script>
 </%def>
-
-${parent.body()}
diff --git a/tailbone/templates/form.mako b/tailbone/templates/form.mako
index 0352b04c..e3a4d5dc 100644
--- a/tailbone/templates/form.mako
+++ b/tailbone/templates/form.mako
@@ -6,20 +6,63 @@
 <%def name="render_form_buttons()"></%def>
 
 <%def name="render_form_template()">
-  ${form.render_deform(buttons=capture(self.render_form_buttons))|n}
+  ${form.render_vue_template(buttons=capture(self.render_form_buttons))|n}
 </%def>
 
 <%def name="render_form()">
   <div class="form">
-    ${form.render_vuejs_component()}
+    ${form.render_vue_tag()}
   </div>
 </%def>
 
 <%def name="page_content()">
-  <div class="form-wrapper">
-    <br />
-    ${self.render_form()}
-  </div>
+  % if main_form_collapsible:
+      <${b}-collapse class="panel"
+                     % if request.use_oruga:
+                         v-model:open="mainFormPanelOpen"
+                     % else:
+                         :open.sync="mainFormPanelOpen"
+                     % endif
+                     >
+        <template #trigger="props">
+          <div class="panel-heading"
+               role="button"
+               style="cursor: pointer;">
+
+            ## TODO: for some reason buefy will "reuse" the icon
+            ## element in such a way that its display does not
+            ## refresh.  so to work around that, we use different
+            ## structure for the two icons, so buefy is forced to
+            ## re-draw
+
+            <b-icon v-if="props.open"
+                    pack="fas"
+                    icon="caret-down">
+            </b-icon>
+
+            <span v-if="!props.open">
+              <b-icon pack="fas"
+                      icon="caret-right">
+              </b-icon>
+            </span>
+
+            &nbsp;
+            <strong>${main_form_title}</strong>
+          </div>
+        </template>
+        <div class="panel-block">
+          <div class="form-wrapper">
+            <br />
+            ${self.render_form()}
+          </div>
+        </div>
+      </${b}-collapse>
+  % else:
+      <div class="form-wrapper">
+        <br />
+        ${self.render_form()}
+      </div>
+  % endif
 </%def>
 
 <%def name="render_this_page()">
@@ -47,25 +90,25 @@
 
 <%def name="before_object_helpers()"></%def>
 
-<%def name="render_this_page_template()">
+<%def name="render_vue_templates()">
+  ${parent.render_vue_templates()}
   % if form is not Undefined:
       ${self.render_form_template()}
   % endif
-  ${parent.render_this_page_template()}
 </%def>
 
-<%def name="finalize_this_page_vars()">
-  ${parent.finalize_this_page_vars()}
-  % if form is not Undefined:
-      <script type="text/javascript">
-
-        ${form.component_studly}.data = function() { return ${form.component_studly}Data }
-
-        Vue.component('${form.component}', ${form.component_studly})
-
+<%def name="modify_vue_vars()">
+  ${parent.modify_vue_vars()}
+  % if main_form_collapsible:
+      <script>
+        ThisPageData.mainFormPanelOpen = ${'false' if main_form_autocollapse else 'true'}
       </script>
   % endif
 </%def>
 
-
-${parent.body()}
+<%def name="make_vue_components()">
+  ${parent.make_vue_components()}
+  % if form is not Undefined:
+      ${form.render_vue_finalize()}
+  % endif
+</%def>
diff --git a/tailbone/templates/formposter.mako b/tailbone/templates/formposter.mako
index ab9c720d..d566a467 100644
--- a/tailbone/templates/formposter.mako
+++ b/tailbone/templates/formposter.mako
@@ -39,7 +39,7 @@
 
             simplePOST(action, params, success, failure) {
 
-                let csrftoken = ${json.dumps(request.session.get_csrf_token() or request.session.new_csrf_token())|n}
+                let csrftoken = ${json.dumps(h.get_csrf_token(request))|n}
 
                 let headers = {
                     '${csrf_header_name}': csrftoken,
diff --git a/tailbone/templates/forms/deform.mako b/tailbone/templates/forms/deform.mako
index 00cf2c50..2100b460 100644
--- a/tailbone/templates/forms/deform.mako
+++ b/tailbone/templates/forms/deform.mako
@@ -1,19 +1,19 @@
 ## -*- coding: utf-8; -*-
 
-<% request.register_component(form.component, form.component_studly) %>
+<% request.register_component(form.vue_tagname, form.vue_component) %>
 
-<script type="text/x-template" id="${form.component}-template">
+<script type="text/x-template" id="${form.vue_tagname}-template">
 
   <div>
   % if not form.readonly:
-  ${h.form(form.action_url, id=dform.formid, method='post', enctype='multipart/form-data', **form_kwargs)}
+  ${h.form(form.action_url, id=dform.formid, method='post', enctype='multipart/form-data', **(form_kwargs or {}))}
   ${h.csrf_token(request)}
   % endif
 
   <section>
     % if form_body is not Undefined and form_body:
         ${form_body|n}
-    % elif form.grouping:
+    % elif getattr(form, 'grouping', None):
         % for group in form.grouping:
             <nav class="panel">
               <p class="panel-heading">${group}</p>
@@ -27,8 +27,8 @@
             </nav>
         % endfor
     % else:
-        % for field in form.fields:
-            ${form.render_field_complete(field)}
+        % for fieldname in form.fields:
+            ${form.render_vue_field(fieldname, session=session)}
         % endfor
     % endif
   </section>
@@ -54,20 +54,20 @@
             <input type="reset" value="Reset" class="button" />
         % endif
         ## TODO: deprecate / remove the latter option here
-        % if form.auto_disable_save or form.auto_disable:
+        % if getattr(form, 'auto_disable_submit', False) or form.auto_disable_save or form.auto_disable:
             <b-button type="is-primary"
                       native-type="submit"
-                      :disabled="${form.component_studly}Submitting"
+                      :disabled="${form.vue_component}Submitting"
                       icon-pack="fas"
-                      icon-left="save">
-              {{ ${form.component_studly}ButtonText }}
+                      icon-left="${form.button_icon_submit}">
+              {{ ${form.vue_component}Submitting ? "Working, please wait..." : "${form.button_label_submit}" }}
             </b-button>
         % else:
             <b-button type="is-primary"
                       native-type="submit"
                       icon-pack="fas"
                       icon-left="save">
-              ${getattr(form, 'submit_label', getattr(form, 'save_label', "Submit"))}
+              ${form.button_label_submit}
             </b-button>
         % endif
       </div>
@@ -122,8 +122,8 @@
 
 <script type="text/javascript">
 
-  let ${form.component_studly} = {
-      template: '#${form.component}-template',
+  let ${form.vue_component} = {
+      template: '#${form.vue_tagname}-template',
       mixins: [FormPosterMixin],
       components: {},
       props: {
@@ -136,10 +136,9 @@
       methods: {
 
           ## TODO: deprecate / remove the latter option here
-          % if form.auto_disable_save or form.auto_disable:
-              submit${form.component_studly}() {
-                  this.${form.component_studly}Submitting = true
-                  this.${form.component_studly}ButtonText = "Working, please wait..."
+          % if getattr(form, 'auto_disable_submit', False) or form.auto_disable_save or form.auto_disable:
+              submit${form.vue_component}() {
+                  this.${form.vue_component}Submitting = true
               },
           % endif
 
@@ -178,10 +177,10 @@
       }
   }
 
-  let ${form.component_studly}Data = {
+  let ${form.vue_component}Data = {
 
       ## TODO: should find a better way to handle CSRF token
-      csrftoken: ${json.dumps(request.session.get_csrf_token() or request.session.new_csrf_token())|n},
+      csrftoken: ${json.dumps(h.get_csrf_token(request))|n},
 
       % if can_edit_help:
           fieldLabels: ${json.dumps(field_labels)|n},
@@ -198,16 +197,14 @@
       % if not form.readonly:
           % for field in form.fields:
               % if field in dform:
-                  <% field = dform[field] %>
-                  field_model_${field.name}: ${form.get_vuejs_model_value(field)|n},
+                  field_model_${field}: ${json.dumps(form.get_vue_field_value(field))|n},
               % endif
           % endfor
       % endif
 
       ## TODO: deprecate / remove the latter option here
-      % if form.auto_disable_save or form.auto_disable:
-          ${form.component_studly}Submitting: false,
-          ${form.component_studly}ButtonText: ${json.dumps(getattr(form, 'submit_label', getattr(form, 'save_label', "Submit")))|n},
+      % if getattr(form, 'auto_disable_submit', False) or form.auto_disable_save or form.auto_disable:
+          ${form.vue_component}Submitting: false,
       % endif
   }
 
diff --git a/tailbone/templates/forms/vue_template.mako b/tailbone/templates/forms/vue_template.mako
new file mode 100644
index 00000000..ac096f67
--- /dev/null
+++ b/tailbone/templates/forms/vue_template.mako
@@ -0,0 +1,3 @@
+## -*- coding: utf-8; -*-
+<%inherit file="/forms/deform.mako" />
+${parent.body()}
diff --git a/tailbone/templates/generate_feature.mako b/tailbone/templates/generate_feature.mako
index a7064331..0f2a9f7b 100644
--- a/tailbone/templates/generate_feature.mako
+++ b/tailbone/templates/generate_feature.mako
@@ -276,9 +276,9 @@
 
 </%def>
 
-<%def name="modify_this_page_vars()">
-  ${parent.modify_this_page_vars()}
-  <script type="text/javascript">
+<%def name="modify_vue_vars()">
+  ${parent.modify_vue_vars()}
+  <script>
 
     ThisPageData.featureType = ${json.dumps(feature_type)|n}
     ThisPageData.resultGenerated = ${json.dumps(bool(result))|n}
@@ -296,7 +296,7 @@
         % endfor
     }
 
-    % for key, form in six.iteritems(feature_forms):
+    % for key, form in feature_forms.items():
         <% safekey = key.replace('-', '_') %>
         ThisPageData.${safekey} = {
             <% dform = feature_forms[key].make_deform_form() %>
@@ -385,6 +385,3 @@
 
   </script>
 </%def>
-
-
-${parent.body()}
diff --git a/tailbone/templates/grids/b-table.mako b/tailbone/templates/grids/b-table.mako
index 632193b5..da9f2aae 100644
--- a/tailbone/templates/grids/b-table.mako
+++ b/tailbone/templates/grids/b-table.mako
@@ -53,11 +53,11 @@
       </${b}-table-column>
   % endfor
 
-  % if grid.main_actions or grid.more_actions:
+  % if grid.actions:
       <${b}-table-column field="actions"
                       label="Actions"
                       v-slot="props">
-        % for action in grid.main_actions:
+        % for action in grid.actions:
             <a :href="props.row._action_url_${action.key}"
                % if action.link_class:
                class="${action.link_class}"
@@ -68,12 +68,7 @@
                @click.prevent="${action.click_handler}"
                % endif
                >
-              % if request.use_oruga:
-                  <o-icon icon="${action.icon}" />
-              % else:
-                  <i class="fas fa-${action.icon}"></i>
-              % endif
-              ${action.label}
+              ${action.render_icon_and_label()}
             </a>
             &nbsp;
         % endfor
diff --git a/tailbone/templates/grids/complete.mako b/tailbone/templates/grids/complete.mako
index e200cdc3..60f9a3b8 100644
--- a/tailbone/templates/grids/complete.mako
+++ b/tailbone/templates/grids/complete.mako
@@ -1,17 +1,79 @@
 ## -*- coding: utf-8; -*-
 
-<% request.register_component(grid.component, grid.component_studly) %>
+<% request.register_component(grid.vue_tagname, grid.vue_component) %>
 
-<script type="text/x-template" id="${grid.component}-template">
+<script type="text/x-template" id="${grid.vue_tagname}-template">
   <div>
 
     <div style="display: flex; justify-content: space-between; margin-bottom: 0.5em;">
 
       <div style="display: flex; flex-direction: column; justify-content: end;">
         <div class="filters">
-          % if grid.filterable:
-              ## TODO: stop using |n filter
-              ${grid.render_filters(allow_save_defaults=allow_save_defaults)|n}
+          % if getattr(grid, 'filterable', False):
+              <form method="GET" @submit.prevent="applyFilters()">
+
+                <div style="display: flex; flex-direction: column; gap: 0.5rem;">
+                  <grid-filter v-for="key in filtersSequence"
+                               :key="key"
+                               :filter="filters[key]"
+                               ref="gridFilters">
+                  </grid-filter>
+                </div>
+
+                <div style="display: flex; gap: 0.5rem; margin-top: 0.5rem;">
+
+                  <b-button type="is-primary"
+                            native-type="submit"
+                            icon-pack="fas"
+                            icon-left="check">
+                    Apply Filters
+                  </b-button>
+
+                  <b-button v-if="!addFilterShow"
+                            icon-pack="fas"
+                            icon-left="plus"
+                            @click="addFilterInit()">
+                    Add Filter
+                  </b-button>
+
+                  <b-autocomplete v-if="addFilterShow"
+                                  ref="addFilterAutocomplete"
+                                  :data="addFilterChoices"
+                                  v-model="addFilterTerm"
+                                  placeholder="Add Filter"
+                                  field="key"
+                                  :custom-formatter="formatAddFilterItem"
+                                  open-on-focus
+                                  keep-first
+                                  icon-pack="fas"
+                                  clearable
+                                  clear-on-select
+                                  @select="addFilterSelect">
+                  </b-autocomplete>
+
+                  <b-button @click="resetView()"
+                            icon-pack="fas"
+                            icon-left="home">
+                    Default View
+                  </b-button>
+
+                  <b-button @click="clearFilters()"
+                            icon-pack="fas"
+                            icon-left="trash">
+                    No Filters
+                  </b-button>
+
+                  % if allow_save_defaults and request.user:
+                      <b-button @click="saveDefaults()"
+                                icon-pack="fas"
+                                icon-left="save"
+                                :disabled="savingDefaults">
+                        {{ savingDefaults ? "Working, please wait..." : "Save Defaults" }}
+                      </b-button>
+                  % endif
+
+                </div>
+              </form>
           % endif
         </div>
       </div>
@@ -55,7 +117,7 @@
 
        :checkable="checkable"
 
-       % if grid.checkboxes:
+       % if getattr(grid, 'checkboxes', False):
            % if request.use_oruga:
                v-model:checked-rows="checkedRows"
            % else:
@@ -66,51 +128,64 @@
            % endif
        % endif
 
-       % if grid.check_handler:
+       % if getattr(grid, 'check_handler', None):
        @check="${grid.check_handler}"
        % endif
-       % if grid.check_all_handler:
+       % if getattr(grid, 'check_all_handler', None):
        @check-all="${grid.check_all_handler}"
        % endif
 
+       % if hasattr(grid, 'checkable'):
        % if isinstance(grid.checkable, str):
        :is-row-checkable="${grid.row_checkable}"
        % elif grid.checkable:
        :is-row-checkable="row => row._checkable"
        % endif
-
-       % if grid.sortable:
-           backend-sorting
-           @sort="onSort"
-           @sorting-priority-removed="sortingPriorityRemoved"
-
-           ## TODO: there is a bug (?) which prevents the arrow from
-           ## displaying for simple default single-column sort.  so to
-           ## work around that, we *disable* multi-sort until the
-           ## component is mounted.  seems to work for now..see also
-           ## https://github.com/buefy/buefy/issues/2584
-           :sort-multiple="allowMultiSort"
-
-           ## nb. specify default sort only if single-column
-           :default-sort="backendSorters.length == 1 ? [backendSorters[0].field, backendSorters[0].order] : null"
-
-           ## nb. otherwise there may be default multi-column sort
-           :sort-multiple-data="sortingPriority"
-
-           ## user must ctrl-click column header to do multi-sort
-           sort-multiple-key="ctrlKey"
        % endif
 
-       % if grid.click_handlers:
+       ## sorting
+       % if grid.sortable:
+           ## nb. buefy/oruga only support *one* default sorter
+           :default-sort="sorters.length ? [sorters[0].field, sorters[0].order] : null"
+           % if grid.sort_on_backend:
+               backend-sorting
+               @sort="onSort"
+           % endif
+           % if grid.sort_multiple:
+               % if grid.sort_on_backend:
+                   ## TODO: there is a bug (?) which prevents the arrow
+                   ## from displaying for simple default single-column sort,
+                   ## when multi-column sort is allowed for the table.  for
+                   ## now we work around that by waiting until mount to
+                   ## enable the multi-column support.  see also
+                   ## https://github.com/buefy/buefy/issues/2584
+                   :sort-multiple="allowMultiSort"
+                   :sort-multiple-data="sortingPriority"
+                   @sorting-priority-removed="sortingPriorityRemoved"
+               % else:
+                   sort-multiple
+               % endif
+               ## nb. user must ctrl-click column header for multi-sort
+               sort-multiple-key="ctrlKey"
+           % endif
+       % endif
+
+       % if getattr(grid, 'click_handlers', None):
        @cellclick="cellClick"
        % endif
 
-       :paginated="paginated"
-       :per-page="perPage"
-       :current-page="currentPage"
-       backend-pagination
-       :total="total"
-       @page-change="onPageChange"
+       ## paging
+       % if grid.paginated:
+           paginated
+           pagination-size="${'small' if request.use_oruga else 'is-small'}"
+           :per-page="perPage"
+           :current-page="currentPage"
+           @page-change="onPageChange"
+           % if grid.paginate_on_backend:
+               backend-pagination
+               :total="pagerStats.item_count"
+           % endif
+       % endif
 
        ## TODO: should let grid (or master view) decide how to set these?
        icon-pack="fas"
@@ -119,17 +194,15 @@
        :hoverable="true"
        :narrowed="true">
 
-      % for column in grid_columns:
+      % for column in grid.get_vue_columns():
           <${b}-table-column field="${column['field']}"
                           label="${column['label']}"
                           v-slot="props"
-                          :sortable="${json.dumps(column['sortable'])}"
-                          % if grid.is_searchable(column['field']):
-                          searchable
-                          % endif
+                          :sortable="${json.dumps(column.get('sortable', False))|n}"
+                          :searchable="${json.dumps(column.get('searchable', False))|n}"
                           cell-class="c_${column['field']}"
-                          :visible="${json.dumps(column['visible'])}">
-            % if column['field'] in grid.raw_renderers:
+                          :visible="${json.dumps(column.get('visible', True))}">
+            % if hasattr(grid, 'raw_renderers') and column['field'] in grid.raw_renderers:
                 ${grid.raw_renderers[column['field']]()}
             % elif grid.is_linked(column['field']):
                 <a :href="props.row._action_url_view"
@@ -144,30 +217,24 @@
           </${b}-table-column>
       % endfor
 
-      % if grid.main_actions or grid.more_actions:
+      % if grid.actions:
           <${b}-table-column field="actions"
                           label="Actions"
                           v-slot="props">
             ## TODO: we do not currently differentiate for "main vs. more"
             ## here, but ideally we would tuck "more" away in a drawer etc.
-            % for action in grid.main_actions + grid.more_actions:
+            % for action in grid.actions:
                 <a v-if="props.row._action_url_${action.key}"
                    :href="props.row._action_url_${action.key}"
                    class="grid-action${' has-text-danger' if action.key == 'delete' else ''} ${action.link_class or ''}"
-                   % if action.click_handler:
+                   % if getattr(action, 'click_handler', None):
                    @click.prevent="${action.click_handler}"
                    % endif
-                   % if action.target:
+                   % if getattr(action, 'target', None):
                    target="${action.target}"
                    % endif
                    >
-                  % if request.use_oruga:
-                      <o-icon icon="${action.icon}" />
-                      <span>${action.render_label()|n}</span>
-                  % else:
-                      ${action.render_icon()|n}
-                      ${action.render_label()|n}
-                  % endif
+                  ${action.render_icon_and_label()}
                 </a>
                 &nbsp;
             % endfor
@@ -192,7 +259,7 @@
       <template #footer>
         <div style="display: flex; justify-content: space-between;">
 
-          % if grid.expose_direct_link:
+          % if getattr(grid, 'expose_direct_link', False):
               <b-button type="is-primary"
                         size="is-small"
                         @click="copyDirectLink()"
@@ -207,13 +274,14 @@
               <div></div>
           % endif
 
-          % if grid.pageable:
-              <div v-if="firstItem"
+          % if grid.paginated:
+              <div v-if="pagerStats.first_item"
                    style="display: flex; gap: 0.5rem; align-items: center;">
                 <span>
                   showing
-                  {{ firstItem.toLocaleString('en') }} - {{ lastItem.toLocaleString('en') }}
-                  of {{ total.toLocaleString('en') }} results;
+                  {{ renderNumber(pagerStats.first_item) }}
+                  - {{ renderNumber(pagerStats.last_item) }}
+                  of {{ renderNumber(pagerStats.item_count) }} results;
                 </span>
                 <b-select v-model="perPage"
                           size="is-small"
@@ -234,7 +302,7 @@
     </${b}-table>
 
     ## dummy input field needed for sharing links on *insecure* sites
-    % if request.scheme == 'http':
+    % if getattr(request, 'scheme', None) == 'http':
         <b-input v-model="shareLink" ref="shareLink" v-show="shareLink"></b-input>
     % endif
 
@@ -243,65 +311,72 @@
 
 <script type="text/javascript">
 
-  let ${grid.component_studly}CurrentData = ${json.dumps(grid_data['data'])|n}
+  const ${grid.vue_component}Context = ${json.dumps(grid.get_vue_context())|n}
+  let ${grid.vue_component}CurrentData = ${grid.vue_component}Context.data
 
-  let ${grid.component_studly}Data = {
+  let ${grid.vue_component}Data = {
       loading: false,
-      ajaxDataUrl: ${json.dumps(grid.ajax_data_url)|n},
+      ajaxDataUrl: ${json.dumps(getattr(grid, 'ajax_data_url', request.path_url))|n},
+
+      ## nb. this tracks whether grid.fetchFirstData() happened
+      fetchedFirstData: false,
 
       savingDefaults: false,
 
-      data: ${grid.component_studly}CurrentData,
-      rowStatusMap: ${json.dumps(grid_data['row_status_map'])|n},
+      data: ${grid.vue_component}CurrentData,
+      rowStatusMap: ${json.dumps(grid_data['row_status_map'] if grid_data is not Undefined else {})|n},
 
-      checkable: ${json.dumps(grid.checkboxes)|n},
-      % if grid.checkboxes:
+      checkable: ${json.dumps(getattr(grid, 'checkboxes', False))|n},
+      % if getattr(grid, 'checkboxes', False):
       checkedRows: ${grid_data['checked_rows_code']|n},
       % endif
 
-      paginated: ${json.dumps(grid.pageable)|n},
-      total: ${len(grid_data['data']) if static_data else grid_data['total_items']},
-      perPage: ${json.dumps(grid.pagesize if grid.pageable else None)|n},
-      currentPage: ${json.dumps(grid.page if grid.pageable else None)|n},
-      firstItem: ${json.dumps(grid_data['first_item'] if grid.pageable else None)|n},
-      lastItem: ${json.dumps(grid_data['last_item'] if grid.pageable else None)|n},
-
-      % if grid.sortable:
-
-          ## TODO: there is a bug (?) which prevents the arrow from
-          ## displaying for simple default single-column sort.  so to
-          ## work around that, we *disable* multi-sort until the
-          ## component is mounted.  seems to work for now..see also
-          ## https://github.com/buefy/buefy/issues/2584
-          allowMultiSort: false,
-
-          ## nb. this contains all truly active sorters
-          backendSorters: ${json.dumps(grid.active_sorters)|n},
-
-          ## nb. whereas this will only contain multi-column sorters,
-          ## but will be *empty* for single-column sorting
-          % if len(grid.active_sorters) > 1:
-              sortingPriority: ${json.dumps(grid.active_sorters)|n},
-          % else:
-              sortingPriority: [],
+      ## paging
+      % if grid.paginated:
+          pageSizeOptions: ${json.dumps(grid.pagesize_options)|n},
+          perPage: ${json.dumps(grid.pagesize)|n},
+          currentPage: ${json.dumps(grid.page)|n},
+          % if grid.paginate_on_backend:
+              pagerStats: ${json.dumps(grid.get_vue_pager_stats())|n},
           % endif
+      % endif
 
+      ## sorting
+      % if grid.sortable:
+          sorters: ${json.dumps(grid.get_vue_active_sorters())|n},
+          % if grid.sort_multiple:
+              % if grid.sort_on_backend:
+                  ## TODO: there is a bug (?) which prevents the arrow
+                  ## from displaying for simple default single-column sort,
+                  ## when multi-column sort is allowed for the table.  for
+                  ## now we work around that by waiting until mount to
+                  ## enable the multi-column support.  see also
+                  ## https://github.com/buefy/buefy/issues/2584
+                  allowMultiSort: false,
+                  ## nb. this should be empty when current sort is single-column
+                  % if len(grid.active_sorters) > 1:
+                      sortingPriority: ${json.dumps(grid.get_vue_active_sorters())|n},
+                  % else:
+                      sortingPriority: [],
+                  % endif
+              % endif
+          % endif
       % endif
 
       ## filterable: ${json.dumps(grid.filterable)|n},
-      filters: ${json.dumps(filters_data if grid.filterable else None)|n},
-      filtersSequence: ${json.dumps(filters_sequence if grid.filterable else None)|n},
+      filters: ${json.dumps(filters_data if getattr(grid, 'filterable', False) else None)|n},
+      filtersSequence: ${json.dumps(filters_sequence if getattr(grid, 'filterable', False) else None)|n},
       addFilterTerm: '',
       addFilterShow: false,
 
       ## dummy input value needed for sharing links on *insecure* sites
-      % if request.scheme == 'http':
+      % if getattr(request, 'scheme', None) == 'http':
       shareLink: null,
       % endif
   }
 
-  let ${grid.component_studly} = {
-      template: '#${grid.component}-template',
+  let ${grid.vue_component} = {
+      template: '#${grid.vue_tagname}-template',
 
       mixins: [FormPosterMixin],
 
@@ -311,6 +386,32 @@
 
       computed: {
 
+          ## TODO: this should be temporary? but anyway 'total' is
+          ## still referenced in other places, e.g. "delete results"
+          % if grid.paginated:
+              total() { return this.pagerStats.item_count },
+          % endif
+
+          % if not grid.paginate_on_backend:
+
+              pagerStats() {
+                  const data = this.visibleData
+                  let last = this.currentPage * this.perPage
+                  let first = last - this.perPage + 1
+                  if (last > data.length) {
+                      last = data.length
+                  }
+                  return {
+                      'item_count': data.length,
+                      'items_per_page': this.perPage,
+                      'page': this.currentPage,
+                      'first_item': first,
+                      'last_item': last,
+                  }
+              },
+
+          % endif
+
           addFilterChoices() {
               // nb. this returns all choices available for "Add Filter" operation
 
@@ -358,21 +459,32 @@
 
           directLink() {
               let params = new URLSearchParams(this.getAllParams())
-              return `${request.current_route_url(_query=None)}?${'$'}{params}`
+              return `${request.path_url}?${'$'}{params}`
           },
       },
 
-      mounted() {
-          ## TODO: there is a bug (?) which prevents the arrow from
-          ## displaying for simple default single-column sort.  so to
-          ## work around that, we *disable* multi-sort until the
-          ## component is mounted.  seems to work for now..see also
-          ## https://github.com/buefy/buefy/issues/2584
-          this.allowMultiSort = true
-      },
+      % if grid.sortable and grid.sort_multiple and grid.sort_on_backend:
+
+            ## TODO: there is a bug (?) which prevents the arrow
+            ## from displaying for simple default single-column sort,
+            ## when multi-column sort is allowed for the table.  for
+            ## now we work around that by waiting until mount to
+            ## enable the multi-column support.  see also
+            ## https://github.com/buefy/buefy/issues/2584
+            mounted() {
+                this.allowMultiSort = true
+            },
+
+      % endif
 
       methods: {
 
+          renderNumber(value) {
+              if (value != undefined) {
+                  return value.toLocaleString('en')
+              }
+          },
+
           formatAddFilterItem(filtr) {
               if (!filtr.key) {
                   filtr = this.filters[filtr]
@@ -380,7 +492,7 @@
               return filtr.label || filtr.key
           },
 
-          % if grid.click_handlers:
+          % if getattr(grid, 'click_handlers', None):
               cellClick(row, column, rowIndex, columnIndex) {
                   % for key in grid.click_handlers:
                       if (column._props.field == '${key}') {
@@ -436,17 +548,18 @@
           },
 
           getBasicParams() {
-              let params = {}
-              % if grid.sortable:
-                  for (let i = 1; i <= this.backendSorters.length; i++) {
-                      params['sort'+i+'key'] = this.backendSorters[i-1].field
-                      params['sort'+i+'dir'] = this.backendSorters[i-1].order
+              const params = {
+                  % if grid.paginated and grid.paginate_on_backend:
+                      pagesize: this.perPage,
+                      page: this.currentPage,
+                  % endif
+              }
+              % if grid.sortable and grid.sort_on_backend:
+                  for (let i = 1; i <= this.sorters.length; i++) {
+                      params['sort'+i+'key'] = this.sorters[i-1].field
+                      params['sort'+i+'dir'] = this.sorters[i-1].order
                   }
               % endif
-              % if grid.pageable:
-                  params.pagesize = this.perPage
-                  params.page = this.currentPage
-              % endif
               return params
           },
 
@@ -470,6 +583,17 @@
                       ...this.getFilterParams()}
           },
 
+          ## nb. this is meant to call for a grid which is hidden at
+          ## first, when it is first being shown to the user.  and if
+          ## it was initialized with empty data set.
+          async fetchFirstData() {
+              if (this.fetchedFirstData) {
+                  return
+              }
+              await this.loadAsyncData()
+              this.fetchedFirstData = true
+          },
+
           ## TODO: i noticed buefy docs show using `async` keyword here,
           ## so now i am too.  knowing nothing at all of if/how this is
           ## supposed to improve anything.  we shall see i guess
@@ -480,27 +604,29 @@
               } else {
                   params = new URLSearchParams(params)
               }
-              params.append('partial', true)
+              if (!params.has('partial')) {
+                  params.append('partial', true)
+              }
               params = params.toString()
 
               this.loading = true
-              this.$http.get(`${'$'}{this.ajaxDataUrl}?${'$'}{params}`).then(({ data }) => {
-                  if (!data.error) {
-                      ${grid.component_studly}CurrentData = data.data
-                      this.data = ${grid.component_studly}CurrentData
-                      this.rowStatusMap = data.row_status_map
-                      this.total = data.total_items
-                      this.firstItem = data.first_item
-                      this.lastItem = data.last_item
+              this.$http.get(`${'$'}{this.ajaxDataUrl}?${'$'}{params}`).then(response => {
+                  if (!response.data.error) {
+                      ${grid.vue_component}CurrentData = response.data.data
+                      this.data = ${grid.vue_component}CurrentData
+                      % if grid.paginated and grid.paginate_on_backend:
+                          this.pagerStats = response.data.pager_stats
+                      % endif
+                      this.rowStatusMap = response.data.row_status_map || {}
                       this.loading = false
                       this.savingDefaults = false
-                      this.checkedRows = this.locateCheckedRows(data.checked_rows)
+                      this.checkedRows = this.locateCheckedRows(response.data.checked_rows || [])
                       if (success) {
                           success()
                       }
                   } else {
                       this.$buefy.toast.open({
-                          message: data.error,
+                          message: response.data.error,
                           type: 'is-danger',
                           duration: 2000, // 4 seconds
                       })
@@ -512,8 +638,11 @@
                   }
               })
               .catch((error) => {
+                  ${grid.vue_component}CurrentData = []
                   this.data = []
-                  this.total = 0
+                  % if grid.paginated and grid.paginate_on_backend:
+                      this.pagerStats = {}
+                  % endif
                   this.loading = false
                   this.savingDefaults = false
                   if (failure) {
@@ -552,55 +681,72 @@
               })
           },
 
-          onSort(field, order, event) {
+          % if grid.sortable and grid.sort_on_backend:
 
-              // nb. buefy passes field name, oruga passes object
-              if (field.field) {
-                  field = field.field
-              }
+              onSort(field, order, event) {
 
-              if (event.ctrlKey) {
+                  ## nb. buefy passes field name; oruga passes field object
+                  % if request.use_oruga:
+                      field = field.field
+                  % endif
 
-                  // engage or enhance multi-column sorting
-                  let sorter = this.backendSorters.filter(i => i.field === field)[0]
-                  if (sorter) {
-                      sorter.order = sorter.order === 'desc' ? 'asc' : 'desc'
-                  } else {
-                      this.backendSorters.push({field, order})
-                  }
-                  this.sortingPriority = this.backendSorters
+                  % if grid.sort_multiple:
 
-              } else {
+                      // did user ctrl-click the column header?
+                      if (event.ctrlKey) {
+
+                          // toggle direction for existing, or add new sorter
+                          const sorter = this.sorters.filter(s => s.field === field)[0]
+                          if (sorter) {
+                              sorter.order = sorter.order === 'desc' ? 'asc' : 'desc'
+                          } else {
+                              this.sorters.push({field, order})
+                          }
+
+                          // apply multi-column sorting
+                          this.sortingPriority = this.sorters
+
+                      } else {
+
+                  % endif
 
                   // sort by single column only
-                  this.backendSorters = [{field, order}]
-                  this.sortingPriority = []
-              }
+                  this.sorters = [{field, order}]
 
-              // always reset to first page when changing sort options
-              // TODO: i mean..right? would we ever not want that?
-              this.currentPage = 1
-              this.loadAsyncData()
-          },
+                  % if grid.sort_multiple:
+                          // multi-column sort not engaged
+                          this.sortingPriority = []
+                      }
+                  % endif
 
-          sortingPriorityRemoved(field) {
+                  // nb. always reset to first page when sorting changes
+                  this.currentPage = 1
+                  this.loadAsyncData()
+              },
 
-              // prune field from active sorters
-              this.backendSorters = this.backendSorters.filter(
-                  (sorter) => sorter.field !== field)
+              % if grid.sort_multiple:
 
-              // nb. must keep active sorter list "as-is" even if
-              // there is only one sorter; buefy seems to expect it
-              this.sortingPriority = this.backendSorters
+                  sortingPriorityRemoved(field) {
 
-              this.loadAsyncData()
-          },
+                      // prune from active sorters
+                      this.sorters = this.sorters.filter(s => s.field !== field)
+
+                      // nb. even though we might have just one sorter
+                      // now, we are still technically in multi-sort mode
+                      this.sortingPriority = this.sorters
+
+                      this.loadAsyncData()
+                  },
+
+              % endif
+
+          % endif
 
           resetView() {
               this.loading = true
 
               // use current url proper, plus reset param
-              let url = '?reset-to-default-filters=true'
+              let url = '?reset-view=true'
 
               // add current hash, to preserve that in redirect
               if (location.hash) {
@@ -774,7 +920,7 @@
               } else {
                   this.checkedRows.push(row)
               }
-              % if grid.check_handler:
+              % if getattr(grid, 'check_handler', None):
               this.${grid.check_handler}(this.checkedRows, row)
               % endif
           },
diff --git a/tailbone/templates/grids/filters.mako b/tailbone/templates/grids/filters.mako
deleted file mode 100644
index 9a80b911..00000000
--- a/tailbone/templates/grids/filters.mako
+++ /dev/null
@@ -1,67 +0,0 @@
-## -*- coding: utf-8; -*-
-
-<form action="${form.action_url}" method="GET" @submit.prevent="applyFilters()">
-
-  <div style="display: flex; flex-direction: column; gap: 0.5rem;">
-    <grid-filter v-for="key in filtersSequence"
-                 :key="key"
-                 :filter="filters[key]"
-                 ref="gridFilters">
-    </grid-filter>
-  </div>
-
-  <div style="display: flex; gap: 0.5rem; margin-top: 0.5rem;">
-
-    <b-button type="is-primary"
-              native-type="submit"
-              icon-pack="fas"
-              icon-left="check">
-      Apply Filters
-    </b-button>
-
-    <b-button v-if="!addFilterShow"
-              icon-pack="fas"
-              icon-left="plus"
-              @click="addFilterInit()">
-      Add Filter
-    </b-button>
-
-    <b-autocomplete v-if="addFilterShow"
-                    ref="addFilterAutocomplete"
-                    :data="addFilterChoices"
-                    v-model="addFilterTerm"
-                    placeholder="Add Filter"
-                    field="key"
-                    :custom-formatter="formatAddFilterItem"
-                    open-on-focus
-                    keep-first
-                    icon-pack="fas"
-                    clearable
-                    clear-on-select
-                    @select="addFilterSelect">
-    </b-autocomplete>
-
-    <b-button @click="resetView()"
-              icon-pack="fas"
-              icon-left="home">
-      Default View
-    </b-button>
-
-    <b-button @click="clearFilters()"
-              icon-pack="fas"
-              icon-left="trash">
-      No Filters
-    </b-button>
-
-    % if allow_save_defaults and request.user:
-        <b-button @click="saveDefaults()"
-                  icon-pack="fas"
-                  icon-left="save"
-                  :disabled="savingDefaults">
-          {{ savingDefaults ? "Working, please wait..." : "Save Defaults" }}
-        </b-button>
-    % endif
-
-  </div>
-
-</form>
diff --git a/tailbone/templates/grids/vue_template.mako b/tailbone/templates/grids/vue_template.mako
new file mode 100644
index 00000000..625f046b
--- /dev/null
+++ b/tailbone/templates/grids/vue_template.mako
@@ -0,0 +1,3 @@
+## -*- coding: utf-8; -*-
+<%inherit file="/grids/complete.mako" />
+${parent.body()}
diff --git a/tailbone/templates/home.mako b/tailbone/templates/home.mako
index e4f7d072..54e44d57 100644
--- a/tailbone/templates/home.mako
+++ b/tailbone/templates/home.mako
@@ -1,33 +1,7 @@
 ## -*- coding: utf-8; -*-
-<%inherit file="/page.mako" />
-<%namespace name="base_meta" file="/base_meta.mako" />
-
-<%def name="title()">Home</%def>
-
-<%def name="extra_styles()">
-  ${parent.extra_styles()}
-  <style type="text/css">
-    .logo {
-        text-align: center;
-    }
-    .logo img {
-        margin: 3em auto;
-        max-height: 350px;
-        max-width: 800px;
-    }
-  </style>
-</%def>
+<%inherit file="wuttaweb:templates/home.mako" />
 
+## DEPRECATED; remains for back-compat
 <%def name="render_this_page()">
   ${self.page_content()}
 </%def>
-
-<%def name="page_content()">
-  <div class="logo">
-    ${h.image(image_url, "{} logo".format(capture(base_meta.app_title)))}
-    <h1>Welcome to ${base_meta.app_title()}</h1>
-  </div>
-</%def>
-
-
-${parent.body()}
diff --git a/tailbone/templates/importing/configure.mako b/tailbone/templates/importing/configure.mako
index 0396745a..2445341d 100644
--- a/tailbone/templates/importing/configure.mako
+++ b/tailbone/templates/importing/configure.mako
@@ -144,9 +144,9 @@
   </b-modal>
 </%def>
 
-<%def name="modify_this_page_vars()">
-  ${parent.modify_this_page_vars()}
-  <script type="text/javascript">
+<%def name="modify_vue_vars()">
+  ${parent.modify_vue_vars()}
+  <script>
 
     ThisPageData.handlersData = ${json.dumps(handlers_data)|n}
 
@@ -203,6 +203,3 @@
 
   </script>
 </%def>
-
-
-${parent.body()}
diff --git a/tailbone/templates/importing/runjob.mako b/tailbone/templates/importing/runjob.mako
index 2bc2a4e9..a9625bc3 100644
--- a/tailbone/templates/importing/runjob.mako
+++ b/tailbone/templates/importing/runjob.mako
@@ -63,28 +63,26 @@
   </div>
 </%def>
 
-<%def name="modify_this_page_vars()">
-  ${parent.modify_this_page_vars()}
-  <script type="text/javascript">
+<%def name="modify_vue_vars()">
+  ${parent.modify_vue_vars()}
+  <script>
 
-    ${form.component_studly}Data.submittingRun = false
-    ${form.component_studly}Data.submittingExplain = false
-    ${form.component_studly}Data.runJob = false
+    ${form.vue_component}Data.submittingRun = false
+    ${form.vue_component}Data.submittingExplain = false
+    ${form.vue_component}Data.runJob = false
 
-    ${form.component_studly}.methods.submitRun = function() {
+    ${form.vue_component}.methods.submitRun = function() {
         this.submittingRun = true
         this.runJob = true
         this.$nextTick(() => {
-            this.$refs.${form.component_studly}.submit()
+            this.$refs.${form.vue_component}.submit()
         })
     }
 
-    ${form.component_studly}.methods.submitExplain = function() {
+    ${form.vue_component}.methods.submitExplain = function() {
         this.submittingExplain = true
-        this.$refs.${form.component_studly}.submit()
+        this.$refs.${form.vue_component}.submit()
     }
 
   </script>
 </%def>
-
-${parent.body()}
diff --git a/tailbone/templates/login.mako b/tailbone/templates/login.mako
index d18323b5..d2ea7828 100644
--- a/tailbone/templates/login.mako
+++ b/tailbone/templates/login.mako
@@ -1,86 +1,17 @@
 ## -*- coding: utf-8; -*-
-<%inherit file="/form.mako" />
-<%namespace name="base_meta" file="/base_meta.mako" />
-
-<%def name="title()">Login</%def>
+<%inherit file="wuttaweb:templates/auth/login.mako" />
 
+## TODO: this will not be needed with wuttaform
 <%def name="extra_styles()">
   ${parent.extra_styles()}
-  <style type="text/css">
-    .logo img {
-        display: block;
-        margin: 3rem auto;
-        max-height: 350px;
-        max-width: 800px;
-    }
-
-    /* must force a particular label with, in order to make sure */
-    /* the username and password inputs are the same size */
-    .field.is-horizontal .field-label .label {
-        text-align: left;
-        width: 6rem;
-    }
-
-    .buttons {
+  <style>
+    .card-content .buttons {
         justify-content: right;
     }
   </style>
 </%def>
 
-<%def name="logo()">
-  ${h.image(image_url, "{} logo".format(capture(base_meta.app_title)))}
-</%def>
-
-<%def name="login_form()">
-  <div class="form">
-    ${form.render_deform(form_kwargs={'data-ajax': 'false'})|n}
-  </div>
-</%def>
-
+## DEPRECATED; remains for back-compat
 <%def name="render_this_page()">
   ${self.page_content()}
 </%def>
-
-<%def name="page_content()">
-  <div class="logo">
-    ${self.logo()}
-  </div>
-
-  <div class="columns is-centered">
-    <div class="column is-narrow">
-      <div class="card">
-        <div class="card-content">
-          <tailbone-form></tailbone-form>
-        </div>
-      </div>
-    </div>
-  </div>
-</%def>
-
-<%def name="modify_this_page_vars()">
-  <script type="text/javascript">
-
-    ${form.component_studly}Data.usernameInput = null
-
-    ${form.component_studly}.mounted = function() {
-        this.$refs.username.focus()
-        this.usernameInput = this.$refs.username.$el.querySelector('input')
-        this.usernameInput.addEventListener('keydown', this.usernameKeydown)
-    }
-
-    ${form.component_studly}.beforeDestroy = function() {
-        this.usernameInput.removeEventListener('keydown', this.usernameKeydown)
-    }
-
-    ${form.component_studly}.methods.usernameKeydown = function(event) {
-        if (event.which == 13) {
-            event.preventDefault()
-            this.$refs.password.focus()
-        }
-    }
-
-  </script>
-</%def>
-
-
-${parent.body()}
diff --git a/tailbone/templates/luigi/configure.mako b/tailbone/templates/luigi/configure.mako
index 49060ceb..de364828 100644
--- a/tailbone/templates/luigi/configure.mako
+++ b/tailbone/templates/luigi/configure.mako
@@ -297,9 +297,9 @@
 
 </%def>
 
-<%def name="modify_this_page_vars()">
-  ${parent.modify_this_page_vars()}
-  <script type="text/javascript">
+<%def name="modify_vue_vars()">
+  ${parent.modify_vue_vars()}
+  <script>
 
     ThisPageData.overnightTasks = ${json.dumps(overnight_tasks)|n}
     ThisPageData.overnightTaskShowDialog = false
@@ -425,6 +425,3 @@
 
   </script>
 </%def>
-
-
-${parent.body()}
diff --git a/tailbone/templates/luigi/index.mako b/tailbone/templates/luigi/index.mako
index bb8d1465..0dd72d01 100644
--- a/tailbone/templates/luigi/index.mako
+++ b/tailbone/templates/luigi/index.mako
@@ -79,8 +79,13 @@
                       @click="overnightTaskLaunchInit(props.row)">
               Launch
             </b-button>
-            <b-modal has-modal-card
-                     :active.sync="overnightTaskShowLaunchDialog">
+            <${b}-modal has-modal-card
+                        % if request.use_oruga:
+                            v-model:active="overnightTaskShowLaunchDialog"
+                        % else:
+                            :active.sync="overnightTaskShowLaunchDialog"
+                        % endif
+                        >
               <div class="modal-card">
 
                 <header class="modal-card-head">
@@ -127,7 +132,7 @@
                   </b-button>
                 </footer>
               </div>
-            </b-modal>
+            </${b}-modal>
           </${b}-table-column>
           <template #empty>
             <p class="block">No tasks defined.</p>
@@ -182,8 +187,13 @@
           </template>
         </${b}-table>
 
-        <b-modal has-modal-card
-                 :active.sync="backfillTaskShowLaunchDialog">
+        <${b}-modal has-modal-card
+                    % if request.use_oruga:
+                        v-model:active="backfillTaskShowLaunchDialog"
+                    % else:
+                        :active.sync="backfillTaskShowLaunchDialog"
+                    % endif
+                    >
           <div class="modal-card">
 
             <header class="modal-card-head">
@@ -238,16 +248,16 @@
               </b-button>
             </footer>
           </div>
-        </b-modal>
+        </${b}-modal>
 
     % endif
 
   </div>
 </%def>
 
-<%def name="modify_this_page_vars()">
-  ${parent.modify_this_page_vars()}
-  <script type="text/javascript">
+<%def name="modify_vue_vars()">
+  ${parent.modify_vue_vars()}
+  <script>
 
     % if master.has_perm('restart_scheduler'):
 
@@ -364,6 +374,3 @@
 
   </script>
 </%def>
-
-
-${parent.body()}
diff --git a/tailbone/templates/master/clone.mako b/tailbone/templates/master/clone.mako
index 59d6aea2..4c7e4662 100644
--- a/tailbone/templates/master/clone.mako
+++ b/tailbone/templates/master/clone.mako
@@ -34,9 +34,9 @@
   ${h.end_form()}
 </%def>
 
-<%def name="modify_this_page_vars()">
-  ${parent.modify_this_page_vars()}
-  <script type="text/javascript">
+<%def name="modify_vue_vars()">
+  ${parent.modify_vue_vars()}
+  <script>
 
     TailboneFormData.formSubmitting = false
     TailboneFormData.submitButtonText = "Yes, please clone away"
@@ -48,6 +48,3 @@
 
   </script>
 </%def>
-
-
-${parent.body()}
diff --git a/tailbone/templates/master/create.mako b/tailbone/templates/master/create.mako
index 27cd404c..d7dcbbd8 100644
--- a/tailbone/templates/master/create.mako
+++ b/tailbone/templates/master/create.mako
@@ -1,6 +1,6 @@
 ## -*- coding: utf-8; -*-
 <%inherit file="/form.mako" />
 
-<%def name="title()">New ${model_title_plural if master.creates_multiple else model_title}</%def>
+<%def name="title()">New ${model_title_plural if getattr(master, 'creates_multiple', False) else model_title}</%def>
 
 ${parent.body()}
diff --git a/tailbone/templates/master/delete.mako b/tailbone/templates/master/delete.mako
index 30bb50ab..d2f517d9 100644
--- a/tailbone/templates/master/delete.mako
+++ b/tailbone/templates/master/delete.mako
@@ -27,26 +27,21 @@
       <b-button type="is-primary is-danger"
                 native-type="submit"
                 :disabled="formSubmitting">
-        {{ formButtonText }}
+        {{ formSubmitting ? "Working, please wait..." : "${form.button_label_submit}" }}
       </b-button>
     </div>
   ${h.end_form()}
 </%def>
 
-<%def name="modify_this_page_vars()">
-  ${parent.modify_this_page_vars()}
-  <script type="text/javascript">
+<%def name="modify_vue_vars()">
+  ${parent.modify_vue_vars()}
+  <script>
 
-    TailboneFormData.formSubmitting = false
-    TailboneFormData.formButtonText = "Yes, please DELETE this data forever!"
+    ${form.vue_component}Data.formSubmitting = false
 
-    TailboneForm.methods.submitForm = function() {
+    ${form.vue_component}.methods.submitForm = function() {
         this.formSubmitting = true
-        this.formButtonText = "Working, please wait..."
     }
 
   </script>
 </%def>
-
-
-${parent.body()}
diff --git a/tailbone/templates/master/form.mako b/tailbone/templates/master/form.mako
index dfe56fa8..17063c21 100644
--- a/tailbone/templates/master/form.mako
+++ b/tailbone/templates/master/form.mako
@@ -1,18 +1,18 @@
 ## -*- coding: utf-8; -*-
 <%inherit file="/form.mako" />
 
-<%def name="modify_this_page_vars()">
-  ${parent.modify_this_page_vars()}
-  <script type="text/javascript">
+<%def name="modify_vue_vars()">
+  ${parent.modify_vue_vars()}
+  <script>
 
     ## declare extra data needed by form
-    % if form is not Undefined:
+    % if form is not Undefined and getattr(form, 'json_data', None):
         % for key, value in form.json_data.items():
-            ${form.component_studly}Data.${key} = ${json.dumps(value)|n}
+            ${form.vue_component}Data.${key} = ${json.dumps(value)|n}
         % endfor
     % endif
 
-    % if master.deletable and instance_deletable and master.has_perm('delete') and master.delete_confirm == 'simple':
+    % if master.deletable and instance_deletable and master.has_perm('delete') and getattr(master, 'delete_confirm', 'full') == 'simple':
 
         ThisPage.methods.deleteObject = function() {
             if (confirm("Are you sure you wish to delete this ${model_title}?")) {
@@ -23,11 +23,8 @@
     % endif
   </script>
 
-  % if form is not Undefined:
+  % if form is not Undefined and hasattr(form, 'render_included_templates'):
       ${form.render_included_templates()}
   % endif
 
 </%def>
-
-
-${parent.body()}
diff --git a/tailbone/templates/master/index.mako b/tailbone/templates/master/index.mako
index 33592559..a2d26c60 100644
--- a/tailbone/templates/master/index.mako
+++ b/tailbone/templates/master/index.mako
@@ -15,7 +15,7 @@
 <%def name="grid_tools()">
 
   ## grid totals
-  % if master.supports_grid_totals:
+  % if getattr(master, 'supports_grid_totals', False):
       <div style="display: flex; align-items: center;">
         <b-button v-if="gridTotalsDisplay == null"
                   :disabled="gridTotalsFetching"
@@ -30,7 +30,7 @@
   % endif
 
   ## download search results
-  % if master.results_downloadable and master.has_perm('download_results'):
+  % if getattr(master, 'results_downloadable', False) and master.has_perm('download_results'):
       <div>
         <b-button type="is-primary"
                   icon-pack="fas"
@@ -180,7 +180,7 @@
   % endif
 
   ## download rows for search results
-  % if master.has_rows and master.results_rows_downloadable and master.has_perm('download_results_rows'):
+  % if getattr(master, 'has_rows', False) and master.results_rows_downloadable and master.has_perm('download_results_rows'):
       <b-button type="is-primary"
                 icon-pack="fas"
                 icon-left="download"
@@ -194,7 +194,7 @@
   % endif
 
   ## merge 2 objects
-  % if master.mergeable and request.has_perm('{}.merge'.format(permission_prefix)):
+  % if getattr(master, 'mergeable', False) and request.has_perm('{}.merge'.format(permission_prefix)):
 
       ${h.form(url('{}.merge'.format(route_prefix)), class_='control', **{'@submit': 'submitMergeForm'})}
       ${h.csrf_token(request)}
@@ -212,7 +212,7 @@
   % endif
 
   ## enable / disable selected objects
-  % if master.supports_set_enabled_toggle and master.has_perm('enable_disable_set'):
+  % if getattr(master, 'supports_set_enabled_toggle', False) and master.has_perm('enable_disable_set'):
 
       ${h.form(url('{}.enable_set'.format(route_prefix)), class_='control', ref='enable_selected_form')}
       ${h.csrf_token(request)}
@@ -234,7 +234,7 @@
   % endif
 
   ## delete selected objects
-  % if master.set_deletable and master.has_perm('delete_set'):
+  % if getattr(master, 'set_deletable', False) and master.has_perm('delete_set'):
       ${h.form(url('{}.delete_set'.format(route_prefix)), ref='delete_selected_form', class_='control')}
       ${h.csrf_token(request)}
       ${h.hidden('uuids', v_model='selected_uuids')}
@@ -249,7 +249,7 @@
   % endif
 
   ## delete search results
-  % if master.bulk_deletable and request.has_perm('{}.bulk_delete'.format(permission_prefix)):
+  % if getattr(master, 'bulk_deletable', False) and request.has_perm('{}.bulk_delete'.format(permission_prefix)):
       ${h.form(url('{}.bulk_delete'.format(route_prefix)), ref='delete_results_form', class_='control')}
       ${h.csrf_token(request)}
       <b-button type="is-danger"
@@ -265,6 +265,11 @@
 
 </%def>
 
+## DEPRECATED; remains for back-compat
+<%def name="render_this_page()">
+  ${self.page_content()}
+</%def>
+
 <%def name="page_content()">
 
   % if download_results_path:
@@ -283,56 +288,42 @@
 
   ${self.render_grid_component()}
 
-  % if master.deletable and request.has_perm('{}.delete'.format(permission_prefix)) and master.delete_confirm == 'simple':
+  % if master.deletable and master.has_perm('delete') and getattr(master, 'delete_confirm', 'full') == 'simple':
       ${h.form('#', ref='deleteObjectForm')}
       ${h.csrf_token(request)}
       ${h.end_form()}
   % endif
 </%def>
 
-<%def name="make_grid_component()">
-  ## TODO: stop using |n filter?
-  ${grid.render_complete(tools=capture(self.grid_tools).strip(), context_menu=capture(self.context_menu_items).strip())|n}
-</%def>
-
 <%def name="render_grid_component()">
-  <${grid.component} ref="grid" :csrftoken="csrftoken"
-     % if master.deletable and request.has_perm('{}.delete'.format(permission_prefix)) and master.delete_confirm == 'simple':
-     @deleteActionClicked="deleteObject"
-     % endif
-     >
-  </${grid.component}>
+  ${grid.render_vue_tag()}
 </%def>
 
-<%def name="make_this_page_component()">
+##############################
+## vue components
+##############################
 
-  ## define grid
+<%def name="render_vue_templates()">
+  ${parent.render_vue_templates()}
+
+  ## DEPRECATED; called for back-compat
   ${self.make_grid_component()}
-
-  ${parent.make_this_page_component()}
-
-  ## finalize grid
-  <script>
-
-    ${grid.component_studly}.data = () => { return ${grid.component_studly}Data }
-    Vue.component('${grid.component}', ${grid.component_studly})
-
-  </script>
 </%def>
 
-<%def name="render_this_page()">
-  ${self.page_content()}
+## DEPRECATED; remains for back-compat
+<%def name="make_grid_component()">
+  ${grid.render_vue_template(tools=capture(self.grid_tools).strip(), context_menu=capture(self.context_menu_items).strip())}
 </%def>
 
-<%def name="modify_this_page_vars()">
-  ${parent.modify_this_page_vars()}
+<%def name="modify_vue_vars()">
+  ${parent.modify_vue_vars()}
   <script type="text/javascript">
 
-    % if master.supports_grid_totals:
-        ${grid.component_studly}Data.gridTotalsDisplay = null
-        ${grid.component_studly}Data.gridTotalsFetching = false
+    % if getattr(master, 'supports_grid_totals', False):
+        ${grid.vue_component}Data.gridTotalsDisplay = null
+        ${grid.vue_component}Data.gridTotalsFetching = false
 
-        ${grid.component_studly}.methods.gridTotalsFetch = function() {
+        ${grid.vue_component}.methods.gridTotalsFetch = function() {
             this.gridTotalsFetching = true
 
             let url = '${url(f'{route_prefix}.fetch_grid_totals')}'
@@ -344,7 +335,7 @@
             })
         }
 
-        ${grid.component_studly}.methods.appliedFiltersHook = function() {
+        ${grid.vue_component}.methods.appliedFiltersHook = function() {
             this.gridTotalsDisplay = null
             this.gridTotalsFetching = false
         }
@@ -388,7 +379,7 @@
     % endif
 
     ## delete single object
-    % if master.deletable and master.has_perm('delete') and master.delete_confirm == 'simple':
+    % if master.deletable and master.has_perm('delete') and getattr(master, 'delete_confirm', 'full') == 'simple':
         ThisPage.methods.deleteObject = function(url) {
             if (confirm("Are you sure you wish to delete this ${model_title}?")) {
                 let form = this.$refs.deleteObjectForm
@@ -399,19 +390,19 @@
     % endif
 
     ## download results
-    % if master.results_downloadable and master.has_perm('download_results'):
+    % if getattr(master, 'results_downloadable', False) and master.has_perm('download_results'):
 
-        ${grid.component_studly}Data.downloadResultsFormat = '${master.download_results_default_format()}'
-        ${grid.component_studly}Data.showDownloadResultsDialog = false
-        ${grid.component_studly}Data.downloadResultsFieldsMode = 'default'
-        ${grid.component_studly}Data.downloadResultsFieldsAvailable = ${json.dumps(download_results_fields_available)|n}
-        ${grid.component_studly}Data.downloadResultsFieldsDefault = ${json.dumps(download_results_fields_default)|n}
-        ${grid.component_studly}Data.downloadResultsFieldsIncluded = ${json.dumps(download_results_fields_default)|n}
+        ${grid.vue_component}Data.downloadResultsFormat = '${master.download_results_default_format()}'
+        ${grid.vue_component}Data.showDownloadResultsDialog = false
+        ${grid.vue_component}Data.downloadResultsFieldsMode = 'default'
+        ${grid.vue_component}Data.downloadResultsFieldsAvailable = ${json.dumps(download_results_fields_available)|n}
+        ${grid.vue_component}Data.downloadResultsFieldsDefault = ${json.dumps(download_results_fields_default)|n}
+        ${grid.vue_component}Data.downloadResultsFieldsIncluded = ${json.dumps(download_results_fields_default)|n}
 
-        ${grid.component_studly}Data.downloadResultsExcludedFieldsSelected = []
-        ${grid.component_studly}Data.downloadResultsIncludedFieldsSelected = []
+        ${grid.vue_component}Data.downloadResultsExcludedFieldsSelected = []
+        ${grid.vue_component}Data.downloadResultsIncludedFieldsSelected = []
 
-        ${grid.component_studly}.computed.downloadResultsFieldsExcluded = function() {
+        ${grid.vue_component}.computed.downloadResultsFieldsExcluded = function() {
             let excluded = []
             this.downloadResultsFieldsAvailable.forEach(field => {
                 if (!this.downloadResultsFieldsIncluded.includes(field)) {
@@ -421,7 +412,7 @@
             return excluded
         }
 
-        ${grid.component_studly}.methods.downloadResultsExcludeFields = function() {
+        ${grid.vue_component}.methods.downloadResultsExcludeFields = function() {
             const selected = Array.from(this.downloadResultsIncludedFieldsSelected)
             if (!selected) {
                 return
@@ -445,7 +436,7 @@
             })
         }
 
-        ${grid.component_studly}.methods.downloadResultsIncludeFields = function() {
+        ${grid.vue_component}.methods.downloadResultsIncludeFields = function() {
             const selected = Array.from(this.downloadResultsExcludedFieldsSelected)
             if (!selected) {
                 return
@@ -466,28 +457,28 @@
             })
         }
 
-        ${grid.component_studly}.methods.downloadResultsUseDefaultFields = function() {
+        ${grid.vue_component}.methods.downloadResultsUseDefaultFields = function() {
             this.downloadResultsFieldsIncluded = Array.from(this.downloadResultsFieldsDefault)
             this.downloadResultsFieldsMode = 'default'
         }
 
-        ${grid.component_studly}.methods.downloadResultsUseAllFields = function() {
+        ${grid.vue_component}.methods.downloadResultsUseAllFields = function() {
             this.downloadResultsFieldsIncluded = Array.from(this.downloadResultsFieldsAvailable)
             this.downloadResultsFieldsMode = 'all'
         }
 
-        ${grid.component_studly}.methods.downloadResultsSubmit = function() {
+        ${grid.vue_component}.methods.downloadResultsSubmit = function() {
             this.$refs.download_results_form.submit()
         }
     % endif
 
     ## download rows for results
-    % if master.has_rows and master.results_rows_downloadable and master.has_perm('download_results_rows'):
+    % if getattr(master, 'has_rows', False) and master.results_rows_downloadable and master.has_perm('download_results_rows'):
 
-        ${grid.component_studly}Data.downloadResultsRowsButtonDisabled = false
-        ${grid.component_studly}Data.downloadResultsRowsButtonText = "Download Rows for Results"
+        ${grid.vue_component}Data.downloadResultsRowsButtonDisabled = false
+        ${grid.vue_component}Data.downloadResultsRowsButtonText = "Download Rows for Results"
 
-        ${grid.component_studly}.methods.downloadResultsRows = function() {
+        ${grid.vue_component}.methods.downloadResultsRows = function() {
             if (confirm("This will generate an Excel file which contains "
                         + "not the results themselves, but the *rows* for "
                         + "each.\n\nAre you sure you want this?")) {
@@ -499,12 +490,12 @@
     % endif
 
     ## enable / disable selected objects
-    % if master.supports_set_enabled_toggle and master.has_perm('enable_disable_set'):
+    % if getattr(master, 'supports_set_enabled_toggle', False) and master.has_perm('enable_disable_set'):
 
-        ${grid.component_studly}Data.enableSelectedSubmitting = false
-        ${grid.component_studly}Data.enableSelectedText = "Enable Selected"
+        ${grid.vue_component}Data.enableSelectedSubmitting = false
+        ${grid.vue_component}Data.enableSelectedText = "Enable Selected"
 
-        ${grid.component_studly}.computed.enableSelectedDisabled = function() {
+        ${grid.vue_component}.computed.enableSelectedDisabled = function() {
             if (this.enableSelectedSubmitting) {
                 return true
             }
@@ -514,7 +505,7 @@
             return false
         }
 
-        ${grid.component_studly}.methods.enableSelectedSubmit = function() {
+        ${grid.vue_component}.methods.enableSelectedSubmit = function() {
             let uuids = this.checkedRowUUIDs()
             if (!uuids.length) {
                 alert("You must first select one or more objects to disable.")
@@ -529,10 +520,10 @@
             this.$refs.enable_selected_form.submit()
         }
 
-        ${grid.component_studly}Data.disableSelectedSubmitting = false
-        ${grid.component_studly}Data.disableSelectedText = "Disable Selected"
+        ${grid.vue_component}Data.disableSelectedSubmitting = false
+        ${grid.vue_component}Data.disableSelectedText = "Disable Selected"
 
-        ${grid.component_studly}.computed.disableSelectedDisabled = function() {
+        ${grid.vue_component}.computed.disableSelectedDisabled = function() {
             if (this.disableSelectedSubmitting) {
                 return true
             }
@@ -542,7 +533,7 @@
             return false
         }
 
-        ${grid.component_studly}.methods.disableSelectedSubmit = function() {
+        ${grid.vue_component}.methods.disableSelectedSubmit = function() {
             let uuids = this.checkedRowUUIDs()
             if (!uuids.length) {
                 alert("You must first select one or more objects to disable.")
@@ -560,12 +551,12 @@
     % endif
 
     ## delete selected objects
-    % if master.set_deletable and master.has_perm('delete_set'):
+    % if getattr(master, 'set_deletable', False) and master.has_perm('delete_set'):
 
-        ${grid.component_studly}Data.deleteSelectedSubmitting = false
-        ${grid.component_studly}Data.deleteSelectedText = "Delete Selected"
+        ${grid.vue_component}Data.deleteSelectedSubmitting = false
+        ${grid.vue_component}Data.deleteSelectedText = "Delete Selected"
 
-        ${grid.component_studly}.computed.deleteSelectedDisabled = function() {
+        ${grid.vue_component}.computed.deleteSelectedDisabled = function() {
             if (this.deleteSelectedSubmitting) {
                 return true
             }
@@ -575,7 +566,7 @@
             return false
         }
 
-        ${grid.component_studly}.methods.deleteSelectedSubmit = function() {
+        ${grid.vue_component}.methods.deleteSelectedSubmit = function() {
             let uuids = this.checkedRowUUIDs()
             if (!uuids.length) {
                 alert("You must first select one or more objects to disable.")
@@ -591,12 +582,12 @@
         }
     % endif
 
-    % if master.bulk_deletable and master.has_perm('bulk_delete'):
+    % if getattr(master, 'bulk_deletable', False) and master.has_perm('bulk_delete'):
 
-        ${grid.component_studly}Data.deleteResultsSubmitting = false
-        ${grid.component_studly}Data.deleteResultsText = "Delete Results"
+        ${grid.vue_component}Data.deleteResultsSubmitting = false
+        ${grid.vue_component}Data.deleteResultsText = "Delete Results"
 
-        ${grid.component_studly}.computed.deleteResultsDisabled = function() {
+        ${grid.vue_component}.computed.deleteResultsDisabled = function() {
             if (this.deleteResultsSubmitting) {
                 return true
             }
@@ -606,7 +597,7 @@
             return false
         }
 
-        ${grid.component_studly}.methods.deleteResultsSubmit = function() {
+        ${grid.vue_component}.methods.deleteResultsSubmit = function() {
             // TODO: show "plural model title" here?
             if (!confirm("You are about to delete " + this.total.toLocaleString('en') + " objects.\n\nAre you sure?")) {
                 return
@@ -619,12 +610,12 @@
 
     % endif
 
-    % if master.mergeable and master.has_perm('merge'):
+    % if getattr(master, 'mergeable', False) and master.has_perm('merge'):
 
-        ${grid.component_studly}Data.mergeFormButtonText = "Merge 2 ${model_title_plural}"
-        ${grid.component_studly}Data.mergeFormSubmitting = false
+        ${grid.vue_component}Data.mergeFormButtonText = "Merge 2 ${model_title_plural}"
+        ${grid.vue_component}Data.mergeFormSubmitting = false
 
-        ${grid.component_studly}.methods.submitMergeForm = function() {
+        ${grid.vue_component}.methods.submitMergeForm = function() {
             this.mergeFormSubmitting = true
             this.mergeFormButtonText = "Working, please wait..."
         }
@@ -632,5 +623,10 @@
   </script>
 </%def>
 
-
-${parent.body()}
+<%def name="make_vue_components()">
+  ${parent.make_vue_components()}
+  <script>
+    ${grid.vue_component}.data = function() { return ${grid.vue_component}Data }
+    Vue.component('${grid.vue_tagname}', ${grid.vue_component})
+  </script>
+</%def>
diff --git a/tailbone/templates/master/merge.mako b/tailbone/templates/master/merge.mako
index 5d90043f..487d258d 100644
--- a/tailbone/templates/master/merge.mako
+++ b/tailbone/templates/master/merge.mako
@@ -109,8 +109,8 @@
   <merge-buttons></merge-buttons>
 </%def>
 
-<%def name="render_this_page_template()">
-  ${parent.render_this_page_template()}
+<%def name="render_vue_templates()">
+  ${parent.render_vue_templates()}
 
   <script type="text/x-template" id="merge-buttons-template">
     <div class="level" style="margin-top: 2em;">
@@ -147,11 +147,7 @@
       </div>
     </div>
   </script>
-</%def>
-
-<%def name="make_this_page_component()">
-  ${parent.make_this_page_component()}
-  <script type="text/javascript">
+  <script>
 
     const MergeButtons = {
         template: '#merge-buttons-template',
@@ -175,12 +171,13 @@
         }
     }
 
-    Vue.component('merge-buttons', MergeButtons)
-
-    <% request.register_component('merge-buttons', 'MergeButtons') %>
-
   </script>
 </%def>
 
-
-${parent.body()}
+<%def name="make_vue_components()">
+  ${parent.make_vue_components()}
+  <script>
+    Vue.component('merge-buttons', MergeButtons)
+    <% request.register_component('merge-buttons', 'MergeButtons') %>
+  </script>
+</%def>
diff --git a/tailbone/templates/master/versions.mako b/tailbone/templates/master/versions.mako
index 307674b8..a6bb14f0 100644
--- a/tailbone/templates/master/versions.mako
+++ b/tailbone/templates/master/versions.mako
@@ -16,27 +16,16 @@
   ${self.page_content()}
 </%def>
 
-<%def name="make_this_page_component()">
-  ${parent.make_this_page_component()}
-  <script type="text/javascript">
-
-    TailboneGrid.data = function() { return TailboneGridData }
-
-    Vue.component('tailbone-grid', TailboneGrid)
-
-  </script>
-</%def>
-
-<%def name="render_this_page_template()">
-  ${parent.render_this_page_template()}
-
-  ## TODO: stop using |n filter
-  ${grid.render_complete()|n}
-</%def>
-
 <%def name="page_content()">
-  <tailbone-grid :csrftoken="csrftoken">
-  </tailbone-grid>
+  ${grid.render_vue_tag(**{':csrftoken': 'csrftoken'})}
 </%def>
 
-${parent.body()}
+<%def name="render_vue_templates()">
+  ${parent.render_vue_templates()}
+  ${grid.render_vue_template()}
+</%def>
+
+<%def name="make_vue_components()">
+  ${parent.make_vue_components()}
+  ${grid.render_vue_finalize()}
+</%def>
diff --git a/tailbone/templates/master/view.mako b/tailbone/templates/master/view.mako
index fe44caa9..118c028c 100644
--- a/tailbone/templates/master/view.mako
+++ b/tailbone/templates/master/view.mako
@@ -8,7 +8,7 @@
 </%def>
 
 <%def name="render_instance_header_title_extras()">
-  % if master.touchable and master.has_perm('touch'):
+  % if getattr(master, 'touchable', False) and master.has_perm('touch'):
       <b-button title="&quot;Touch&quot; this record to trigger sync"
                 @click="touchRecord()"
                 :disabled="touchSubmitting">
@@ -93,7 +93,7 @@
     ${parent.render_this_page()}
 
     ## render row grid
-    % if master.has_rows:
+    % if getattr(master, 'has_rows', False):
         <br />
         % if rows_title:
             <h4 class="block is-size-4">${rows_title}</h4>
@@ -120,9 +120,7 @@
           </p>
         </div>
 
-        <versions-grid ref="versionsGrid"
-                       @view-revision="viewRevision">
-        </versions-grid>
+        ${versions_grid.render_vue_tag(ref='versionsGrid', **{'@view-revision': 'viewRevision'})}
 
         <${b}-modal :width="1200"
                     % if request.use_oruga:
@@ -198,6 +196,7 @@
 
                   <p class="block has-text-weight-bold">
                     {{ version.model_title }}
+                    ({{ version.operation }})
                   </p>
 
                   <table class="diff monospace is-size-7"
@@ -237,25 +236,37 @@
 </%def>
 
 <%def name="render_row_grid_component()">
-  <tailbone-grid ref="rowGrid" id="rowGrid"></tailbone-grid>
+  ${rows_grid.render_vue_tag(id='rowGrid', ref='rowGrid')}
 </%def>
 
-<%def name="render_this_page_template()">
-  % if master.has_rows:
-      ## TODO: stop using |n filter
-      ${rows_grid.render_complete(allow_save_defaults=False, tools=capture(self.render_row_grid_tools))|n}
+<%def name="render_vue_templates()">
+  ${parent.render_vue_templates()}
+  % if getattr(master, 'has_rows', False):
+      ${rows_grid.render_vue_template(allow_save_defaults=False, tools=capture(self.render_row_grid_tools))}
   % endif
-  ${parent.render_this_page_template()}
   % if expose_versions:
-      ${versions_grid.render_complete()|n}
+      ${versions_grid.render_vue_template()}
   % endif
 </%def>
 
-<%def name="modify_this_page_vars()">
-  ${parent.modify_this_page_vars()}
-  % if expose_versions:
-      <script type="text/javascript">
+<%def name="modify_vue_vars()">
+  ${parent.modify_vue_vars()}
+  <script>
 
+    % if getattr(master, 'touchable', False) and master.has_perm('touch'):
+
+        WholePageData.touchSubmitting = false
+
+        WholePage.methods.touchRecord = function() {
+            this.touchSubmitting = true
+            location.href = '${master.get_action_url('touch', instance)}'
+        }
+
+    % endif
+
+    % if expose_versions:
+
+        WholePageData.viewingHistory = false
         ThisPage.props.viewingHistory = Boolean
 
         ThisPageData.gettingRevisions = false
@@ -310,48 +321,16 @@
             this.viewVersionShowAllFields = !this.viewVersionShowAllFields
         }
 
-      </script>
+    % endif
+  </script>
+</%def>
+
+<%def name="make_vue_components()">
+  ${parent.make_vue_components()}
+  % if getattr(master, 'has_rows', False):
+      ${rows_grid.render_vue_finalize()}
+  % endif
+  % if expose_versions:
+      ${versions_grid.render_vue_finalize()}
   % endif
 </%def>
-
-<%def name="modify_whole_page_vars()">
-  ${parent.modify_whole_page_vars()}
-  <script type="text/javascript">
-
-    % if master.touchable and master.has_perm('touch'):
-
-        WholePageData.touchSubmitting = false
-
-        WholePage.methods.touchRecord = function() {
-            this.touchSubmitting = true
-            location.href = '${master.get_action_url('touch', instance)}'
-        }
-
-    % endif
-
-    % if expose_versions:
-        WholePageData.viewingHistory = false
-    % endif
-
-  </script>
-</%def>
-
-<%def name="finalize_this_page_vars()">
-  ${parent.finalize_this_page_vars()}
-  <script type="text/javascript">
-
-    % if master.has_rows:
-        TailboneGrid.data = function() { return TailboneGridData }
-        Vue.component('tailbone-grid', TailboneGrid)
-    % endif
-
-    % if expose_versions:
-        VersionsGrid.data = function() { return VersionsGridData }
-        Vue.component('versions-grid', VersionsGrid)
-    % endif
-
-  </script>
-</%def>
-
-
-${parent.body()}
diff --git a/tailbone/templates/members/configure.mako b/tailbone/templates/members/configure.mako
index 465bf611..f1f0e39f 100644
--- a/tailbone/templates/members/configure.mako
+++ b/tailbone/templates/members/configure.mako
@@ -52,9 +52,9 @@
   </div>
 </%def>
 
-<%def name="modify_this_page_vars()">
-  ${parent.modify_this_page_vars()}
-  <script type="text/javascript">
+<%def name="modify_vue_vars()">
+  ${parent.modify_vue_vars()}
+  <script>
 
     ThisPage.methods.getLabelForKey = function(key) {
         switch (key) {
@@ -75,6 +75,3 @@
 
   </script>
 </%def>
-
-
-${parent.body()}
diff --git a/tailbone/templates/messages/create.mako b/tailbone/templates/messages/create.mako
index 4a15573b..39236f75 100644
--- a/tailbone/templates/messages/create.mako
+++ b/tailbone/templates/messages/create.mako
@@ -32,14 +32,14 @@
   % endif
 </%def>
 
-<%def name="render_this_page_template()">
-  ${parent.render_this_page_template()}
+<%def name="render_vue_templates()">
+  ${parent.render_vue_templates()}
   ${message_recipients_template()}
 </%def>
 
-<%def name="modify_this_page_vars()">
-  ${parent.modify_this_page_vars()}
-  <script type="text/javascript">
+<%def name="modify_vue_vars()">
+  ${parent.modify_vue_vars()}
+  <script>
 
     TailboneFormData.possibleRecipients = new Map(${json.dumps(available_recipients)|n})
     TailboneFormData.recipientDisplayMap = ${json.dumps(recipient_display_map)|n}
@@ -59,6 +59,3 @@
 
   </script>
 </%def>
-
-
-${parent.body()}
diff --git a/tailbone/templates/messages/index.mako b/tailbone/templates/messages/index.mako
index 3fc82fd3..eaa4b6c9 100644
--- a/tailbone/templates/messages/index.mako
+++ b/tailbone/templates/messages/index.mako
@@ -22,15 +22,15 @@
   % endif
 </%def>
 
-<%def name="modify_this_page_vars()">
-  ${parent.modify_this_page_vars()}
+<%def name="modify_vue_vars()">
+  ${parent.modify_vue_vars()}
   % if request.matched_route.name in ('messages.inbox', 'messages.archive'):
-      <script type="text/javascript">
+      <script>
 
-        TailboneGridData.moveMessagesSubmitting = false
-        TailboneGridData.moveMessagesText = null
+        ${grid.vue_component}Data.moveMessagesSubmitting = false
+        ${grid.vue_component}Data.moveMessagesText = null
 
-        TailboneGrid.computed.moveMessagesTextCurrent = function() {
+        ${grid.vue_component}.computed.moveMessagesTextCurrent = function() {
             if (this.moveMessagesText) {
                 return this.moveMessagesText
             }
@@ -38,7 +38,7 @@
             return "Move " + count.toString() + " selected to ${'Archive' if request.matched_route.name == 'messages.inbox' else 'Inbox'}"
         }
 
-        TailboneGrid.methods.moveMessagesSubmit = function() {
+        ${grid.vue_component}.methods.moveMessagesSubmit = function() {
             this.moveMessagesSubmitting = true
             this.moveMessagesText = "Working, please wait..."
         }
@@ -46,6 +46,3 @@
       </script>
   % endif
 </%def>
-
-
-${parent.body()}
diff --git a/tailbone/templates/messages/view.mako b/tailbone/templates/messages/view.mako
index 2e2baa60..36418698 100644
--- a/tailbone/templates/messages/view.mako
+++ b/tailbone/templates/messages/view.mako
@@ -82,22 +82,19 @@
   </div>
 </%def>
 
-<%def name="modify_this_page_vars()">
-  ${parent.modify_this_page_vars()}
-  <script type="text/javascript">
+<%def name="modify_vue_vars()">
+  ${parent.modify_vue_vars()}
+  <script>
 
-    TailboneFormData.showingAllRecipients = false
+    ${form.vue_component}Data.showingAllRecipients = false
 
-    TailboneForm.methods.showMoreRecipients = function() {
+    ${form.vue_component}.methods.showMoreRecipients = function() {
         this.showingAllRecipients = true
     }
 
-    TailboneForm.methods.hideMoreRecipients = function() {
+    ${form.vue_component}.methods.hideMoreRecipients = function() {
         this.showingAllRecipients = false
     }
 
   </script>
 </%def>
-
-
-${parent.body()}
diff --git a/tailbone/templates/ordering/configure.mako b/tailbone/templates/ordering/configure.mako
new file mode 100644
index 00000000..dc505c42
--- /dev/null
+++ b/tailbone/templates/ordering/configure.mako
@@ -0,0 +1,74 @@
+## -*- coding: utf-8; -*-
+<%inherit file="/configure.mako" />
+
+<%def name="form_content()">
+
+  <h3 class="block is-size-3">Workflows</h3>
+  <div class="block" style="padding-left: 2rem;">
+
+    <p class="block">
+      Users can only choose from the workflows enabled below.
+    </p>
+
+    <b-field>
+      <b-checkbox name="rattail.batch.purchase.allow_ordering_from_scratch"
+                  v-model="simpleSettings['rattail.batch.purchase.allow_ordering_from_scratch']"
+                  native-value="true"
+                  @input="settingsNeedSaved = true">
+        From Scratch
+      </b-checkbox>
+    </b-field>
+
+    <b-field>
+      <b-checkbox name="rattail.batch.purchase.allow_ordering_from_file"
+                  v-model="simpleSettings['rattail.batch.purchase.allow_ordering_from_file']"
+                  native-value="true"
+                  @input="settingsNeedSaved = true">
+        From Order File
+      </b-checkbox>
+    </b-field>
+
+  </div>
+
+  <h3 class="block is-size-3">Vendors</h3>
+  <div class="block" style="padding-left: 2rem;">
+
+    <b-field message="If not set, user must choose a &quot;supported&quot; vendor.">
+      <b-checkbox name="rattail.batch.purchase.allow_ordering_any_vendor"
+                  v-model="simpleSettings['rattail.batch.purchase.allow_ordering_any_vendor']"
+                  native-value="true"
+                  @input="settingsNeedSaved = true">
+        Allow ordering for <span class="has-text-weight-bold">any</span> vendor
+      </b-checkbox>
+    </b-field>
+
+  </div>
+
+  <h3 class="block is-size-3">Order Parsers</h3>
+  <div class="block" style="padding-left: 2rem;">
+
+    <p class="block">
+      Only the selected file parsers will be exposed to users.
+    </p>
+
+    % for Parser in order_parsers:
+        <b-field message="${Parser.key}">
+          <b-checkbox name="order_parser_${Parser.key}"
+                      v-model="orderParsers['${Parser.key}']"
+                      native-value="true"
+                      @input="settingsNeedSaved = true">
+            ${Parser.title}
+          </b-checkbox>
+        </b-field>
+    % endfor
+
+  </div>
+
+</%def>
+
+<%def name="modify_vue_vars()">
+  ${parent.modify_vue_vars()}
+  <script>
+    ThisPageData.orderParsers = ${json.dumps(order_parsers_data)|n}
+  </script>
+</%def>
diff --git a/tailbone/templates/ordering/view.mako b/tailbone/templates/ordering/view.mako
index aed6fd75..34a6085f 100644
--- a/tailbone/templates/ordering/view.mako
+++ b/tailbone/templates/ordering/view.mako
@@ -21,8 +21,8 @@
   % endif
 </%def>
 
-<%def name="render_this_page_template()">
-  ${parent.render_this_page_template()}
+<%def name="render_vue_templates()">
+  ${parent.render_vue_templates()}
   % if not batch.executed and not batch.complete and master.has_perm('edit_row'):
       <script type="text/x-template" id="ordering-scanner-template">
         <div>
@@ -185,10 +185,10 @@
   % endif
 </%def>
 
-<%def name="modify_this_page_vars()">
-  ${parent.modify_this_page_vars()}
+<%def name="modify_vue_vars()">
+  ${parent.modify_vue_vars()}
   % if not batch.executed and not batch.complete and master.has_perm('edit_row'):
-      <script type="text/javascript">
+      <script>
 
         let OrderingScanner = {
             template: '#ordering-scanner-template',
@@ -204,7 +204,7 @@
                     saving: false,
 
                     ## TODO: should find a better way to handle CSRF token
-                    csrftoken: ${json.dumps(request.session.get_csrf_token() or request.session.new_csrf_token())|n},
+                    csrftoken: ${json.dumps(h.get_csrf_token(request))|n},
                 }
             },
             computed: {
@@ -408,16 +408,11 @@
   % endif
 </%def>
 
-<%def name="make_this_page_component()">
-  ${parent.make_this_page_component()}
+<%def name="make_vue_components()">
+  ${parent.make_vue_components()}
   % if not batch.executed and not batch.complete and master.has_perm('edit_row'):
-      <script type="text/javascript">
-
+      <script>
         Vue.component('ordering-scanner', OrderingScanner)
-
       </script>
   % endif
 </%def>
-
-
-${parent.body()}
diff --git a/tailbone/templates/ordering/worksheet.mako b/tailbone/templates/ordering/worksheet.mako
index e41fe15f..eb2077e7 100644
--- a/tailbone/templates/ordering/worksheet.mako
+++ b/tailbone/templates/ordering/worksheet.mako
@@ -73,7 +73,7 @@
   <div class="grid">
     <table class="order-form">
       <% column_count = 8 + len(header_columns) + (0 if ignore_cases else 1) + int(capture(self.extra_count)) %>
-      % for department in sorted(six.itervalues(departments), key=lambda d: d.name if d else ''):
+      % for department in sorted(departments.values(), key=lambda d: d.name if d else ''):
           <thead>
             <tr>
               <th class="department" colspan="${column_count}">Department
@@ -84,7 +84,7 @@
                 % endif
               </th>
             </tr>
-            % for subdepartment in sorted(six.itervalues(department._order_subdepartments), key=lambda s: s.name if s else ''):
+            % for subdepartment in sorted(department._order_subdepartments.values(), key=lambda s: s.name if s else ''):
                 <tr>
                   <th class="subdepartment" colspan="${column_count}">Subdepartment
                     % if subdepartment.number or subdepartment.name:
@@ -199,9 +199,8 @@
   <ordering-worksheet></ordering-worksheet>
 </%def>
 
-<%def name="render_this_page_template()">
-  ${parent.render_this_page_template()}
-
+<%def name="render_vue_templates()">
+  ${parent.render_vue_templates()}
   <script type="text/x-template" id="ordering-worksheet-template">
     <div>
       <div class="form-wrapper">
@@ -239,11 +238,7 @@
       ${self.order_form_grid()}
     </div>
   </script>
-</%def>
-
-<%def name="make_this_page_component()">
-  ${parent.make_this_page_component()}
-  <script type="text/javascript">
+  <script>
 
     const OrderingWorksheet = {
         template: '#ordering-worksheet-template',
@@ -255,7 +250,7 @@
                 submitting: false,
 
                 ## TODO: should find a better way to handle CSRF token
-                csrftoken: ${json.dumps(request.session.get_csrf_token() or request.session.new_csrf_token())|n},
+                csrftoken: ${json.dumps(h.get_csrf_token(request))|n},
             }
         },
         methods: {
@@ -298,14 +293,12 @@
         },
     }
 
-    Vue.component('ordering-worksheet', OrderingWorksheet)
-
   </script>
 </%def>
 
-
-##############################
-## page body
-##############################
-
-${parent.body()}
+<%def name="make_vue_components()">
+  ${parent.make_vue_components()}
+  <script>
+    Vue.component('ordering-worksheet', OrderingWorksheet)
+  </script>
+</%def>
diff --git a/tailbone/templates/page.mako b/tailbone/templates/page.mako
index 17d87c9a..43b0a266 100644
--- a/tailbone/templates/page.mako
+++ b/tailbone/templates/page.mako
@@ -1,42 +1,26 @@
 ## -*- coding: utf-8; -*-
 <%inherit file="/base.mako" />
 
-<%def name="context_menu_items()">
-  % if context_menu_list_items is not Undefined:
-      % for item in context_menu_list_items:
-          <li>${item}</li>
-      % endfor
-  % endif
+<%def name="render_vue_templates()">
+  ${parent.render_vue_templates()}
+  ${self.render_vue_template_this_page()}
 </%def>
 
-<%def name="page_content()"></%def>
-
-<%def name="render_this_page()">
-  <div style="display: flex;">
-
-    <div class="this-page-content" style="flex-grow: 1;">
-      ${self.page_content()}
-    </div>
-
-    <ul id="context-menu">
-      ${self.context_menu_items()}
-    </ul>
-
-  </div>
+<%def name="render_vue_template_this_page()">
+  ## DEPRECATED; called for back-compat
+  ${self.render_this_page_template()}
 </%def>
 
 <%def name="render_this_page_template()">
   <script type="text/x-template" id="this-page-template">
     <div>
+      ## DEPRECATED; called for back-compat
       ${self.render_this_page()}
     </div>
   </script>
-</%def>
+  <script>
 
-<%def name="declare_this_page_vars()">
-  <script type="text/javascript">
-
-    let ThisPage = {
+    const ThisPage = {
         template: '#this-page-template',
         mixins: [SimpleRequestMixin],
         props: {
@@ -52,37 +36,71 @@
         },
     }
 
-    let ThisPageData = {
+    const ThisPageData = {
         ## TODO: should find a better way to handle CSRF token
-        csrftoken: ${json.dumps(request.session.get_csrf_token() or request.session.new_csrf_token())|n},
+        csrftoken: ${json.dumps(h.get_csrf_token(request))|n},
     }
 
   </script>
 </%def>
 
-<%def name="modify_this_page_vars()">
-  ## NOTE: if you override this, must use <script> tags
+## DEPRECATED; remains for back-compat
+<%def name="render_this_page()">
+  <div style="display: flex;">
+
+    <div class="this-page-content" style="flex-grow: 1;">
+      ${self.page_content()}
+    </div>
+
+    ## DEPRECATED; remains for back-compat
+    <ul id="context-menu">
+      ${self.context_menu_items()}
+    </ul>
+  </div>
 </%def>
 
-<%def name="finalize_this_page_vars()">
-  ## NOTE: if you override this, must use <script> tags
+## nb. this is the canonical block for page content!
+<%def name="page_content()"></%def>
+
+## DEPRECATED; remains for back-compat
+<%def name="context_menu_items()">
+  % if context_menu_list_items is not Undefined:
+      % for item in context_menu_list_items:
+          <li>${item}</li>
+      % endfor
+  % endif
+</%def>
+
+<%def name="modify_vue_vars()">
+  ${parent.modify_vue_vars()}
+
+  ## DEPRECATED; called for back-compat
+  ${self.declare_this_page_vars()}
+  ${self.modify_this_page_vars()}
+</%def>
+
+<%def name="make_vue_components()">
+  ${parent.make_vue_components()}
+
+  ## DEPRECATED; called for back-compat
+  ${self.make_this_page_component()}
 </%def>
 
 <%def name="make_this_page_component()">
-  ${self.declare_this_page_vars()}
-  ${self.modify_this_page_vars()}
   ${self.finalize_this_page_vars()}
-
-  <script type="text/javascript">
-
+  <script>
     ThisPage.data = function() { return ThisPageData }
-
     Vue.component('this-page', ThisPage)
     <% request.register_component('this-page', 'ThisPage') %>
-
   </script>
 </%def>
 
+##############################
+## DEPRECATED
+##############################
 
-${self.render_this_page_template()}
-${self.make_this_page_component()}
+<%def name="declare_this_page_vars()"></%def>
+
+<%def name="modify_this_page_vars()"></%def>
+
+<%def name="finalize_this_page_vars()"></%def>
diff --git a/tailbone/templates/people/configure.mako b/tailbone/templates/people/configure.mako
index 9e6ce5fb..257432dc 100644
--- a/tailbone/templates/people/configure.mako
+++ b/tailbone/templates/people/configure.mako
@@ -4,7 +4,7 @@
 <%def name="form_content()">
 
   <h3 class="block is-size-3">General</h3>
-  <div class="block" style="padding-left: 2rem;">
+  <div class="block" style="padding-left: 2rem; width: 50%;">
 
     <b-field message="If set, grid links are to Personal tab of Profile view.">
       <b-checkbox name="rattail.people.straight_to_profile"
@@ -28,8 +28,30 @@
              message="Leave blank for default handler.">
       <b-input name="rattail.people.handler"
                v-model="simpleSettings['rattail.people.handler']"
-               @input="settingsNeedSaved = true">
-      </b-input>
+               @input="settingsNeedSaved = true"
+               expanded />
+    </b-field>
+
+  </div>
+
+  <h3 class="block is-size-3">Profile View</h3>
+  <div class="block" style="padding-left: 2rem; width: 50%;">
+
+    <b-field>
+      <b-checkbox name="tailbone.people.profile.expose_members"
+                  v-model="simpleSettings['tailbone.people.profile.expose_members']"
+                  native-value="true"
+                  @input="settingsNeedSaved = true">
+        Show tab for Member Accounts
+      </b-checkbox>
+    </b-field>
+    <b-field>
+      <b-checkbox name="tailbone.people.profile.expose_transactions"
+                  v-model="simpleSettings['tailbone.people.profile.expose_transactions']"
+                  native-value="true"
+                  @input="settingsNeedSaved = true">
+        Show tab for Customer POS Transactions
+      </b-checkbox>
     </b-field>
 
   </div>
diff --git a/tailbone/templates/people/index.mako b/tailbone/templates/people/index.mako
index c819050a..cd6fddf1 100644
--- a/tailbone/templates/people/index.mako
+++ b/tailbone/templates/people/index.mako
@@ -3,7 +3,7 @@
 
 <%def name="grid_tools()">
 
-  % if master.mergeable and master.has_perm('request_merge'):
+  % if getattr(master, 'mergeable', False) and master.has_perm('request_merge'):
       <b-button @click="showMergeRequest()"
                 icon-pack="fas"
                 icon-left="object-ungroup"
@@ -61,37 +61,37 @@
   ${parent.grid_tools()}
 </%def>
 
-<%def name="modify_this_page_vars()">
-  ${parent.modify_this_page_vars()}
-  <script type="text/javascript">
+<%def name="modify_vue_vars()">
+  ${parent.modify_vue_vars()}
+  <script>
 
-    % if master.mergeable and master.has_perm('request_merge'):
+    % if getattr(master, 'mergeable', False) and master.has_perm('request_merge'):
 
-        ${grid.component_studly}Data.mergeRequestShowDialog = false
-        ${grid.component_studly}Data.mergeRequestRows = []
-        ${grid.component_studly}Data.mergeRequestSubmitText = "Submit Merge Request"
-        ${grid.component_studly}Data.mergeRequestSubmitting = false
+        ${grid.vue_component}Data.mergeRequestShowDialog = false
+        ${grid.vue_component}Data.mergeRequestRows = []
+        ${grid.vue_component}Data.mergeRequestSubmitText = "Submit Merge Request"
+        ${grid.vue_component}Data.mergeRequestSubmitting = false
 
-        ${grid.component_studly}.computed.mergeRequestRemovingUUID = function() {
+        ${grid.vue_component}.computed.mergeRequestRemovingUUID = function() {
             if (this.mergeRequestRows.length) {
                 return this.mergeRequestRows[0].uuid
             }
             return null
         }
 
-        ${grid.component_studly}.computed.mergeRequestKeepingUUID = function() {
+        ${grid.vue_component}.computed.mergeRequestKeepingUUID = function() {
             if (this.mergeRequestRows.length) {
                 return this.mergeRequestRows[1].uuid
             }
             return null
         }
 
-        ${grid.component_studly}.methods.showMergeRequest = function() {
+        ${grid.vue_component}.methods.showMergeRequest = function() {
             this.mergeRequestRows = this.checkedRows
             this.mergeRequestShowDialog = true
         }
 
-        ${grid.component_studly}.methods.submitMergeRequest = function() {
+        ${grid.vue_component}.methods.submitMergeRequest = function() {
             this.mergeRequestSubmitting = true
             this.mergeRequestSubmitText = "Working, please wait..."
         }
@@ -100,5 +100,3 @@
 
   </script>
 </%def>
-
-${parent.body()}
diff --git a/tailbone/templates/people/merge-requests/view.mako b/tailbone/templates/people/merge-requests/view.mako
index 9e8905cf..e2db1476 100644
--- a/tailbone/templates/people/merge-requests/view.mako
+++ b/tailbone/templates/people/merge-requests/view.mako
@@ -18,10 +18,10 @@
   % endif
 </%def>
 
-<%def name="modify_this_page_vars()">
-  ${parent.modify_this_page_vars()}
+<%def name="modify_vue_vars()">
+  ${parent.modify_vue_vars()}
   % if not instance.merged and request.has_perm('people.merge'):
-      <script type="text/javascript">
+      <script>
 
         ThisPageData.mergeFormButtonText = "Perform Merge"
         ThisPageData.mergeFormSubmitting = false
@@ -34,5 +34,3 @@
       </script>
   % endif
 </%def>
-
-${parent.body()}
diff --git a/tailbone/templates/people/view.mako b/tailbone/templates/people/view.mako
index 184f2b91..15c669fa 100644
--- a/tailbone/templates/people/view.mako
+++ b/tailbone/templates/people/view.mako
@@ -2,34 +2,6 @@
 <%inherit file="/master/view.mako" />
 <%namespace file="/util.mako" import="view_profiles_helper" />
 
-<%def name="object_helpers()">
-  ${parent.object_helpers()}
-  ${view_profiles_helper([instance])}
-</%def>
-
-<%def name="render_form()">
-  <div class="form">
-    <tailbone-form v-on:make-user="makeUser"></tailbone-form>
-  </div>
-</%def>
-
-<%def name="modify_this_page_vars()">
-  ${parent.modify_this_page_vars()}
-  <script type="text/javascript">
-
-    TailboneForm.methods.clickMakeUser = function(event) {
-        this.$emit('make-user')
-    }
-
-    ThisPage.methods.makeUser = function(event) {
-        if (confirm("Really make a user account for this person?")) {
-            this.$refs.makeUserForm.submit()
-        }
-    }
-
-  </script>
-</%def>
-
 <%def name="page_content()">
   ${parent.page_content()}
   % if not instance.users and request.has_perm('users.create'):
@@ -40,6 +12,30 @@
   % endif
 </%def>
 
+<%def name="object_helpers()">
+  ${parent.object_helpers()}
+  ${view_profiles_helper([instance])}
+</%def>
 
-${parent.body()}
+<%def name="render_form()">
+  <div class="form">
+    <${form.vue_tagname} v-on:make-user="makeUser"></${form.vue_tagname}>
+  </div>
+</%def>
 
+<%def name="modify_vue_vars()">
+  ${parent.modify_vue_vars()}
+  <script>
+
+    ${form.vue_component}.methods.clickMakeUser = function(event) {
+        this.$emit('make-user')
+    }
+
+    ThisPage.methods.makeUser = function(event) {
+        if (confirm("Really make a user account for this person?")) {
+            this.$refs.makeUserForm.submit()
+        }
+    }
+
+  </script>
+</%def>
diff --git a/tailbone/templates/people/view_profile.mako b/tailbone/templates/people/view_profile.mako
index 3520d924..6ca5a84c 100644
--- a/tailbone/templates/people/view_profile.mako
+++ b/tailbone/templates/people/view_profile.mako
@@ -15,7 +15,7 @@
 </%def>
 
 <%def name="content_title()">
-  ${dynamic_content_title}
+  ${dynamic_content_title or str(instance)}
 </%def>
 
 <%def name="render_instance_header_title_extras()">
@@ -461,72 +461,75 @@
 
         </${b}-table>
 
-        <${b}-modal has-modal-card
-                    % if request.use_oruga:
-                        v-model:active="deletePhoneShowDialog"
-                    % else:
-                        :active.sync="deletePhoneShowDialog"
-                    % endif
-                    >
-          <div class="modal-card">
+        % if request.has_perm('people_profile.edit_person'):
 
-            <header class="modal-card-head">
-              <p class="modal-card-title">Delete Phone</p>
-            </header>
+            <${b}-modal has-modal-card
+                        % if request.use_oruga:
+                            v-model:active="deletePhoneShowDialog"
+                        % else:
+                            :active.sync="deletePhoneShowDialog"
+                        % endif
+                        >
+              <div class="modal-card">
 
-            <section class="modal-card-body">
-              <p class="block">Really delete this phone number?</p>
-              <p class="block has-text-weight-bold">{{ deletePhoneNumber }}</p>
-            </section>
+                <header class="modal-card-head">
+                  <p class="modal-card-title">Delete Phone</p>
+                </header>
 
-            <footer class="modal-card-foot">
-              <b-button type="is-danger"
-                        @click="deletePhoneSave()"
-                        :disabled="deletePhoneSaving"
-                        icon-pack="fas"
-                        icon-left="trash">
-                {{ deletePhoneSaving ? "Working, please wait..." : "Delete" }}
-              </b-button>
-              <b-button @click="deletePhoneShowDialog = false">
-                Cancel
-              </b-button>
-            </footer>
-          </div>
-        </${b}-modal>
+                <section class="modal-card-body">
+                  <p class="block">Really delete this phone number?</p>
+                  <p class="block has-text-weight-bold">{{ deletePhoneNumber }}</p>
+                </section>
 
-        <${b}-modal has-modal-card
-                    % if request.use_oruga:
-                        v-model:active="preferPhoneShowDialog"
-                    % else:
-                        :active.sync="preferPhoneShowDialog"
-                    % endif
-                    >
-          <div class="modal-card">
+                <footer class="modal-card-foot">
+                  <b-button type="is-danger"
+                            @click="deletePhoneSave()"
+                            :disabled="deletePhoneSaving"
+                            icon-pack="fas"
+                            icon-left="trash">
+                    {{ deletePhoneSaving ? "Working, please wait..." : "Delete" }}
+                  </b-button>
+                  <b-button @click="deletePhoneShowDialog = false">
+                    Cancel
+                  </b-button>
+                </footer>
+              </div>
+            </${b}-modal>
 
-            <header class="modal-card-head">
-              <p class="modal-card-title">Set Preferred Phone</p>
-            </header>
+            <${b}-modal has-modal-card
+                        % if request.use_oruga:
+                            v-model:active="preferPhoneShowDialog"
+                        % else:
+                            :active.sync="preferPhoneShowDialog"
+                        % endif
+                        >
+              <div class="modal-card">
 
-            <section class="modal-card-body">
-              <p class="block">Really make this the preferred phone number?</p>
-              <p class="block has-text-weight-bold">{{ preferPhoneNumber }}</p>
-            </section>
+                <header class="modal-card-head">
+                  <p class="modal-card-title">Set Preferred Phone</p>
+                </header>
 
-            <footer class="modal-card-foot">
-              <b-button type="is-primary"
-                        @click="preferPhoneSave()"
-                        :disabled="preferPhoneSaving"
-                        icon-pack="fas"
-                        icon-left="save">
-                {{ preferPhoneSaving ? "Working, please wait..." : "Set Preferred" }}
-              </b-button>
-              <b-button @click="preferPhoneShowDialog = false">
-                Cancel
-              </b-button>
-            </footer>
-          </div>
-        </${b}-modal>
+                <section class="modal-card-body">
+                  <p class="block">Really make this the preferred phone number?</p>
+                  <p class="block has-text-weight-bold">{{ preferPhoneNumber }}</p>
+                </section>
 
+                <footer class="modal-card-foot">
+                  <b-button type="is-primary"
+                            @click="preferPhoneSave()"
+                            :disabled="preferPhoneSaving"
+                            icon-pack="fas"
+                            icon-left="save">
+                    {{ preferPhoneSaving ? "Working, please wait..." : "Set Preferred" }}
+                  </b-button>
+                  <b-button @click="preferPhoneShowDialog = false">
+                    Cancel
+                  </b-button>
+                </footer>
+              </div>
+            </${b}-modal>
+
+        % endif
       </div>
     </div>
   </div>
@@ -694,72 +697,75 @@
 
         </${b}-table>
 
-        <${b}-modal has-modal-card
-                    % if request.use_oruga:
-                        v-model:active="deleteEmailShowDialog"
-                    % else:
-                        :active.sync="deleteEmailShowDialog"
-                    % endif
-                    >
-          <div class="modal-card">
+        % if request.has_perm('people_profile.edit_person'):
 
-            <header class="modal-card-head">
-              <p class="modal-card-title">Delete Email</p>
-            </header>
+            <${b}-modal has-modal-card
+                        % if request.use_oruga:
+                            v-model:active="deleteEmailShowDialog"
+                        % else:
+                            :active.sync="deleteEmailShowDialog"
+                        % endif
+                        >
+              <div class="modal-card">
 
-            <section class="modal-card-body">
-              <p class="block">Really delete this email address?</p>
-              <p class="block has-text-weight-bold">{{ deleteEmailAddress }}</p>
-            </section>
+                <header class="modal-card-head">
+                  <p class="modal-card-title">Delete Email</p>
+                </header>
 
-            <footer class="modal-card-foot">
-              <b-button type="is-danger"
-                        @click="deleteEmailSave()"
-                        :disabled="deleteEmailSaving"
-                        icon-pack="fas"
-                        icon-left="trash">
-                {{ deleteEmailSaving ? "Working, please wait..." : "Delete" }}
-              </b-button>
-              <b-button @click="deleteEmailShowDialog = false">
-                Cancel
-              </b-button>
-            </footer>
-          </div>
-        </${b}-modal>
+                <section class="modal-card-body">
+                  <p class="block">Really delete this email address?</p>
+                  <p class="block has-text-weight-bold">{{ deleteEmailAddress }}</p>
+                </section>
 
-        <${b}-modal has-modal-card
-                    % if request.use_oruga:
-                        v-model:active="preferEmailShowDialog"
-                    % else:
-                        :active.sync="preferEmailShowDialog"
-                    % endif
-                    >
-          <div class="modal-card">
+                <footer class="modal-card-foot">
+                  <b-button type="is-danger"
+                            @click="deleteEmailSave()"
+                            :disabled="deleteEmailSaving"
+                            icon-pack="fas"
+                            icon-left="trash">
+                    {{ deleteEmailSaving ? "Working, please wait..." : "Delete" }}
+                  </b-button>
+                  <b-button @click="deleteEmailShowDialog = false">
+                    Cancel
+                  </b-button>
+                </footer>
+              </div>
+            </${b}-modal>
 
-            <header class="modal-card-head">
-              <p class="modal-card-title">Set Preferred Email</p>
-            </header>
+            <${b}-modal has-modal-card
+                        % if request.use_oruga:
+                            v-model:active="preferEmailShowDialog"
+                        % else:
+                            :active.sync="preferEmailShowDialog"
+                        % endif
+                        >
+              <div class="modal-card">
 
-            <section class="modal-card-body">
-              <p class="block">Really make this the preferred email address?</p>
-              <p class="block has-text-weight-bold">{{ preferEmailAddress }}</p>
-            </section>
+                <header class="modal-card-head">
+                  <p class="modal-card-title">Set Preferred Email</p>
+                </header>
 
-            <footer class="modal-card-foot">
-              <b-button type="is-primary"
-                        @click="preferEmailSave()"
-                        :disabled="preferEmailSaving"
-                        icon-pack="fas"
-                        icon-left="save">
-                {{ preferEmailSaving ? "Working, please wait..." : "Set Preferred" }}
-              </b-button>
-              <b-button @click="preferEmailShowDialog = false">
-                Cancel
-              </b-button>
-            </footer>
-          </div>
-        </${b}-modal>
+                <section class="modal-card-body">
+                  <p class="block">Really make this the preferred email address?</p>
+                  <p class="block has-text-weight-bold">{{ preferEmailAddress }}</p>
+                </section>
 
+                <footer class="modal-card-foot">
+                  <b-button type="is-primary"
+                            @click="preferEmailSave()"
+                            :disabled="preferEmailSaving"
+                            icon-pack="fas"
+                            icon-left="save">
+                    {{ preferEmailSaving ? "Working, please wait..." : "Set Preferred" }}
+                  </b-button>
+                  <b-button @click="preferEmailShowDialog = false">
+                    Cancel
+                  </b-button>
+                </footer>
+              </div>
+            </${b}-modal>
+
+        % endif
       </div>
     </div>
   </div>
@@ -813,6 +819,7 @@
   </${b}-tab-item>
 </%def>
 
+% if expose_members:
 <%def name="render_member_tab_template()">
   <script type="text/x-template" id="member-tab-template">
     <div>
@@ -830,20 +837,35 @@
             </div>
 
             <br />
-            <b-collapse v-for="member in members"
-                        :key="member.uuid"
-                        class="panel"
-                        :open="members.length == 1">
+            <${b}-collapse v-for="member in members"
+                           :key="member.uuid"
+                           class="panel"
+                           :open="members.length == 1">
 
-              <div slot="trigger"
-                   slot-scope="props"
-                   class="panel-heading"
-                   role="button">
-                <b-icon pack="fas"
-                        icon="caret-right">
-                </b-icon>
-                <strong>{{ member._key }} - {{ member.display }}</strong>
-              </div>
+              <template #trigger="props">
+                <div class="panel-heading"
+                     role="button"
+                     style="cursor: pointer;">
+
+                  ## TODO: for some reason buefy will "reuse" the icon
+                  ## element in such a way that its display does not
+                  ## refresh.  so to work around that, we use different
+                  ## structure for the two icons, so buefy is forced to
+                  ## re-draw
+
+                  <b-icon v-if="props.open"
+                          pack="fas"
+                          icon="caret-down" />
+
+                  <span v-if="!props.open">
+                    <b-icon pack="fas"
+                            icon="caret-right" />
+                  </span>
+
+                  &nbsp;
+                  <strong>{{ member._key }} - {{ member.display }}</strong>
+                </div>
+              </template>
 
               <div class="panel-block">
                 <div style="display: flex; justify-content: space-between; width: 100%;">
@@ -911,7 +933,7 @@
                   </div>
                 </div>
               </div>
-            </b-collapse>
+            </${b}-collapse>
           </div>
 
           <div v-if="!members.length">
@@ -940,6 +962,7 @@
     </member-tab>
   </${b}-tab-item>
 </%def>
+% endif
 
 <%def name="render_customer_tab_template()">
   <script type="text/x-template" id="customer-tab-template">
@@ -951,26 +974,41 @@
         </div>
 
         <br />
-        <b-collapse v-for="customer in customers"
-                    :key="customer.uuid"
-                    class="panel"
-                    :open="customers.length == 1">
+        <${b}-collapse v-for="customer in customers"
+                       :key="customer.uuid"
+                       class="panel"
+                       :open="customers.length == 1">
 
-          <div slot="trigger"
-               slot-scope="props"
-               class="panel-heading"
-               role="button">
-            <b-icon pack="fas"
-                    icon="caret-right">
-            </b-icon>
-            <strong>{{ customer._key }} - {{ customer.name }}</strong>
-          </div>
+          <template #trigger="props">
+            <div class="panel-heading"
+                 role="button"
+                 style="cursor: pointer;">
+
+              ## TODO: for some reason buefy will "reuse" the icon
+              ## element in such a way that its display does not
+              ## refresh.  so to work around that, we use different
+              ## structure for the two icons, so buefy is forced to
+              ## re-draw
+
+              <b-icon v-if="props.open"
+                      pack="fas"
+                      icon="caret-down" />
+
+              <span v-if="!props.open">
+                <b-icon pack="fas"
+                        icon="caret-right" />
+              </span>
+
+              &nbsp;
+              <strong>{{ customer._key }} - {{ customer.name }}</strong>
+            </div>
+          </template>
 
           <div class="panel-block">
             <div style="display: flex; justify-content: space-between; width: 100%;">
               <div style="flex-grow: 1;">
 
-                <b-field horizontal label="${customer_key_label}">
+                <b-field horizontal label="${customer_key_label or 'TODO: Customer Key'}">
                   {{ customer._key }}
                 </b-field>
 
@@ -1039,7 +1077,7 @@
               </div>
             </div>
           </div>
-        </b-collapse>
+        </${b}-collapse>
       </div>
 
       <div v-if="!customers.length">
@@ -1268,141 +1306,151 @@
 
         </div>
 
-        <div>
-          <div class="buttons">
+        <div style="display: flex; gap: 0.75rem;">
 
-            % if request.has_perm('people_profile.toggle_employee'):
+          % if request.has_perm('people_profile.toggle_employee'):
 
-                <b-button v-if="!employee.current"
-                          type="is-primary"
-                          @click="startEmployeeInit()">
-                  ${person} is now an Employee
-                </b-button>
+              <b-button v-if="!employee.current"
+                        type="is-primary"
+                        @click="startEmployeeInit()">
+                ${person} is now an Employee
+              </b-button>
 
-                <b-button v-if="employee.current"
-                          type="is-primary"
-                          @click="stopEmployeeInit()">
-                  ${person} is no longer an Employee
-                </b-button>
+              <b-button v-if="employee.current"
+                        type="is-primary"
+                        @click="stopEmployeeInit()">
+                ${person} is no longer an Employee
+              </b-button>
 
-                <${b}-modal has-modal-card
-                            % if request.use_oruga:
-                                v-model:active="startEmployeeShowDialog"
-                            % else:
-                                :active.sync="startEmployeeShowDialog"
-                            % endif
-                            >
-                  <div class="modal-card">
+              <${b}-modal has-modal-card
+                          % if request.use_oruga:
+                              v-model:active="startEmployeeShowDialog"
+                          % else:
+                              :active.sync="startEmployeeShowDialog"
+                          % endif
+                          >
+                <div class="modal-card">
 
-                    <header class="modal-card-head">
-                      <p class="modal-card-title">Employee Start</p>
-                    </header>
+                  <header class="modal-card-head">
+                    <p class="modal-card-title">Employee Start</p>
+                  </header>
 
-                    <section class="modal-card-body">
-                      <b-field label="Employee Number">
-                        <b-input v-model="startEmployeeID"></b-input>
-                      </b-field>
-                      <b-field label="Start Date">
-                        <tailbone-datepicker v-model="startEmployeeStartDate"
-                                             ref="startEmployeeStartDate" />
-                      </b-field>
-                    </section>
+                  <section class="modal-card-body">
+                    <b-field label="Employee Number">
+                      <b-input v-model="startEmployeeID"></b-input>
+                    </b-field>
+                    <b-field label="Start Date">
+                      <tailbone-datepicker v-model="startEmployeeStartDate"
+                                           ref="startEmployeeStartDate" />
+                    </b-field>
+                  </section>
 
-                    <footer class="modal-card-foot">
-                      <b-button @click="startEmployeeShowDialog = false">
-                        Cancel
-                      </b-button>
-                      <b-button type="is-primary"
-                                @click="startEmployeeSave()"
-                                :disabled="startEmployeeSaveDisabled"
-                                icon-pack="fas"
-                                icon-left="save">
-                        {{ startEmployeeSaving ? "Working, please wait..." : "Save" }}
-                      </b-button>
-                    </footer>
-                  </div>
-                </${b}-modal>
+                  <footer class="modal-card-foot">
+                    <b-button @click="startEmployeeShowDialog = false">
+                      Cancel
+                    </b-button>
+                    <b-button type="is-primary"
+                              @click="startEmployeeSave()"
+                              :disabled="startEmployeeSaveDisabled"
+                              icon-pack="fas"
+                              icon-left="save">
+                      {{ startEmployeeSaving ? "Working, please wait..." : "Save" }}
+                    </b-button>
+                  </footer>
+                </div>
+              </${b}-modal>
 
-                <${b}-modal has-modal-card
-                            % if request.use_oruga:
-                                v-model:active="stopEmployeeShowDialog"
-                            % else:
-                                :active.sync="stopEmployeeShowDialog"
-                            % endif
-                            >
-                  <div class="modal-card">
+              <${b}-modal has-modal-card
+                          % if request.use_oruga:
+                              v-model:active="stopEmployeeShowDialog"
+                          % else:
+                              :active.sync="stopEmployeeShowDialog"
+                          % endif
+                          >
+                <div class="modal-card">
 
-                    <header class="modal-card-head">
-                      <p class="modal-card-title">Employee End</p>
-                    </header>
+                  <header class="modal-card-head">
+                    <p class="modal-card-title">Employee End</p>
+                  </header>
 
-                    <section class="modal-card-body">
-                      <b-field label="End Date"
-                               :type="stopEmployeeEndDate ? null : 'is-danger'">
-                        <tailbone-datepicker v-model="stopEmployeeEndDate"></tailbone-datepicker>
-                      </b-field>
-                      <b-field label="Revoke Internal App Access">
-                        <b-checkbox v-model="stopEmployeeRevokeAccess">
-                        </b-checkbox>
-                      </b-field>
-                    </section>
+                  <section class="modal-card-body">
+                    <b-field label="End Date"
+                             :type="stopEmployeeEndDate ? null : 'is-danger'">
+                      <tailbone-datepicker v-model="stopEmployeeEndDate"></tailbone-datepicker>
+                    </b-field>
+                    <b-field label="Revoke Internal App Access">
+                      <b-checkbox v-model="stopEmployeeRevokeAccess">
+                      </b-checkbox>
+                    </b-field>
+                  </section>
 
-                    <footer class="modal-card-foot">
-                      <b-button @click="stopEmployeeShowDialog = false">
-                        Cancel
-                      </b-button>
-                      <b-button type="is-primary"
-                                @click="stopEmployeeSave()"
-                                :disabled="stopEmployeeSaveDisabled"
-                                icon-pack="fas"
-                                icon-left="save">
-                        {{ stopEmployeeSaving ? "Working, please wait..." : "Save" }}
-                      </b-button>
-                    </footer>
-                  </div>
-                </${b}-modal>
-            % endif
+                  <footer class="modal-card-foot">
+                    <b-button @click="stopEmployeeShowDialog = false">
+                      Cancel
+                    </b-button>
+                    <b-button type="is-primary"
+                              @click="stopEmployeeSave()"
+                              :disabled="stopEmployeeSaveDisabled"
+                              icon-pack="fas"
+                              icon-left="save">
+                      {{ stopEmployeeSaving ? "Working, please wait..." : "Save" }}
+                    </b-button>
+                  </footer>
+                </div>
+              </${b}-modal>
+          % endif
 
-            % if request.has_perm('people_profile.edit_employee_history'):
-                <${b}-modal has-modal-card
-                            % if request.use_oruga:
-                                v-model:active="editEmployeeHistoryShowDialog"
-                            % else:
-                                :active.sync="editEmployeeHistoryShowDialog"
-                            % endif
-                            >
-                  <div class="modal-card">
+          % if request.has_perm('people_profile.edit_employee_history'):
+              <${b}-modal has-modal-card
+                          % if request.use_oruga:
+                              v-model:active="editEmployeeHistoryShowDialog"
+                          % else:
+                              :active.sync="editEmployeeHistoryShowDialog"
+                          % endif
+                          >
+                <div class="modal-card">
 
-                    <header class="modal-card-head">
-                      <p class="modal-card-title">Edit Employee History</p>
-                    </header>
+                  <header class="modal-card-head">
+                    <p class="modal-card-title">Edit Employee History</p>
+                  </header>
 
-                    <section class="modal-card-body">
-                      <b-field label="Start Date">
-                        <tailbone-datepicker v-model="editEmployeeHistoryStartDate"></tailbone-datepicker>
-                      </b-field>
-                      <b-field label="End Date">
-                        <tailbone-datepicker v-model="editEmployeeHistoryEndDate"
-                                             :disabled="!editEmployeeHistoryEndDateRequired">
-                        </tailbone-datepicker>
-                      </b-field>
-                    </section>
+                  <section class="modal-card-body">
+                    <b-field label="Start Date">
+                      <tailbone-datepicker v-model="editEmployeeHistoryStartDate"></tailbone-datepicker>
+                    </b-field>
+                    <b-field label="End Date">
+                      <tailbone-datepicker v-model="editEmployeeHistoryEndDate"
+                                           :disabled="!editEmployeeHistoryEndDateRequired">
+                      </tailbone-datepicker>
+                    </b-field>
+                  </section>
 
-                    <footer class="modal-card-foot">
-                      <b-button @click="editEmployeeHistoryShowDialog = false">
-                        Cancel
-                      </b-button>
-                      <b-button type="is-primary"
-                                @click="editEmployeeHistorySave()"
-                                :disabled="editEmployeeHistorySaveDisabled"
-                                icon-pack="fas"
-                                icon-left="save">
-                        {{ editEmployeeHistorySaving ? "Working, please wait..." : "Save" }}
-                      </b-button>
-                    </footer>
-                  </div>
-                </${b}-modal>
-            % endif
+                  <footer class="modal-card-foot">
+                    <b-button @click="editEmployeeHistoryShowDialog = false">
+                      Cancel
+                    </b-button>
+                    <b-button type="is-primary"
+                              @click="editEmployeeHistorySave()"
+                              :disabled="editEmployeeHistorySaveDisabled"
+                              icon-pack="fas"
+                              icon-left="save">
+                      {{ editEmployeeHistorySaving ? "Working, please wait..." : "Save" }}
+                    </b-button>
+                  </footer>
+                </div>
+              </${b}-modal>
+          % endif
+
+          <div style="display: flex; flex-direction: column; align-items: right; gap: 0.75rem;">
+
+            <b-button v-for="link in employee.external_links"
+                      :key="link.url"
+                      type="is-primary"
+                      tag="a" :href="link.url" target="_blank"
+                      icon-pack="fas"
+                      icon-left="external-link-alt">
+              {{ link.label }}
+            </b-button>
 
             % if request.has_perm('employees.view'):
                 <b-button v-if="employee.view_url"
@@ -1610,6 +1658,34 @@
   </${b}-tab-item>
 </%def>
 
+% if expose_transactions:
+
+    <%def name="render_transactions_tab_template()">
+      <script type="text/x-template" id="transactions-tab-template">
+        <div>
+          <transactions-grid
+            ref="transactionsGrid"
+             />
+        </div>
+      </script>
+    </%def>
+
+    <%def name="render_transactions_tab()">
+      <${b}-tab-item label="Transactions"
+                     value="transactions"
+                     % if not request.use_oruga:
+                         icon-pack="fas"
+                     % endif
+                     icon="bars">
+        <transactions-tab ref="tab_transactions"
+                          :person="person"
+                          @profile-changed="profileChanged" />
+      </${b}-tab-item>
+    </%def>
+
+% endif
+
+
 <%def name="render_user_tab_template()">
   <script type="text/x-template" id="user-tab-template">
     <div>
@@ -1619,28 +1695,45 @@
         <br />
         <div id="users-accordion">
 
-          <b-collapse class="panel"
-                      v-for="user in users"
-                      :key="user.uuid">
+          <${b}-collapse v-for="user in users"
+                      :key="user.uuid"
+                      class="panel">
 
-            <div slot="trigger"
-                 class="panel-heading"
-                 role="button">
-              <strong>{{ user.username }}</strong>
-            </div>
+            <template #trigger="props">
+              <div class="panel-heading"
+                   role="button"
+                   style="cursor: pointer;">
+
+                ## TODO: for some reason buefy will "reuse" the icon
+                ## element in such a way that its display does not
+                ## refresh.  so to work around that, we use different
+                ## structure for the two icons, so buefy is forced to
+                ## re-draw
+
+                <b-icon v-if="props.open"
+                        pack="fas"
+                        icon="caret-down" />
+
+                <span v-if="!props.open">
+                  <b-icon pack="fas"
+                          icon="caret-right" />
+                </span>
+
+                &nbsp;
+                <strong>{{ user.username }}</strong>
+              </div>
+            </template>
 
             <div class="panel-block">
               <div style="display: flex; justify-content: space-between; width: 100%;">
 
-                <div>
-                  <div class="field-wrapper id">
-                    <div class="field-row">
-                      <label>Username</label>
-                      <div class="field">
-                        {{ user.username }}
-                      </div>
-                    </div>
-                  </div>
+                <div style="flex-grow: 1;">
+                  <b-field horizontal label="Username">
+                    {{ user.username }}
+                  </b-field>
+                  <b-field horizontal label="Active">
+                    {{ user.active ? "Yes" : "No" }}
+                  </b-field>
                 </div>
 
                 <div>
@@ -1653,13 +1746,66 @@
 
               </div>
             </div>
-          </b-collapse>
+          </${b}-collapse>
         </div>
       </div>
 
-      <div v-if="!users.length">
+      <div v-if="!users.length"
+           style="display: flex; justify-content: space-between;">
+
         <p>{{ person.display_name }} does not have a user account.</p>
+
+        % if request.has_perm('users.create'):
+            <b-button type="primary"
+                      icon-pack="fas"
+                      icon-left="plus"
+                      @click="createUserInit()">
+              Create User
+            </b-button>
+
+            <${b}-modal has-modal-card
+                        % if request.use_oruga:
+                            v-model:active="createUserShowDialog"
+                        % else:
+                            :active.sync="createUserShowDialog"
+                        % endif
+                        >
+              <div class="modal-card">
+
+                <header class="modal-card-head">
+                  <p class="modal-card-title">Create User</p>
+                </header>
+
+                <section class="modal-card-body">
+                  <b-field label="Person">
+                    <span>{{ person.display_name }}</span>
+                  </b-field>
+                  <b-field label="Username">
+                    <b-input v-model="createUserUsername"
+                             ref="username" />
+                  </b-field>
+                  <b-field label="Active">
+                    <b-checkbox v-model="createUserActive" />
+                  </b-field>
+                </section>
+
+                <footer class="modal-card-foot">
+                  <b-button @click="createUserShowDialog = false">
+                    Cancel
+                  </b-button>
+                  <b-button type="is-primary"
+                            @click="createUserSave()"
+                            :disabled="createUserSaveDisabled"
+                            icon-pack="fas"
+                            icon-left="save">
+                    {{ createUserSaving ? "Working, please wait..." : "Save" }}
+                  </b-button>
+                </footer>
+              </div>
+            </${b}-modal>
+        % endif
       </div>
+
       % if request.use_oruga:
           <o-loading v-model:active="refreshingTab" :full-page="false"></o-loading>
       % else:
@@ -1683,13 +1829,20 @@
 
 <%def name="render_profile_tabs()">
   ${self.render_personal_tab()}
-  ${self.render_member_tab()}
+
+  % if expose_members:
+      ${self.render_member_tab()}
+  % endif
+
   ${self.render_customer_tab()}
   % if expose_customer_shoppers:
       ${self.render_shopper_tab()}
   % endif
   ${self.render_employee_tab()}
   ${self.render_notes_tab()}
+  % if expose_transactions:
+      ${self.render_transactions_tab()}
+  % endif
   ${self.render_user_tab()}
 </%def>
 
@@ -1813,27 +1966,106 @@
 
     </div>
   </script>
-</%def>
+  <script>
 
-<%def name="render_this_page_template()">
-  ${parent.render_this_page_template()}
-  ${self.render_personal_tab_template()}
-  ${self.render_member_tab_template()}
-  ${self.render_customer_tab_template()}
-  % if expose_customer_shoppers:
-      ${self.render_shopper_tab_template()}
-  % endif
-  ${self.render_employee_tab_template()}
-  ${self.render_notes_tab_template()}
-  ${self.render_user_tab_template()}
-  ${self.render_profile_info_template()}
+    let ProfileInfoData = {
+        activeTab: location.hash ? location.hash.substring(1) : 'personal',
+        tabchecks: ${json.dumps(tabchecks or {})|n},
+        today: '${rattail_app.today()}',
+        profileLastChanged: Date.now(),
+        person: ${json.dumps(person_data or {})|n},
+        phoneTypeOptions: ${json.dumps(phone_type_options or [])|n},
+        emailTypeOptions: ${json.dumps(email_type_options or [])|n},
+        maxLengths: ${json.dumps(max_lengths or {})|n},
+
+        % if request.has_perm('people_profile.view_versions'):
+            loadingRevisions: false,
+            showingRevisionDialog: false,
+            revision: {},
+            revisionShowAllFields: false,
+        % endif
+    }
+
+    let ProfileInfo = {
+        template: '#profile-info-template',
+        props: {
+            % if request.has_perm('people_profile.view_versions'):
+                viewingHistory: Boolean,
+                gettingRevisions: Boolean,
+                revisions: Array,
+                revisionVersionMap: null,
+            % endif
+        },
+        computed: {},
+        mounted() {
+
+            // auto-refresh whichever tab is shown first
+            ## TODO: how to not assume 'personal' is the default tab?
+            let tab = this.$refs['tab_' + (this.activeTab || 'personal')]
+            if (tab && tab.refreshTab) {
+                tab.refreshTab()
+            }
+        },
+        methods: {
+
+            profileChanged(data) {
+                this.$emit('change-content-title', data.person.dynamic_content_title)
+                this.person = data.person
+                this.tabchecks = data.tabchecks
+                this.profileLastChanged = Date.now()
+            },
+
+            activeTabChanged(value) {
+                location.hash = value
+                this.refreshTabIfNeeded(value)
+                this.activeTabChangedExtra(value)
+            },
+
+            refreshTabIfNeeded(key) {
+                // TODO: this is *always* refreshing, should be more selective (?)
+                let tab = this.$refs['tab_' + key]
+                if (tab && tab.refreshIfNeeded) {
+                    tab.refreshIfNeeded(this.profileLastChanged)
+                }
+            },
+
+            activeTabChangedExtra(value) {},
+
+            % if request.has_perm('people_profile.view_versions'):
+
+                viewRevision(row) {
+                    this.revision = this.revisionVersionMap[row.txnid]
+                    this.showingRevisionDialog = true
+                },
+
+                viewPrevRevision() {
+                    let txnid = this.revision.prev_txnid
+                    this.revision = this.revisionVersionMap[txnid]
+                },
+
+                viewNextRevision() {
+                    let txnid = this.revision.next_txnid
+                    this.revision = this.revisionVersionMap[txnid]
+                },
+
+                toggleVersionFields() {
+                    this.revisionShowAllFields = !this.revisionShowAllFields
+                },
+
+            % endif
+        },
+    }
+
+  </script>
 </%def>
 
 <%def name="declare_personal_tab_vars()">
   <script type="text/javascript">
 
     let PersonalTabData = {
+        % if hasattr(master, 'profile_tab_personal'):
         refreshTabURL: '${url('people.profile_tab_personal', uuid=person.uuid)}',
+        % endif
 
         // nb. hack to force refresh for vue3
         refreshPersonalCard: 1,
@@ -2232,6 +2464,7 @@
   </script>
 </%def>
 
+% if expose_members:
 <%def name="declare_member_tab_vars()">
   <script type="text/javascript">
 
@@ -2277,12 +2510,15 @@
 
   </script>
 </%def>
+% endif
 
 <%def name="declare_customer_tab_vars()">
   <script type="text/javascript">
 
     let CustomerTabData = {
+        % if hasattr(master, 'profile_tab_customer'):
         refreshTabURL: '${url('people.profile_tab_customer', uuid=person.uuid)}',
+        % endif
         customers: [],
     }
 
@@ -2356,7 +2592,9 @@
   <script type="text/javascript">
 
     let EmployeeTabData = {
+        % if hasattr(master, 'profile_tab_employee'):
         refreshTabURL: '${url('people.profile_tab_employee', uuid=person.uuid)}',
+        % endif
         employee: {},
         employeeHistory: [],
 
@@ -2591,7 +2829,9 @@
   <script type="text/javascript">
 
     let NotesTabData = {
+        % if hasattr(master, 'profile_tab_notes'):
         refreshTabURL: '${url('people.profile_tab_notes', uuid=person.uuid)}',
+        % endif
         notes: [],
         noteTypeOptions: [],
 
@@ -2708,12 +2948,64 @@
   </script>
 </%def>
 
+% if expose_transactions:
+
+    <%def name="declare_transactions_tab_vars()">
+      <script type="text/javascript">
+
+        let TransactionsTabData = {}
+
+        let TransactionsTab = {
+            template: '#transactions-tab-template',
+            mixins: [TabMixin, SimpleRequestMixin],
+            props: {
+                person: Object,
+            },
+            computed: {},
+            methods: {
+
+                // nb. we override this completely, just tell the grid to refresh
+                refreshTab() {
+                    this.refreshingTab = true
+                    this.$refs.transactionsGrid.loadAsyncData(null, () => {
+                        this.refreshed = Date.now()
+                        this.refreshingTab = false
+                    })
+                }
+            },
+        }
+
+      </script>
+    </%def>
+
+    <%def name="make_transactions_tab_component()">
+      ${self.declare_transactions_tab_vars()}
+      <script type="text/javascript">
+
+        TransactionsTab.data = function() { return TransactionsTabData }
+        Vue.component('transactions-tab', TransactionsTab)
+        <% request.register_component('transactions-tab', 'TransactionsTab') %>
+
+      </script>
+    </%def>
+
+% endif
+
 <%def name="declare_user_tab_vars()">
   <script type="text/javascript">
 
     let UserTabData = {
+        % if hasattr(master, 'profile_tab_user'):
         refreshTabURL: '${url('people.profile_tab_user', uuid=person.uuid)}',
+        % endif
         users: [],
+
+        % if request.has_perm('users.create'):
+            createUserShowDialog: false,
+            createUserUsername: null,
+            createUserActive: false,
+            createUserSaving: false,
+        % endif
     }
 
     let UserTab = {
@@ -2722,12 +3014,64 @@
         props: {
             person: Object,
         },
-        computed: {},
+
+        computed: {
+
+            % if request.has_perm('users.create'):
+
+                createUserSaveDisabled() {
+                    if (this.createUserSaving) {
+                        return true
+                    }
+                    if (!this.createUserUsername) {
+                        return true
+                    }
+                    return false
+                },
+
+            % endif
+        },
+
         methods: {
 
             refreshTabSuccess(response) {
                 this.users = response.data.users
+                this.createUserSuggestedUsername = response.data.suggested_username
             },
+
+            % if request.has_perm('users.create'):
+
+                createUserInit() {
+                    this.createUserUsername = this.createUserSuggestedUsername
+                    this.createUserActive = true
+                    this.createUserShowDialog = true
+                    this.$nextTick(() => {
+                        this.$refs.username.focus()
+                    })
+                },
+
+                createUserSave() {
+                    this.createUserSaving = true
+
+                    % if hasattr(master, 'profile_make_user'):
+                    let url = '${master.get_action_url('profile_make_user', instance)}'
+                    % endif
+                    let params = {
+                        username: this.createUserUsername,
+                        active: this.createUserActive,
+                    }
+
+                    this.simplePOST(url, params, response => {
+                        this.$emit('profile-changed', response.data)
+                        this.createUserSaving = false
+                        this.createUserShowDialog = false
+                        this.refreshTab()
+                    }, response => {
+                        this.createUserSaving = false
+                    })
+                },
+
+            % endif
         },
     }
 
@@ -2745,114 +3089,46 @@
   </script>
 </%def>
 
-<%def name="declare_profile_info_vars()">
-  <script type="text/javascript">
-
-    let ProfileInfoData = {
-        activeTab: location.hash ? location.hash.substring(1) : 'personal',
-        tabchecks: ${json.dumps(tabchecks)|n},
-        today: '${rattail_app.today()}',
-        profileLastChanged: Date.now(),
-        person: ${json.dumps(person_data)|n},
-        phoneTypeOptions: ${json.dumps(phone_type_options)|n},
-        emailTypeOptions: ${json.dumps(email_type_options)|n},
-        maxLengths: ${json.dumps(max_lengths)|n},
-
-        % if request.has_perm('people_profile.view_versions'):
-            loadingRevisions: false,
-            showingRevisionDialog: false,
-            revision: {},
-            revisionShowAllFields: false,
-        % endif
-    }
-
-    let ProfileInfo = {
-        template: '#profile-info-template',
-        props: {
-            % if request.has_perm('people_profile.view_versions'):
-                viewingHistory: Boolean,
-                gettingRevisions: Boolean,
-                revisions: Array,
-                revisionVersionMap: null,
-            % endif
-        },
-        computed: {},
-        mounted() {
-
-            // auto-refresh whichever tab is shown first
-            ## TODO: how to not assume 'personal' is the default tab?
-            let tab = this.$refs['tab_' + (this.activeTab || 'personal')]
-            if (tab && tab.refreshTab) {
-                tab.refreshTab()
-            }
-        },
-        methods: {
-
-            profileChanged(data) {
-                this.$emit('change-content-title', data.person.dynamic_content_title)
-                this.person = data.person
-                this.tabchecks = data.tabchecks
-                this.profileLastChanged = Date.now()
-            },
-
-            activeTabChanged(value) {
-                location.hash = value
-                this.refreshTabIfNeeded(value)
-                this.activeTabChangedExtra(value)
-            },
-
-            refreshTabIfNeeded(key) {
-                // TODO: this is *always* refreshing, should be more selective (?)
-                let tab = this.$refs['tab_' + key]
-                if (tab && tab.refreshIfNeeded) {
-                    tab.refreshIfNeeded(this.profileLastChanged)
-                }
-            },
-
-            activeTabChangedExtra(value) {},
-
-            % if request.has_perm('people_profile.view_versions'):
-
-                viewRevision(row) {
-                    this.revision = this.revisionVersionMap[row.txnid]
-                    this.showingRevisionDialog = true
-                },
-
-                viewPrevRevision() {
-                    let txnid = this.revision.prev_txnid
-                    this.revision = this.revisionVersionMap[txnid]
-                },
-
-                viewNextRevision() {
-                    let txnid = this.revision.next_txnid
-                    this.revision = this.revisionVersionMap[txnid]
-                },
-
-                toggleVersionFields() {
-                    this.revisionShowAllFields = !this.revisionShowAllFields
-                },
-
-            % endif
-        },
-    }
-
-  </script>
-</%def>
-
 <%def name="make_profile_info_component()">
-  ${self.declare_profile_info_vars()}
-  <script type="text/javascript">
 
+  ## DEPRECATED; called for back-compat
+  ${self.declare_profile_info_vars()}
+
+  <script>
     ProfileInfo.data = function() { return ProfileInfoData }
     Vue.component('profile-info', ProfileInfo)
     <% request.register_component('profile-info', 'ProfileInfo') %>
-
   </script>
 </%def>
 
-<%def name="modify_this_page_vars()">
-  ${parent.modify_this_page_vars()}
-  <script type="text/javascript">
+<%def name="render_vue_templates()">
+  ${parent.render_vue_templates()}
+
+  ${self.render_personal_tab_template()}
+
+  % if expose_members:
+      ${self.render_member_tab_template()}
+  % endif
+
+  ${self.render_customer_tab_template()}
+  % if expose_customer_shoppers:
+      ${self.render_shopper_tab_template()}
+  % endif
+  ${self.render_employee_tab_template()}
+  ${self.render_notes_tab_template()}
+
+  % if expose_transactions:
+      ${transactions_grid.render_complete(allow_save_defaults=False)|n}
+      ${self.render_transactions_tab_template()}
+  % endif
+
+  ${self.render_user_tab_template()}
+  ${self.render_profile_info_template()}
+</%def>
+
+<%def name="modify_vue_vars()">
+  ${parent.modify_vue_vars()}
+  <script>
 
     % if request.has_perm('people_profile.view_versions'):
         ThisPage.props.viewingHistory = Boolean
@@ -2900,28 +3176,8 @@
         },
     }
 
-  </script>
-</%def>
 
-<%def name="make_this_page_component()">
-  ${parent.make_this_page_component()}
-  ${self.make_personal_tab_component()}
-  ${self.make_member_tab_component()}
-  ${self.make_customer_tab_component()}
-  % if expose_customer_shoppers:
-      ${self.make_shopper_tab_component()}
-  % endif
-  ${self.make_employee_tab_component()}
-  ${self.make_notes_tab_component()}
-  ${self.make_user_tab_component()}
-  ${self.make_profile_info_component()}
-</%def>
-
-<%def name="modify_whole_page_vars()">
-  ${parent.modify_whole_page_vars()}
-
-  % if request.has_perm('people_profile.view_versions'):
-      <script type="text/javascript">
+    % if request.has_perm('people_profile.view_versions'):
 
         WholePageData.viewingHistory = false
         WholePageData.gettingRevisions = false
@@ -2957,9 +3213,44 @@
             })
         }
 
-      </script>
-  % endif
+    % endif
+  </script>
 </%def>
 
+<%def name="make_vue_components()">
+  ${parent.make_vue_components()}
 
-${parent.body()}
+  ${self.make_personal_tab_component()}
+
+  % if expose_members:
+      ${self.make_member_tab_component()}
+  % endif
+
+  ${self.make_customer_tab_component()}
+  % if expose_customer_shoppers:
+      ${self.make_shopper_tab_component()}
+  % endif
+  ${self.make_employee_tab_component()}
+  ${self.make_notes_tab_component()}
+
+  % if expose_transactions:
+      <script type="text/javascript">
+
+        TransactionsGrid.data = function() { return TransactionsGridData }
+        Vue.component('transactions-grid', TransactionsGrid)
+        ## TODO: why is this line not needed?
+        ## <% request.register_component('transactions-grid', 'TransactionsGrid') %>
+
+      </script>
+      ${self.make_transactions_tab_component()}
+  % endif
+
+  ${self.make_user_tab_component()}
+  ${self.make_profile_info_component()}
+</%def>
+
+##############################
+## DEPRECATED
+##############################
+
+<%def name="declare_profile_info_vars()"></%def>
diff --git a/tailbone/templates/poser/reports/view.mako b/tailbone/templates/poser/reports/view.mako
index aac0c7ae..cb8b51aa 100644
--- a/tailbone/templates/poser/reports/view.mako
+++ b/tailbone/templates/poser/reports/view.mako
@@ -62,19 +62,13 @@
   <br />
 </%def>
 
-<%def name="modify_this_page_vars()">
-  ${parent.modify_this_page_vars()}
+<%def name="modify_vue_vars()">
+  ${parent.modify_vue_vars()}
   % if master.has_perm('replace'):
-  <script type="text/javascript">
-
-    ${form.component_studly}Data.showUploadForm = false
-
-    ${form.component_studly}Data.uploadFile = null
-
-    ${form.component_studly}Data.uploadSubmitting = false
-
-  </script>
+      <script>
+        ${form.vue_component}Data.showUploadForm = false
+        ${form.vue_component}Data.uploadFile = null
+        ${form.vue_component}Data.uploadSubmitting = false
+      </script>
   % endif
 </%def>
-
-${parent.body()}
diff --git a/tailbone/templates/poser/setup.mako b/tailbone/templates/poser/setup.mako
index 8d01bb33..239e7db2 100644
--- a/tailbone/templates/poser/setup.mako
+++ b/tailbone/templates/poser/setup.mako
@@ -118,14 +118,9 @@
   % endif
 </%def>
 
-<%def name="modify_this_page_vars()">
-  ${parent.modify_this_page_vars()}
-  <script type="text/javascript">
-
+<%def name="modify_vue_vars()">
+  ${parent.modify_vue_vars()}
+  <script>
     ThisPageData.setupSubmitting = false
-
   </script>
 </%def>
-
-
-${parent.body()}
diff --git a/tailbone/templates/poser/views/configure.mako b/tailbone/templates/poser/views/configure.mako
index f4d75779..cdde15c5 100644
--- a/tailbone/templates/poser/views/configure.mako
+++ b/tailbone/templates/poser/views/configure.mako
@@ -9,7 +9,7 @@
 
   % for topkey, topgroup in sorted(view_settings.items(), key=lambda itm: 'aaaa' if itm[0] == 'rattail' else itm[0]):
       <h3 class="block is-size-3">Views for:&nbsp; ${topkey}</h3>
-      % for group_key, group in six.iteritems(topgroup):
+      % for group_key, group in topgroup.items():
           <h4 class="block is-size-4">${group_key.capitalize()}</h4>
           % for key, label in group:
               ${self.simple_flag(key, label)}
diff --git a/tailbone/templates/principal/find_by_perm.mako b/tailbone/templates/principal/find_by_perm.mako
index 1a0a4b7d..ddc44e3d 100644
--- a/tailbone/templates/principal/find_by_perm.mako
+++ b/tailbone/templates/principal/find_by_perm.mako
@@ -10,12 +10,20 @@
   </find-principals>
 </%def>
 
-<%def name="render_this_page_template()">
-  ${parent.render_this_page_template()}
+<%def name="principal_table()">
+  <div
+    style="width: 50%;"
+    >
+    ${grid.render_table_element(data_prop='principalsData')|n}
+  </div>
+</%def>
+
+<%def name="render_vue_templates()">
+  ${parent.render_vue_templates()}
   <script type="text/x-template" id="find-principals-template">
     <div>
 
-      ${h.form(request.current_route_url(), method='GET', **{'@submit': 'formSubmitting = true'})}
+      ${h.form(request.url, method='GET', **{'@submit': 'formSubmitting = true'})}
         <div style="margin-left: 10rem; max-width: 50%;">
 
           ${h.hidden('permission_group', **{':value': 'selectedGroup'})}
@@ -63,7 +71,7 @@
           <b-field horizontal>
             <div class="buttons" style="margin-top: 1rem;">
               <once-button tag="a"
-                           href="${request.current_route_url(_query=None)}"
+                           href="${request.path_url}"
                            text="Reset Form">
               </once-button>
               <b-button type="is-primary"
@@ -90,28 +98,6 @@
 
     </div>
   </script>
-</%def>
-
-<%def name="principal_table()">
-  <div
-    style="width: 50%;"
-    >
-    ${grid.render_table_element(data_prop='principalsData')|n}
-  </div>
-</%def>
-
-<%def name="modify_this_page_vars()">
-  ${parent.modify_this_page_vars()}
-  <script type="text/javascript">
-
-    ThisPageData.permissionGroups = ${json.dumps(perms_data)|n}
-    ThisPageData.sortedGroups = ${json.dumps(sorted_groups_data)|n}
-
-  </script>
-</%def>
-
-<%def name="make_this_page_component()">
-  ${parent.make_this_page_component()}
   <script type="text/javascript">
 
     const FindPrincipals = {
@@ -240,12 +226,21 @@
         }
     }
 
-    Vue.component('find-principals', FindPrincipals)
-
-    <% request.register_component('find-principals', 'FindPrincipals') %>
-
   </script>
 </%def>
 
+<%def name="modify_vue_vars()">
+  ${parent.modify_vue_vars()}
+  <script>
+    ThisPageData.permissionGroups = ${json.dumps(perms_data)|n}
+    ThisPageData.sortedGroups = ${json.dumps(sorted_groups_data)|n}
+  </script>
+</%def>
 
-${parent.body()}
+<%def name="make_vue_components()">
+  ${parent.make_vue_components()}
+  <script>
+    Vue.component('find-principals', FindPrincipals)
+    <% request.register_component('find-principals', 'FindPrincipals') %>
+  </script>
+</%def>
diff --git a/tailbone/templates/products/batch.mako b/tailbone/templates/products/batch.mako
index e0b93bd6..db029e5a 100644
--- a/tailbone/templates/products/batch.mako
+++ b/tailbone/templates/products/batch.mako
@@ -22,7 +22,7 @@
 </%def>
 
 <%def name="render_form_innards()">
-  ${h.form(request.current_route_url(), **{'@submit': 'submit{}'.format(form.component_studly)})}
+  ${h.form(request.current_route_url(), **{'@submit': 'submit{}'.format(form.vue_component)})}
   ${h.csrf_token(request)}
 
   <section>
@@ -30,7 +30,7 @@
     ${render_deform_field(form, dform['description'])}
     ${render_deform_field(form, dform['notes'])}
 
-    % for key, pform in six.iteritems(params_forms):
+    % for key, pform in params_forms.items():
         <div v-show="field_model_batch_type == '${key}'">
           % for field in pform.make_deform_form():
               ${render_deform_field(pform, field)}
@@ -43,8 +43,8 @@
   <div class="buttons">
     <b-button type="is-primary"
               native-type="submit"
-              :disabled="${form.component_studly}Submitting">
-      {{ ${form.component_studly}ButtonText }}
+              :disabled="${form.vue_component}Submitting">
+      {{ ${form.vue_component}ButtonText }}
     </b-button>
     <b-button tag="a" href="${url('products')}">
       Cancel
@@ -55,32 +55,33 @@
 </%def>
 
 <%def name="render_form_template()">
-  <script type="text/x-template" id="${form.component}-template">
+  <script type="text/x-template" id="${form.vue_tagname}-template">
     ${self.render_form_innards()}
   </script>
 </%def>
 
-<%def name="modify_this_page_vars()">
-  ${parent.modify_this_page_vars()}
-  <script type="text/javascript">
+<%def name="modify_vue_vars()">
+  ${parent.modify_vue_vars()}
+  <% request.register_component(form.vue_tagname, form.vue_component) %>
+  <script>
 
     ## TODO: ugh, an awful lot of duplicated code here (from /forms/deform.mako)
 
-    let ${form.component_studly} = {
-        template: '#${form.component}-template',
+    let ${form.vue_component} = {
+        template: '#${form.vue_tagname}-template',
         methods: {
 
             ## TODO: deprecate / remove the latter option here
             % if form.auto_disable_save or form.auto_disable:
-                submit${form.component_studly}() {
-                    this.${form.component_studly}Submitting = true
-                    this.${form.component_studly}ButtonText = "Working, please wait..."
+                submit${form.vue_component}() {
+                    this.${form.vue_component}Submitting = true
+                    this.${form.vue_component}ButtonText = "Working, please wait..."
                 }
             % endif
         }
     }
 
-    let ${form.component_studly}Data = {
+    let ${form.vue_component}Data = {
 
         ## TODO: ugh, this seems pretty hacky.  need to declare some data models
         ## for various field components to bind to...
@@ -95,8 +96,8 @@
 
         ## TODO: deprecate / remove the latter option here
         % if form.auto_disable_save or form.auto_disable:
-            ${form.component_studly}Submitting: false,
-            ${form.component_studly}ButtonText: ${json.dumps(getattr(form, 'submit_label', getattr(form, 'save_label', "Submit")))|n},
+            ${form.vue_component}Submitting: false,
+            ${form.vue_component}ButtonText: ${json.dumps(getattr(form, 'submit_label', getattr(form, 'save_label', "Submit")))|n},
         % endif
 
         ## TODO: more hackiness, this is for the sake of batch params
@@ -114,6 +115,3 @@
 
   </script>
 </%def>
-
-
-${parent.body()}
diff --git a/tailbone/templates/products/configure.mako b/tailbone/templates/products/configure.mako
index 10f3c0e5..a43a85d4 100644
--- a/tailbone/templates/products/configure.mako
+++ b/tailbone/templates/products/configure.mako
@@ -41,8 +41,8 @@
       <b-input name="rattail.pod.pictures.gtin.root_url"
                v-model="simpleSettings['rattail.pod.pictures.gtin.root_url']"
                :disabled="!simpleSettings['tailbone.products.show_pod_image']"
-               @input="settingsNeedSaved = true">
-      </b-input>
+               @input="settingsNeedSaved = true"
+               expanded />
     </b-field>
 
   </div>
@@ -95,9 +95,9 @@
   </div>
 </%def>
 
-<%def name="modify_this_page_vars()">
-  ${parent.modify_this_page_vars()}
-  <script type="text/javascript">
+<%def name="modify_vue_vars()">
+  ${parent.modify_vue_vars()}
+  <script>
 
     ThisPage.methods.getTitleForKey = function(key) {
         switch (key) {
@@ -118,6 +118,3 @@
 
   </script>
 </%def>
-
-
-${parent.body()}
diff --git a/tailbone/templates/products/index.mako b/tailbone/templates/products/index.mako
index 0d4bc410..5ffa9512 100644
--- a/tailbone/templates/products/index.mako
+++ b/tailbone/templates/products/index.mako
@@ -36,16 +36,16 @@
   </${grid.component}>
 </%def>
 
-<%def name="modify_this_page_vars()">
-  ${parent.modify_this_page_vars()}
+<%def name="modify_vue_vars()">
+  ${parent.modify_vue_vars()}
   % if label_profiles and master.has_perm('print_labels'):
-      <script type="text/javascript">
+      <script>
 
-        ${grid.component_studly}Data.quickLabelProfile = ${json.dumps(label_profiles[0].uuid)|n}
-        ${grid.component_studly}Data.quickLabelQuantity = 1
-        ${grid.component_studly}Data.quickLabelSpeedbumpThreshold = ${json.dumps(quick_label_speedbump_threshold)|n}
+        ${grid.vue_component}Data.quickLabelProfile = ${json.dumps(label_profiles[0].uuid)|n}
+        ${grid.vue_component}Data.quickLabelQuantity = 1
+        ${grid.vue_component}Data.quickLabelSpeedbumpThreshold = ${json.dumps(quick_label_speedbump_threshold)|n}
 
-        ${grid.component_studly}.methods.quickLabelPrint = function(row) {
+        ${grid.vue_component}.methods.quickLabelPrint = function(row) {
 
             let quantity = parseInt(this.quickLabelQuantity)
             if (isNaN(quantity)) {
@@ -83,6 +83,3 @@
       </script>
   % endif
 </%def>
-
-
-${parent.body()}
diff --git a/tailbone/templates/products/lookup.mako b/tailbone/templates/products/lookup.mako
index 7997eb7d..bb9590b2 100644
--- a/tailbone/templates/products/lookup.mako
+++ b/tailbone/templates/products/lookup.mako
@@ -56,7 +56,11 @@
             <b-field grouped>
 
               <b-input v-model="searchTerm" 
-                       ref="searchTermInput" />
+                       ref="searchTermInput"
+                       % if not request.use_oruga:
+                           @keydown.native="searchTermInputKeydown"
+                       % endif
+                       />
 
               <b-button class="control"
                         type="is-primary"
@@ -243,8 +247,10 @@
                 lookupShowDialog: false,
 
                 searchTerm: null,
-                searchTermInputElement: null,
                 searchTermLastUsed: null,
+                % if request.use_oruga:
+                    searchTermInputElement: null,
+                % endif
 
                 searchProductKey: true,
                 searchVendorItemCode: true,
@@ -259,14 +265,18 @@
             }
         },
 
-        mounted() {
-            this.searchTermInputElement = this.$refs.searchTermInput.$el.querySelector('input')
-            this.searchTermInputElement.addEventListener('keydown', this.searchTermInputKeydown)
-        },
+        % if request.use_oruga:
 
-        beforeDestroy() {
-            this.searchTermInputElement.removeEventListener('keydown', this.searchTermInputKeydown)
-        },
+            mounted() {
+                this.searchTermInputElement = this.$refs.searchTermInput.$el.querySelector('input')
+                this.searchTermInputElement.addEventListener('keydown', this.searchTermInputKeydown)
+            },
+
+            beforeDestroy() {
+                this.searchTermInputElement.removeEventListener('keydown', this.searchTermInputKeydown)
+            },
+
+        % endif
 
         methods: {
 
diff --git a/tailbone/templates/products/pending/view.mako b/tailbone/templates/products/pending/view.mako
index 765c8838..72c9c76d 100644
--- a/tailbone/templates/products/pending/view.mako
+++ b/tailbone/templates/products/pending/view.mako
@@ -2,11 +2,6 @@
 <%inherit file="/master/view.mako" />
 <%namespace name="product_lookup" file="/products/lookup.mako" />
 
-<%def name="render_this_page_template()">
-  ${parent.render_this_page_template()}
-  ${product_lookup.tailbone_product_lookup_template()}
-</%def>
-
 <%def name="page_content()">
   ${parent.page_content()}
 
@@ -67,9 +62,14 @@
   % endif
 </%def>
 
-<%def name="modify_this_page_vars()">
-  ${parent.modify_this_page_vars()}
-  <script type="text/javascript">
+<%def name="render_vue_templates()">
+  ${parent.render_vue_templates()}
+  ${product_lookup.tailbone_product_lookup_template()}
+</%def>
+
+<%def name="modify_vue_vars()">
+  ${parent.modify_vue_vars()}
+  <script>
 
     % if master.has_perm('ignore_product') and instance.status_code in (enum.PENDING_PRODUCT_STATUS_PENDING, enum.PENDING_PRODUCT_STATUS_READY):
 
@@ -124,10 +124,7 @@
   </script>
 </%def>
 
-<%def name="make_this_page_component()">
-  ${parent.make_this_page_component()}
+<%def name="make_vue_components()">
+  ${parent.make_vue_components()}
   ${product_lookup.tailbone_product_lookup_component()}
 </%def>
-
-
-${parent.body()}
diff --git a/tailbone/templates/products/view.mako b/tailbone/templates/products/view.mako
index bd4afc7f..66ca3128 100644
--- a/tailbone/templates/products/view.mako
+++ b/tailbone/templates/products/view.mako
@@ -282,9 +282,9 @@
   % endif
 </%def>
 
-<%def name="modify_this_page_vars()">
-  ${parent.modify_this_page_vars()}
-  <script type="text/javascript">
+<%def name="modify_vue_vars()">
+  ${parent.modify_vue_vars()}
+  <script>
 
     ThisPageData.vendorSourcesData = ${json.dumps(vendor_sources['data'])|n}
     ThisPageData.lookupCodesData = ${json.dumps(lookup_codes['data'])|n}
@@ -411,6 +411,3 @@
     % endif
   </script>
 </%def>
-
-
-${parent.body()}
diff --git a/tailbone/templates/purchases/credits/index.mako b/tailbone/templates/purchases/credits/index.mako
index 4248d4ad..94028bdb 100644
--- a/tailbone/templates/purchases/credits/index.mako
+++ b/tailbone/templates/purchases/credits/index.mako
@@ -59,27 +59,24 @@
 
 </%def>
 
-<%def name="modify_this_page_vars()">
-  ${parent.modify_this_page_vars()}
-  <script type="text/javascript">
+<%def name="modify_vue_vars()">
+  ${parent.modify_vue_vars()}
+  <script>
 
-    ${grid.component_studly}Data.changeStatusShowDialog = false
-    ${grid.component_studly}Data.changeStatusOptions = ${json.dumps(status_options)|n}
-    ${grid.component_studly}Data.changeStatusValue = null
-    ${grid.component_studly}Data.changeStatusSubmitting = false
+    ${grid.vue_component}Data.changeStatusShowDialog = false
+    ${grid.vue_component}Data.changeStatusOptions = ${json.dumps(status_options)|n}
+    ${grid.vue_component}Data.changeStatusValue = null
+    ${grid.vue_component}Data.changeStatusSubmitting = false
 
-    ${grid.component_studly}.methods.changeStatusInit = function() {
+    ${grid.vue_component}.methods.changeStatusInit = function() {
         this.changeStatusValue = null
         this.changeStatusShowDialog = true
     }
 
-    ${grid.component_studly}.methods.changeStatusSubmit = function() {
+    ${grid.vue_component}.methods.changeStatusSubmit = function() {
         this.changeStatusSubmitting = true
         this.$refs.changeStatusForm.submit()
     }
 
   </script>
 </%def>
-
-
-${parent.body()}
diff --git a/tailbone/templates/receiving/configure.mako b/tailbone/templates/receiving/configure.mako
index f613e13e..a36dde43 100644
--- a/tailbone/templates/receiving/configure.mako
+++ b/tailbone/templates/receiving/configure.mako
@@ -69,12 +69,12 @@
   <h3 class="block is-size-3">Vendors</h3>
   <div class="block" style="padding-left: 2rem;">
 
-    <b-field message="If set, user must choose a &quot;supported&quot; vendor; otherwise they may choose &quot;any&quot; vendor.">
-      <b-checkbox name="rattail.batch.purchase.supported_vendors_only"
-                  v-model="simpleSettings['rattail.batch.purchase.supported_vendors_only']"
+    <b-field message="If not set, user must choose a &quot;supported&quot; vendor.">
+      <b-checkbox name="rattail.batch.purchase.allow_receiving_any_vendor"
+                  v-model="simpleSettings['rattail.batch.purchase.allow_receiving_any_vendor']"
                   native-value="true"
                   @input="settingsNeedSaved = true">
-        Only allow batch for "supported" vendors
+        Allow receiving for <span class="has-text-weight-bold">any</span> vendor
       </b-checkbox>
     </b-field>
 
diff --git a/tailbone/templates/receiving/view.mako b/tailbone/templates/receiving/view.mako
index 5f103d7f..710dec4a 100644
--- a/tailbone/templates/receiving/view.mako
+++ b/tailbone/templates/receiving/view.mako
@@ -139,9 +139,15 @@
   % endif
 </%def>
 
-<%def name="render_this_page_template()">
-  ${parent.render_this_page_template()}
+<%def name="object_helpers()">
+  ${self.render_status_breakdown()}
+  ${self.render_po_vs_invoice_helper()}
+  ${self.render_execute_helper()}
+  ${self.render_tools_helper()}
+</%def>
 
+<%def name="render_vue_templates()">
+  ${parent.render_vue_templates()}
   % if allow_edit_catalog_unit_cost or allow_edit_invoice_unit_cost:
       <script type="text/x-template" id="receiving-cost-editor-template">
         <div>
@@ -162,16 +168,9 @@
   % endif
 </%def>
 
-<%def name="object_helpers()">
-  ${self.render_status_breakdown()}
-  ${self.render_po_vs_invoice_helper()}
-  ${self.render_execute_helper()}
-  ${self.render_tools_helper()}
-</%def>
-
-<%def name="modify_this_page_vars()">
-  ${parent.modify_this_page_vars()}
-  <script type="text/javascript">
+<%def name="modify_vue_vars()">
+  ${parent.modify_vue_vars()}
+  <script>
 
     % if allow_confirm_all_costs:
 
@@ -318,13 +317,13 @@
 
     % if allow_edit_catalog_unit_cost:
 
-        ${rows_grid.component_studly}.methods.catalogUnitCostClicked = function(row) {
+        ${rows_grid.vue_component}.methods.catalogUnitCostClicked = function(row) {
 
             // start edit for clicked cell
             this.$refs['catalogUnitCost_' + row.uuid].startEdit()
         }
 
-        ${rows_grid.component_studly}.methods.catalogCostConfirmed = function(amount, index) {
+        ${rows_grid.vue_component}.methods.catalogCostConfirmed = function(amount, index) {
 
             // update display to indicate cost was confirmed
             this.addRowClass(index, 'catalog_cost_confirmed')
@@ -353,13 +352,13 @@
 
     % if allow_edit_invoice_unit_cost:
 
-        ${rows_grid.component_studly}.methods.invoiceUnitCostClicked = function(row) {
+        ${rows_grid.vue_component}.methods.invoiceUnitCostClicked = function(row) {
 
             // start edit for clicked cell
             this.$refs['invoiceUnitCost_' + row.uuid].startEdit()
         }
 
-        ${rows_grid.component_studly}.methods.invoiceCostConfirmed = function(amount, index) {
+        ${rows_grid.vue_component}.methods.invoiceCostConfirmed = function(amount, index) {
 
             // update display to indicate cost was confirmed
             this.addRowClass(index, 'invoice_cost_confirmed')
@@ -389,6 +388,3 @@
 
   </script>
 </%def>
-
-
-${parent.body()}
diff --git a/tailbone/templates/receiving/view_row.mako b/tailbone/templates/receiving/view_row.mako
index 5077539c..086754c6 100644
--- a/tailbone/templates/receiving/view_row.mako
+++ b/tailbone/templates/receiving/view_row.mako
@@ -484,9 +484,9 @@
   </div>
 </%def>
 
-<%def name="modify_this_page_vars()">
-  ${parent.modify_this_page_vars()}
-  <script type="text/javascript">
+<%def name="modify_vue_vars()">
+  ${parent.modify_vue_vars()}
+  <script>
 
 ##     ThisPage.methods.editUnitCost = function() {
 ##         alert("TODO: not yet implemented")
@@ -720,6 +720,3 @@
 
   </script>
 </%def>
-
-
-${parent.body()}
diff --git a/tailbone/templates/reports/generated/choose.mako b/tailbone/templates/reports/generated/choose.mako
index a952fb6a..0921530c 100644
--- a/tailbone/templates/reports/generated/choose.mako
+++ b/tailbone/templates/reports/generated/choose.mako
@@ -53,13 +53,13 @@
   % endif
 </%def>
 
-<%def name="modify_this_page_vars()">
-  ${parent.modify_this_page_vars()}
-  <script type="text/javascript">
+<%def name="modify_vue_vars()">
+  ${parent.modify_vue_vars()}
+  <script>
 
-    TailboneFormData.reportDescriptions = ${json.dumps(report_descriptions)|n}
+    ${form.vue_component}Data.reportDescriptions = ${json.dumps(report_descriptions)|n}
 
-    TailboneForm.methods.reportTypeChanged = function(reportType) {
+    ${form.vue_component}.methods.reportTypeChanged = function(reportType) {
         this.$emit('report-change', this.reportDescriptions[reportType])
     }
 
@@ -71,6 +71,3 @@
 
   </script>
 </%def>
-
-
-${parent.body()}
diff --git a/tailbone/templates/reports/generated/delete.mako b/tailbone/templates/reports/generated/delete.mako
index 0c994ad0..f60a9819 100644
--- a/tailbone/templates/reports/generated/delete.mako
+++ b/tailbone/templates/reports/generated/delete.mako
@@ -1,16 +1,11 @@
 ## -*- coding: utf-8; -*-
 <%inherit file="/master/delete.mako" />
 
-<%def name="modify_this_page_vars()">
-  ${parent.modify_this_page_vars()}
-  <script type="text/javascript">
-
+<%def name="modify_vue_vars()">
+  ${parent.modify_vue_vars()}
+  <script>
     % if params_data is not Undefined:
-        ${form.component_studly}Data.paramsData = ${json.dumps(params_data)|n}
+        ${form.vue_component}Data.paramsData = ${json.dumps(params_data)|n}
     % endif
-
   </script>
 </%def>
-
-
-${parent.body()}
diff --git a/tailbone/templates/reports/generated/view.mako b/tailbone/templates/reports/generated/view.mako
index 6260efba..cce6f346 100644
--- a/tailbone/templates/reports/generated/view.mako
+++ b/tailbone/templates/reports/generated/view.mako
@@ -23,16 +23,11 @@
   % endif
 </%def>
 
-<%def name="modify_this_page_vars()">
-  ${parent.modify_this_page_vars()}
-  <script type="text/javascript">
-
+<%def name="modify_vue_vars()">
+  ${parent.modify_vue_vars()}
+  <script>
     % if params_data is not Undefined:
-        ${form.component_studly}Data.paramsData = ${json.dumps(params_data)|n}
+        ${form.vue_component}Data.paramsData = ${json.dumps(params_data)|n}
     % endif
-
   </script>
 </%def>
-
-
-${parent.body()}
diff --git a/tailbone/templates/reports/inventory.mako b/tailbone/templates/reports/inventory.mako
index f051959f..cc5adc10 100644
--- a/tailbone/templates/reports/inventory.mako
+++ b/tailbone/templates/reports/inventory.mako
@@ -48,15 +48,10 @@
   ${h.end_form()}
 </%def>
 
-<%def name="modify_this_page_vars()">
-  ${parent.modify_this_page_vars()}
-  <script type="text/javascript">
-
+<%def name="modify_vue_vars()">
+  ${parent.modify_vue_vars()}
+  <script>
     ThisPageData.departments = ${json.dumps([{'uuid': d.uuid, 'name': d.name} for d in departments])|n}
     ThisPageData.excludeNotForSale = true
-
   </script>
 </%def>
-
-
-${parent.body()}
diff --git a/tailbone/templates/reports/ordering.mako b/tailbone/templates/reports/ordering.mako
index 1e526792..61ccdb16 100644
--- a/tailbone/templates/reports/ordering.mako
+++ b/tailbone/templates/reports/ordering.mako
@@ -81,9 +81,9 @@
 
 <%def name="extra_fields()"></%def>
 
-<%def name="modify_this_page_vars()">
-  ${parent.modify_this_page_vars()}
-  <script type="text/javascript">
+<%def name="modify_vue_vars()">
+  ${parent.modify_vue_vars()}
+  <script>
 
     ThisPageData.vendorUUID = null
     ThisPageData.departments = []
@@ -127,6 +127,3 @@
 
   </script>
 </%def>
-
-
-${parent.body()}
diff --git a/tailbone/templates/reports/problems/view.mako b/tailbone/templates/reports/problems/view.mako
index 026c73dc..5cdf2be5 100644
--- a/tailbone/templates/reports/problems/view.mako
+++ b/tailbone/templates/reports/problems/view.mako
@@ -45,11 +45,10 @@
             <b-button @click="runReportShowDialog = false">
               Cancel
             </b-button>
-            ${h.form(master.get_action_url('execute', instance))}
+            ${h.form(master.get_action_url('execute', instance), **{'@submit': 'runReportSubmitting = true'})}
             ${h.csrf_token(request)}
             <b-button type="is-primary"
                       native-type="submit"
-                      @click="runReportSubmitting = true"
                       :disabled="runReportSubmitting"
                       icon-pack="fas"
                       icon-left="arrow-circle-right">
@@ -62,12 +61,12 @@
   % endif
 </%def>
 
-<%def name="modify_this_page_vars()">
-  ${parent.modify_this_page_vars()}
-  <script type="text/javascript">
+<%def name="modify_vue_vars()">
+  ${parent.modify_vue_vars()}
+  <script>
 
     % if weekdays_data is not Undefined:
-        ${form.component_studly}Data.weekdaysData = ${json.dumps(weekdays_data)|n}
+        ${form.vue_component}Data.weekdaysData = ${json.dumps(weekdays_data)|n}
     % endif
 
     ThisPageData.runReportShowDialog = false
@@ -75,6 +74,3 @@
 
   </script>
 </%def>
-
-
-${parent.body()}
diff --git a/tailbone/templates/roles/create.mako b/tailbone/templates/roles/create.mako
index 625b2675..89dd56c3 100644
--- a/tailbone/templates/roles/create.mako
+++ b/tailbone/templates/roles/create.mako
@@ -6,15 +6,11 @@
   ${h.stylesheet_link(request.static_url('tailbone:static/css/perms.css'))}
 </%def>
 
-<%def name="modify_this_page_vars()">
-  ${parent.modify_this_page_vars()}
-  <script type="text/javascript">
-
+<%def name="modify_vue_vars()">
+  ${parent.modify_vue_vars()}
+  <script>
     // TODO: this variable name should be more dynamic (?) since this is
     // connected to (and only here b/c of) the permissions field
-    TailboneFormData.showingPermissionGroup = ''
-
+    ${form.vue_component}Data.showingPermissionGroup = ''
   </script>
 </%def>
-
-${parent.body()}
diff --git a/tailbone/templates/roles/edit.mako b/tailbone/templates/roles/edit.mako
index 67f63013..e77cca33 100644
--- a/tailbone/templates/roles/edit.mako
+++ b/tailbone/templates/roles/edit.mako
@@ -6,15 +6,11 @@
   ${h.stylesheet_link(request.static_url('tailbone:static/css/perms.css'))}
 </%def>
 
-<%def name="modify_this_page_vars()">
-  ${parent.modify_this_page_vars()}
-  <script type="text/javascript">
-
+<%def name="modify_vue_vars()">
+  ${parent.modify_vue_vars()}
+  <script>
     // TODO: this variable name should be more dynamic (?) since this is
     // connected to (and only here b/c of) the permissions field
-    TailboneFormData.showingPermissionGroup = ''
-
+    ${form.vue_component}Data.showingPermissionGroup = ''
   </script>
 </%def>
-
-${parent.body()}
diff --git a/tailbone/templates/roles/view.mako b/tailbone/templates/roles/view.mako
index 0f4ce472..f5588695 100644
--- a/tailbone/templates/roles/view.mako
+++ b/tailbone/templates/roles/view.mako
@@ -6,12 +6,12 @@
   ${h.stylesheet_link(request.static_url('tailbone:static/css/perms.css'))}
 </%def>
 
-<%def name="modify_this_page_vars()">
-  ${parent.modify_this_page_vars()}
-  <script type="text/javascript">
+<%def name="modify_vue_vars()">
+  ${parent.modify_vue_vars()}
+  <script>
 
     % if users_data is not Undefined:
-        ${form.component_studly}Data.usersData = ${json.dumps(users_data)|n}
+        ${form.vue_component}Data.usersData = ${json.dumps(users_data)|n}
     % endif
 
     ThisPage.methods.detachPerson = function(url) {
@@ -23,5 +23,3 @@
 
   </script>
 </%def>
-
-${parent.body()}
diff --git a/tailbone/templates/settings/email/configure.mako b/tailbone/templates/settings/email/configure.mako
index ef487809..f9c815c2 100644
--- a/tailbone/templates/settings/email/configure.mako
+++ b/tailbone/templates/settings/email/configure.mako
@@ -86,9 +86,9 @@
   </div>
 </%def>
 
-<%def name="modify_this_page_vars()">
-  ${parent.modify_this_page_vars()}
-  <script type="text/javascript">
+<%def name="modify_vue_vars()">
+  ${parent.modify_vue_vars()}
+  <script>
 
     ThisPageData.testRecipient = ${json.dumps(user_email_address)|n}
     ThisPageData.sendingTest = false
@@ -137,6 +137,3 @@
     % endif
   </script>
 </%def>
-
-
-${parent.body()}
diff --git a/tailbone/templates/settings/email/index.mako b/tailbone/templates/settings/email/index.mako
index dbc963b9..ab8d6fa4 100644
--- a/tailbone/templates/settings/email/index.mako
+++ b/tailbone/templates/settings/email/index.mako
@@ -15,10 +15,10 @@
   ${parent.render_grid_component()}
 </%def>
 
-<%def name="modify_this_page_vars()">
-  ${parent.modify_this_page_vars()}
+<%def name="modify_vue_vars()">
+  ${parent.modify_vue_vars()}
   % if master.has_perm('configure'):
-      <script type="text/javascript">
+      <script>
 
         ThisPageData.showEmails = 'available'
 
@@ -26,9 +26,9 @@
             this.$refs.grid.showEmails = this.showEmails
         }
 
-        ${grid.component_studly}Data.showEmails = 'available'
+        ${grid.vue_component}Data.showEmails = 'available'
 
-        ${grid.component_studly}.computed.visibleData = function() {
+        ${grid.vue_component}.computed.visibleData = function() {
 
             if (this.showEmails == 'available') {
                 return this.data.filter(email => email.hidden == 'No')
@@ -41,11 +41,11 @@
             return this.data
         }
 
-        ${grid.component_studly}.methods.renderLabelToggleHidden = function(row) {
+        ${grid.vue_component}.methods.renderLabelToggleHidden = function(row) {
             return row.hidden == 'Yes' ? "Un-hide" : "Hide"
         }
 
-        ${grid.component_studly}.methods.toggleHidden = function(row) {
+        ${grid.vue_component}.methods.toggleHidden = function(row) {
             let url = '${url('{}.toggle_hidden'.format(route_prefix))}'
             let params = {
                 key: row.key,
@@ -65,5 +65,3 @@
       </script>
   % endif
 </%def>
-
-${parent.body()}
diff --git a/tailbone/templates/settings/email/view.mako b/tailbone/templates/settings/email/view.mako
index c1bc5ed4..73ad7066 100644
--- a/tailbone/templates/settings/email/view.mako
+++ b/tailbone/templates/settings/email/view.mako
@@ -6,8 +6,8 @@
   <email-preview-tools></email-preview-tools>
 </%def>
 
-<%def name="render_this_page_template()">
-  ${parent.render_this_page_template()}
+<%def name="render_vue_templates()">
+  ${parent.render_vue_templates()}
   <script type="text/x-template" id="email-preview-tools-template">
 
   ${h.form(url('email.preview'), **{'@submit': 'submitPreviewForm'})}
@@ -72,10 +72,6 @@
 
   ${h.end_form()}
   </script>
-</%def>
-
-<%def name="make_this_page_component()">
-  ${parent.make_this_page_component()}
   <script type="text/javascript">
 
     const EmailPreviewTools = {
@@ -100,12 +96,13 @@
         }
     }
 
-    Vue.component('email-preview-tools', EmailPreviewTools)
-
-    <% request.register_component('email-preview-tools', 'EmailPreviewTools') %>
-
   </script>
 </%def>
 
-
-${parent.body()}
+<%def name="make_vue_components()">
+  ${parent.make_vue_components()}
+  <script>
+    Vue.component('email-preview-tools', EmailPreviewTools)
+    <% request.register_component('email-preview-tools', 'EmailPreviewTools') %>
+  </script>
+</%def>
diff --git a/tailbone/templates/shifts/base.mako b/tailbone/templates/shifts/base.mako
index 4bae5ebf..52b48832 100644
--- a/tailbone/templates/shifts/base.mako
+++ b/tailbone/templates/shifts/base.mako
@@ -57,7 +57,7 @@
                 <div class="field-wrapper employee">
                   <label>Employee</label>
                   <div class="field">
-                    ${dform['employee'].serialize(text=six.text_type(employee), selected_callback='employee_selected')|n}
+                    ${dform['employee'].serialize(text=str(employee), selected_callback='employee_selected')|n}
                   </div>
                 </div>
             % endif
@@ -152,7 +152,7 @@
       </tr>
     </thead>
     <tbody>
-      % for emp in sorted(employees, key=six.text_type):
+      % for emp in sorted(employees, key=str):
           <tr data-employee-uuid="${emp.uuid}">
             <td class="employee">
               ## TODO: add link to single employee schedule / timesheet here...
diff --git a/tailbone/templates/tables/create.mako b/tailbone/templates/tables/create.mako
index 4fc2eb96..34844c5c 100644
--- a/tailbone/templates/tables/create.mako
+++ b/tailbone/templates/tables/create.mako
@@ -695,9 +695,9 @@
   </b-steps>
 </%def>
 
-<%def name="modify_this_page_vars()">
-  ${parent.modify_this_page_vars()}
-  <script type="text/javascript">
+<%def name="modify_vue_vars()">
+  ${parent.modify_vue_vars()}
+  <script>
 
     // nb. for warning user they may lose changes if leaving page
     ThisPageData.dirty = false
@@ -983,6 +983,3 @@
 
   </script>
 </%def>
-
-
-${parent.body()}
diff --git a/tailbone/templates/tempmon/appliances/view.mako b/tailbone/templates/tempmon/appliances/view.mako
index 07a524b8..a55af922 100644
--- a/tailbone/templates/tempmon/appliances/view.mako
+++ b/tailbone/templates/tempmon/appliances/view.mako
@@ -8,14 +8,9 @@
   % endif
 </%def>
 
-<%def name="modify_this_page_vars()">
-  ${parent.modify_this_page_vars()}
-  <script type="text/javascript">
-
-    ${form.component_studly}Data.probesData = ${json.dumps(probes_data)|n}
-
+<%def name="modify_vue_vars()">
+  ${parent.modify_vue_vars()}
+  <script>
+    ${form.vue_component}Data.probesData = ${json.dumps(probes_data)|n}
   </script>
 </%def>
-
-
-${parent.body()}
diff --git a/tailbone/templates/tempmon/clients/view.mako b/tailbone/templates/tempmon/clients/view.mako
index cff22fed..434da4c8 100644
--- a/tailbone/templates/tempmon/clients/view.mako
+++ b/tailbone/templates/tempmon/clients/view.mako
@@ -22,14 +22,9 @@
   % endif
 </%def>
 
-<%def name="modify_this_page_vars()">
-  ${parent.modify_this_page_vars()}
-  <script type="text/javascript">
-
-    ${form.component_studly}Data.probesData = ${json.dumps(probes_data)|n}
-
+<%def name="modify_vue_vars()">
+  ${parent.modify_vue_vars()}
+  <script>
+    ${form.vue_component}Data.probesData = ${json.dumps(probes_data)|n}
   </script>
 </%def>
-
-
-${parent.body()}
diff --git a/tailbone/templates/tempmon/dashboard.mako b/tailbone/templates/tempmon/dashboard.mako
index 396b0e68..befaf8b4 100644
--- a/tailbone/templates/tempmon/dashboard.mako
+++ b/tailbone/templates/tempmon/dashboard.mako
@@ -59,9 +59,9 @@
   % endif
 </%def>
 
-<%def name="modify_this_page_vars()">
-  ${parent.modify_this_page_vars()}
-  <script type="text/javascript">
+<%def name="modify_vue_vars()">
+  ${parent.modify_vue_vars()}
+  <script>
 
     ThisPageData.appliances = ${json.dumps(appliances_data)|n}
     ThisPageData.applianceUUID = ${json.dumps(appliance.uuid if appliance else None)|n}
@@ -118,6 +118,3 @@
 
   </script>
 </%def>
-
-
-${parent.body()}
diff --git a/tailbone/templates/tempmon/probes/graph.mako b/tailbone/templates/tempmon/probes/graph.mako
index 412f25dd..94a440e0 100644
--- a/tailbone/templates/tempmon/probes/graph.mako
+++ b/tailbone/templates/tempmon/probes/graph.mako
@@ -66,9 +66,9 @@
   <canvas ref="tempchart" width="400" height="150"></canvas>
 </%def>
 
-<%def name="modify_this_page_vars()">
-  ${parent.modify_this_page_vars()}
-  <script type="text/javascript">
+<%def name="modify_vue_vars()">
+  ${parent.modify_vue_vars()}
+  <script>
 
     ThisPageData.currentTimeRange = ${json.dumps(current_time_range)|n}
     ThisPageData.chart = null
@@ -128,6 +128,3 @@
 
   </script>
 </%def>
-
-
-${parent.body()}
diff --git a/tailbone/templates/themes/butterball/base.mako b/tailbone/templates/themes/butterball/base.mako
index 3f0253ce..b69eacfb 100644
--- a/tailbone/templates/themes/butterball/base.mako
+++ b/tailbone/templates/themes/butterball/base.mako
@@ -20,38 +20,21 @@
   </head>
 
   <body>
-    <div id="app" style="height: 100%; display: flex; flex-direction: column; justify-content: space-between;">
+    <div id="app" style="height: 100%;">
       <whole-page></whole-page>
     </div>
 
     ## TODO: this must come before the self.body() call..but why?
     ${declare_formposter_mixin()}
 
-    ## global components used by various (but not all) pages
-    ${make_field_components()}
-    ${make_grid_filter_components()}
-
-    ## global components for buefy-based template compatibility
-    ${make_http_plugin()}
-    ${make_buefy_plugin()}
-    ${make_buefy_components()}
-
-    ## special global components, used by WholePage
-    ${self.make_menu_search_component()}
-    ${page_help.render_template()}
-    ${page_help.declare_vars()}
-    % if request.has_perm('common.feedback'):
-        ${self.make_feedback_component()}
-    % endif
-
-    ## WholePage component
-    ${self.make_whole_page_component()}
-
     ## content body from derived/child template
     ${self.body()}
 
     ## Vue app
-    ${self.make_whole_page_app()}
+    ${self.render_vue_templates()}
+    ${self.modify_vue_vars()}
+    ${self.make_vue_components()}
+    ${self.make_vue_app()}
   </body>
 </html>
 
@@ -71,12 +54,12 @@
     {
         ## TODO: eventually version / url should be configurable
         "imports": {
-            "vue": "${h.get_liburl(request, 'bb_vue')}",
-            "@oruga-ui/oruga-next": "${h.get_liburl(request, 'bb_oruga')}",
-            "@oruga-ui/theme-bulma": "${h.get_liburl(request, 'bb_oruga_bulma')}",
-            "@fortawesome/fontawesome-svg-core": "${h.get_liburl(request, 'bb_fontawesome_svg_core')}",
-            "@fortawesome/free-solid-svg-icons": "${h.get_liburl(request, 'bb_free_solid_svg_icons')}",
-            "@fortawesome/vue-fontawesome": "${h.get_liburl(request, 'bb_vue_fontawesome')}"
+            "vue": "${h.get_liburl(request, 'bb_vue', prefix='tailbone')}",
+            "@oruga-ui/oruga-next": "${h.get_liburl(request, 'bb_oruga', prefix='tailbone')}",
+            "@oruga-ui/theme-bulma": "${h.get_liburl(request, 'bb_oruga_bulma', prefix='tailbone')}",
+            "@fortawesome/fontawesome-svg-core": "${h.get_liburl(request, 'bb_fontawesome_svg_core', prefix='tailbone')}",
+            "@fortawesome/free-solid-svg-icons": "${h.get_liburl(request, 'bb_free_solid_svg_icons', prefix='tailbone')}",
+            "@fortawesome/vue-fontawesome": "${h.get_liburl(request, 'bb_vue_fontawesome', prefix='tailbone')}"
         }
     }
   </script>
@@ -92,7 +75,7 @@
   % if user_css:
       ${h.stylesheet_link(user_css)}
   % else:
-      ${h.stylesheet_link(h.get_liburl(request, 'bb_oruga_bulma_css'))}
+      ${h.stylesheet_link(h.get_liburl(request, 'bb_oruga_bulma_css', prefix='tailbone'))}
   % endif
 </%def>
 
@@ -421,7 +404,7 @@
                 referrer: null,
                 % if request.user:
                     userUUID: ${json.dumps(request.user.uuid)|n},
-                    userName: ${json.dumps(six.text_type(request.user))|n},
+                    userName: ${json.dumps(str(request.user))|n},
                 % else:
                     userUUID: null,
                     userName: null,
@@ -596,7 +579,7 @@
   </script>
 </%def>
 
-<%def name="render_whole_page_template()">
+<%def name="render_vue_template_whole_page()">
   <script type="text/x-template" id="whole-page-template">
     <div id="whole-page" style="height: 100%; display: flex; flex-direction: column; justify-content: space-between;">
 
@@ -686,7 +669,7 @@
                       <h1 class="title">
                         ${index_title}
                       </h1>
-                      % if master.creatable and master.show_create_link and master.has_perm('create'):
+                      % if master.creatable and getattr(master, 'show_create_link', True) and master.has_perm('create'):
                           <once-button type="is-primary"
                                        tag="a" href="${url('{}.create'.format(route_prefix))}"
                                        icon-left="plus"
@@ -712,7 +695,7 @@
                           <h1 class="title">
                             ${h.link_to(instance_title, instance_url)}
                           </h1>
-                      % elif master.creatable and master.show_create_link and master.has_perm('create'):
+                      % elif master.creatable and getattr(master, 'show_create_link', True) and master.has_perm('create'):
                           % if not request.matched_route.name.endswith('.create'):
                               <once-button type="is-primary"
                                            tag="a" href="${url('{}.create'.format(route_prefix))}"
@@ -747,6 +730,7 @@
                   ${h.form(url('change_db_engine'), ref='dbPickerForm')}
                   ${h.csrf_token(request)}
                   ${h.hidden('engine_type', value=master.engine_type_key)}
+                  <input type="hidden" name="referrer" :value="referrer" />
                   <b-select name="dbkey"
                             v-model="dbSelected"
                             @input="changeDB()">
@@ -895,8 +879,6 @@
       </footer>
     </div>
   </script>
-
-##   ${multi_file_upload.render_template()}
 </%def>
 
 <%def name="render_this_page_component()">
@@ -924,9 +906,23 @@
         % endif
         <div class="navbar-dropdown">
           % if request.is_root:
-              ${h.link_to("Stop being root", url('stop_root'), class_='navbar-item has-background-danger has-text-white')}
+              ${h.form(url('stop_root'), ref='stopBeingRootForm')}
+              ${h.csrf_token(request)}
+              <input type="hidden" name="referrer" value="${request.current_route_url()}" />
+              <a @click="$refs.stopBeingRootForm.submit()"
+                 class="navbar-item has-background-danger has-text-white">
+                Stop being root
+              </a>
+              ${h.end_form()}
           % elif request.is_admin:
-              ${h.link_to("Become root", url('become_root'), class_='navbar-item has-background-danger has-text-white')}
+              ${h.form(url('become_root'), ref='startBeingRootForm')}
+              ${h.csrf_token(request)}
+              <input type="hidden" name="referrer" value="${request.current_route_url()}" />
+              <a @click="$refs.startBeingRootForm.submit()"
+                 class="navbar-item has-background-danger has-text-white">
+                Become root
+              </a>
+              ${h.end_form()}
           % endif
           % if messaging_enabled:
               ${h.link_to("Messages{}".format(" ({})".format(inbox_count) if inbox_count else ''), url('messages.inbox'), class_='navbar-item')}
@@ -951,23 +947,23 @@
 </%def>
 
 <%def name="render_crud_header_buttons()">
-  % if master and master.viewing and not master.cloning:
+% if master and master.viewing and not getattr(master, 'cloning', False):
       ## TODO: is there a better way to check if viewing parent?
       % if parent_instance is Undefined:
           % if master.editable and instance_editable and master.has_perm('edit'):
-              <once-button tag="a" href="${action_url('edit', instance)}"
+              <once-button tag="a" href="${master.get_action_url('edit', instance)}"
                            icon-left="edit"
                            text="Edit This">
               </once-button>
           % endif
-          % if not master.cloning and master.cloneable and master.has_perm('clone'):
-              <once-button tag="a" href="${action_url('clone', instance)}"
+          % if not getattr(master, 'cloning', False) and getattr(master, 'cloneable', False) and master.has_perm('clone'):
+              <once-button tag="a" href="${master.get_action_url('clone', instance)}"
                            icon-left="object-ungroup"
                            text="Clone This">
               </once-button>
           % endif
           % if master.deletable and instance_deletable and master.has_perm('delete'):
-              <once-button tag="a" href="${action_url('delete', instance)}"
+              <once-button tag="a" href="${master.get_action_url('delete', instance)}"
                            type="is-danger"
                            icon-left="trash"
                            text="Delete This">
@@ -976,7 +972,7 @@
       % else:
           ## viewing row
           % if instance_deletable and master.has_perm('delete_row'):
-              <once-button tag="a" href="${action_url('delete', instance)}"
+              <once-button tag="a" href="${master.get_action_url('delete', instance)}"
                            type="is-danger"
                            icon-left="trash"
                            text="Delete This">
@@ -985,13 +981,13 @@
       % endif
   % elif master and master.editing:
       % if master.viewable and master.has_perm('view'):
-          <once-button tag="a" href="${action_url('view', instance)}"
+          <once-button tag="a" href="${master.get_action_url('view', instance)}"
                        icon-left="eye"
                        text="View This">
           </once-button>
       % endif
       % if master.deletable and instance_deletable and master.has_perm('delete'):
-          <once-button tag="a" href="${action_url('delete', instance)}"
+          <once-button tag="a" href="${master.get_action_url('delete', instance)}"
                        type="is-danger"
                        icon-left="trash"
                        text="Delete This">
@@ -999,20 +995,20 @@
       % endif
   % elif master and master.deleting:
       % if master.viewable and master.has_perm('view'):
-          <once-button tag="a" href="${action_url('view', instance)}"
+          <once-button tag="a" href="${master.get_action_url('view', instance)}"
                        icon-left="eye"
                        text="View This">
           </once-button>
       % endif
       % if master.editable and instance_editable and master.has_perm('edit'):
-          <once-button tag="a" href="${action_url('edit', instance)}"
+          <once-button tag="a" href="${master.get_action_url('edit', instance)}"
                        icon-left="edit"
                        text="Edit This">
           </once-button>
       % endif
-  % elif master and master.cloning:
+  % elif master and getattr(master, 'cloning', False):
       % if master.viewable and master.has_perm('view'):
-          <once-button tag="a" href="${action_url('view', instance)}"
+          <once-button tag="a" href="${master.get_action_url('view', instance)}"
                        icon-left="eye"
                        text="View This">
           </once-button>
@@ -1053,9 +1049,7 @@
   % endif
 </%def>
 
-<%def name="declare_whole_page_vars()">
-##   ${multi_file_upload.declare_vars()}
-
+<%def name="render_vue_script_whole_page()">
   <script>
 
     const WholePage = {
@@ -1145,26 +1139,71 @@
   </script>
 </%def>
 
-<%def name="modify_whole_page_vars()"></%def>
+##############################
+## vue components + app
+##############################
 
-## TODO: do we really need this?
-## <%def name="finalize_whole_page_vars()"></%def>
+<%def name="render_vue_templates()">
+##   ${multi_file_upload.render_template()}
+##   ${multi_file_upload.declare_vars()}
 
-<%def name="make_whole_page_component()">
+  ## global components used by various (but not all) pages
+  ${make_field_components()}
+  ${make_grid_filter_components()}
+
+  ## global components for buefy-based template compatibility
+  ${make_http_plugin()}
+  ${make_buefy_plugin()}
+  ${make_buefy_components()}
+
+  ## special global components, used by WholePage
+  ${self.make_menu_search_component()}
+  ${page_help.render_template()}
+  ${page_help.declare_vars()}
+  % if request.has_perm('common.feedback'):
+      ${self.make_feedback_component()}
+  % endif
+
+  ## DEPRECATED; called for back-compat
   ${self.render_whole_page_template()}
+
+  ## DEPRECATED; called for back-compat
   ${self.declare_whole_page_vars()}
+</%def>
+
+## DEPRECATED; remains for back-compat
+<%def name="render_whole_page_template()">
+  ${self.render_vue_template_whole_page()}
+  ${self.render_vue_script_whole_page()}
+</%def>
+
+<%def name="modify_vue_vars()">
+  ## DEPRECATED; called for back-compat
   ${self.modify_whole_page_vars()}
-##   ${self.finalize_whole_page_vars()}
+</%def>
 
+<%def name="make_vue_components()">
   ${page_help.make_component()}
-##   ${multi_file_upload.make_component()}
+  ## ${multi_file_upload.make_component()}
 
+  ## DEPRECATED; called for back-compat (?)
+  ${self.make_whole_page_component()}
+</%def>
+
+## DEPRECATED; remains for back-compat
+<%def name="make_whole_page_component()">
   <script>
     WholePage.data = () => { return WholePageData }
   </script>
   <% request.register_component('whole-page', 'WholePage') %>
 </%def>
 
+<%def name="make_vue_app()">
+  ## DEPRECATED; called for back-compat
+  ${self.make_whole_page_app()}
+</%def>
+
+## DEPRECATED; remains for back-compat
 <%def name="make_whole_page_app()">
   <script type="module">
     import {createApp} from 'vue'
@@ -1196,3 +1235,11 @@
     app.mount('#app')
   </script>
 </%def>
+
+##############################
+## DEPRECATED
+##############################
+
+<%def name="declare_whole_page_vars()"></%def>
+
+<%def name="modify_whole_page_vars()"></%def>
diff --git a/tailbone/templates/themes/butterball/buefy-components.mako b/tailbone/templates/themes/butterball/buefy-components.mako
index 51a0deb9..3a2cd798 100644
--- a/tailbone/templates/themes/butterball/buefy-components.mako
+++ b/tailbone/templates/themes/butterball/buefy-components.mako
@@ -666,6 +666,7 @@
 <%def name="make_b_tooltip_component()">
   <script type="text/x-template" id="b-tooltip-template">
     <o-tooltip :label="label"
+               :position="orugaPosition"
                :multiline="multilined">
       <slot />
     </o-tooltip>
@@ -676,6 +677,14 @@
         props: {
             label: String,
             multilined: Boolean,
+            position: String,
+        },
+        computed: {
+            orugaPosition() {
+                if (this.position) {
+                    return this.position.replace(/^is-/, '')
+                }
+            },
         },
     }
   </script>
diff --git a/tailbone/templates/themes/butterball/field-components.mako b/tailbone/templates/themes/butterball/field-components.mako
index d79c88f4..917083c4 100644
--- a/tailbone/templates/themes/butterball/field-components.mako
+++ b/tailbone/templates/themes/butterball/field-components.mako
@@ -517,6 +517,9 @@
             },
 
             parseTime(value) {
+                if (!value) {
+                    return value
+                }
 
                 if (value.getHours) {
                     return value
diff --git a/tailbone/templates/themes/waterpark/base.mako b/tailbone/templates/themes/waterpark/base.mako
new file mode 100644
index 00000000..774479ba
--- /dev/null
+++ b/tailbone/templates/themes/waterpark/base.mako
@@ -0,0 +1,504 @@
+## -*- coding: utf-8; -*-
+<%inherit file="wuttaweb:templates/base.mako" />
+<%namespace name="base_meta" file="/base_meta.mako" />
+<%namespace file="/formposter.mako" import="declare_formposter_mixin" />
+<%namespace file="/grids/filter-components.mako" import="make_grid_filter_components" />
+<%namespace name="page_help" file="/page_help.mako" />
+
+<%def name="base_styles()">
+  ${parent.base_styles()}
+  ${h.stylesheet_link(request.static_url('tailbone:static/css/diffs.css') + '?ver={}'.format(tailbone.__version__))}
+  <style>
+
+    .filters .filter-fieldname .field,
+    .filters .filter-fieldname .field label {
+        width: 100%;
+    }
+
+    .filters .filter-fieldname,
+    .filters .filter-fieldname .field label,
+    .filters .filter-fieldname .button {
+        justify-content: left;
+    }
+
+    .filters .filter-verb .select,
+    .filters .filter-verb .select select {
+        width: 100%;
+    }
+
+    % if filter_fieldname_width is not Undefined:
+
+        .filters .filter-fieldname,
+        .filters .filter-fieldname .button {
+            min-width: ${filter_fieldname_width};
+        }
+
+        .filters .filter-verb {
+            min-width: ${filter_verb_width};
+        }
+
+    % endif
+
+  </style>
+</%def>
+
+<%def name="before_content()">
+  ## TODO: this must come before the self.body() call..but why?
+  ${declare_formposter_mixin()}
+</%def>
+
+<%def name="render_navbar_brand()">
+  <div class="navbar-brand">
+    <a class="navbar-item" href="${url('home')}"
+       v-show="!menuSearchActive">
+      <div style="display: flex; align-items: center;">
+        ${base_meta.header_logo()}
+        <div id="navbar-brand-title">
+          ${base_meta.global_title()}
+        </div>
+      </div>
+    </a>
+    <div v-show="menuSearchActive"
+         class="navbar-item">
+      <b-autocomplete ref="menuSearchAutocomplete"
+                      v-model="menuSearchTerm"
+                      :data="menuSearchFilteredData"
+                      field="label"
+                      open-on-focus
+                      keep-first
+                      icon-pack="fas"
+                      clearable
+                      @keydown.native="menuSearchKeydown"
+                      @select="menuSearchSelect">
+      </b-autocomplete>
+    </div>
+    <a role="button" class="navbar-burger" data-target="navbar-menu" aria-label="menu" aria-expanded="false">
+      <span aria-hidden="true"></span>
+      <span aria-hidden="true"></span>
+      <span aria-hidden="true"></span>
+    </a>
+  </div>
+</%def>
+
+<%def name="render_navbar_start()">
+  <div class="navbar-start">
+
+    <div v-if="menuSearchData.length"
+         class="navbar-item">
+      <b-button type="is-primary"
+                size="is-small"
+                @click="menuSearchInit()">
+        <span><i class="fa fa-search"></i></span>
+      </b-button>
+    </div>
+
+    % for topitem in menus:
+        % if topitem['is_link']:
+            ${h.link_to(topitem['title'], topitem['url'], target=topitem['target'], class_='navbar-item')}
+        % else:
+            <div class="navbar-item has-dropdown is-hoverable">
+              <a class="navbar-link">${topitem['title']}</a>
+              <div class="navbar-dropdown">
+                % for item in topitem['items']:
+                    % if item['is_menu']:
+                        <% item_hash = id(item) %>
+                        <% toggle = 'menu_{}_shown'.format(item_hash) %>
+                        <div>
+                          <a class="navbar-link" @click.prevent="toggleNestedMenu('${item_hash}')">
+                            ${item['title']}
+                          </a>
+                        </div>
+                        % for subitem in item['items']:
+                            % if subitem['is_sep']:
+                                <hr class="navbar-divider" v-show="${toggle}">
+                            % else:
+                                ${h.link_to("{}".format(subitem['title']), subitem['url'], class_='navbar-item nested', target=subitem['target'], **{'v-show': toggle})}
+                            % endif
+                        % endfor
+                    % else:
+                        % if item['is_sep']:
+                            <hr class="navbar-divider">
+                        % else:
+                            ${h.link_to(item['title'], item['url'], class_='navbar-item', target=item['target'])}
+                        % endif
+                    % endif
+                % endfor
+              </div>
+            </div>
+        % endif
+    % endfor
+
+  </div>
+</%def>
+
+<%def name="render_theme_picker()">
+  % if expose_theme_picker and request.has_perm('common.change_app_theme'):
+      <div class="level-item">
+        ${h.form(url('change_theme'), method="post", ref='themePickerForm')}
+          ${h.csrf_token(request)}
+          <input type="hidden" name="referrer" :value="referrer" />
+          <div style="display: flex; align-items: center; gap: 0.5rem;">
+            <span>Theme:</span>
+            <b-select name="theme"
+                      v-model="globalTheme"
+                      @input="changeTheme()">
+              % for option in theme_picker_options:
+                  <option value="${option.value}">
+                    ${option.label}
+                  </option>
+              % endfor
+            </b-select>
+          </div>
+        ${h.end_form()}
+      </div>
+  % endif
+</%def>
+
+<%def name="render_feedback_button()">
+
+  <div class="level-item">
+    <page-help
+      % if can_edit_help:
+      @configure-fields-help="configureFieldsHelp = true"
+      % endif
+      />
+  </div>
+
+  ${parent.render_feedback_button()}
+</%def>
+
+<%def name="render_crud_header_buttons()">
+  % if master:
+      % if master.viewing:
+          % if instance_editable and master.has_perm('edit'):
+              <wutta-button once
+                            tag="a" href="${master.get_action_url('edit', instance)}"
+                            icon-left="edit"
+                            label="Edit This" />
+          % endif
+          % if getattr(master, 'cloneable', False) and not master.cloning and master.has_perm('clone'):
+              <wutta-button once
+                            tag="a" href="${master.get_action_url('clone', instance)}"
+                            icon-left="object-ungroup"
+                            label="Clone This" />
+          % endif
+          % if instance_deletable and master.has_perm('delete'):
+              <wutta-button once type="is-danger"
+                            tag="a" href="${master.get_action_url('delete', instance)}"
+                            icon-left="trash"
+                            label="Delete This" />
+          % endif
+      % elif master.editing:
+          % if master.has_perm('view'):
+              <wutta-button once
+                            tag="a" href="${master.get_action_url('view', instance)}"
+                            icon-left="eye"
+                            label="View This" />
+          % endif
+          % if instance_deletable and master.has_perm('delete'):
+              <wutta-button once type="is-danger"
+                            tag="a" href="${master.get_action_url('delete', instance)}"
+                            icon-left="trash"
+                            label="Delete This" />
+          % endif
+      % elif master.deleting:
+          % if master.has_perm('view'):
+              <wutta-button once
+                            tag="a" href="${master.get_action_url('view', instance)}"
+                            icon-left="eye"
+                            label="View This" />
+          % endif
+          % if instance_editable and master.has_perm('edit'):
+              <wutta-button once
+                            tag="a" href="${master.get_action_url('edit', instance)}"
+                            icon-left="edit"
+                            label="Edit This" />
+          % endif
+      % endif
+  % endif
+</%def>
+
+<%def name="render_prevnext_header_buttons()">
+  % if show_prev_next is not Undefined and show_prev_next:
+      % if prev_url:
+          <wutta-button once
+                        tag="a" href="${prev_url}"
+                        icon-left="arrow-left"
+                        label="Older" />
+      % else:
+          <b-button tag="a" href="#"
+                    disabled
+                    icon-pack="fas"
+                    icon-left="arrow-left">
+            Older
+          </b-button>
+      % endif
+      % if next_url:
+          <wutta-button once
+                        tag="a" href="${next_url}"
+                        icon-left="arrow-right"
+                        label="Newer" />
+      % else:
+          <b-button tag="a" href="#"
+                    disabled
+                    icon-pack="fas"
+                    icon-left="arrow-right">
+            Newer
+          </b-button>
+      % endif
+  % endif
+</%def>
+
+<%def name="render_this_page_component()">
+  <this-page @change-content-title="changeContentTitle"
+             % if can_edit_help:
+                 :configure-fields-help="configureFieldsHelp"
+             % endif
+             />
+</%def>
+
+<%def name="render_vue_template_feedback()">
+  <script type="text/x-template" id="feedback-template">
+    <div>
+
+      <div class="level-item">
+        <b-button type="is-primary"
+                  @click="showFeedback()"
+                  icon-pack="fas"
+                  icon-left="comment">
+          Feedback
+        </b-button>
+      </div>
+
+      <b-modal has-modal-card
+               :active.sync="showDialog">
+        <div class="modal-card">
+
+          <header class="modal-card-head">
+            <p class="modal-card-title">User Feedback</p>
+          </header>
+
+          <section class="modal-card-body">
+            <p class="block">
+              Questions, suggestions, comments, complaints, etc.
+              <span class="red">regarding this website</span> are
+              welcome and may be submitted below.
+            </p>
+
+            <b-field label="User Name">
+              <b-input v-model="userName"
+                       % if request.user:
+                           disabled
+                       % endif
+                       >
+              </b-input>
+            </b-field>
+
+            <b-field label="Referring URL">
+              <b-input
+                 v-model="referrer"
+                 disabled="true">
+              </b-input>
+            </b-field>
+
+            <b-field label="Message">
+              <b-input type="textarea"
+                       v-model="message"
+                       ref="textarea">
+              </b-input>
+            </b-field>
+
+            % if config.get_bool('tailbone.feedback_allows_reply'):
+                <div class="level">
+                  <div class="level-left">
+                    <div class="level-item">
+                      <b-checkbox v-model="pleaseReply"
+                                  @input="pleaseReplyChanged">
+                        Please email me back{{ pleaseReply ? " at: " : "" }}
+                      </b-checkbox>
+                    </div>
+                    <div class="level-item" v-show="pleaseReply">
+                      <b-input v-model="userEmail"
+                               ref="userEmail">
+                      </b-input>
+                    </div>
+                  </div>
+                </div>
+            % endif
+
+          </section>
+
+          <footer class="modal-card-foot">
+            <b-button @click="showDialog = false">
+              Cancel
+            </b-button>
+            <b-button type="is-primary"
+                      icon-pack="fas"
+                      icon-left="paper-plane"
+                      @click="sendFeedback()"
+                      :disabled="sendingFeedback || !message || !message.trim()">
+              {{ sendingFeedback ? "Working, please wait..." : "Send Message" }}
+            </b-button>
+          </footer>
+        </div>
+      </b-modal>
+
+    </div>
+  </script>
+</%def>
+
+<%def name="render_vue_script_feedback()">
+  ${parent.render_vue_script_feedback()}
+  <script>
+
+    WuttaFeedbackForm.template = '#feedback-template'
+    WuttaFeedbackForm.props.message = String
+
+    % if config.get_bool('tailbone.feedback_allows_reply'):
+
+        WuttaFeedbackFormData.pleaseReply = false
+        WuttaFeedbackFormData.userEmail = null
+
+        WuttaFeedbackForm.methods.pleaseReplyChanged = function(value) {
+            this.$nextTick(() => {
+                this.$refs.userEmail.focus()
+            })
+        }
+
+        WuttaFeedbackForm.methods.getExtraParams = function() {
+            return {
+                please_reply_to: this.pleaseReply ? this.userEmail : null,
+            }
+        }
+
+    % endif
+
+    // TODO: deprecate / remove these
+    const FeedbackForm = WuttaFeedbackForm
+    const FeedbackFormData = WuttaFeedbackFormData
+
+  </script>
+</%def>
+
+<%def name="render_vue_templates()">
+  ${parent.render_vue_templates()}
+  ${page_help.render_template()}
+  ${page_help.declare_vars()}
+</%def>
+
+<%def name="modify_vue_vars()">
+  ${parent.modify_vue_vars()}
+  <script>
+
+    ##############################
+    ## menu search
+    ##############################
+
+    WholePageData.menuSearchActive = false
+    WholePageData.menuSearchTerm = ''
+    WholePageData.menuSearchData = ${json.dumps(global_search_data or [])|n}
+
+    WholePage.computed.menuSearchFilteredData = function() {
+        if (!this.menuSearchTerm.length) {
+            return this.menuSearchData
+        }
+
+        const terms = []
+        for (let term of this.menuSearchTerm.toLowerCase().split(' ')) {
+            term = term.trim()
+            if (term) {
+                terms.push(term)
+            }
+        }
+        if (!terms.length) {
+            return this.menuSearchData
+        }
+
+        // all terms must match
+        return this.menuSearchData.filter((option) => {
+            const label = option.label.toLowerCase()
+            for (const term of terms) {
+                if (label.indexOf(term) < 0) {
+                    return false
+                }
+            }
+            return true
+        })
+    }
+
+    WholePage.methods.globalKey = function(event) {
+
+        // Ctrl+8 opens menu search
+        if (event.target.tagName == 'BODY') {
+            if (event.ctrlKey && event.key == '8') {
+                this.menuSearchInit()
+            }
+        }
+    }
+
+    WholePage.mounted = function() {
+        window.addEventListener('keydown', this.globalKey)
+        for (let hook of this.mountedHooks) {
+            hook(this)
+        }
+    }
+
+    WholePage.beforeDestroy = function() {
+        window.removeEventListener('keydown', this.globalKey)
+    }
+
+    WholePage.methods.menuSearchInit = function() {
+        this.menuSearchTerm = ''
+        this.menuSearchActive = true
+        this.$nextTick(() => {
+            this.$refs.menuSearchAutocomplete.focus()
+        })
+    }
+
+    WholePage.methods.menuSearchKeydown = function(event) {
+
+        // ESC will dismiss searchbox
+        if (event.which == 27) {
+            this.menuSearchActive = false
+        }
+    }
+
+    WholePage.methods.menuSearchSelect = function(option) {
+        location.href = option.url
+    }
+
+    ##############################
+    ## theme picker
+    ##############################
+
+    % if expose_theme_picker and request.has_perm('common.change_app_theme'):
+
+        WholePageData.globalTheme = ${json.dumps(theme or None)|n}
+        ## WholePageData.referrer = location.href
+
+        WholePage.methods.changeTheme = function() {
+            this.$refs.themePickerForm.submit()
+        }
+
+    % endif
+
+    ##############################
+    ## edit fields help
+    ##############################
+
+    % if can_edit_help:
+        WholePageData.configureFieldsHelp = false
+    % endif
+
+  </script>
+</%def>
+
+<%def name="make_vue_components()">
+  ${parent.make_vue_components()}
+  ${h.javascript_link(request.static_url('tailbone:static/js/tailbone.buefy.datepicker.js') + f'?ver={tailbone.__version__}')}
+  ${h.javascript_link(request.static_url('tailbone:static/js/tailbone.buefy.numericinput.js') + f'?ver={tailbone.__version__}')}
+  ${h.javascript_link(request.static_url('tailbone:static/js/tailbone.buefy.oncebutton.js') + f'?ver={tailbone.__version__}')}
+  ${h.javascript_link(request.static_url('tailbone:static/js/tailbone.buefy.timepicker.js') + f'?ver={tailbone.__version__}')}
+  ${make_grid_filter_components()}
+  ${page_help.make_component()}
+</%def>
diff --git a/tailbone/templates/themes/waterpark/configure.mako b/tailbone/templates/themes/waterpark/configure.mako
new file mode 100644
index 00000000..7a3e5261
--- /dev/null
+++ b/tailbone/templates/themes/waterpark/configure.mako
@@ -0,0 +1,78 @@
+## -*- coding: utf-8; -*-
+<%inherit file="wuttaweb:templates/configure.mako" />
+<%namespace name="tailbone_base" file="tailbone:templates/configure.mako" />
+
+<%def name="input_file_templates_section()">
+  ${tailbone_base.input_file_templates_section()}
+</%def>
+
+<%def name="output_file_templates_section()">
+  ${tailbone_base.output_file_templates_section()}
+</%def>
+
+<%def name="modify_vue_vars()">
+  ${parent.modify_vue_vars()}
+  <script>
+
+    ##############################
+    ## input file templates
+    ##############################
+
+    % if input_file_template_settings is not Undefined:
+
+        ThisPageData.inputFileTemplateSettings = ${json.dumps(input_file_template_settings)|n}
+        ThisPageData.inputFileTemplateFileOptions = ${json.dumps(input_file_options)|n}
+        ThisPageData.inputFileTemplateUploads = {
+            % for key in input_file_templates:
+                '${key}': null,
+            % endfor
+        }
+
+        ThisPage.methods.validateInputFileTemplateSettings = function() {
+            % for tmpl in input_file_templates.values():
+                if (this.inputFileTemplateSettings['${tmpl['setting_mode']}'] == 'hosted') {
+                    if (!this.inputFileTemplateSettings['${tmpl['setting_file']}']) {
+                        if (!this.inputFileTemplateUploads['${tmpl['key']}']) {
+                            return "You must provide a file to upload for the ${tmpl['label']} template."
+                        }
+                    }
+                }
+            % endfor
+        }
+
+        ThisPageData.validators.push(ThisPage.methods.validateInputFileTemplateSettings)
+
+    % endif
+
+    ##############################
+    ## output file templates
+    ##############################
+
+    % if output_file_template_settings is not Undefined:
+
+        ThisPageData.outputFileTemplateSettings = ${json.dumps(output_file_template_settings)|n}
+        ThisPageData.outputFileTemplateFileOptions = ${json.dumps(output_file_options)|n}
+        ThisPageData.outputFileTemplateUploads = {
+            % for key in output_file_templates:
+                '${key}': null,
+            % endfor
+        }
+
+        ThisPage.methods.validateOutputFileTemplateSettings = function() {
+            % for tmpl in output_file_templates.values():
+                if (this.outputFileTemplateSettings['${tmpl['setting_mode']}'] == 'hosted') {
+                    if (!this.outputFileTemplateSettings['${tmpl['setting_file']}']) {
+                        if (!this.outputFileTemplateUploads['${tmpl['key']}']) {
+                            return "You must provide a file to upload for the ${tmpl['label']} template."
+                        }
+                    }
+                }
+            % endfor
+        }
+
+        ThisPageData.validators.push(ThisPage.methods.validateOutputFileTemplateSettings)
+
+    % endif
+
+  </script>
+</%def>
diff --git a/tailbone/templates/themes/waterpark/form.mako b/tailbone/templates/themes/waterpark/form.mako
new file mode 100644
index 00000000..f88d6821
--- /dev/null
+++ b/tailbone/templates/themes/waterpark/form.mako
@@ -0,0 +1,10 @@
+## -*- coding: utf-8; -*-
+<%inherit file="wuttaweb:templates/form.mako" />
+
+<%def name="render_vue_template_form()">
+  % if form is not Undefined:
+      ${form.render_vue_template(buttons=capture(self.render_form_buttons))}
+  % endif
+</%def>
+
+<%def name="render_form_buttons()"></%def>
diff --git a/tailbone/templates/themes/waterpark/master/configure.mako b/tailbone/templates/themes/waterpark/master/configure.mako
new file mode 100644
index 00000000..51da5b0a
--- /dev/null
+++ b/tailbone/templates/themes/waterpark/master/configure.mako
@@ -0,0 +1,2 @@
+## -*- coding: utf-8; -*-
+<%inherit file="wuttaweb:templates/master/configure.mako" />
diff --git a/tailbone/templates/themes/waterpark/master/create.mako b/tailbone/templates/themes/waterpark/master/create.mako
new file mode 100644
index 00000000..23399b9e
--- /dev/null
+++ b/tailbone/templates/themes/waterpark/master/create.mako
@@ -0,0 +1,2 @@
+## -*- coding: utf-8; -*-
+<%inherit file="wuttaweb:templates/master/create.mako" />
diff --git a/tailbone/templates/themes/waterpark/master/delete.mako b/tailbone/templates/themes/waterpark/master/delete.mako
new file mode 100644
index 00000000..a15dfaf8
--- /dev/null
+++ b/tailbone/templates/themes/waterpark/master/delete.mako
@@ -0,0 +1,46 @@
+## -*- coding: utf-8; -*-
+<%inherit file="tailbone:templates/form.mako" />
+
+<%def name="title()">Delete ${model_title}: ${instance_title}</%def>
+
+<%def name="render_form()">
+  <br />
+  <b-notification type="is-danger" :closable="false">
+    You are about to delete the following ${model_title} and all associated data:
+  </b-notification>
+  ${parent.render_form()}
+</%def>
+
+<%def name="render_form_buttons()">
+  <br />
+  <b-notification type="is-danger" :closable="false">
+    Are you sure about this?
+  </b-notification>
+  <br />
+
+  ${h.form(request.current_route_url(), **{'@submit': 'submitForm'})}
+  ${h.csrf_token(request)}
+    <div class="buttons">
+      <wutta-button once tag="a" href="${form.cancel_url}"
+                    label="Whoops, nevermind..." />
+      <b-button type="is-primary is-danger"
+                native-type="submit"
+                :disabled="formSubmitting">
+        {{ formSubmitting ? "Working, please wait..." : "${form.button_label_submit}" }}
+      </b-button>
+    </div>
+  ${h.end_form()}
+</%def>
+
+<%def name="modify_vue_vars()">
+  ${parent.modify_vue_vars()}
+  <script>
+
+    ${form.vue_component}Data.formSubmitting = false
+
+    ${form.vue_component}.methods.submitForm = function() {
+        this.formSubmitting = true
+    }
+
+  </script>
+</%def>
diff --git a/tailbone/templates/themes/waterpark/master/edit.mako b/tailbone/templates/themes/waterpark/master/edit.mako
new file mode 100644
index 00000000..18a2fa2f
--- /dev/null
+++ b/tailbone/templates/themes/waterpark/master/edit.mako
@@ -0,0 +1,2 @@
+## -*- coding: utf-8; -*-
+<%inherit file="wuttaweb:templates/master/edit.mako" />
diff --git a/tailbone/templates/themes/waterpark/master/form.mako b/tailbone/templates/themes/waterpark/master/form.mako
new file mode 100644
index 00000000..db56843b
--- /dev/null
+++ b/tailbone/templates/themes/waterpark/master/form.mako
@@ -0,0 +1,2 @@
+## -*- coding: utf-8; -*-
+<%inherit file="wuttaweb:templates/master/form.mako" />
diff --git a/tailbone/templates/themes/waterpark/master/index.mako b/tailbone/templates/themes/waterpark/master/index.mako
new file mode 100644
index 00000000..e6702599
--- /dev/null
+++ b/tailbone/templates/themes/waterpark/master/index.mako
@@ -0,0 +1,299 @@
+## -*- coding: utf-8; -*-
+<%inherit file="wuttaweb:templates/master/index.mako" />
+
+<%def name="grid_tools()">
+
+  ## grid totals
+  % if getattr(master, 'supports_grid_totals', False):
+      <div style="display: flex; align-items: center;">
+        <b-button v-if="gridTotalsDisplay == null"
+                  :disabled="gridTotalsFetching"
+                  @click="gridTotalsFetch()">
+          {{ gridTotalsFetching ? "Working, please wait..." : "Show Totals" }}
+        </b-button>
+        <div v-if="gridTotalsDisplay != null"
+             class="control">
+          Totals: {{ gridTotalsDisplay }}
+        </div>
+      </div>
+  % endif
+
+  ## download search results
+  % if getattr(master, 'results_downloadable', False) and master.has_perm('download_results'):
+      <div>
+        <b-button type="is-primary"
+                  icon-pack="fas"
+                  icon-left="download"
+                  @click="showDownloadResultsDialog = true"
+                  :disabled="!total">
+          Download Results
+        </b-button>
+
+        ${h.form(url('{}.download_results'.format(route_prefix)), ref='download_results_form')}
+        ${h.csrf_token(request)}
+        <input type="hidden" name="fmt" :value="downloadResultsFormat" />
+        <input type="hidden" name="fields" :value="downloadResultsFieldsIncluded" />
+        ${h.end_form()}
+
+        <b-modal :active.sync="showDownloadResultsDialog">
+          <div class="card">
+
+            <div class="card-content">
+              <p>
+                There are
+                <span class="is-size-4 has-text-weight-bold">
+                  {{ total.toLocaleString('en') }} ${model_title_plural}
+                </span>
+                matching your current filters.
+              </p>
+              <p>
+                You may download this set as a single data file if you like.
+              </p>
+              <br />
+
+              <b-notification type="is-warning" :closable="false"
+                              v-if="downloadResultsFormat == 'xlsx' && total >= 1000">
+                Excel downloads for large data sets can take a long time to
+                generate, and bog down the server in the meantime.  You are
+                encouraged to choose CSV for a large data set, even though
+                the end result (file size) may be larger with CSV.
+              </b-notification>
+
+              <div style="display: flex; justify-content: space-between">
+
+                <div>
+                  <b-field label="Format">
+                    <b-select v-model="downloadResultsFormat">
+                      % for key, label in master.download_results_supported_formats().items():
+                      <option value="${key}">${label}</option>
+                      % endfor
+                    </b-select>
+                  </b-field>
+                </div>
+
+                <div>
+
+                  <div v-show="downloadResultsFieldsMode != 'choose'"
+                       class="has-text-right">
+                    <p v-if="downloadResultsFieldsMode == 'default'">
+                      Will use DEFAULT fields.
+                    </p>
+                    <p v-if="downloadResultsFieldsMode == 'all'">
+                      Will use ALL fields.
+                    </p>
+                    <br />
+                  </div>
+
+                  <div class="buttons is-right">
+                    <b-button type="is-primary"
+                              v-show="downloadResultsFieldsMode != 'default'"
+                              @click="downloadResultsUseDefaultFields()">
+                      Use Default Fields
+                    </b-button>
+                    <b-button type="is-primary"
+                              v-show="downloadResultsFieldsMode != 'all'"
+                              @click="downloadResultsUseAllFields()">
+                      Use All Fields
+                    </b-button>
+                    <b-button type="is-primary"
+                              v-show="downloadResultsFieldsMode != 'choose'"
+                              @click="downloadResultsFieldsMode = 'choose'">
+                      Choose Fields
+                    </b-button>
+                  </div>
+
+                  <div v-show="downloadResultsFieldsMode == 'choose'">
+                    <div style="display: flex;">
+                      <div>
+                        <b-field label="Excluded Fields">
+                          <b-select multiple native-size="8"
+                                    expanded
+                                    v-model="downloadResultsExcludedFieldsSelected"
+                                    ref="downloadResultsExcludedFields">
+                            <option v-for="field in downloadResultsFieldsExcluded"
+                                    :key="field"
+                                    :value="field">
+                              {{ field }}
+                            </option>
+                          </b-select>
+                        </b-field>
+                      </div>
+                      <div>
+                        <br /><br />
+                        <b-button style="margin: 0.5rem;"
+                                  @click="downloadResultsExcludeFields()">
+                          &lt;
+                        </b-button>
+                        <br />
+                        <b-button style="margin: 0.5rem;"
+                                  @click="downloadResultsIncludeFields()">
+                          &gt;
+                        </b-button>
+                      </div>
+                      <div>
+                        <b-field label="Included Fields">
+                          <b-select multiple native-size="8"
+                                    expanded
+                                    v-model="downloadResultsIncludedFieldsSelected"
+                                    ref="downloadResultsIncludedFields">
+                            <option v-for="field in downloadResultsFieldsIncluded"
+                                    :key="field"
+                                    :value="field">
+                              {{ field }}
+                            </option>
+                          </b-select>
+                        </b-field>
+                      </div>
+                    </div>
+                  </div>
+
+                </div>
+              </div>
+            </div> <!-- card-content -->
+
+            <footer class="modal-card-foot">
+              <b-button @click="showDownloadResultsDialog = false">
+                Cancel
+              </b-button>
+              <once-button type="is-primary"
+                           @click="downloadResultsSubmit()"
+                           icon-pack="fas"
+                           icon-left="download"
+                           :disabled="!downloadResultsFieldsIncluded.length"
+                           text="Download Results">
+              </once-button>
+            </footer>
+          </div>
+        </b-modal>
+      </div>
+  % endif
+
+  ## download rows for search results
+  % if getattr(master, 'has_rows', False) and master.results_rows_downloadable and master.has_perm('download_results_rows'):
+      <b-button type="is-primary"
+                icon-pack="fas"
+                icon-left="download"
+                @click="downloadResultsRows()"
+                :disabled="downloadResultsRowsButtonDisabled">
+        {{ downloadResultsRowsButtonText }}
+      </b-button>
+      ${h.form(url('{}.download_results_rows'.format(route_prefix)), ref='downloadResultsRowsForm')}
+      ${h.csrf_token(request)}
+      ${h.end_form()}
+  % endif
+
+  ## merge 2 objects
+  % if getattr(master, 'mergeable', False) and request.has_perm('{}.merge'.format(permission_prefix)):
+
+      ${h.form(url('{}.merge'.format(route_prefix)), class_='control', **{'@submit': 'submitMergeForm'})}
+      ${h.csrf_token(request)}
+      <input type="hidden"
+             name="uuids"
+             :value="checkedRowUUIDs()" />
+      <b-button type="is-primary"
+                native-type="submit"
+                icon-pack="fas"
+                icon-left="object-ungroup"
+                :disabled="mergeFormSubmitting || checkedRows.length != 2">
+        {{ mergeFormButtonText }}
+      </b-button>
+      ${h.end_form()}
+  % endif
+
+  ## enable / disable selected objects
+  % if getattr(master, 'supports_set_enabled_toggle', False) and master.has_perm('enable_disable_set'):
+
+      ${h.form(url('{}.enable_set'.format(route_prefix)), class_='control', ref='enable_selected_form')}
+      ${h.csrf_token(request)}
+      ${h.hidden('uuids', v_model='selected_uuids')}
+      <b-button :disabled="enableSelectedDisabled"
+                @click="enableSelectedSubmit()">
+        {{ enableSelectedText }}
+      </b-button>
+      ${h.end_form()}
+
+      ${h.form(url('{}.disable_set'.format(route_prefix)), ref='disable_selected_form', class_='control')}
+      ${h.csrf_token(request)}
+      ${h.hidden('uuids', v_model='selected_uuids')}
+      <b-button :disabled="disableSelectedDisabled"
+                @click="disableSelectedSubmit()">
+        {{ disableSelectedText }}
+      </b-button>
+      ${h.end_form()}
+  % endif
+
+  ## delete selected objects
+  % if getattr(master, 'set_deletable', False) and master.has_perm('delete_set'):
+      ${h.form(url('{}.delete_set'.format(route_prefix)), ref='delete_selected_form', class_='control')}
+      ${h.csrf_token(request)}
+      ${h.hidden('uuids', v_model='selected_uuids')}
+      <b-button type="is-danger"
+                :disabled="deleteSelectedDisabled"
+                @click="deleteSelectedSubmit()"
+                icon-pack="fas"
+                icon-left="trash">
+        {{ deleteSelectedText }}
+      </b-button>
+      ${h.end_form()}
+  % endif
+
+  ## delete search results
+  % if getattr(master, 'bulk_deletable', False) and request.has_perm('{}.bulk_delete'.format(permission_prefix)):
+      ${h.form(url('{}.bulk_delete'.format(route_prefix)), ref='delete_results_form', class_='control')}
+      ${h.csrf_token(request)}
+      <b-button type="is-danger"
+                :disabled="deleteResultsDisabled"
+                :title="total ? null : 'There are no results to delete'"
+                @click="deleteResultsSubmit()"
+                icon-pack="fas"
+                icon-left="trash">
+        {{ deleteResultsText }}
+      </b-button>
+      ${h.end_form()}
+  % endif
+
+</%def>
+
+## DEPRECATED; remains for back-compat
+<%def name="render_this_page()">
+  ${self.page_content()}
+</%def>
+
+<%def name="render_vue_template_grid()">
+  ${grid.render_vue_template(tools=capture(self.grid_tools).strip(), context_menu=capture(self.context_menu_items).strip())}
+</%def>
+
+<%def name="modify_vue_vars()">
+  ${parent.modify_vue_vars()}
+  <script>
+
+    % if getattr(master, 'bulk_deletable', False) and master.has_perm('bulk_delete'):
+
+        ${grid.vue_component}Data.deleteResultsSubmitting = false
+        ${grid.vue_component}Data.deleteResultsText = "Delete Results"
+
+        ${grid.vue_component}.computed.deleteResultsDisabled = function() {
+            if (this.deleteResultsSubmitting) {
+                return true
+            }
+            if (!this.total) {
+                return true
+            }
+            return false
+        }
+
+        ${grid.vue_component}.methods.deleteResultsSubmit = function() {
+            // TODO: show "plural model title" here?
+            if (!confirm("You are about to delete " + this.total.toLocaleString('en') + " objects.\n\nAre you sure?")) {
+                return
+            }
+
+            this.deleteResultsSubmitting = true
+            this.deleteResultsText = "Working, please wait..."
+            this.$refs.delete_results_form.submit()
+        }
+
+    % endif
+
+  </script>
+</%def>
diff --git a/tailbone/templates/themes/waterpark/master/view.mako b/tailbone/templates/themes/waterpark/master/view.mako
new file mode 100644
index 00000000..99194469
--- /dev/null
+++ b/tailbone/templates/themes/waterpark/master/view.mako
@@ -0,0 +1,2 @@
+## -*- coding: utf-8; -*-
+<%inherit file="wuttaweb:templates/master/view.mako" />
diff --git a/tailbone/templates/themes/waterpark/page.mako b/tailbone/templates/themes/waterpark/page.mako
new file mode 100644
index 00000000..66ce47dc
--- /dev/null
+++ b/tailbone/templates/themes/waterpark/page.mako
@@ -0,0 +1,48 @@
+## -*- coding: utf-8; -*-
+<%inherit file="wuttaweb:templates/page.mako" />
+
+<%def name="render_vue_template_this_page()">
+  <script type="text/x-template" id="this-page-template">
+    <div style="height: 100%;">
+      ## DEPRECATED; called for back-compat
+      ${self.render_this_page()}
+    </div>
+  </script>
+</%def>
+
+## DEPRECATED; remains for back-compat
+<%def name="render_this_page()">
+  <div style="display: flex;">
+
+    <div class="this-page-content" style="flex-grow: 1;">
+      ${self.page_content()}
+    </div>
+
+    ## DEPRECATED; remains for back-compat
+    <ul id="context-menu">
+      ${self.context_menu_items()}
+    </ul>
+  </div>
+</%def>
+
+## DEPRECATED; remains for back-compat
+<%def name="context_menu_items()">
+  % if context_menu_list_items is not Undefined:
+      % for item in context_menu_list_items:
+          <li>${item}</li>
+      % endfor
+  % endif
+</%def>
+
+<%def name="modify_vue_vars()">
+  ${parent.modify_vue_vars()}
+  <script>
+
+    ThisPageData.csrftoken = ${json.dumps(h.get_csrf_token(request))|n}
+
+    % if can_edit_help:
+        ThisPage.props.configureFieldsHelp = Boolean
+    % endif
+
+  </script>
+</%def>
diff --git a/tailbone/templates/trainwreck/transactions/configure.mako b/tailbone/templates/trainwreck/transactions/configure.mako
index fd6c53a7..10c57e18 100644
--- a/tailbone/templates/trainwreck/transactions/configure.mako
+++ b/tailbone/templates/trainwreck/transactions/configure.mako
@@ -3,6 +3,19 @@
 
 <%def name="form_content()">
 
+  <h3 class="block is-size-3">Display</h3>
+  <div class="block" style="padding-left: 2rem;">
+
+    <b-field>
+      <b-checkbox name="tailbone.trainwreck.view_txn.autocollapse_header"
+                  v-model="simpleSettings['tailbone.trainwreck.view_txn.autocollapse_header']"
+                  native-value="true"
+                  @input="settingsNeedSaved = true">
+        Auto-collapse header when viewing transaction
+      </b-checkbox>
+    </b-field>
+  </div>
+
   <h3 class="block is-size-3">Rotation</h3>
   <div class="block" style="padding-left: 2rem;">
 
@@ -33,7 +46,7 @@
       The selected DBs will be hidden from the DB picker when viewing
       Trainwreck data.
     </p>
-    % for key, engine in six.iteritems(trainwreck_engines):
+    % for key, engine in trainwreck_engines.items():
         <b-field>
           <b-checkbox name="hidedb_${key}"
                       v-model="hiddenDatabases['${key}']"
@@ -49,14 +62,9 @@
   </div>
 </%def>
 
-<%def name="modify_this_page_vars()">
-  ${parent.modify_this_page_vars()}
-  <script type="text/javascript">
-
+<%def name="modify_vue_vars()">
+  ${parent.modify_vue_vars()}
+  <script>
     ThisPageData.hiddenDatabases = ${json.dumps(hidden_databases)|n}
-
   </script>
 </%def>
-
-
-${parent.body()}
diff --git a/tailbone/templates/trainwreck/transactions/rollover.mako b/tailbone/templates/trainwreck/transactions/rollover.mako
index 8e27d087..f26515b5 100644
--- a/tailbone/templates/trainwreck/transactions/rollover.mako
+++ b/tailbone/templates/trainwreck/transactions/rollover.mako
@@ -8,7 +8,7 @@
 <%def name="page_content()">
   <br />
 
-  % if six.text_type(next_year) not in trainwreck_engines:
+  % if str(next_year) not in trainwreck_engines:
       <b-notification type="is-warning">
         You do not have a database configured for next year (${next_year}).&nbsp;
         You should be sure to configure it before next year rolls around.
@@ -48,14 +48,9 @@
   </b-table>
 </%def>
 
-<%def name="modify_this_page_vars()">
-  ${parent.modify_this_page_vars()}
-  <script type="text/javascript">
-
+<%def name="modify_vue_vars()">
+  ${parent.modify_vue_vars()}
+  <script>
     ThisPageData.engines = ${json.dumps(engines_data)|n}
-
   </script>
 </%def>
-
-
-${parent.body()}
diff --git a/tailbone/templates/trainwreck/transactions/view.mako b/tailbone/templates/trainwreck/transactions/view.mako
index 2be51c7d..630950cf 100644
--- a/tailbone/templates/trainwreck/transactions/view.mako
+++ b/tailbone/templates/trainwreck/transactions/view.mako
@@ -1,15 +1,11 @@
 ## -*- coding: utf-8; -*-
 <%inherit file="/master/view.mako" />
 
-<%def name="modify_this_page_vars()">
-  ${parent.modify_this_page_vars()}
-  <script type="text/javascript">
-
+<%def name="modify_vue_vars()">
+  ${parent.modify_vue_vars()}
+  <script>
     % if custorder_xref_markers_data is not Undefined:
-        ${form.component_studly}Data.custorderXrefMarkersData = ${json.dumps(custorder_xref_markers_data)|n}
+        ${form.vue_component}Data.custorderXrefMarkersData = ${json.dumps(custorder_xref_markers_data)|n}
     % endif
-
   </script>
 </%def>
-
-${parent.body()}
diff --git a/tailbone/templates/trainwreck/transactions/view_row.mako b/tailbone/templates/trainwreck/transactions/view_row.mako
index 9abcb8ba..2507492e 100644
--- a/tailbone/templates/trainwreck/transactions/view_row.mako
+++ b/tailbone/templates/trainwreck/transactions/view_row.mako
@@ -1,16 +1,11 @@
 ## -*- coding: utf-8; -*-
 <%inherit file="/master/view_row.mako" />
 
-<%def name="modify_this_page_vars()">
-  ${parent.modify_this_page_vars()}
-  <script type="text/javascript">
-
+<%def name="modify_vue_vars()">
+  ${parent.modify_vue_vars()}
+  <script>
     % if discounts_data is not Undefined:
-        ${form.component_studly}Data.discountsData = ${json.dumps(discounts_data)|n}
+        ${form.vue_component}Data.discountsData = ${json.dumps(discounts_data)|n}
     % endif
-
   </script>
 </%def>
-
-
-${parent.body()}
diff --git a/tailbone/templates/units-of-measure/index.mako b/tailbone/templates/units-of-measure/index.mako
index 597cabfd..4815fc79 100644
--- a/tailbone/templates/units-of-measure/index.mako
+++ b/tailbone/templates/units-of-measure/index.mako
@@ -51,20 +51,17 @@
   % endif
 </%def>
 
-<%def name="modify_this_page_vars()">
-  ${parent.modify_this_page_vars()}
+<%def name="modify_vue_vars()">
+  ${parent.modify_vue_vars()}
   % if master.has_perm('collect_wild_uoms'):
-  <script type="text/javascript">
+      <script>
 
-    TailboneGridData.showingCollectWildDialog = false
+        ${grid.vue_component}Data.showingCollectWildDialog = false
 
-    TailboneGrid.methods.collectFromWild = function() {
-        this.$refs['collect-wild-uoms-form'].submit()
-    }
+        ${grid.vue_component}.methods.collectFromWild = function() {
+            this.$refs['collect-wild-uoms-form'].submit()
+        }
 
-  </script>
+      </script>
   % endif
 </%def>
-
-
-${parent.body()}
diff --git a/tailbone/templates/upgrades/configure.mako b/tailbone/templates/upgrades/configure.mako
index f7af685c..9439f830 100644
--- a/tailbone/templates/upgrades/configure.mako
+++ b/tailbone/templates/upgrades/configure.mako
@@ -111,9 +111,9 @@
   </div>
 </%def>
 
-<%def name="modify_this_page_vars()">
-  ${parent.modify_this_page_vars()}
-  <script type="text/javascript">
+<%def name="modify_vue_vars()">
+  ${parent.modify_vue_vars()}
+  <script>
 
     ThisPageData.upgradeSystems = ${json.dumps(upgrade_systems)|n}
     ThisPageData.upgradeSystemShowDialog = false
@@ -161,6 +161,3 @@
 
   </script>
 </%def>
-
-
-${parent.body()}
diff --git a/tailbone/templates/upgrades/view.mako b/tailbone/templates/upgrades/view.mako
index 6ae110e0..c3fca81d 100644
--- a/tailbone/templates/upgrades/view.mako
+++ b/tailbone/templates/upgrades/view.mako
@@ -137,11 +137,11 @@
   % endif
 </%def>
 
-<%def name="modify_this_page_vars()">
-  ${parent.modify_this_page_vars()}
-  <script type="text/javascript">
+<%def name="modify_vue_vars()">
+  ${parent.modify_vue_vars()}
+  <script>
 
-    TailboneFormData.showingPackages = 'diffs'
+    ${form.vue_component}Data.showingPackages = 'diffs'
 
     % if master.has_perm('execute'):
 
@@ -153,7 +153,7 @@
             // execute upgrade
             //////////////////////////////
 
-            TailboneForm.props.upgradeExecuting = {
+            ${form.vue_component}.props.upgradeExecuting = {
                 type: Boolean,
                 default: false,
             }
@@ -253,9 +253,9 @@
             // execute upgrade
             //////////////////////////////
 
-            TailboneFormData.formSubmitting = false
+            ${form.vue_component}Data.formSubmitting = false
 
-            TailboneForm.methods.submitForm = function() {
+            ${form.vue_component}.methods.submitForm = function() {
                 this.formSubmitting = true
             }
 
@@ -265,12 +265,12 @@
         // declare failure
         //////////////////////////////
 
-        TailboneForm.props.declareFailureSubmitting = {
+        ${form.vue_component}.props.declareFailureSubmitting = {
             type: Boolean,
             default: false,
         }
 
-        TailboneForm.methods.declareFailureClick = function() {
+        ${form.vue_component}.methods.declareFailureClick = function() {
             this.$emit('declare-failure-click')
         }
 
@@ -287,6 +287,3 @@
 
   </script>
 </%def>
-
-
-${parent.body()}
diff --git a/tailbone/templates/users/preferences.mako b/tailbone/templates/users/preferences.mako
index c2e17396..ecfdd1c7 100644
--- a/tailbone/templates/users/preferences.mako
+++ b/tailbone/templates/users/preferences.mako
@@ -42,14 +42,9 @@
   </div>
 </%def>
 
-<%def name="modify_this_page_vars()">
-  ${parent.modify_this_page_vars()}
-  <script type="text/javascript">
-
+<%def name="modify_vue_vars()">
+  ${parent.modify_vue_vars()}
+  <script>
     ThisPageData.themeStyleOptions = ${json.dumps(theme_style_options)|n}
-
   </script>
 </%def>
-
-
-${parent.body()}
diff --git a/tailbone/templates/users/view.mako b/tailbone/templates/users/view.mako
index ed2b5f16..d1afd218 100644
--- a/tailbone/templates/users/view.mako
+++ b/tailbone/templates/users/view.mako
@@ -76,12 +76,12 @@
   % endif
 </%def>
 
-<%def name="modify_this_page_vars()">
-  ${parent.modify_this_page_vars()}
+<%def name="modify_vue_vars()">
+  ${parent.modify_vue_vars()}
   % if master.has_perm('manage_api_tokens'):
-    <script type="text/javascript">
+    <script>
 
-      ${form.component_studly}.props.apiTokens = null
+      ${form.vue_component}.props.apiTokens = null
 
       ThisPageData.apiTokens = ${json.dumps(api_tokens_data)|n}
 
@@ -134,6 +134,3 @@
     </script>
   % endif
 </%def>
-
-
-${parent.body()}
diff --git a/tailbone/templates/vendors/configure.mako b/tailbone/templates/vendors/configure.mako
index 79dad455..6b135346 100644
--- a/tailbone/templates/vendors/configure.mako
+++ b/tailbone/templates/vendors/configure.mako
@@ -44,14 +44,9 @@
   </div>
 </%def>
 
-<%def name="modify_this_page_vars()">
-  ${parent.modify_this_page_vars()}
-  <script type="text/javascript">
-
+<%def name="modify_vue_vars()">
+  ${parent.modify_vue_vars()}
+  <script>
     ThisPageData.supportedVendorSettings = ${json.dumps(supported_vendor_settings)|n}
-
   </script>
 </%def>
-
-
-${parent.body()}
diff --git a/tailbone/templates/views/model/create.mako b/tailbone/templates/views/model/create.mako
index c5e22cfb..e902fd48 100644
--- a/tailbone/templates/views/model/create.mako
+++ b/tailbone/templates/views/model/create.mako
@@ -259,9 +259,9 @@ def includeme(config):
   </b-steps>
 </%def>
 
-<%def name="modify_this_page_vars()">
-  ${parent.modify_this_page_vars()}
-  <script type="text/javascript">
+<%def name="modify_vue_vars()">
+  ${parent.modify_vue_vars()}
+  <script>
 
     ThisPageData.activeStep = 'enter-details'
 
@@ -334,6 +334,3 @@ def includeme(config):
 
   </script>
 </%def>
-
-
-${parent.body()}
diff --git a/tailbone/templates/workorders/view.mako b/tailbone/templates/workorders/view.mako
index 8740b4c9..432e011d 100644
--- a/tailbone/templates/workorders/view.mako
+++ b/tailbone/templates/workorders/view.mako
@@ -145,9 +145,9 @@
   </nav>
 </%def>
 
-<%def name="modify_this_page_vars()">
-  ${parent.modify_this_page_vars()}
-  <script type="text/javascript">
+<%def name="modify_vue_vars()">
+  ${parent.modify_vue_vars()}
+  <script>
 
     ThisPageData.receiveButtonDisabled = false
     ThisPageData.receiveButtonText = "I've received the order from customer"
@@ -216,6 +216,3 @@
 
   </script>
 </%def>
-
-
-${parent.body()}
diff --git a/tailbone/tweens.py b/tailbone/tweens.py
index f944a66f..9c06c1be 100644
--- a/tailbone/tweens.py
+++ b/tailbone/tweens.py
@@ -2,7 +2,7 @@
 ################################################################################
 #
 #  Rattail -- Retail Software Framework
-#  Copyright © 2010-2018 Lance Edgar
+#  Copyright © 2010-2024 Lance Edgar
 #
 #  This file is part of Rattail.
 #
@@ -24,9 +24,6 @@
 Tween Factories
 """
 
-from __future__ import unicode_literals, absolute_import
-
-import six
 from sqlalchemy.exc import OperationalError
 
 
@@ -64,7 +61,7 @@ def sqlerror_tween_factory(handler, registry):
                     mark_error_retryable(error)
                     raise error
                 else:
-                    raise TransientError(six.text_type(error))
+                    raise TransientError(str(error))
 
             # if connection was *not* invalid, raise original error
             raise
diff --git a/tailbone/util.py b/tailbone/util.py
index 7d838541..71aa35e3 100644
--- a/tailbone/util.py
+++ b/tailbone/util.py
@@ -25,6 +25,7 @@ Utilities
 """
 
 import datetime
+import importlib
 import logging
 import warnings
 
@@ -38,6 +39,12 @@ from pyramid.renderers import get_renderer
 from pyramid.interfaces import IRoutesMapper
 from webhelpers2.html import HTML, tags
 
+from wuttaweb.util import (get_form_data as wutta_get_form_data,
+                           get_libver as wutta_get_libver,
+                           get_liburl as wutta_get_liburl,
+                           get_csrf_token as wutta_get_csrf_token,
+                           render_csrf_token)
+
 
 log = logging.getLogger(__name__)
 
@@ -54,37 +61,30 @@ class SortColumn(object):
 
 
 def get_csrf_token(request):
-    """
-    Convenience function to retrieve the effective CSRF token for the given
-    request.
-    """
-    token = request.session.get_csrf_token()
-    if token is None:
-        token = request.session.new_csrf_token()
-    return token
+    """ """
+    warnings.warn("tailbone.util.get_csrf_token() is deprecated; "
+                  "please use wuttaweb.util.get_csrf_token() instead",
+                  DeprecationWarning, stacklevel=2)
+    return wutta_get_csrf_token(request)
 
 
 def csrf_token(request, name='_csrf'):
-    """
-    Convenience function. Returns CSRF hidden tag inside hidden DIV.
-    """
-    token = get_csrf_token(request)
-    return HTML.tag("div", tags.hidden(name, value=token), style="display:none;")
+    """ """
+    warnings.warn("tailbone.util.csrf_token() is deprecated; "
+                  "please use wuttaweb.util.render_csrf_token() instead",
+                  DeprecationWarning, stacklevel=2)
+    return render_csrf_token(request, name=name)
 
 
 def get_form_data(request):
     """
-    Returns the effective form data for the given request.  Mostly
-    this is a convenience, to return either POST or JSON depending on
-    the type of request.
+    DEPECATED - use :func:`wuttaweb:wuttaweb.util.get_form_data()`
+    instead.
     """
-    # nb. we prefer JSON only if no POST is present
-    # TODO: this seems to work for our use case at least, but perhaps
-    # there is a better way?  see also
-    # https://docs.pylonsproject.org/projects/pyramid/en/latest/api/request.html#pyramid.request.Request.is_xhr
-    if (request.is_xhr or request.content_type == 'application/json') and not request.POST:
-        return request.json_body
-    return request.POST
+    warnings.warn("tailbone.util.get_form_data() is deprecated; "
+                  "please use wuttaweb.util.get_form_data() instead",
+                  DeprecationWarning, stacklevel=2)
+    return wutta_get_form_data(request)
 
 
 def get_global_search_options(request):
@@ -104,132 +104,32 @@ def get_global_search_options(request):
     return options
 
 
-def get_libver(request, key, fallback=True, default_only=False):
+def get_libver(request, key, fallback=True, default_only=False): # pragma: no cover
     """
-    Return the appropriate URL for the library identified by ``key``.
+    DEPRECATED - use :func:`wuttaweb:wuttaweb.util.get_libver()`
+    instead.
     """
-    config = request.rattail_config
+    warnings.warn("tailbone.util.get_libver() is deprecated; "
+                  "please use wuttaweb.util.get_libver() instead",
+                  DeprecationWarning, stacklevel=2)
 
-    if not default_only:
-        version = config.get('tailbone', 'libver.{}'.format(key))
-        if version:
-            return version
-
-    if not fallback and not default_only:
-
-        if key == 'buefy':
-            version = config.get('tailbone', 'buefy_version')
-            if version:
-                return version
-
-        elif key == 'buefy.css':
-            version = get_libver(request, 'buefy', fallback=False)
-            if version:
-                return version
-
-        elif key == 'vue':
-            version = config.get('tailbone', 'vue_version')
-            if version:
-                return version
-
-        return
-
-    if key == 'buefy':
-        if not default_only:
-            version = config.get('tailbone', 'buefy_version')
-            if version:
-                return version
-        return 'latest'
-
-    elif key == 'buefy.css':
-        version = get_libver(request, 'buefy', default_only=default_only)
-        if version:
-            return version
-        return 'latest'
-
-    elif key == 'vue':
-        if not default_only:
-            version = config.get('tailbone', 'vue_version')
-            if version:
-                return version
-        return '2.6.14'
-
-    elif key == 'vue_resource':
-        return 'latest'
-
-    elif key == 'fontawesome':
-        return '5.3.1'
-
-    elif key == 'bb_vue':
-        # TODO: iiuc vue 3.4 does not work with oruga yet
-        return '3.3.11'
-
-    elif key == 'bb_oruga':
-        return '0.8.9'
-
-    elif key in ('bb_oruga_bulma', 'bb_oruga_bulma_css'):
-        return '0.3.0'
-
-    elif key == 'bb_fontawesome_svg_core':
-        return '6.5.2'
-
-    elif key == 'bb_free_solid_svg_icons':
-        return '6.5.2'
-
-    elif key == 'bb_vue_fontawesome':
-        return '3.0.6'
+    return wutta_get_libver(request, key, prefix='tailbone',
+                            configured_only=not fallback,
+                            default_only=default_only)
 
 
-def get_liburl(request, key, fallback=True):
+def get_liburl(request, key, fallback=True): # pragma: no cover
     """
-    Return the appropriate URL for the library identified by ``key``.
+    DEPRECATED - use :func:`wuttaweb:wuttaweb.util.get_liburl()`
+    instead.
     """
-    config = request.rattail_config
+    warnings.warn("tailbone.util.get_liburl() is deprecated; "
+                  "please use wuttaweb.util.get_liburl() instead",
+                  DeprecationWarning, stacklevel=2)
 
-    url = config.get('tailbone', 'liburl.{}'.format(key))
-    if url:
-        return url
-
-    if not fallback:
-        return
-
-    version = get_libver(request, key)
-
-    if key == 'buefy':
-        return 'https://unpkg.com/buefy@{}/dist/buefy.min.js'.format(version)
-
-    elif key == 'buefy.css':
-        return 'https://unpkg.com/buefy@{}/dist/buefy.min.css'.format(version)
-
-    elif key == 'vue':
-        return 'https://unpkg.com/vue@{}/dist/vue.min.js'.format(version)
-
-    elif key == 'vue_resource':
-        return 'https://cdn.jsdelivr.net/npm/vue-resource@{}'.format(version)
-
-    elif key == 'fontawesome':
-        return 'https://use.fontawesome.com/releases/v{}/js/all.js'.format(version)
-
-    elif key == 'bb_vue':
-        return f'https://unpkg.com/vue@{version}/dist/vue.esm-browser.prod.js'
-
-    elif key == 'bb_oruga':
-        return f'https://unpkg.com/@oruga-ui/oruga-next@{version}/dist/oruga.mjs'
-
-    elif key == 'bb_oruga_bulma':
-        return f'https://unpkg.com/@oruga-ui/theme-bulma@{version}/dist/bulma.mjs'
-
-    elif key == 'bb_oruga_bulma_css':
-        return f'https://unpkg.com/@oruga-ui/theme-bulma@{version}/dist/bulma.css'
-
-    elif key == 'bb_fontawesome_svg_core':
-        return f'https://cdn.jsdelivr.net/npm/@fortawesome/fontawesome-svg-core@{version}/+esm'
-
-    elif key == 'bb_free_solid_svg_icons':
-        return f'https://cdn.jsdelivr.net/npm/@fortawesome/free-solid-svg-icons@{version}/+esm'
-
-    elif key == 'bb_vue_fontawesome':
-        return f'https://cdn.jsdelivr.net/npm/@fortawesome/vue-fontawesome@{version}/+esm'
+    return wutta_get_liburl(request, key, prefix='tailbone',
+                            configured_only=not fallback,
+                            default_only=False)
 
 
 def pretty_datetime(config, value):
@@ -438,8 +338,8 @@ def should_use_oruga(request):
     supports (and therefore should use) Oruga + Vue 3 as opposed to
     the default of Buefy + Vue 2.
     """
-    theme = request.registry.settings['tailbone.theme']
-    if 'butterball' in theme:
+    theme = request.registry.settings.get('tailbone.theme')
+    if theme and 'butterball' in theme:
         return True
     return False
 
@@ -483,7 +383,7 @@ def include_configured_views(pyramid_config):
     """
     rattail_config = pyramid_config.registry.settings.get('rattail_config')
     app = rattail_config.get_app()
-    model = rattail_config.get_model()
+    model = app.model
     session = app.make_session()
 
     # fetch all include-related settings at once
diff --git a/tailbone/views/asgi/__init__.py b/tailbone/views/asgi/__init__.py
index bebe16f3..33888654 100644
--- a/tailbone/views/asgi/__init__.py
+++ b/tailbone/views/asgi/__init__.py
@@ -2,7 +2,7 @@
 ################################################################################
 #
 #  Rattail -- Retail Software Framework
-#  Copyright © 2010-2023 Lance Edgar
+#  Copyright © 2010-2024 Lance Edgar
 #
 #  This file is part of Rattail.
 #
@@ -41,12 +41,13 @@ class MockRequest(dict):
         pass
 
 
-class WebsocketView(object):
+class WebsocketView:
 
     def __init__(self, pyramid_config):
         self.pyramid_config = pyramid_config
         self.registry = self.pyramid_config.registry
-        self.model = self.rattail_config.get_model()
+        app = self.get_rattail_app()
+        self.model = app.model
 
     @property
     def rattail_config(self):
diff --git a/tailbone/views/auth.py b/tailbone/views/auth.py
index 0f0d1687..eceab803 100644
--- a/tailbone/views/auth.py
+++ b/tailbone/views/auth.py
@@ -24,8 +24,6 @@
 Auth Views
 """
 
-from rattail.db.auth import set_user_password
-
 import colander
 from deform import widget as dfwidget
 from pyramid.httpexceptions import HTTPForbidden
@@ -46,28 +44,6 @@ class UserLogin(colander.MappingSchema):
                                    widget=dfwidget.PasswordWidget())
 
 
-@colander.deferred
-def current_password_correct(node, kw):
-    request = kw['request']
-    app = request.rattail_config.get_app()
-    auth = app.get_auth_handler()
-    user = kw['user']
-    def validate(node, value):
-        if not auth.authenticate_user(Session(), user.username, value):
-            raise colander.Invalid(node, "The password is incorrect")
-    return validate
-
-
-class ChangePassword(colander.MappingSchema):
-
-    current_password = colander.SchemaNode(colander.String(),
-                                           widget=dfwidget.PasswordWidget(),
-                                           validator=current_password_correct)
-
-    new_password = colander.SchemaNode(colander.String(),
-                                       widget=dfwidget.CheckedPasswordWidget())
-
-
 class AuthenticationView(View):
 
     def forbidden(self):
@@ -92,6 +68,7 @@ class AuthenticationView(View):
         """
         The login view, responsible for displaying and handling the login form.
         """
+        app = self.get_rattail_app()
         referrer = self.request.get_referrer(default=self.request.route_url('home'))
 
         # redirect if already logged in
@@ -103,6 +80,7 @@ class AuthenticationView(View):
         form.save_label = "Login"
         form.show_reset = True
         form.show_cancel = False
+        form.button_icon_submit = 'user'
         if form.validate():
             user = self.authenticate_user(form.validated['username'],
                                           form.validated['password'])
@@ -116,10 +94,6 @@ class AuthenticationView(View):
             else:
                 self.request.session.flash("Invalid username or password", 'error')
 
-        image_url = self.rattail_config.get(
-            'tailbone', 'main_image_url',
-            default=self.request.static_url('tailbone:static/img/home_logo.png'))
-
         # nb. hacky..but necessary, to add the refs, for autofocus
         # (also add key handler, so ENTER acts like TAB)
         dform = form.make_deform_form()
@@ -132,8 +106,7 @@ class AuthenticationView(View):
         return {
             'form': form,
             'referrer': referrer,
-            'image_url': image_url,
-            'index_title': self.rattail_config.node_title(),
+            'index_title': app.get_node_title(),
             'help_url': global_help_url(self.rattail_config),
         }
 
@@ -181,10 +154,27 @@ class AuthenticationView(View):
                 self.request.user))
             return self.redirect(self.request.get_referrer())
 
-        schema = ChangePassword().bind(user=self.request.user, request=self.request)
+        def check_user_password(node, value):
+            auth = self.app.get_auth_handler()
+            user = self.request.user
+            if not auth.check_user_password(user, value):
+                node.raise_invalid("The password is incorrect")
+
+        schema = colander.Schema()
+
+        schema.add(colander.SchemaNode(colander.String(),
+                                       name='current_password',
+                                       widget=dfwidget.PasswordWidget(),
+                                       validator=check_user_password))
+
+        schema.add(colander.SchemaNode(colander.String(),
+                                       name='new_password',
+                                       widget=dfwidget.CheckedPasswordWidget()))
+
         form = forms.Form(schema=schema, request=self.request)
         if form.validate():
-            set_user_password(self.request.user, form.validated['new_password'])
+            auth = self.app.get_auth_handler()
+            auth.set_user_password(self.request.user, form.validated['new_password'])
             self.request.session.flash("Your password has been changed.")
             return self.redirect(self.request.get_referrer())
 
@@ -237,6 +227,9 @@ class AuthenticationView(View):
         config.add_view(cls, attr='change_password', route_name='change_password', renderer='/change_password.mako')
 
         # become/stop root
+        # TODO: these should require POST but i won't bother until
+        # after butterball becomes default theme..or probably should
+        # just refactor the falafel theme accordingly..?
         config.add_route('become_root', '/root/yes')
         config.add_view(cls, attr='become_root', route_name='become_root')
         config.add_route('stop_root', '/root/no')
diff --git a/tailbone/views/batch/core.py b/tailbone/views/batch/core.py
index 84ef451f..c162b579 100644
--- a/tailbone/views/batch/core.py
+++ b/tailbone/views/batch/core.py
@@ -46,10 +46,11 @@ import colander
 from deform import widget as dfwidget
 from webhelpers2.html import HTML, tags
 
+from wuttaweb.util import render_csrf_token
+
 from tailbone import forms, grids
 from tailbone.db import Session
 from tailbone.views import MasterView
-from tailbone.util import csrf_token
 
 
 log = logging.getLogger(__name__)
@@ -186,7 +187,9 @@ class BatchMasterView(MasterView):
         breakdown = self.make_status_breakdown(batch)
 
         factory = self.get_grid_factory()
-        g = factory('batch_row_status_breakdown', [],
+        g = factory(self.request,
+                    key='batch_row_status_breakdown',
+                    data=[],
                     columns=['title', 'count'])
         g.set_click_handler('title', "autoFilterStatus(props.row)")
         kwargs['status_breakdown_data'] = breakdown
@@ -381,7 +384,7 @@ class BatchMasterView(MasterView):
         f.set_label('executed_by', "Executed by")
 
         # notes
-        f.set_type('notes', 'text')
+        f.set_type('notes', 'text_wrapped')
 
         # if self.creating and self.request.user:
         #     batch = fs.model
@@ -439,7 +442,7 @@ class BatchMasterView(MasterView):
 
         form = [
             begin_form,
-            csrf_token(self.request),
+            render_csrf_token(self.request),
             tags.hidden('complete', value=value),
             submit,
             tags.end_form(),
@@ -693,7 +696,7 @@ class BatchMasterView(MasterView):
         batch = self.get_instance()
 
         # TODO: most of this logic is copied from MasterView, should refactor/merge somehow...
-        if 'main_actions' not in kwargs:
+        if 'actions' not in kwargs:
             actions = []
 
             # view action
@@ -714,7 +717,7 @@ class BatchMasterView(MasterView):
                     actions.append(self.make_action('delete', icon='trash', url=self.row_delete_action_url))
                     kwargs.setdefault('delete_speedbump', self.rows_deletable_speedbump)
 
-            kwargs['main_actions'] = actions
+            kwargs['actions'] = actions
 
         return super().make_row_grid_kwargs(**kwargs)
 
@@ -853,13 +856,13 @@ class BatchMasterView(MasterView):
                     if isinstance(field.widget, forms.widgets.PlainSelectWidget):
                         warnings.warn("PlainSelectWidget is deprecated; "
                                       "please use deform.widget.SelectWidget instead",
-                                      DeprecationWarning)
+                                      DeprecationWarning, stacklevel=2)
                         field.widget = dfwidget.SelectWidget(values=field.widget.values)
 
         if not schema:
             schema = colander.Schema()
 
-        kwargs['component'] = 'execute-form'
+        kwargs['vue_tagname'] = 'execute-form'
         form = forms.Form(schema=schema, request=self.request, defaults=defaults, **kwargs)
         self.configure_execute_form(form)
         return form
diff --git a/tailbone/views/batch/handheld.py b/tailbone/views/batch/handheld.py
index eb22f367..486d8774 100644
--- a/tailbone/views/batch/handheld.py
+++ b/tailbone/views/batch/handheld.py
@@ -46,7 +46,7 @@ class ExecutionOptions(colander.Schema):
     action = colander.SchemaNode(
         colander.String(),
         validator=colander.OneOf(ACTION_OPTIONS),
-        widget=dfwidget.SelectWidget(values=ACTION_OPTIONS.items()))
+        widget=dfwidget.SelectWidget(values=list(ACTION_OPTIONS.items())))
 
 
 class HandheldBatchView(FileBatchMasterView):
diff --git a/tailbone/views/batch/labels.py b/tailbone/views/batch/labels.py
index 79b14a76..7291b05e 100644
--- a/tailbone/views/batch/labels.py
+++ b/tailbone/views/batch/labels.py
@@ -2,7 +2,7 @@
 ################################################################################
 #
 #  Rattail -- Retail Software Framework
-#  Copyright © 2010-2022 Lance Edgar
+#  Copyright © 2010-2024 Lance Edgar
 #
 #  This file is part of Rattail.
 #
@@ -24,10 +24,6 @@
 Views for label batches
 """
 
-from __future__ import unicode_literals, absolute_import
-
-import six
-
 from rattail.db import model
 
 from deform import widget as dfwidget
@@ -123,7 +119,7 @@ class LabelBatchView(BatchMasterView):
     ]
 
     def configure_form(self, f):
-        super(LabelBatchView, self).configure_form(f)
+        super().configure_form(f)
 
         # handheld_batches
         if self.creating:
@@ -142,7 +138,7 @@ class LabelBatchView(BatchMasterView):
                 f.replace('label_profile', 'label_profile_uuid')
                 # TODO: should restrict somehow? just allow override?
                 profiles = self.Session.query(model.LabelProfile)
-                values = [(p.uuid, six.text_type(p))
+                values = [(p.uuid, str(p))
                           for p in profiles]
                 require_profile = False
                 if not require_profile:
@@ -159,7 +155,7 @@ class LabelBatchView(BatchMasterView):
         return HTML.tag('ul', c=items)
 
     def configure_row_grid(self, g):
-        super(LabelBatchView, self).configure_row_grid(g)
+        super().configure_row_grid(g)
 
         # short labels
         g.set_label('brand_name', "Brand")
@@ -171,7 +167,7 @@ class LabelBatchView(BatchMasterView):
             return 'warning'
 
     def configure_row_form(self, f):
-        super(LabelBatchView, self).configure_row_form(f)
+        super().configure_row_form(f)
 
         # readonly fields
         f.set_readonly('sequence')
@@ -219,7 +215,7 @@ class LabelBatchView(BatchMasterView):
             profiles = self.Session.query(model.LabelProfile)\
                                    .filter(model.LabelProfile.visible == True)\
                                    .order_by(model.LabelProfile.ordinal)
-            profile_values = [(p.uuid, six.text_type(p))
+            profile_values = [(p.uuid, str(p))
                               for p in profiles]
             f.set_widget('label_profile_uuid', forms.widgets.JQuerySelectWidget(values=profile_values))
 
diff --git a/tailbone/views/batch/pos.py b/tailbone/views/batch/pos.py
index 11031353..b6fef6c8 100644
--- a/tailbone/views/batch/pos.py
+++ b/tailbone/views/batch/pos.py
@@ -195,6 +195,7 @@ class POSBatchView(BatchMasterView):
 
         factory = self.get_grid_factory()
         g = factory(
+            self.request,
             key=f'{route_prefix}.taxes',
             data=[],
             columns=[
diff --git a/tailbone/views/batch/pricing.py b/tailbone/views/batch/pricing.py
index 6ba28889..5b5d013b 100644
--- a/tailbone/views/batch/pricing.py
+++ b/tailbone/views/batch/pricing.py
@@ -2,7 +2,7 @@
 ################################################################################
 #
 #  Rattail -- Retail Software Framework
-#  Copyright © 2010-2022 Lance Edgar
+#  Copyright © 2010-2024 Lance Edgar
 #
 #  This file is part of Rattail.
 #
@@ -24,10 +24,6 @@
 Views for pricing batches
 """
 
-from __future__ import unicode_literals, absolute_import
-
-import six
-
 from rattail.db import model
 from rattail.time import localtime
 
@@ -155,7 +151,7 @@ class PricingBatchView(BatchMasterView):
         return self.batch_handler.allow_future()
 
     def configure_form(self, f):
-        super(PricingBatchView, self).configure_form(f)
+        super().configure_form(f)
         app = self.get_rattail_app()
         batch = f.model_instance
 
@@ -192,7 +188,7 @@ class PricingBatchView(BatchMasterView):
                 f.set_required('input_filename', False)
 
     def get_batch_kwargs(self, batch, **kwargs):
-        kwargs = super(PricingBatchView, self).get_batch_kwargs(batch, **kwargs)
+        kwargs = super().get_batch_kwargs(batch, **kwargs)
         kwargs['start_date'] = batch.start_date
         kwargs['min_diff_threshold'] = batch.min_diff_threshold
         kwargs['min_diff_percent'] = batch.min_diff_percent
@@ -213,7 +209,7 @@ class PricingBatchView(BatchMasterView):
         return kwargs
 
     def configure_row_grid(self, g):
-        super(PricingBatchView, self).configure_row_grid(g)
+        super().configure_row_grid(g)
 
         g.set_joiner('vendor_id', lambda q: q.outerjoin(model.Vendor))
         g.set_sorter('vendor_id', model.Vendor.id)
@@ -241,13 +237,13 @@ class PricingBatchView(BatchMasterView):
         if row.subdepartment_number:
             if row.subdepartment_name:
                 return HTML.tag('span', title=row.subdepartment_name,
-                                c=six.text_type(row.subdepartment_number))
+                                c=str(row.subdepartment_number))
             return row.subdepartment_number
 
     def render_true_margin(self, row, field):
         margin = row.true_margin
         if margin:
-            margin = six.text_type(margin)
+            margin = str(margin)
         else:
             margin = HTML.literal('&nbsp;')
         if row.old_true_margin is not None:
@@ -295,7 +291,7 @@ class PricingBatchView(BatchMasterView):
         return HTML.tag('span', title=title, c=text)
 
     def configure_row_form(self, f):
-        super(PricingBatchView, self).configure_row_form(f)
+        super().configure_row_form(f)
 
         # readonly fields
         f.set_readonly('product')
@@ -328,7 +324,7 @@ class PricingBatchView(BatchMasterView):
         return tags.link_to(text, url)
 
     def get_row_csv_fields(self):
-        fields = super(PricingBatchView, self).get_row_csv_fields()
+        fields = super().get_row_csv_fields()
 
         if 'vendor_uuid' in fields:
             i = fields.index('vendor_uuid')
@@ -344,7 +340,7 @@ class PricingBatchView(BatchMasterView):
 
     # TODO: this is the same as xlsx row! should merge/share somehow?
     def get_row_csv_row(self, row, fields):
-        csvrow = super(PricingBatchView, self).get_row_csv_row(row, fields)
+        csvrow = super().get_row_csv_row(row, fields)
 
         vendor = row.vendor
         if 'vendor_id' in fields:
@@ -358,7 +354,7 @@ class PricingBatchView(BatchMasterView):
 
     # TODO: this is the same as csv row! should merge/share somehow?
     def get_row_xlsx_row(self, row, fields):
-        xlrow = super(PricingBatchView, self).get_row_xlsx_row(row, fields)
+        xlrow = super().get_row_xlsx_row(row, fields)
 
         vendor = row.vendor
         if 'vendor_id' in fields:
diff --git a/tailbone/views/batch/product.py b/tailbone/views/batch/product.py
index af8374ac..590c3ff0 100644
--- a/tailbone/views/batch/product.py
+++ b/tailbone/views/batch/product.py
@@ -46,7 +46,7 @@ class ExecutionOptions(colander.Schema):
     action = colander.SchemaNode(
         colander.String(),
         validator=colander.OneOf(ACTION_OPTIONS),
-        widget=dfwidget.SelectWidget(values=ACTION_OPTIONS.items()))
+        widget=dfwidget.SelectWidget(values=list(ACTION_OPTIONS.items())))
 
 
 class ProductBatchView(BatchMasterView):
diff --git a/tailbone/views/batch/vendorinvoice.py b/tailbone/views/batch/vendorinvoice.py
index 6b8bdef7..4815d1f4 100644
--- a/tailbone/views/batch/vendorinvoice.py
+++ b/tailbone/views/batch/vendorinvoice.py
@@ -2,7 +2,7 @@
 ################################################################################
 #
 #  Rattail -- Retail Software Framework
-#  Copyright © 2010-2022 Lance Edgar
+#  Copyright © 2010-2024 Lance Edgar
 #
 #  This file is part of Rattail.
 #
@@ -24,10 +24,6 @@
 Views for maintaining vendor invoices
 """
 
-from __future__ import unicode_literals, absolute_import
-
-import six
-
 from rattail.db import model
 from rattail.vendors.invoices import iter_invoice_parsers, require_invoice_parser
 
@@ -89,10 +85,10 @@ class VendorInvoiceView(FileBatchMasterView):
     ]
 
     def get_instance_title(self, batch):
-        return six.text_type(batch.vendor)
+        return str(batch.vendor)
 
     def configure_grid(self, g):
-        super(VendorInvoiceView, self).configure_grid(g)
+        super().configure_grid(g)
 
         # vendor
         g.set_joiner('vendor', lambda q: q.join(model.Vendor))
@@ -118,7 +114,7 @@ class VendorInvoiceView(FileBatchMasterView):
         g.set_link('executed', False)
 
     def configure_form(self, f):
-        super(VendorInvoiceView, self).configure_form(f)
+        super().configure_form(f)
 
         # vendor
         if self.creating:
@@ -167,7 +163,7 @@ class VendorInvoiceView(FileBatchMasterView):
     #         raise formalchemy.ValidationError(unicode(error))
 
     def get_batch_kwargs(self, batch):
-        kwargs = super(VendorInvoiceView, self).get_batch_kwargs(batch)
+        kwargs = super().get_batch_kwargs(batch)
         kwargs['parser_key'] = batch.parser_key
         return kwargs
 
@@ -183,7 +179,7 @@ class VendorInvoiceView(FileBatchMasterView):
         return True
 
     def configure_row_grid(self, g):
-        super(VendorInvoiceView, self).configure_row_grid(g)
+        super().configure_row_grid(g)
         g.set_label('upc', "UPC")
         g.set_label('brand_name', "Brand")
         g.set_label('shipped_cases', "Cases")
diff --git a/tailbone/views/common.py b/tailbone/views/common.py
index 266561fd..f4d98c05 100644
--- a/tailbone/views/common.py
+++ b/tailbone/views/common.py
@@ -24,12 +24,12 @@
 Various common views
 """
 
-import importlib
 import os
+import warnings
 from collections import OrderedDict
 
 from rattail.batch import consume_batch_id
-from rattail.util import simple_error
+from rattail.util import get_pkg_version, simple_error
 from rattail.files import resource_path
 
 from tailbone import forms
@@ -50,17 +50,36 @@ class CommonView(View):
         """
         Home page view.
         """
-        if not self.request.user:
-            if self.rattail_config.getbool('tailbone', 'login_is_home', default=True):
-                raise self.redirect(self.request.route_url('login'))
+        app = self.get_rattail_app()
 
-        image_url = self.rattail_config.get(
-            'tailbone', 'main_image_url',
-            default=self.request.static_url('tailbone:static/img/home_logo.png'))
+        # maybe auto-redirect anons to login
+        if not self.request.user:
+            redirect = self.config.get_bool('wuttaweb.home_redirect_to_login')
+            if redirect is None:
+                redirect = self.config.get_bool('tailbone.login_is_home')
+                if redirect is not None:
+                    warnings.warn("tailbone.login_is_home setting is deprecated; "
+                                  "please set wuttaweb.home_redirect_to_login instead",
+                                  DeprecationWarning)
+                else:
+                    # TODO: this is opposite of upstream default, should change
+                    redirect = True
+            if redirect:
+                return self.redirect(self.request.route_url('login'))
+
+        image_url = self.config.get('wuttaweb.logo_url')
+        if not image_url:
+            image_url = self.config.get('tailbone.main_image_url')
+            if image_url:
+                warnings.warn("tailbone.main_image_url setting is deprecated; "
+                              "please set wuttaweb.logo_url instead",
+                              DeprecationWarning)
+            else:
+                image_url = self.request.static_url('tailbone:static/img/home_logo.png')
 
         context = {
             'image_url': image_url,
-            'index_title': self.rattail_config.node_title(),
+            'index_title': app.get_node_title(),
             'help_url': global_help_url(self.rattail_config),
         }
 
@@ -99,7 +118,8 @@ class CommonView(View):
         return response
 
     def get_project_title(self):
-        return self.rattail_config.app_title()
+        app = self.get_rattail_app()
+        return app.get_title()
 
     def get_project_version(self):
 
@@ -107,9 +127,8 @@ class CommonView(View):
         if hasattr(self, 'project_version'):
             return self.project_version
 
-        pkg = self.rattail_config.app_package()
-        mod = importlib.import_module(pkg)
-        return mod.__version__
+        app = self.get_rattail_app()
+        return app.get_version()
 
     def exception(self):
         """
@@ -121,11 +140,12 @@ class CommonView(View):
         """
         Generic view to show "about project" info page.
         """
+        app = self.get_rattail_app()
         return {
             'project_title': self.get_project_title(),
             'project_version': self.get_project_version(),
             'packages': self.get_packages(),
-            'index_title': self.rattail_config.node_title(),
+            'index_title': app.get_node_title(),
         }
 
     def get_packages(self):
@@ -133,10 +153,9 @@ class CommonView(View):
         Should return the full set of packages which should be displayed on the
         'about' page.
         """
-        import rattail, tailbone
         return OrderedDict([
-            ('rattail', rattail.__version__),
-            ('Tailbone', tailbone.__version__),
+            ('rattail', get_pkg_version('rattail')),
+            ('Tailbone', get_pkg_version('Tailbone')),
         ])
 
     def change_theme(self):
@@ -151,8 +170,6 @@ class CommonView(View):
             except Exception as error:
                 msg = "Failed to set theme: {}: {}".format(error.__class__.__name__, error)
                 self.request.session.flash(msg, 'error')
-            else:
-                self.request.session.flash("App theme has been changed to: {}".format(theme))
         referrer = self.request.params.get('referrer') or self.request.get_referrer()
         return self.redirect(referrer)
 
@@ -209,7 +226,7 @@ class CommonView(View):
             raise self.forbidden()
 
         app = self.get_rattail_app()
-        app_title = self.rattail_config.app_title()
+        app_title = app.get_title()
         poser_handler = app.get_poser_handler()
         poser_dir = poser_handler.get_default_poser_dir()
         poser_dir_exists = os.path.isdir(poser_dir)
diff --git a/tailbone/views/core.py b/tailbone/views/core.py
index 97b59c10..88b2519f 100644
--- a/tailbone/views/core.py
+++ b/tailbone/views/core.py
@@ -2,7 +2,7 @@
 ################################################################################
 #
 #  Rattail -- Retail Software Framework
-#  Copyright © 2010-2023 Lance Edgar
+#  Copyright © 2010-2024 Lance Edgar
 #
 #  This file is part of Rattail.
 #
@@ -26,10 +26,6 @@ Base View Class
 
 import os
 
-from rattail.db import model
-from rattail.core import Object
-from rattail.util import progress_loop
-
 from pyramid import httpexceptions
 from pyramid.renderers import render_to_response
 from pyramid.response import FileResponse
@@ -40,7 +36,7 @@ from tailbone.progress import SessionProgress
 from tailbone.config import protected_usernames
 
 
-class View(object):
+class View:
     """
     Base class for all class-based views.
     """
@@ -62,8 +58,10 @@ class View(object):
 
         config = self.rattail_config
         if config:
-            self.enum = config.get_enum()
-            self.model = config.get_model()
+            self.config = config
+            self.app = self.config.get_app()
+            self.model = self.app.model
+            self.enum = self.app.enum
 
     @property
     def rattail_config(self):
@@ -94,6 +92,7 @@ class View(object):
         Returns the :class:`rattail:rattail.db.model.User` instance
         corresponding to the "late login" form data (if any), or ``None``.
         """
+        model = self.model
         if self.request.method == 'POST':
             uuid = self.request.POST.get('late-login-user')
             if uuid:
@@ -120,7 +119,8 @@ class View(object):
         return httpexceptions.HTTPFound(location=url, **kwargs)
 
     def progress_loop(self, func, items, factory, *args, **kwargs):
-        return progress_loop(func, items, factory, *args, **kwargs)
+        app = self.get_rattail_app()
+        return app.progress_loop(func, items, factory, *args, **kwargs)
 
     def make_progress(self, key, **kwargs):
         """
@@ -165,7 +165,8 @@ class View(object):
         return self.expose_quickie_search
 
     def get_quickie_context(self):
-        return Object(
+        app = self.get_rattail_app()
+        return app.make_object(
             url=self.get_quickie_url(),
             perm=self.get_quickie_perm(),
             placeholder=self.get_quickie_placeholder())
diff --git a/tailbone/views/customers.py b/tailbone/views/customers.py
index 2958a98a..7e49ccef 100644
--- a/tailbone/views/customers.py
+++ b/tailbone/views/customers.py
@@ -208,8 +208,7 @@ class CustomerView(MasterView):
             url = lambda r, i: self.request.route_url(
                 f'{route_prefix}.view', **self.get_action_route_kwargs(r))
             # nb. insert to slot 1, just after normal View action
-            g.main_actions.insert(1, self.make_action(
-                'view_raw', url=url, icon='eye'))
+            g.actions.insert(1, self.make_action('view_raw', url=url, icon='eye'))
 
         g.set_link('name')
         g.set_link('person')
@@ -471,7 +470,8 @@ class CustomerView(MasterView):
 
         factory = self.get_grid_factory()
         g = factory(
-            key='{}.people'.format(route_prefix),
+            self.request,
+            key=f'{route_prefix}.people',
             data=[],
             columns=[
                 'shopper_number',
@@ -500,7 +500,8 @@ class CustomerView(MasterView):
 
         factory = self.get_grid_factory()
         g = factory(
-            key='{}.people'.format(route_prefix),
+            self.request,
+            key=f'{route_prefix}.people',
             data=[],
             columns=[
                 'full_name',
@@ -512,13 +513,13 @@ class CustomerView(MasterView):
         )
 
         if self.request.has_perm('people.view'):
-            g.main_actions.append(self.make_action('view', icon='eye'))
+            g.actions.append(self.make_action('view', icon='eye'))
         if self.request.has_perm('people.edit'):
-            g.main_actions.append(self.make_action('edit', icon='edit'))
+            g.actions.append(self.make_action('edit', icon='edit'))
         if self.people_detachable and self.has_perm('detach_person'):
-            g.main_actions.append(self.make_action('detach', icon='minus-circle',
-                                                   link_class='has-text-warning',
-                                                   click_handler="$emit('detach-person', props.row._action_url_detach)"))
+            g.actions.append(self.make_action('detach', icon='minus-circle',
+                                              link_class='has-text-warning',
+                                              click_handler="$emit('detach-person', props.row._action_url_detach)"))
 
         return HTML.literal(
             g.render_table_element(data_prop='peopleData'))
diff --git a/tailbone/views/custorders/batch.py b/tailbone/views/custorders/batch.py
index 38d2eda7..fa0df901 100644
--- a/tailbone/views/custorders/batch.py
+++ b/tailbone/views/custorders/batch.py
@@ -2,7 +2,7 @@
 ################################################################################
 #
 #  Rattail -- Retail Software Framework
-#  Copyright © 2010-2023 Lance Edgar
+#  Copyright © 2010-2024 Lance Edgar
 #
 #  This file is part of Rattail.
 #
@@ -24,7 +24,7 @@
 Base class for customer order batch views
 """
 
-from rattail.db import model
+from rattail.db.model import CustomerOrderBatch, CustomerOrderBatchRow
 
 import colander
 from webhelpers2.html import tags
@@ -38,8 +38,8 @@ class CustomerOrderBatchView(BatchMasterView):
     Master view base class, for customer order batches.  The views for the
     various mode/workflow batches will derive from this.
     """
-    model_class = model.CustomerOrderBatch
-    model_row_class = model.CustomerOrderBatchRow
+    model_class = CustomerOrderBatch
+    model_row_class = CustomerOrderBatchRow
     default_handler_spec = 'rattail.batch.custorder:CustomerOrderBatchHandler'
 
     grid_columns = [
@@ -122,7 +122,7 @@ class CustomerOrderBatchView(BatchMasterView):
     ]
 
     def configure_grid(self, g):
-        super(CustomerOrderBatchView, self).configure_grid(g)
+        super().configure_grid(g)
 
         g.set_type('total_price', 'currency')
 
@@ -131,9 +131,9 @@ class CustomerOrderBatchView(BatchMasterView):
         g.set_link('created_by')
 
     def configure_form(self, f):
-        super(CustomerOrderBatchView, self).configure_form(f)
+        super().configure_form(f)
         order = f.model_instance
-        model = self.rattail_config.get_model()
+        model = self.model
 
         # readonly fields
         f.set_readonly('rows')
@@ -201,7 +201,7 @@ class CustomerOrderBatchView(BatchMasterView):
             return 'notice'
 
     def configure_row_grid(self, g):
-        super(CustomerOrderBatchView, self).configure_row_grid(g)
+        super().configure_row_grid(g)
 
         g.set_type('case_quantity', 'quantity')
         g.set_type('cases_ordered', 'quantity')
@@ -215,7 +215,7 @@ class CustomerOrderBatchView(BatchMasterView):
         g.set_link('product_description')
 
     def configure_row_form(self, f):
-        super(CustomerOrderBatchView, self).configure_row_form(f)
+        super().configure_row_form(f)
 
         f.set_renderer('product', self.render_product)
         f.set_renderer('pending_product', self.render_pending_product)
diff --git a/tailbone/views/custorders/items.py b/tailbone/views/custorders/items.py
index d8e39f55..e7edf3aa 100644
--- a/tailbone/views/custorders/items.py
+++ b/tailbone/views/custorders/items.py
@@ -385,6 +385,7 @@ class CustomerOrderItemView(MasterView):
 
         factory = self.get_grid_factory()
         g = factory(
+            self.request,
             key=f'{route_prefix}.events',
             data=[],
             columns=[
diff --git a/tailbone/views/custorders/orders.py b/tailbone/views/custorders/orders.py
index f76d4d93..b1a9831a 100644
--- a/tailbone/views/custorders/orders.py
+++ b/tailbone/views/custorders/orders.py
@@ -2,7 +2,7 @@
 ################################################################################
 #
 #  Rattail -- Retail Software Framework
-#  Copyright © 2010-2023 Lance Edgar
+#  Copyright © 2010-2024 Lance Edgar
 #
 #  This file is part of Rattail.
 #
@@ -29,13 +29,12 @@ import logging
 
 from sqlalchemy import orm
 
-from rattail.db import model
-from rattail.util import pretty_quantity, simple_error
+from rattail.db.model import CustomerOrder, CustomerOrderItem
+from rattail.util import simple_error
 from rattail.batch import get_batch_handler
 
 from webhelpers2.html import tags, HTML
 
-from tailbone.db import Session
 from tailbone.views import MasterView
 
 
@@ -46,7 +45,7 @@ class CustomerOrderView(MasterView):
     """
     Master view for customer orders
     """
-    model_class = model.CustomerOrder
+    model_class = CustomerOrder
     route_prefix = 'custorders'
     editable = False
     configurable = True
@@ -80,7 +79,7 @@ class CustomerOrderView(MasterView):
     ]
 
     has_rows = True
-    model_row_class = model.CustomerOrderItem
+    model_row_class = CustomerOrderItem
     rows_viewable = False
 
     row_labels = {
@@ -116,15 +115,17 @@ class CustomerOrderView(MasterView):
     ]
 
     def __init__(self, request):
-        super(CustomerOrderView, self).__init__(request)
+        super().__init__(request)
         self.batch_handler = self.get_batch_handler()
 
     def query(self, session):
+        model = self.app.model
         return session.query(model.CustomerOrder)\
                       .options(orm.joinedload(model.CustomerOrder.customer))
 
     def configure_grid(self, g):
         super().configure_grid(g)
+        model = self.app.model
 
         # id
         g.set_link('id')
@@ -163,7 +164,7 @@ class CustomerOrderView(MasterView):
         return f"#{order.id} for {order.customer or order.person}"
 
     def configure_form(self, f):
-        super(CustomerOrderView, self).configure_form(f)
+        super().configure_form(f)
         order = f.model_instance
 
         f.set_readonly('id')
@@ -233,6 +234,7 @@ class CustomerOrderView(MasterView):
                             class_='has-background-warning')
 
     def get_row_data(self, order):
+        model = self.app.model
         return self.Session.query(model.CustomerOrderItem)\
                            .filter(model.CustomerOrderItem.order == order)
 
@@ -240,11 +242,13 @@ class CustomerOrderView(MasterView):
         return item.order
 
     def make_row_grid_kwargs(self, **kwargs):
-        kwargs = super(CustomerOrderView, self).make_row_grid_kwargs(**kwargs)
+        kwargs = super().make_row_grid_kwargs(**kwargs)
 
-        assert not kwargs['main_actions']
-        kwargs['main_actions'].append(
-            self.make_action('view', icon='eye', url=self.row_view_action_url))
+        actions = kwargs.get('actions', [])
+        if not actions:
+            actions.append(self.make_action('view', icon='eye',
+                                            url=self.row_view_action_url))
+            kwargs['actions'] = actions
 
         return kwargs
 
@@ -253,7 +257,7 @@ class CustomerOrderView(MasterView):
             return self.request.route_url('custorders.items.view', uuid=item.uuid)
 
     def configure_row_grid(self, g):
-        super(CustomerOrderView, self).configure_row_grid(g)
+        super().configure_row_grid(g)
         app = self.get_rattail_app()
         handler = app.get_batch_handler(
             'custorder',
@@ -423,6 +427,7 @@ class CustomerOrderView(MasterView):
         if not user:
             raise RuntimeError("this feature requires a user to be logged in")
 
+        model = self.app.model
         try:
             # there should be at most *one* new batch per user
             batch = self.Session.query(model.CustomerOrderBatch)\
@@ -488,6 +493,7 @@ class CustomerOrderView(MasterView):
         if not uuid:
             return {'error': "Must specify a customer UUID"}
 
+        model = self.app.model
         customer = self.Session.get(model.Customer, uuid)
         if not customer:
             return {'error': "Customer not found"}
@@ -508,6 +514,7 @@ class CustomerOrderView(MasterView):
         return info
 
     def assign_contact(self, batch, data):
+        model = self.app.model
         kwargs = {}
 
         # this will either be a Person or Customer UUID
@@ -662,6 +669,7 @@ class CustomerOrderView(MasterView):
         if not uuid:
             return {'error': "Must specify a product UUID"}
 
+        model = self.app.model
         product = self.Session.get(model.Product, uuid)
         if not product:
             return {'error': "Product not found"}
@@ -725,8 +733,7 @@ class CustomerOrderView(MasterView):
             return app.render_currency(obj.unit_price)
 
     def normalize_row(self, row):
-        app = self.get_rattail_app()
-        products_handler = app.get_products_handler()
+        products_handler = self.app.get_products_handler()
 
         data = {
             'uuid': row.uuid,
@@ -742,20 +749,20 @@ class CustomerOrderView(MasterView):
             'product_size': row.product_size,
             'product_weighed': row.product_weighed,
 
-            'case_quantity': pretty_quantity(row.case_quantity),
-            'cases_ordered': pretty_quantity(row.cases_ordered),
-            'units_ordered': pretty_quantity(row.units_ordered),
-            'order_quantity': pretty_quantity(row.order_quantity),
+            'case_quantity': self.app.render_quantity(row.case_quantity),
+            'cases_ordered': self.app.render_quantity(row.cases_ordered),
+            'units_ordered': self.app.render_quantity(row.units_ordered),
+            'order_quantity': self.app.render_quantity(row.order_quantity),
             'order_uom': row.order_uom,
             'order_uom_choices': self.uom_choices_for_row(row),
-            'discount_percent': pretty_quantity(row.discount_percent),
+            'discount_percent': self.app.render_quantity(row.discount_percent),
 
             'department_display': row.department_name,
 
             'unit_price': float(row.unit_price) if row.unit_price is not None else None,
             'unit_price_display': self.get_unit_price_display(row),
             'total_price': float(row.total_price) if row.total_price is not None else None,
-            'total_price_display': app.render_currency(row.total_price),
+            'total_price_display': self.app.render_currency(row.total_price),
 
             'status_code': row.status_code,
             'status_text': row.status_text,
@@ -763,15 +770,15 @@ class CustomerOrderView(MasterView):
 
         if row.unit_regular_price:
             data['unit_regular_price'] = float(row.unit_regular_price)
-            data['unit_regular_price_display'] = app.render_currency(row.unit_regular_price)
+            data['unit_regular_price_display'] = self.app.render_currency(row.unit_regular_price)
 
         if row.unit_sale_price:
             data['unit_sale_price'] = float(row.unit_sale_price)
-            data['unit_sale_price_display'] = app.render_currency(row.unit_sale_price)
+            data['unit_sale_price_display'] = self.app.render_currency(row.unit_sale_price)
         if row.sale_ends:
-            sale_ends = app.localtime(row.sale_ends, from_utc=True).date()
+            sale_ends = self.app.localtime(row.sale_ends, from_utc=True).date()
             data['sale_ends'] = str(sale_ends)
-            data['sale_ends_display'] = app.render_date(sale_ends)
+            data['sale_ends_display'] = self.app.render_date(sale_ends)
 
         if row.unit_sale_price and row.unit_price == row.unit_sale_price:
             data['pricing_reflects_sale'] = True
@@ -808,12 +815,12 @@ class CustomerOrderView(MasterView):
 
         case_price = self.batch_handler.get_case_price_for_row(row)
         data['case_price'] = float(case_price) if case_price is not None else None
-        data['case_price_display'] = app.render_currency(case_price)
+        data['case_price_display'] = self.app.render_currency(case_price)
 
         if self.batch_handler.product_price_may_be_questionable():
             data['price_needs_confirmation'] = row.price_needs_confirmation
 
-        key = app.get_product_key_field()
+        key = self.app.get_product_key_field()
         if key == 'upc':
             data['product_key'] = data['product_upc_pretty']
         elif key == 'item_id':
@@ -837,7 +844,7 @@ class CustomerOrderView(MasterView):
                 case_qty = unit_qty = '??'
             else:
                 case_qty = data['case_quantity']
-                unit_qty = pretty_quantity(row.order_quantity * row.case_quantity)
+                unit_qty = self.app.render_quantity(row.order_quantity * row.case_quantity)
             data.update({
                 'order_quantity_display': "{} {} (&times; {} {} = {} {})".format(
                     data['order_quantity'],
@@ -850,14 +857,14 @@ class CustomerOrderView(MasterView):
         else:
             data.update({
                 'order_quantity_display': "{} {}".format(
-                    pretty_quantity(row.order_quantity),
+                    self.app.render_quantity(row.order_quantity),
                     self.enum.UNIT_OF_MEASURE[unit_uom]),
             })
 
         return data
 
     def add_item(self, batch, data):
-        app = self.get_rattail_app()
+        model = self.app.model
 
         order_quantity = decimal.Decimal(data.get('order_quantity') or '0')
         order_uom = data.get('order_uom')
@@ -888,7 +895,7 @@ class CustomerOrderView(MasterView):
             pending_info = dict(data['pending_product'])
 
             if 'upc' in pending_info:
-                pending_info['upc'] = app.make_gpc(pending_info['upc'])
+                pending_info['upc'] = self.app.make_gpc(pending_info['upc'])
 
             for field in ('unit_cost', 'regular_price_amount', 'case_size'):
                 if field in pending_info:
@@ -917,6 +924,7 @@ class CustomerOrderView(MasterView):
         if not uuid:
             return {'error': "Must specify a row UUID"}
 
+        model = self.app.model
         row = self.Session.get(model.CustomerOrderBatchRow, uuid)
         if not row:
             return {'error': "Row not found"}
@@ -975,6 +983,7 @@ class CustomerOrderView(MasterView):
         if not uuid:
             return {'error': "Must specify a row UUID"}
 
+        model = self.app.model
         row = self.Session.get(model.CustomerOrderBatchRow, uuid)
         if not row:
             return {'error': "Row not found"}
diff --git a/tailbone/views/datasync.py b/tailbone/views/datasync.py
index 7616d288..2b955b5f 100644
--- a/tailbone/views/datasync.py
+++ b/tailbone/views/datasync.py
@@ -79,11 +79,13 @@ class DataSyncThreadView(MasterView):
 
     def get_context_menu_items(self, thread=None):
         items = super().get_context_menu_items(thread)
+        route_prefix = self.get_route_prefix()
 
-        # nb. just one view here, no need to check if listing etc.
-        if self.request.has_perm('datasync_changes.list'):
-            url = self.request.route_url('datasyncchanges')
-            items.append(tags.link_to("View DataSync Changes", url))
+        # nb. do not show this for /configure page
+        if self.request.matched_route.name != f'{route_prefix}.configure':
+            if self.request.has_perm('datasync_changes.list'):
+                url = self.request.route_url('datasyncchanges')
+                items.append(tags.link_to("View DataSync Changes", url))
 
         return items
 
@@ -200,10 +202,36 @@ class DataSyncThreadView(MasterView):
         return self.redirect(self.request.get_referrer(
             default=self.request.route_url('datasyncchanges')))
 
-    def configure_get_context(self):
+    def configure_get_simple_settings(self):
+        """ """
+        return [
+
+            # basic
+            {'section': 'rattail.datasync',
+             'option': 'use_profile_settings',
+             'type': bool},
+
+            # misc.
+            {'section': 'rattail.datasync',
+             'option': 'supervisor_process_name'},
+            {'section': 'rattail.datasync',
+             'option': 'batch_size_limit',
+             'type': int},
+
+            # legacy
+            {'section': 'tailbone',
+             'option': 'datasync.restart'},
+
+        ]
+
+    def configure_get_context(self, **kwargs):
+        """ """
+        context = super().configure_get_context(**kwargs)
+
         profiles = self.datasync_handler.get_configured_profiles(
             include_disabled=True,
             ignore_problems=True)
+        context['profiles'] = profiles
 
         profiles_data = []
         for profile in sorted(profiles.values(), key=lambda p: p.key):
@@ -241,25 +269,15 @@ class DataSyncThreadView(MasterView):
             data['consumers_data'] = consumers
             profiles_data.append(data)
 
-        return {
-            'profiles': profiles,
-            'profiles_data': profiles_data,
-            'use_profile_settings': self.datasync_handler.should_use_profile_settings(),
-            'supervisor_process_name': self.rattail_config.get(
-                'rattail.datasync', 'supervisor_process_name'),
-            'restart_command': self.rattail_config.get(
-                'tailbone', 'datasync.restart'),
-        }
+        context['profiles_data'] = profiles_data
+        return context
 
-    def configure_gather_settings(self, data):
-        settings = []
-        watch = []
+    def configure_gather_settings(self, data, **kwargs):
+        """ """
+        settings = super().configure_gather_settings(data, **kwargs)
 
-        use_profile_settings = data.get('use_profile_settings') == 'true'
-        settings.append({'name': 'rattail.datasync.use_profile_settings',
-                         'value': 'true' if use_profile_settings else 'false'})
-
-        if use_profile_settings:
+        if data.get('rattail.datasync.use_profile_settings') == 'true':
+            watch = []
 
             for profile in json.loads(data['profiles']):
                 pkey = profile['key']
@@ -321,17 +339,12 @@ class DataSyncThreadView(MasterView):
                 settings.append({'name': 'rattail.datasync.watch',
                                  'value': ', '.join(watch)})
 
-        if data['supervisor_process_name']:
-            settings.append({'name': 'rattail.datasync.supervisor_process_name',
-                             'value': data['supervisor_process_name']})
-
-        if data['restart_command']:
-            settings.append({'name': 'tailbone.datasync.restart',
-                             'value': data['restart_command']})
-
         return settings
 
-    def configure_remove_settings(self):
+    def configure_remove_settings(self, **kwargs):
+        """ """
+        super().configure_remove_settings(**kwargs)
+
         purge_datasync_settings(self.rattail_config, self.Session())
 
     @classmethod
diff --git a/tailbone/views/departments.py b/tailbone/views/departments.py
index 6ee1439f..47de8dca 100644
--- a/tailbone/views/departments.py
+++ b/tailbone/views/departments.py
@@ -128,8 +128,8 @@ class DepartmentView(MasterView):
 
         factory = self.get_grid_factory()
         g = factory(
-            key='{}.employees'.format(route_prefix),
-            request=self.request,
+            self.request,
+            key=f'{route_prefix}.employees',
             data=[],
             columns=[
                 'first_name',
@@ -140,9 +140,9 @@ class DepartmentView(MasterView):
         )
 
         if self.request.has_perm('employees.view'):
-            g.main_actions.append(self.make_action('view', icon='eye'))
+            g.actions.append(self.make_action('view', icon='eye'))
         if self.request.has_perm('employees.edit'):
-            g.main_actions.append(self.make_action('edit', icon='edit'))
+            g.actions.append(self.make_action('edit', icon='edit'))
 
         return HTML.literal(
             g.render_table_element(data_prop='employeesData'))
diff --git a/tailbone/views/email.py b/tailbone/views/email.py
index 22954782..98bd4295 100644
--- a/tailbone/views/email.py
+++ b/tailbone/views/email.py
@@ -2,7 +2,7 @@
 ################################################################################
 #
 #  Rattail -- Retail Software Framework
-#  Copyright © 2010-2023 Lance Edgar
+#  Copyright © 2010-2024 Lance Edgar
 #
 #  This file is part of Rattail.
 #
@@ -28,14 +28,13 @@ import logging
 import re
 import warnings
 
-from rattail import mail
-from rattail.db import model
-from rattail.config import parse_list
+from wuttjamaican.util import parse_list
+
+from rattail.db.model import EmailAttempt
 from rattail.util import simple_error
 
 import colander
 from deform import widget as dfwidget
-from webhelpers2.html import HTML
 
 from tailbone import grids
 from tailbone.db import Session
@@ -85,7 +84,7 @@ class EmailSettingView(MasterView):
     ]
 
     def __init__(self, request):
-        super(EmailSettingView, self).__init__(request)
+        super().__init__(request)
         self.email_handler = self.get_handler()
 
     @property
@@ -117,11 +116,12 @@ class EmailSettingView(MasterView):
         return data
 
     def configure_grid(self, g):
-        g.sorters['key'] = g.make_simple_sorter('key', foldcase=True)
-        g.sorters['prefix'] = g.make_simple_sorter('prefix', foldcase=True)
-        g.sorters['subject'] = g.make_simple_sorter('subject', foldcase=True)
-        g.sorters['enabled'] = g.make_simple_sorter('enabled')
+        super().configure_grid(g)
+
+        g.sort_on_backend = False
+        g.sort_multiple = False
         g.set_sort_defaults('key')
+
         g.set_type('enabled', 'boolean')
         g.set_link('key')
         g.set_link('subject')
@@ -131,18 +131,16 @@ class EmailSettingView(MasterView):
 
         # to
         g.set_renderer('to', self.render_to_short)
-        g.sorters['to'] = g.make_simple_sorter('to', foldcase=True)
 
         # hidden
         if self.has_perm('configure'):
-            g.sorters['hidden'] = g.make_simple_sorter('hidden')
             g.set_type('hidden', 'boolean')
         else:
             g.remove('hidden')
 
         # toggle hidden
         if self.has_perm('configure'):
-            g.main_actions.append(
+            g.actions.append(
                 self.make_action('toggle_hidden', url='#', icon='ban',
                                  click_handler='toggleHidden(props.row)',
                                  factory=ToggleHidden))
@@ -204,7 +202,7 @@ class EmailSettingView(MasterView):
         return True
 
     def configure_form(self, f):
-        super(EmailSettingView, self).configure_form(f)
+        super().configure_form(f)
         profile = f.model_instance['_email']
 
         # key
@@ -437,7 +435,7 @@ class EmailPreview(View):
     """
 
     def __init__(self, request):
-        super(EmailPreview, self).__init__(request)
+        super().__init__(request)
 
         if hasattr(self, 'get_handler'):
             warnings.warn("defining a get_handler() method is deprecated; "
@@ -520,7 +518,7 @@ class EmailAttemptView(MasterView):
     """
     Master view for email attempts.
     """
-    model_class = model.EmailAttempt
+    model_class = EmailAttempt
     route_prefix = 'email_attempts'
     url_prefix = '/email/attempts'
     creatable = False
@@ -553,7 +551,7 @@ class EmailAttemptView(MasterView):
     ]
 
     def configure_grid(self, g):
-        super(EmailAttemptView, self).configure_grid(g)
+        super().configure_grid(g)
 
         # sent
         g.set_sort_defaults('sent', 'desc')
@@ -583,13 +581,12 @@ class EmailAttemptView(MasterView):
             if len(recips) > 2:
                 recips = recips[:2]
                 recips.append('...')
-            recips = [HTML.escape(r) for r in recips]
             return ', '.join(recips)
 
         return value
 
     def configure_form(self, f):
-        super(EmailAttemptView, self).configure_form(f)
+        super().configure_form(f)
 
         # key
         f.set_renderer('key', self.render_email_key)
diff --git a/tailbone/views/employees.py b/tailbone/views/employees.py
index f4f99058..debd8fcb 100644
--- a/tailbone/views/employees.py
+++ b/tailbone/views/employees.py
@@ -167,8 +167,7 @@ class EmployeeView(MasterView):
             url = lambda r, i: self.request.route_url(
                 f'{route_prefix}.view', **self.get_action_route_kwargs(r))
             # nb. insert to slot 1, just after normal View action
-            g.main_actions.insert(1, self.make_action(
-                'view_raw', url=url, icon='eye'))
+            g.actions.insert(1, self.make_action('view_raw', url=url, icon='eye'))
 
     def default_view_url(self):
         if (self.request.has_perm('people.view_profile')
diff --git a/tailbone/views/exports.py b/tailbone/views/exports.py
index 82591099..44df359f 100644
--- a/tailbone/views/exports.py
+++ b/tailbone/views/exports.py
@@ -2,7 +2,7 @@
 ################################################################################
 #
 #  Rattail -- Retail Software Framework
-#  Copyright © 2010-2022 Lance Edgar
+#  Copyright © 2010-2024 Lance Edgar
 #
 #  This file is part of Rattail.
 #
@@ -24,13 +24,9 @@
 Master class for generic export history views
 """
 
-from __future__ import unicode_literals, absolute_import
-
 import os
 import shutil
 
-import six
-
 from pyramid.response import FileResponse
 from webhelpers2.html import tags
 
@@ -83,7 +79,7 @@ class ExportMasterView(MasterView):
         return self.get_file_path(export)
 
     def configure_grid(self, g):
-        super(ExportMasterView, self).configure_grid(g)
+        super().configure_grid(g)
         model = self.model
 
         # id
@@ -106,7 +102,7 @@ class ExportMasterView(MasterView):
         return export.id_str
 
     def configure_form(self, f):
-        super(ExportMasterView, self).configure_form(f)
+        super().configure_form(f)
         export = f.model_instance
 
         # NOTE: we try to handle the 'creating' scenario even though this class
@@ -149,7 +145,7 @@ class ExportMasterView(MasterView):
             f.set_renderer('filename', self.render_downloadable_file)
 
     def objectify(self, form, data=None):
-        obj = super(ExportMasterView, self).objectify(form, data=data)
+        obj = super().objectify(form, data=data)
         if self.creating:
             obj.created_by = self.request.user
         return obj
@@ -158,7 +154,7 @@ class ExportMasterView(MasterView):
         user = export.created_by
         if not user:
             return ""
-        text = six.text_type(user)
+        text = str(user)
         if self.request.has_perm('users.view'):
             url = self.request.route_url('users.view', uuid=user.uuid)
             return tags.link_to(text, url)
@@ -175,12 +171,8 @@ class ExportMasterView(MasterView):
         export = self.get_instance()
         path = self.get_file_path(export)
         response = FileResponse(path, request=self.request)
-        if six.PY3:
-            response.headers['Content-Length'] = str(os.path.getsize(path))
-            response.headers['Content-Disposition'] = 'attachment; filename="{}"'.format(export.filename)
-        else:
-            response.headers[b'Content-Length'] = six.binary_type(os.path.getsize(path))
-            response.headers[b'Content-Disposition'] = b'attachment; filename="{}"'.format(export.filename)
+        response.headers['Content-Length'] = str(os.path.getsize(path))
+        response.headers['Content-Disposition'] = 'attachment; filename="{}"'.format(export.filename)
         return response
 
     def delete_instance(self, export):
@@ -195,4 +187,4 @@ class ExportMasterView(MasterView):
                 shutil.rmtree(dirname)
 
         # continue w/ normal deletion
-        super(ExportMasterView, self).delete_instance(export)
+        super().delete_instance(export)
diff --git a/tailbone/views/importing.py b/tailbone/views/importing.py
index e9167132..48b32cc2 100644
--- a/tailbone/views/importing.py
+++ b/tailbone/views/importing.py
@@ -34,7 +34,6 @@ import time
 
 import sqlalchemy as sa
 
-from rattail.exceptions import ConfigurationError
 from rattail.threads import Thread
 
 import colander
@@ -458,22 +457,7 @@ And here is the output:
         return HTML.tag('div', class_='tailbone-markdown', c=[notes])
 
     def get_cmd_for_handler(self, handler, ignore_errors=False):
-        handler_key = handler.get_key()
-
-        cmd = self.rattail_config.getlist('rattail.importing',
-                                          '{}.cmd'.format(handler_key))
-        if not cmd or len(cmd) != 2:
-            cmd = self.rattail_config.getlist('rattail.importing',
-                                              '{}.default_cmd'.format(handler_key))
-
-            if not cmd or len(cmd) != 2:
-                msg = ("Missing or invalid config; please set '{}.default_cmd' in the "
-                       "[rattail.importing] section of your config file".format(handler_key))
-                if ignore_errors:
-                    return
-                raise ConfigurationError(msg)
-
-        return cmd
+        return handler.get_cmd(ignore_errors=ignore_errors)
 
     def get_runas_for_handler(self, handler):
         handler_key = handler.get_key()
diff --git a/tailbone/views/master.py b/tailbone/views/master.py
index 48bc32fe..21a5e58f 100644
--- a/tailbone/views/master.py
+++ b/tailbone/views/master.py
@@ -39,8 +39,9 @@ from sqlalchemy import orm
 import sqlalchemy_continuum as continuum
 from sqlalchemy_utils.functions import get_primary_keys, get_columns
 
+from wuttjamaican.util import get_class_hierarchy
 from rattail.db.continuum import model_transaction_query
-from rattail.util import simple_error, get_class_hierarchy
+from rattail.util import simple_error
 from rattail.threads import Thread
 from rattail.csvutil import UnicodeDictWriter
 from rattail.excel import ExcelWriter
@@ -116,6 +117,7 @@ class MasterView(View):
     supports_prev_next = False
     supports_import_batch_from_file = False
     has_input_file_templates = False
+    has_output_file_templates = False
     configurable = False
 
     # set to True to add "View *global* Objects" permission, and
@@ -136,6 +138,7 @@ class MasterView(View):
     deleting = False
     executing = False
     cloning = False
+    configuring = False
     has_pk_fields = False
     has_image = False
     has_thumbnail = False
@@ -332,7 +335,7 @@ class MasterView(View):
 
         # If user just refreshed the page with a reset instruction, issue a
         # redirect in order to clear out the query string.
-        if self.request.GET.get('reset-to-default-filters') == 'true':
+        if self.request.GET.get('reset-view'):
             kw = {'_query': None}
             hash_ = self.request.GET.get('hash')
             if hash_:
@@ -340,14 +343,16 @@ class MasterView(View):
             return self.redirect(self.request.current_route_url(**kw))
 
         # Stash some grid stats, for possible use when generating URLs.
-        if grid.pageable and hasattr(grid, 'pager'):
+        if grid.paginated and hasattr(grid, 'pager'):
             self.first_visible_grid_index = grid.pager.first_item
 
         # return grid data only, if partial page was requested
-        if self.request.params.get('partial'):
-            return self.json_response(grid.get_table_data())
+        if self.request.GET.get('partial'):
+            context = grid.get_table_data()
+            return self.json_response(context)
 
         context = {
+            'index_url': None, # nb. avoid title link since this *is* the index
             'grid': grid,
         }
 
@@ -378,7 +383,7 @@ class MasterView(View):
         grid contents etc.
         """
 
-    def make_grid(self, factory=None, key=None, data=None, columns=None, **kwargs):
+    def make_grid(self, factory=None, key=None, data=None, columns=None, session=None, **kwargs):
         """
         Creates a new grid instance
         """
@@ -387,13 +392,12 @@ class MasterView(View):
         if key is None:
             key = self.get_grid_key()
         if data is None:
-            data = self.get_data(session=kwargs.get('session'))
+            data = self.get_data(session=session)
         if columns is None:
             columns = self.get_grid_columns()
 
-        kwargs.setdefault('request', self.request)
         kwargs = self.make_grid_kwargs(**kwargs)
-        grid = factory(key, data, columns, **kwargs)
+        grid = factory(self.request, key=key, data=data, columns=columns, **kwargs)
         self.configure_grid(grid)
         grid.load_settings()
         return grid
@@ -406,9 +410,9 @@ class MasterView(View):
         """
         if session is None:
             session = self.Session()
-        kwargs.setdefault('pageable', False)
+        kwargs.setdefault('paginated', False)
         grid = self.make_grid(session=session, **kwargs)
-        return grid.make_visible_data()
+        return grid.get_visible_data()
 
     def get_grid_columns(self):
         """
@@ -439,7 +443,8 @@ class MasterView(View):
             'filterable': self.filterable,
             'use_byte_string_filters': self.use_byte_string_filters,
             'sortable': self.sortable,
-            'pageable': self.pageable,
+            'sort_multiple': not self.request.use_oruga,
+            'paginated': self.pageable,
             'extra_row_class': self.grid_extra_class,
             'url': lambda obj: self.get_action_url('view', obj),
             'checkboxes': checkboxes,
@@ -453,10 +458,26 @@ class MasterView(View):
         if self.sortable or self.pageable or self.filterable:
             defaults['expose_direct_link'] = True
 
-        if 'main_actions' not in kwargs and 'more_actions' not in kwargs:
-            main, more = self.get_grid_actions()
-            defaults['main_actions'] = main
-            defaults['more_actions'] = more
+        if 'actions' not in kwargs:
+
+            if 'main_actions' in kwargs:
+                warnings.warn("main_actions param is deprecated for make_grid_kwargs(); "
+                              "please use actions param instead",
+                              DeprecationWarning, stacklevel=2)
+                main = kwargs.pop('main_actions')
+            else:
+                main = self.get_main_actions()
+
+            if 'more_actions' in kwargs:
+                warnings.warn("more_actions param is deprecated for make_grid_kwargs(); "
+                              "please use actions param instead",
+                              DeprecationWarning, stacklevel=2)
+                more = kwargs.pop('more_actions')
+            else:
+                more = self.get_more_actions()
+
+            defaults['actions'] = main + more
+
         defaults.update(kwargs)
         return defaults
 
@@ -530,7 +551,8 @@ class MasterView(View):
     def get_quickie_result_url(self, obj):
         return self.get_action_url('view', obj)
 
-    def make_row_grid(self, factory=None, key=None, data=None, columns=None, **kwargs):
+    def make_row_grid(self, factory=None, key=None, data=None, columns=None,
+                      session=None, **kwargs):
         """
         Make and return a new (configured) rows grid instance.
         """
@@ -547,9 +569,8 @@ class MasterView(View):
         if columns is None:
             columns = self.get_row_grid_columns()
 
-        kwargs.setdefault('request', self.request)
         kwargs = self.make_row_grid_kwargs(**kwargs)
-        grid = factory(key, data, columns, **kwargs)
+        grid = factory(self.request, key=key, data=data, columns=columns, **kwargs)
         self.configure_row_grid(grid)
         grid.load_settings()
         return grid
@@ -568,15 +589,16 @@ class MasterView(View):
             'filterable': self.rows_filterable,
             'use_byte_string_filters': self.use_byte_string_filters,
             'sortable': self.rows_sortable,
-            'pageable': self.rows_pageable,
+            'sort_multiple': not self.request.use_oruga,
+            'paginated': self.rows_pageable,
             'extra_row_class': self.row_grid_extra_class,
             'url': lambda obj: self.get_row_action_url('view', obj),
         }
 
         if self.rows_default_pagesize:
-            defaults['default_pagesize'] = self.rows_default_pagesize
+            defaults['pagesize'] = self.rows_default_pagesize
 
-        if self.has_rows and 'main_actions' not in defaults:
+        if self.has_rows and 'actions' not in defaults:
             actions = []
 
             # view action
@@ -591,10 +613,12 @@ class MasterView(View):
 
             # delete action
             if self.rows_deletable and self.has_perm('delete_row'):
-                actions.append(self.make_action('delete', icon='trash', url=self.row_delete_action_url))
+                actions.append(self.make_action('delete', icon='trash',
+                                                url=self.row_delete_action_url,
+                                                link_class='has-text-danger'))
                 defaults['delete_speedbump'] = self.rows_deletable_speedbump
 
-            defaults['main_actions'] = actions
+            defaults['actions'] = actions
 
         defaults.update(kwargs)
         return defaults
@@ -629,9 +653,8 @@ class MasterView(View):
         if columns is None:
             columns = self.get_version_grid_columns()
 
-        kwargs.setdefault('request', self.request)
         kwargs = self.make_version_grid_kwargs(**kwargs)
-        grid = factory(key, data, columns, **kwargs)
+        grid = factory(self.request, key=key, data=data, columns=columns, **kwargs)
         self.configure_version_grid(grid)
         grid.load_settings()
         return grid
@@ -657,12 +680,12 @@ class MasterView(View):
         defaults = {
             'model_class': continuum.transaction_class(self.get_model_class()),
             'width': 'full',
-            'pageable': True,
+            'paginated': True,
             'url': lambda txn: self.request.route_url(route, uuid=instance.uuid, txnid=txn.id),
         }
-        if 'main_actions' not in kwargs:
+        if 'actions' not in kwargs:
             url = lambda txn, i: self.request.route_url(route, uuid=instance.uuid, txnid=txn.id)
-            defaults['main_actions'] = [
+            defaults['actions'] = [
                 self.make_action('view', icon='eye', url=url),
             ]
         defaults.update(kwargs)
@@ -880,7 +903,7 @@ class MasterView(View):
 
     def valid_employee_uuid(self, node, value):
         if value:
-            model = self.model
+            model = self.app.model
             employee = self.Session.get(model.Employee, value)
             if not employee:
                 node.raise_invalid("Employee not found")
@@ -916,7 +939,7 @@ class MasterView(View):
 
     def valid_vendor_uuid(self, node, value):
         if value:
-            model = self.model
+            model = self.app.model
             vendor = self.Session.get(model.Vendor, value)
             if not vendor:
                 node.raise_invalid("Vendor not found")
@@ -1164,7 +1187,7 @@ class MasterView(View):
 
             # If user just refreshed the page with a reset instruction, issue a
             # redirect in order to clear out the query string.
-            if self.request.GET.get('reset-to-default-filters') == 'true':
+            if self.request.GET.get('reset-view'):
                 kw = {'_query': None}
                 hash_ = self.request.GET.get('hash')
                 if hash_:
@@ -1359,19 +1382,19 @@ class MasterView(View):
         return classes
 
     def make_revisions_grid(self, obj, empty_data=False):
-        model = self.model
+        model = self.app.model
         route_prefix = self.get_route_prefix()
         row_url = lambda txn, i: self.request.route_url(f'{route_prefix}.version',
                                                         uuid=obj.uuid,
                                                         txnid=txn.id)
 
         kwargs = {
-            'component': 'versions-grid',
+            'vue_tagname': 'versions-grid',
             'ajax_data_url': self.get_action_url('revisions_data', obj),
             'sortable': True,
-            'default_sortkey': 'changed',
-            'default_sortdir': 'desc',
-            'main_actions': [
+            'sort_multiple': not self.request.use_oruga,
+            'sort_defaults': ('changed', 'desc'),
+            'actions': [
                 self.make_action('view', icon='eye', url='#',
                                  click_handler='viewRevision(props.row)'),
                 self.make_action('view_separate', url=row_url, target='_blank',
@@ -1684,10 +1707,10 @@ class MasterView(View):
         """
         if session is None:
             session = self.Session()
-        kwargs.setdefault('pageable', False)
+        kwargs.setdefault('paginated', False)
         kwargs.setdefault('sortable', sort)
         grid = self.make_row_grid(session=session, **kwargs)
-        return grid.make_visible_data()
+        return grid.get_visible_data()
 
     @classmethod
     def get_row_url_prefix(cls):
@@ -1801,6 +1824,26 @@ class MasterView(View):
         path = os.path.join(basedir, filespec)
         return self.file_response(path)
 
+    def download_output_file_template(self):
+        """
+        View for downloading an output file template.
+        """
+        key = self.request.GET['key']
+        filespec = self.request.GET['file']
+
+        matches = [tmpl for tmpl in self.get_output_file_templates()
+                   if tmpl['key'] == key]
+        if not matches:
+            raise self.notfound()
+
+        template = matches[0]
+        templatesdir = os.path.join(self.rattail_config.datadir(),
+                                    'templates', 'output_files',
+                                    self.get_route_prefix())
+        basedir = os.path.join(templatesdir, template['key'])
+        path = os.path.join(basedir, filespec)
+        return self.file_response(path)
+
     def edit(self):
         """
         View for editing an existing model record.
@@ -1862,6 +1905,7 @@ class MasterView(View):
             return self.redirect(self.get_action_url('view', instance))
 
         form = self.make_form(instance)
+        form.save_label = "DELETE Forever"
 
         # TODO: Add better validation, ideally CSRF etc.
         if self.request.method == 'POST':
@@ -2109,7 +2153,7 @@ class MasterView(View):
         Thread target for executing an object.
         """
         app = self.get_rattail_app()
-        model = self.model
+        model = self.app.model
         session = app.make_session()
         obj = self.get_instance_for_key(key, session)
         user = session.get(model.User, user_uuid)
@@ -2292,9 +2336,13 @@ class MasterView(View):
                     except Exception as error:
                         self.request.session.flash("Requested merge cannot proceed (maybe swap kept/removed and try again?): {}".format(error), 'error')
                     else:
-                        self.merge_objects(object_to_remove, object_to_keep)
-                        self.request.session.flash("{} has been merged into {}".format(msg, object_to_keep))
-                        return self.redirect(self.get_action_url('view', object_to_keep))
+                        try:
+                            self.merge_objects(object_to_remove, object_to_keep)
+                            self.request.session.flash("{} has been merged into {}".format(msg, object_to_keep))
+                            return self.redirect(self.get_action_url('view', object_to_keep))
+                        except Exception as error:
+                            error = simple_error(error)
+                            self.request.session.flash(f"merge failed: {error}", 'error')
 
         if not object_to_remove or not object_to_keep or object_to_remove is object_to_keep:
             return self.redirect(self.get_index_url())
@@ -2544,11 +2592,12 @@ class MasterView(View):
         so if you like you can return a different help URL depending on which
         type of CRUD view is in effect, etc.
         """
-        model = self.model
+        # nb. self.Session may differ, so use tailbone.db.Session
+        session = Session()
+        model = self.app.model
         route_prefix = self.get_route_prefix()
 
-        # nb. self.Session may differ, so use tailbone.db.Session
-        info = Session.query(model.TailbonePageHelp)\
+        info = session.query(model.TailbonePageHelp)\
                       .filter(model.TailbonePageHelp.route_prefix == route_prefix)\
                       .first()
         if info and info.help_url:
@@ -2566,11 +2615,12 @@ class MasterView(View):
         """
         Return the markdown help text for current page, if defined.
         """
-        model = self.model
+        # nb. self.Session may differ, so use tailbone.db.Session
+        session = Session()
+        model = self.app.model
         route_prefix = self.get_route_prefix()
 
-        # nb. self.Session may differ, so use tailbone.db.Session
-        info = Session.query(model.TailbonePageHelp)\
+        info = session.query(model.TailbonePageHelp)\
                       .filter(model.TailbonePageHelp.route_prefix == route_prefix)\
                       .first()
         if info and info.markdown_text:
@@ -2587,7 +2637,9 @@ class MasterView(View):
         if not self.can_edit_help():
             raise self.forbidden()
 
-        model = self.model
+        # nb. self.Session may differ, so use tailbone.db.Session
+        session = Session()
+        model = self.app.model
         route_prefix = self.get_route_prefix()
         schema = colander.Schema()
 
@@ -2604,13 +2656,12 @@ class MasterView(View):
         if not form.validate():
             return {'error': "Form did not validate"}
 
-        # nb. self.Session may differ, so use tailbone.db.Session
-        info = Session.query(model.TailbonePageHelp)\
+        info = session.query(model.TailbonePageHelp)\
                       .filter(model.TailbonePageHelp.route_prefix == route_prefix)\
                       .first()
         if not info:
             info = model.TailbonePageHelp(route_prefix=route_prefix)
-            Session.add(info)
+            session.add(info)
 
         info.help_url = form.validated['help_url']
         info.markdown_text = form.validated['markdown_text']
@@ -2620,7 +2671,9 @@ class MasterView(View):
         if not self.can_edit_help():
             raise self.forbidden()
 
-        model = self.model
+        # nb. self.Session may differ, so use tailbone.db.Session
+        session = Session()
+        model = self.app.model
         route_prefix = self.get_route_prefix()
         schema = colander.Schema()
 
@@ -2636,15 +2689,14 @@ class MasterView(View):
         if not form.validate():
             return {'error': "Form did not validate"}
 
-        # nb. self.Session may differ, so use tailbone.db.Session
-        info = Session.query(model.TailboneFieldInfo)\
+        info = session.query(model.TailboneFieldInfo)\
                       .filter(model.TailboneFieldInfo.route_prefix == route_prefix)\
                       .filter(model.TailboneFieldInfo.field_name == form.validated['field_name'])\
                       .first()
         if not info:
             info = model.TailboneFieldInfo(route_prefix=route_prefix,
                                            field_name=form.validated['field_name'])
-            Session.add(info)
+            session.add(info)
 
         info.markdown_text = form.validated['markdown_text']
         return {'ok': True}
@@ -2820,6 +2872,12 @@ class MasterView(View):
             kwargs['input_file_templates'] = OrderedDict([(tmpl['key'], tmpl)
                                                           for tmpl in templates])
 
+        # add info for downloadable output file templates, if any
+        if self.has_output_file_templates:
+            templates = self.normalize_output_file_templates()
+            kwargs['output_file_templates'] = OrderedDict([(tmpl['key'], tmpl)
+                                                           for tmpl in templates])
+
         return kwargs
 
     def get_input_file_templates(self):
@@ -2894,6 +2952,81 @@ class MasterView(View):
 
         return templates
 
+    def get_output_file_templates(self):
+        return []
+
+    def normalize_output_file_templates(self, templates=None,
+                                        include_file_options=False):
+        if templates is None:
+            templates = self.get_output_file_templates()
+
+        route_prefix = self.get_route_prefix()
+
+        if include_file_options:
+            templatesdir = os.path.join(self.rattail_config.datadir(),
+                                        'templates', 'output_files',
+                                        route_prefix)
+
+        for template in templates:
+
+            if 'config_section' not in template:
+                if hasattr(self, 'output_file_template_config_section'):
+                    template['config_section'] = self.output_file_template_config_section
+                else:
+                    template['config_section'] = route_prefix
+            section = template['config_section']
+
+            if 'config_prefix' not in template:
+                template['config_prefix'] = '{}.{}'.format(
+                    self.output_file_template_config_prefix,
+                    template['key'])
+            prefix = template['config_prefix']
+
+            for key in ('mode', 'file', 'url'):
+
+                if 'option_{}'.format(key) not in template:
+                    template['option_{}'.format(key)] = '{}.{}'.format(prefix, key)
+
+                if 'setting_{}'.format(key) not in template:
+                    template['setting_{}'.format(key)] = '{}.{}'.format(
+                        section,
+                        template['option_{}'.format(key)])
+
+                if key not in template:
+                    value = self.rattail_config.get(
+                        section,
+                        template['option_{}'.format(key)])
+                    if value is not None:
+                        template[key] = value
+
+            template.setdefault('mode', 'default')
+            template.setdefault('file', None)
+            template.setdefault('url', template['default_url'])
+
+            if include_file_options:
+                options = []
+                basedir = os.path.join(templatesdir, template['key'])
+                if os.path.exists(basedir):
+                    for name in sorted(os.listdir(basedir)):
+                        if len(name) == 4 and name.isdigit():
+                            files = os.listdir(os.path.join(basedir, name))
+                            if len(files) == 1:
+                                options.append(os.path.join(name, files[0]))
+                template['file_options'] = options
+                template['file_options_dir'] = basedir
+
+            if template['mode'] == 'external':
+                template['effective_url'] = template['url']
+            elif template['mode'] == 'hosted':
+                template['effective_url'] = self.request.route_url(
+                    '{}.download_output_file_template'.format(route_prefix),
+                    _query={'key': template['key'],
+                            'file': template['file']})
+            else:
+                template['effective_url'] = template['default_url']
+
+        return templates
+
     def template_kwargs_index(self, **kwargs):
         """
         Method stub, so subclass can always invoke super() for it.
@@ -2941,6 +3074,12 @@ class MasterView(View):
                     items.append(tags.link_to(f"Download {template['label']} Template",
                                               template['effective_url']))
 
+            if self.has_output_file_templates and self.has_perm('configure'):
+                templates = self.normalize_output_file_templates()
+                for template in templates:
+                    items.append(tags.link_to(f"Download {template['label']} Template",
+                                              template['effective_url']))
+
         # if self.viewing:
 
         #     # # TODO: either make this configurable, or just lose it.
@@ -3106,6 +3245,11 @@ class MasterView(View):
         return key
 
     def get_grid_actions(self):
+        """ """
+        warnings.warn("get_grid_actions() method is deprecated; "
+                      "please use get_main_actions() or get_more_actions() instead",
+                      DeprecationWarning, stacklevel=2)
+
         main, more = self.get_main_actions(), self.get_more_actions()
         if len(more) == 1:
             main, more = main + more, []
@@ -3181,7 +3325,7 @@ class MasterView(View):
                                 url=self.default_clone_url)
 
     def make_grid_action_delete(self):
-        kwargs = {}
+        kwargs = {'link_class': 'has-text-danger'}
         if self.delete_confirm == 'simple':
             kwargs['click_handler'] = 'deleteObject'
         return self.make_action('delete', icon='trash', url=self.default_delete_url, **kwargs)
@@ -3215,14 +3359,18 @@ class MasterView(View):
 
     def make_action(self, key, url=None, factory=None, **kwargs):
         """
-        Make a new :class:`GridAction` instance for the current grid.
+        Make and return a new :class:`~tailbone.grids.core.GridAction`
+        instance.
+
+        This can be called to make actions for any grid, not just the
+        one from :meth:`index()`.
         """
         if url is None:
             route = '{}.{}'.format(self.get_route_prefix(), key)
             url = lambda r, i: self.request.route_url(route, **self.get_action_route_kwargs(r))
         if not factory:
             factory = grids.GridAction
-        return factory(key, url=url, **kwargs)
+        return factory(self.request, key, url=url, **kwargs)
 
     def get_action_route_kwargs(self, obj):
         """
@@ -4417,7 +4565,7 @@ class MasterView(View):
             'request': self.request,
             'readonly': self.viewing,
             'model_class': getattr(self, 'model_class', None),
-            'action_url': self.request.current_route_url(_query=None),
+            'action_url': self.request.path_url,
             'assume_local_times': self.has_local_times,
             'route_prefix': route_prefix,
             'can_edit_help': self.can_edit_help(),
@@ -5085,6 +5233,7 @@ class MasterView(View):
         """
         Generic view for configuring some aspect of the software.
         """
+        self.configuring = True
         app = self.get_rattail_app()
         if self.request.method == 'POST':
             if self.request.POST.get('remove_settings'):
@@ -5166,6 +5315,39 @@ class MasterView(View):
                     data[template['setting_file']] = os.path.join(numdir,
                                                                   info['filename'])
 
+        if self.has_output_file_templates:
+            templatesdir = os.path.join(self.rattail_config.datadir(),
+                                        'templates', 'output_files',
+                                        self.get_route_prefix())
+
+            def get_next_filedir(basedir):
+                nextid = 1
+                while True:
+                    path = os.path.join(basedir, '{:04d}'.format(nextid))
+                    if not os.path.exists(path):
+                        # this should fail if there happens to be a race
+                        # condition and someone else got to this id first
+                        os.mkdir(path)
+                        return path
+                    nextid += 1
+
+            for template in self.normalize_output_file_templates():
+                key = '{}.upload'.format(template['setting_file'])
+                if key in uploads:
+                    assert self.request.POST[template['setting_mode']] == 'hosted'
+                    assert not self.request.POST[template['setting_file']]
+                    info = uploads[key]
+                    basedir = os.path.join(templatesdir, template['key'])
+                    if not os.path.exists(basedir):
+                        os.makedirs(basedir)
+                    filedir = get_next_filedir(basedir)
+                    filepath = os.path.join(filedir, info['filename'])
+                    shutil.copyfile(info['filepath'], filepath)
+                    shutil.rmtree(info['filedir'])
+                    numdir = os.path.basename(filedir)
+                    data[template['setting_file']] = os.path.join(numdir,
+                                                                  info['filename'])
+
     def configure_get_simple_settings(self):
         """
         If you have some "simple" settings, each of which basically
@@ -5210,7 +5392,8 @@ class MasterView(View):
                               simple['option'])
 
     def configure_get_context(self, simple_settings=None,
-                              input_file_templates=True):
+                              input_file_templates=True,
+                              output_file_templates=True):
         """
         Returns the full context dict, for rendering the configure
         page template.
@@ -5259,7 +5442,7 @@ class MasterView(View):
             for template in self.normalize_input_file_templates(
                     include_file_options=True):
                 settings[template['setting_mode']] = template['mode']
-                settings[template['setting_file']] = template['file']
+                settings[template['setting_file']] = template['file'] or ''
                 settings[template['setting_url']] = template['url']
                 file_options[template['key']] = template['file_options']
                 file_option_dirs[template['key']] = template['file_options_dir']
@@ -5267,10 +5450,27 @@ class MasterView(View):
             context['input_file_options'] = file_options
             context['input_file_option_dirs'] = file_option_dirs
 
+        # add settings for output file templates, if any
+        if output_file_templates and self.has_output_file_templates:
+            settings = {}
+            file_options = {}
+            file_option_dirs = {}
+            for template in self.normalize_output_file_templates(
+                    include_file_options=True):
+                settings[template['setting_mode']] = template['mode']
+                settings[template['setting_file']] = template['file'] or ''
+                settings[template['setting_url']] = template['url']
+                file_options[template['key']] = template['file_options']
+                file_option_dirs[template['key']] = template['file_options_dir']
+            context['output_file_template_settings'] = settings
+            context['output_file_options'] = file_options
+            context['output_file_option_dirs'] = file_option_dirs
+
         return context
 
     def configure_gather_settings(self, data, simple_settings=None,
-                                  input_file_templates=True):
+                                  input_file_templates=True,
+                                  output_file_templates=True):
         settings = []
 
         # maybe collect "simple" settings
@@ -5316,12 +5516,32 @@ class MasterView(View):
                 settings.append({'name': template['setting_url'],
                                  'value': data.get(template['setting_url'])})
 
+        # maybe also collect output file template settings
+        if output_file_templates and self.has_output_file_templates:
+            for template in self.normalize_output_file_templates():
+
+                # mode
+                settings.append({'name': template['setting_mode'],
+                                 'value': data.get(template['setting_mode'])})
+
+                # file
+                value = data.get(template['setting_file'])
+                if value:
+                    # nb. avoid saving if empty, so can remain "null"
+                    settings.append({'name': template['setting_file'],
+                                     'value': value})
+
+                # url
+                settings.append({'name': template['setting_url'],
+                                 'value': data.get(template['setting_url'])})
+
         return settings
 
     def configure_remove_settings(self, simple_settings=None,
-                                  input_file_templates=True):
+                                  input_file_templates=True,
+                                  output_file_templates=True):
         app = self.get_rattail_app()
-        model = self.model
+        model = self.app.model
         names = []
 
         if simple_settings is None:
@@ -5338,6 +5558,14 @@ class MasterView(View):
                     template['setting_url'],
                 ])
 
+        if output_file_templates and self.has_output_file_templates:
+            for template in self.normalize_output_file_templates():
+                names.extend([
+                    template['setting_mode'],
+                    template['setting_file'],
+                    template['setting_url'],
+                ])
+
         if names:
             # nb. using thread-local session here; we do not use
             # self.Session b/c it may not point to Rattail
@@ -5600,6 +5828,15 @@ class MasterView(View):
                             route_name='{}.download_input_file_template'.format(route_prefix),
                             permission='{}.create'.format(permission_prefix))
 
+        # download output file template
+        if cls.has_output_file_templates and cls.configurable:
+            config.add_route(f'{route_prefix}.download_output_file_template',
+                             f'{url_prefix}/download-output-file-template')
+            config.add_view(cls, attr='download_output_file_template',
+                            route_name=f'{route_prefix}.download_output_file_template',
+                            # TODO: this is different from input file, should change?
+                            permission=f'{permission_prefix}.configure')
+
         # view
         if cls.viewable:
             cls._defaults_view(config)
@@ -5863,7 +6100,7 @@ class MasterView(View):
                         renderer='json')
 
 
-class ViewSupplement(object):
+class ViewSupplement:
     """
     Base class for view "supplements" - which are sort of like plugins
     which can "supplement" certain aspects of the view.
@@ -5890,6 +6127,7 @@ class ViewSupplement(object):
     def __init__(self, master):
         self.master = master
         self.request = master.request
+        self.app = master.app
         self.model = master.model
         self.rattail_config = master.rattail_config
         self.Session = master.Session
@@ -5923,7 +6161,7 @@ class ViewSupplement(object):
         This is accomplished by subjecting the current base query to a
         join, e.g. something like::
 
-           model = self.model
+           model = self.app.model
            query = query.outerjoin(model.MyExtension)
            return query
         """
diff --git a/tailbone/views/members.py b/tailbone/views/members.py
index de844eb7..46ed7e4b 100644
--- a/tailbone/views/members.py
+++ b/tailbone/views/members.py
@@ -229,8 +229,7 @@ class MemberView(MasterView):
             url = lambda r, i: self.request.route_url(
                 f'{route_prefix}.view', **self.get_action_route_kwargs(r))
             # nb. insert to slot 1, just after normal View action
-            g.main_actions.insert(1, self.make_action(
-                'view_raw', url=url, icon='eye'))
+            g.actions.insert(1, self.make_action('view_raw', url=url, icon='eye'))
 
         # equity_total
         # TODO: should make this configurable
diff --git a/tailbone/views/menus.py b/tailbone/views/menus.py
index f60ad274..b606e4e7 100644
--- a/tailbone/views/menus.py
+++ b/tailbone/views/menus.py
@@ -2,7 +2,7 @@
 ################################################################################
 #
 #  Rattail -- Retail Software Framework
-#  Copyright © 2010-2023 Lance Edgar
+#  Copyright © 2010-2024 Lance Edgar
 #
 #  This file is part of Rattail.
 #
@@ -30,7 +30,6 @@ import sqlalchemy as sa
 
 from tailbone.views import View
 from tailbone.db import Session
-from tailbone.menus import make_menu_key
 
 
 class MenuConfigView(View):
@@ -79,12 +78,16 @@ class MenuConfigView(View):
         return context
 
     def configure_gather_settings(self, data):
+        app = self.get_rattail_app()
+        web = app.get_web_handler()
+        menus = web.get_menu_handler()
+
         settings = [{'name': 'tailbone.menu.from_settings',
                      'value': 'true'}]
 
         main_keys = []
         for topitem in json.loads(data['menus']):
-            key = make_menu_key(self.rattail_config, topitem['title'])
+            key = menus._make_menu_key(self.rattail_config, topitem['title'])
             main_keys.append(key)
 
             settings.extend([
@@ -99,7 +102,7 @@ class MenuConfigView(View):
                     if item.get('route'):
                         item_key = item['route']
                     else:
-                        item_key = make_menu_key(self.rattail_config, item['title'])
+                        item_key = menus._make_menu_key(self.rattail_config, item['title'])
                     item_keys.append(item_key)
 
                     settings.extend([
diff --git a/tailbone/views/people.py b/tailbone/views/people.py
index d8e36ec9..405b1ca3 100644
--- a/tailbone/views/people.py
+++ b/tailbone/views/people.py
@@ -40,6 +40,7 @@ import colander
 from webhelpers2.html import HTML, tags
 
 from tailbone import forms, grids
+from tailbone.db import TrainwreckSession
 from tailbone.views import MasterView
 from tailbone.util import raw_datetime
 
@@ -174,8 +175,7 @@ class PersonView(MasterView):
             url = lambda r, i: self.request.route_url(
                 f'{route_prefix}.view', **self.get_action_route_kwargs(r))
             # nb. insert to slot 1, just after normal View action
-            g.main_actions.insert(1, self.make_action(
-                'view_raw', url=url, icon='eye'))
+            g.actions.insert(1, self.make_action('view_raw', url=url, icon='eye'))
 
         g.set_link('display_name')
         g.set_link('first_name')
@@ -487,16 +487,112 @@ class PersonView(MasterView):
             'expose_customer_shoppers': self.customers_should_expose_shoppers(),
             'max_one_member': app.get_membership_handler().max_one_per_person(),
             'use_preferred_first_name': self.people_handler.should_use_preferred_first_name(),
+            'expose_members': self.should_expose_profile_members(),
+            'expose_transactions': self.should_expose_profile_transactions(),
         }
 
+        if context['expose_transactions']:
+            context['transactions_grid'] = self.profile_transactions_grid(person, empty=True)
+
         if self.request.has_perm('people_profile.view_versions'):
             context['revisions_grid'] = self.profile_revisions_grid(person)
 
         return self.render_to_response('view_profile', context)
 
+    def should_expose_profile_members(self):
+        return self.rattail_config.get_bool('tailbone.people.profile.expose_members',
+                                            default=False)
+
+    def should_expose_profile_transactions(self):
+        return self.rattail_config.get_bool('tailbone.people.profile.expose_transactions',
+                                            default=False)
+
+    def profile_transactions_grid(self, person, empty=False):
+        app = self.get_rattail_app()
+        trainwreck = app.get_trainwreck_handler()
+        model = trainwreck.get_model()
+        route_prefix = self.get_route_prefix()
+        if empty:
+            # TODO: surely there is a better way to have empty data..? but so
+            # much logic depends on a query, can't just pass empty list here
+            data = TrainwreckSession.query(model.Transaction)\
+                                    .filter(model.Transaction.uuid == 'bogus')
+        else:
+            data = self.profile_transactions_query(person)
+        factory = self.get_grid_factory()
+        g = factory(
+            self.request,
+            key=f'{route_prefix}.profile.transactions.{person.uuid}',
+            data=data,
+            model_class=model.Transaction,
+            ajax_data_url=self.get_action_url('view_profile_transactions', person),
+            columns=[
+                'start_time',
+                'end_time',
+                'system',
+                'terminal_id',
+                'receipt_number',
+                'cashier_name',
+                'customer_id',
+                'customer_name',
+                'total',
+            ],
+            labels={
+                'terminal_id': "Terminal",
+                'customer_id': "Customer " + app.get_customer_key_label(),
+            },
+            filterable=True,
+            sortable=True,
+            paginated=True,
+            default_sortkey='end_time',
+            default_sortdir='desc',
+            component='transactions-grid',
+        )
+        if self.request.has_perm('trainwreck.transactions.view'):
+            url = lambda row, i: self.request.route_url('trainwreck.transactions.view',
+                                                        uuid=row.uuid)
+            g.actions.append(self.make_action('view', icon='eye', url=url))
+        g.load_settings()
+
+        g.set_enum('system', self.enum.TRAINWRECK_SYSTEM)
+        g.set_type('total', 'currency')
+
+        return g
+
+    def profile_transactions_query(self, person):
+        """
+        Method which must return the base query for the profile's POS
+        Transactions grid data.
+        """
+        customer = self.app.get_customer(person)
+
+        if customer:
+            key_field = self.app.get_customer_key_field()
+            customer_key = getattr(customer, key_field)
+            if customer_key is not None:
+                customer_key = str(customer_key)
+        else:
+            # nb. this should *not* match anything, so query returns
+            # no results..
+            customer_key = person.uuid
+
+        trainwreck = self.app.get_trainwreck_handler()
+        model = trainwreck.get_model()
+        query = TrainwreckSession.query(model.Transaction)\
+                                 .filter(model.Transaction.customer_id == customer_key)
+        return query
+
+    def profile_transactions_data(self):
+        """
+        AJAX view to return new sorted, filtered data for transactions
+        grid within profile view.
+        """
+        person = self.get_instance()
+        grid = self.profile_transactions_grid(person)
+        return grid.get_table_data()
+
     def get_context_tabchecks(self, person):
         app = self.get_rattail_app()
-        membership = app.get_membership_handler()
         clientele = app.get_clientele_handler()
         tabchecks = {}
 
@@ -507,12 +603,14 @@ class PersonView(MasterView):
         tabchecks['personal'] = True
 
         # member
-        if membership.max_one_per_person():
-            member = app.get_member(person)
-            tabchecks['member'] = bool(member and member.active)
-        else:
-            members = membership.get_members_for_account_holder(person)
-            tabchecks['member'] = any([m.active for m in members])
+        if self.should_expose_profile_members():
+            membership = app.get_membership_handler()
+            if membership.max_one_per_person():
+                member = app.get_member(person)
+                tabchecks['member'] = bool(member and member.active)
+            else:
+                members = membership.get_members_for_account_holder(person)
+                tabchecks['member'] = any([m.active for m in members])
 
         # customer
         customers = clientele.get_customers_for_account_holder(person)
@@ -742,10 +840,15 @@ class PersonView(MasterView):
         membership = app.get_membership_handler()
 
         data = OrderedDict()
-
         members = membership.get_members_for_account_holder(person)
         for member in members:
-            data[member.uuid] = self.get_context_member(member)
+            context = self.get_context_member(member)
+
+            for supp in self.iter_view_supplements():
+                if hasattr(supp, 'get_context_for_member'):
+                    context = supp.get_context_for_member(member, context)
+
+            data[member.uuid] = context
 
         return list(data.values())
 
@@ -798,6 +901,12 @@ class PersonView(MasterView):
         app = self.get_rattail_app()
         handler = app.get_employment_handler()
         context = handler.get_context_employee(employee)
+        context.setdefault('external_links', [])
+
+        for supp in self.iter_view_supplements():
+            if hasattr(supp, 'get_context_for_employee'):
+                context = supp.get_context_for_employee(employee, context)
+
         context['view_url'] = self.request.route_url('employees.view', uuid=employee.uuid)
         return context
 
@@ -1269,18 +1378,47 @@ class PersonView(MasterView):
         """
         Fetch user tab data for profile view.
         """
+        app = self.get_rattail_app()
+        auth = app.get_auth_handler()
         person = self.get_instance()
-        return {
+        context = {
             'users': self.get_context_users(person),
         }
 
+        if not context['users']:
+            context['suggested_username'] = auth.make_unique_username(self.Session(),
+                                                                      person=person)
+
+        return context
+
+    def profile_make_user(self):
+        """
+        Create a new user account, presumably from the profile view.
+        """
+        app = self.get_rattail_app()
+        model = self.model
+        auth = app.get_auth_handler()
+
+        person = self.get_instance()
+        if person.users:
+            return {'error': f"This person already has {len(person.users)} user accounts."}
+
+        data = self.request.json_body
+        user = auth.make_user(session=self.Session(),
+                              person=person,
+                              username=data['username'],
+                              active=data['active'])
+
+        self.Session.flush()
+        return self.profile_changed_response(person)
+
     def profile_revisions_grid(self, person):
         route_prefix = self.get_route_prefix()
         factory = self.get_grid_factory()
         g = factory(
-            '{}.profile.revisions'.format(route_prefix),
-            [],                 # start with empty data!
-            request=self.request,
+            self.request,
+            key=f'{route_prefix}.profile.revisions',
+            data=[],                 # start with empty data!
             columns=[
                 'changed',
                 'changed_by',
@@ -1295,7 +1433,7 @@ class PersonView(MasterView):
                 'changed_by',
                 'comment',
             ],
-            main_actions=[
+            actions=[
                 self.make_action('view', icon='eye', url='#',
                                  click_handler='viewRevision(props.row)'),
             ],
@@ -1565,6 +1703,14 @@ class PersonView(MasterView):
             {'section': 'rattail',
              'option': 'people.handler'},
 
+
+            # Profile View
+            {'section': 'tailbone',
+             'option': 'people.profile.expose_members',
+             'type': bool},
+            {'section': 'tailbone',
+             'option': 'people.profile.expose_transactions',
+             'type': bool},
         ]
 
     @classmethod
@@ -1776,6 +1922,15 @@ class PersonView(MasterView):
                         route_name=f'{route_prefix}.profile_tab_user',
                         renderer='json')
 
+        # profile - make user
+        config.add_route(f'{route_prefix}.profile_make_user',
+                         f'{instance_url_prefix}/make-user',
+                         request_method='POST')
+        config.add_view(cls, attr='profile_make_user',
+                        route_name=f'{route_prefix}.profile_make_user',
+                        permission='users.create',
+                        renderer='json')
+
         # profile - revisions data
         config.add_tailbone_permission('people_profile',
                                        'people_profile.view_versions',
@@ -1824,6 +1979,15 @@ class PersonView(MasterView):
                         permission='people_profile.delete_note',
                         renderer='json')
 
+        # profile - transactions data
+        config.add_route(f'{route_prefix}.view_profile_transactions',
+                         f'{instance_url_prefix}/profile/transactions',
+                         request_method='GET')
+        config.add_view(cls, attr='profile_transactions_data',
+                        route_name=f'{route_prefix}.view_profile_transactions',
+                        permission=f'{permission_prefix}.view_profile',
+                        renderer='json')
+
         # make user for person
         config.add_route('{}.make_user'.format(route_prefix), '{}/make-user'.format(url_prefix),
                          request_method='POST')
@@ -2026,4 +2190,8 @@ def defaults(config, **kwargs):
 
 
 def includeme(config):
-    defaults(config)
+    wutta_config = config.registry.settings['wutta_config']
+    if wutta_config.get_bool('tailbone.use_wutta_views', default=False, usedb=False):
+        config.include('tailbone.views.wutta.people')
+    else:
+        defaults(config)
diff --git a/tailbone/views/poser/reports.py b/tailbone/views/poser/reports.py
index 43ba211d..ded80b18 100644
--- a/tailbone/views/poser/reports.py
+++ b/tailbone/views/poser/reports.py
@@ -2,7 +2,7 @@
 ################################################################################
 #
 #  Rattail -- Retail Software Framework
-#  Copyright © 2010-2022 Lance Edgar
+#  Copyright © 2010-2024 Lance Edgar
 #
 #  This file is part of Rattail.
 #
@@ -24,12 +24,8 @@
 Poser Report Views
 """
 
-from __future__ import unicode_literals, absolute_import
-
 import os
 
-import six
-
 from rattail.util import simple_error
 
 import colander
@@ -95,7 +91,7 @@ class PoserReportView(PoserMasterView):
         return self.poser_handler.get_all_reports(ignore_errors=False)
 
     def configure_grid(self, g):
-        super(PoserReportView, self).configure_grid(g)
+        super().configure_grid(g)
 
         g.sorters['report_key'] = g.make_simple_sorter('report_key', foldcase=True)
         g.sorters['report_name'] = g.make_simple_sorter('report_name', foldcase=True)
@@ -114,7 +110,7 @@ class PoserReportView(PoserMasterView):
         g.set_searchable('description')
 
         if self.request.has_perm('report_output.create'):
-            g.more_actions.append(self.make_action(
+            g.actions.append(self.make_action(
                 'generate', icon='arrow-circle-right',
                 url=self.get_generate_url))
 
@@ -157,7 +153,7 @@ class PoserReportView(PoserMasterView):
         return report
 
     def configure_form(self, f):
-        super(PoserReportView, self).configure_form(f)
+        super().configure_form(f)
         report = f.model_instance
 
         # report_key
@@ -179,7 +175,7 @@ class PoserReportView(PoserMasterView):
             f.set_helptext('flavor', "Determines the type of sample code to generate.")
             flavors = self.poser_handler.get_supported_report_flavors()
             values = [(key, flavor['description'])
-                      for key, flavor in six.iteritems(flavors)]
+                      for key, flavor in flavors.items()]
             f.set_widget('flavor', dfwidget.SelectWidget(values=values))
             f.set_validator('flavor', colander.OneOf(flavors))
             if flavors:
@@ -231,7 +227,7 @@ class PoserReportView(PoserMasterView):
                     return report
 
     def configure_row_grid(self, g):
-        super(PoserReportView, self).configure_row_grid(g)
+        super().configure_row_grid(g)
 
         g.set_renderer('id', self.render_id_str)
 
diff --git a/tailbone/views/poser/views.py b/tailbone/views/poser/views.py
index 14c97a61..27efd549 100644
--- a/tailbone/views/poser/views.py
+++ b/tailbone/views/poser/views.py
@@ -2,7 +2,7 @@
 ################################################################################
 #
 #  Rattail -- Retail Software Framework
-#  Copyright © 2010-2022 Lance Edgar
+#  Copyright © 2010-2024 Lance Edgar
 #
 #  This file is part of Rattail.
 #
@@ -24,10 +24,6 @@
 Poser Views for Views...
 """
 
-from __future__ import unicode_literals, absolute_import
-
-import six
-
 import colander
 
 from .master import PoserMasterView
@@ -68,7 +64,7 @@ class PoserViewView(PoserMasterView):
         return self.make_form({})
 
     def configure_form(self, f):
-        super(PoserViewView, self).configure_form(f)
+        super().configure_form(f)
         view = f.model_instance
 
         # key
@@ -224,28 +220,28 @@ class PoserViewView(PoserMasterView):
             },
         }}
 
-        for key, views in six.iteritems(everything['rattail']):
-            for vkey, view in six.iteritems(views):
+        for key, views in everything['rattail'].items():
+            for vkey, view in views.items():
                 view['options'] = [vkey]
 
         providers = get_all_providers(self.rattail_config)
-        for provider in six.itervalues(providers):
+        for provider in providers.values():
 
             # loop thru provider top-level groups
-            for topkey, groups in six.iteritems(provider.get_provided_views()):
+            for topkey, groups in provider.get_provided_views().items():
 
                 # get or create top group
                 topgroup = everything.setdefault(topkey, {})
 
                 # loop thru provider view groups
-                for key, views in six.iteritems(groups):
+                for key, views in groups.items():
 
                     # add group to top group, if it's new
                     if key not in topgroup:
                         topgroup[key] = views
 
                         # also must init the options for group
-                        for vkey, view in six.iteritems(views):
+                        for vkey, view in views.items():
                             view['options'] = [vkey]
 
                     else: # otherwise must "update" existing group
@@ -254,7 +250,7 @@ class PoserViewView(PoserMasterView):
                         stdgroup = topgroup[key]
 
                         # loop thru views within provider group
-                        for vkey, view in six.iteritems(views):
+                        for vkey, view in views.items():
 
                             # add view to group if it's new
                             if vkey not in stdgroup:
@@ -270,8 +266,8 @@ class PoserViewView(PoserMasterView):
         settings = []
 
         view_settings = self.collect_available_view_settings()
-        for topgroup in six.itervalues(view_settings):
-            for view_section, section_settings in six.iteritems(topgroup):
+        for topgroup in view_settings.values():
+            for view_section, section_settings in topgroup.items():
                 for key in section_settings:
                     settings.append({'section': 'tailbone.includes',
                                      'option': key})
@@ -282,25 +278,25 @@ class PoserViewView(PoserMasterView):
                               input_file_templates=True):
 
         # first get normal context
-        context = super(PoserViewView, self).configure_get_context(
+        context = super().configure_get_context(
             simple_settings=simple_settings,
             input_file_templates=input_file_templates)
 
         # first add available options
         view_settings = self.collect_available_view_settings()
         view_options = {}
-        for topgroup in six.itervalues(view_settings):
-            for key, views in six.iteritems(topgroup):
-                for vkey, view in six.iteritems(views):
+        for topgroup in view_settings.values():
+            for key, views in topgroup.items():
+                for vkey, view in views.items():
                     view_options[vkey] = view['options']
         context['view_options'] = view_options
 
         # then add all available settings as sorted (key, label) options
-        for topkey, topgroup in six.iteritems(view_settings):
+        for topkey, topgroup in view_settings.items():
             for key in list(topgroup):
                 settings = topgroup[key]
                 settings = [(key, setting.get('label', key))
-                            for key, setting in six.iteritems(settings)]
+                            for key, setting in settings.items()]
                 settings.sort(key=lambda itm: itm[1])
                 topgroup[key] = settings
         context['view_settings'] = view_settings
@@ -308,7 +304,7 @@ class PoserViewView(PoserMasterView):
         return context
 
     def configure_flash_settings_saved(self):
-        super(PoserViewView, self).configure_flash_settings_saved()
+        super().configure_flash_settings_saved()
         self.request.session.flash("Please restart the web app!", 'warning')
 
 
diff --git a/tailbone/views/principal.py b/tailbone/views/principal.py
index fb09306b..3986f8b0 100644
--- a/tailbone/views/principal.py
+++ b/tailbone/views/principal.py
@@ -54,7 +54,7 @@ class PrincipalMasterView(MasterView):
         View for finding all users who have been granted a given permission
         """
         permissions = copy.deepcopy(
-            self.request.registry.settings.get('tailbone_permissions', {}))
+            self.request.registry.settings.get('wutta_permissions', {}))
 
         # sort groups, and permissions for each group, for UI's sake
         sorted_perms = sorted(permissions.items(), key=self.perm_sortkey)
@@ -124,11 +124,11 @@ class PrincipalMasterView(MasterView):
     def find_by_perm_make_results_grid(self, principals):
         route_prefix = self.get_route_prefix()
         factory = self.get_grid_factory()
-        g = factory(key=f'{route_prefix}.results',
-                    request=self.request,
+        g = factory(self.request,
+                    key=f'{route_prefix}.results',
                     data=[],
                     columns=[],
-                    main_actions=[
+                    actions=[
                         self.make_action('view', icon='eye',
                                          click_handler='navigateTo(props.row._url)'),
                     ])
@@ -194,7 +194,7 @@ class PermissionsRenderer(Object):
             rendered = False
             for key in sorted(perms, key=lambda p: perms[p]['label'].lower()):
                 checked = auth.has_permission(Session(), principal, key,
-                                              include_guest=self.include_guest,
+                                              include_anonymous=self.include_guest,
                                               include_authenticated=self.include_authenticated)
                 if checked:
                     label = perms[key]['label']
diff --git a/tailbone/views/products.py b/tailbone/views/products.py
index 28186ac3..8461ae03 100644
--- a/tailbone/views/products.py
+++ b/tailbone/views/products.py
@@ -34,7 +34,7 @@ import sqlalchemy_continuum as continuum
 
 from rattail import enum, pod, sil
 from rattail.db import api, auth, Session as RattailSession
-from rattail.db.model import Product, PendingProduct, CustomerOrderItem
+from rattail.db.model import Product, PendingProduct, ProductCost, CustomerOrderItem
 from rattail.gpc import GPC
 from rattail.threads import Thread
 from rattail.exceptions import LabelPrintingError
@@ -81,6 +81,7 @@ class ProductView(MasterView):
     supports_autocomplete = True
     bulk_deletable = True
     mergeable = True
+    touchable = True
     configurable = True
 
     labels = {
@@ -383,7 +384,7 @@ class ProductView(MasterView):
         g.set_filter('report_code_name', model.ReportCode.name)
 
         if self.expose_label_printing and self.has_perm('print_labels'):
-            g.more_actions.append(self.make_action(
+            g.actions.append(self.make_action(
                 'print_label', icon='print', url='#',
                 click_handler='quickLabelPrint(props.row)'))
 
@@ -1196,8 +1197,9 @@ class ProductView(MasterView):
 
             # regular price
             data = []       # defer fetching until user asks for it
-            grid = grids.Grid('products.regular_price_history', data,
-                              request=self.request,
+            grid = grids.Grid(self.request,
+                              key='products.regular_price_history',
+                              data=data,
                               columns=[
                                   'price',
                                   'since',
@@ -1210,8 +1212,9 @@ class ProductView(MasterView):
 
             # current price
             data = []       # defer fetching until user asks for it
-            grid = grids.Grid('products.current_price_history', data,
-                              request=self.request,
+            grid = grids.Grid(self.request,
+                              key='products.current_price_history',
+                              data=data,
                               columns=[
                                   'price',
                                   'price_type',
@@ -1228,8 +1231,9 @@ class ProductView(MasterView):
 
             # suggested price
             data = []       # defer fetching until user asks for it
-            grid = grids.Grid('products.suggested_price_history', data,
-                              request=self.request,
+            grid = grids.Grid(self.request,
+                              key='products.suggested_price_history',
+                              data=data,
                               columns=[
                                   'price',
                                   'since',
@@ -1242,8 +1246,9 @@ class ProductView(MasterView):
 
             # cost history
             data = []       # defer fetching until user asks for it
-            grid = grids.Grid('products.cost_history', data,
-                              request=self.request,
+            grid = grids.Grid(self.request,
+                              key='products.cost_history',
+                              data=data,
                               columns=[
                                   'cost',
                                   'vendor',
@@ -1334,7 +1339,8 @@ class ProductView(MasterView):
 
         factory = self.get_grid_factory()
         g = factory(
-            key='{}.vendor_sources'.format(route_prefix),
+            self.request,
+            key=f'{route_prefix}.vendor_sources',
             data=[],
             columns=columns,
             labels={
@@ -1375,7 +1381,8 @@ class ProductView(MasterView):
 
         factory = self.get_grid_factory()
         g = factory(
-            key='{}.lookup_codes'.format(route_prefix),
+            self.request,
+            key=f'{route_prefix}.lookup_codes',
             data=[],
             columns=[
                 'sequence',
@@ -1850,7 +1857,8 @@ class ProductView(MasterView):
             lookup_fields.append('alt_code')
         if lookup_fields:
             product = self.products_handler.locate_product_for_entry(
-                session, term, lookup_fields=lookup_fields)
+                session, term, lookup_fields=lookup_fields,
+                first_if_multiple=True)
             if product:
                 final_results.append(self.search_normalize_result(product))
 
@@ -2456,9 +2464,10 @@ class PendingProductView(MasterView):
         # resolved*
         if self.creating:
             f.remove('resolved', 'resolved_by')
+        elif pending.resolved:
+            f.set_renderer('resolved_by', self.render_user)
         else:
-            if not pending.resolved:
-                f.remove('resolved', 'resolved_by')
+            f.remove('resolved', 'resolved_by')
 
     def render_status_code(self, pending, field):
         status = pending.status_code
@@ -2562,7 +2571,14 @@ class PendingProductView(MasterView):
         app = self.get_rattail_app()
         products_handler = app.get_products_handler()
         kwargs = self.get_resolve_product_kwargs()
-        products_handler.resolve_product(pending, product, self.request.user, **kwargs)
+
+        try:
+            products_handler.resolve_product(pending, product, self.request.user, **kwargs)
+        except Exception as error:
+            log.warning("failed to resolve product", exc_info=True)
+            self.request.session.flash(f"Resolve failed: {simple_error(error)}", 'error')
+            return redirect
+
         return redirect
 
     def get_resolve_product_kwargs(self, **kwargs):
@@ -2653,6 +2669,78 @@ class PendingProductView(MasterView):
                         permission=f'{permission_prefix}.ignore_product')
 
 
+class ProductCostView(MasterView):
+    """
+    Master view for Product Costs
+    """
+    model_class = ProductCost
+    route_prefix = 'product_costs'
+    url_prefix = '/products/costs'
+    has_versions = True
+
+    grid_columns = [
+        '_product_key_',
+        'vendor',
+        'preference',
+        'code',
+        'case_size',
+        'case_cost',
+        'pack_size',
+        'pack_cost',
+        'unit_cost',
+    ]
+
+    def query(self, session):
+        """ """
+        query = super().query(session)
+        model = self.app.model
+
+        # always join on Product
+        return query.join(model.Product)
+
+    def configure_grid(self, g):
+        """ """
+        super().configure_grid(g)
+        model = self.app.model
+
+        # product key
+        field = self.get_product_key_field()
+        g.set_renderer(field, self.render_product_key)
+        g.set_sorter(field, getattr(model.Product, field))
+        g.set_sort_defaults(field)
+        g.set_filter(field, getattr(model.Product, field))
+
+        # vendor
+        g.set_joiner('vendor', lambda q: q.join(model.Vendor))
+        g.set_sorter('vendor', model.Vendor.name)
+        g.set_filter('vendor', model.Vendor.name, label="Vendor Name")
+
+    def render_product_key(self, cost, field):
+        """ """
+        handler = self.app.get_products_handler()
+        return handler.render_product_key(cost.product)
+
+    def configure_form(self, f):
+        """ """
+        super().configure_form(f)
+
+        # product
+        f.set_renderer('product', self.render_product)
+        if 'product_uuid' in f and 'product' in f:
+            f.remove('product')
+            f.replace('product_uuid', 'product')
+
+        # vendor
+        f.set_renderer('vendor', self.render_vendor)
+        if 'vendor_uuid' in f and 'vendor' in f:
+            f.remove('vendor')
+            f.replace('vendor_uuid', 'vendor')
+
+        # futures
+        # TODO: should eventually show a subgrid here?
+        f.remove('futures')
+
+
 def defaults(config, **kwargs):
     base = globals()
 
@@ -2662,6 +2750,9 @@ def defaults(config, **kwargs):
     PendingProductView = kwargs.get('PendingProductView', base['PendingProductView'])
     PendingProductView.defaults(config)
 
+    ProductCostView = kwargs.get('ProductCostView', base['ProductCostView'])
+    ProductCostView.defaults(config)
+
 
 def includeme(config):
     defaults(config)
diff --git a/tailbone/views/progress.py b/tailbone/views/progress.py
index 169f324e..3f47ba3e 100644
--- a/tailbone/views/progress.py
+++ b/tailbone/views/progress.py
@@ -2,7 +2,7 @@
 ################################################################################
 #
 #  Rattail -- Retail Software Framework
-#  Copyright © 2010-2022 Lance Edgar
+#  Copyright © 2010-2024 Lance Edgar
 #
 #  This file is part of Rattail.
 #
@@ -24,10 +24,6 @@
 Progress Views
 """
 
-from __future__ import unicode_literals, absolute_import
-
-import six
-
 from tailbone.progress import get_progress_session
 
 
@@ -44,7 +40,7 @@ def progress(request):
 
         bits = session.get('extra_session_bits')
         if bits:
-            for key, value in six.iteritems(bits):
+            for key, value in bits.items():
                 request.session[key] = value
 
     elif session.get('error'):
diff --git a/tailbone/views/purchasing/batch.py b/tailbone/views/purchasing/batch.py
index 1d11130c..5e00704e 100644
--- a/tailbone/views/purchasing/batch.py
+++ b/tailbone/views/purchasing/batch.py
@@ -24,6 +24,8 @@
 Base class for purchasing batch views
 """
 
+import warnings
+
 from rattail.db.model import PurchaseBatch, PurchaseBatchRow
 
 import colander
@@ -67,6 +69,8 @@ class PurchasingBatchView(BatchMasterView):
         'store',
         'buyer',
         'vendor',
+        'description',
+        'workflow',
         'department',
         'purchase',
         'vendor_email',
@@ -158,6 +162,174 @@ class PurchasingBatchView(BatchMasterView):
     def batch_mode(self):
         raise NotImplementedError("Please define `batch_mode` for your purchasing batch view")
 
+    def get_supported_workflows(self):
+        """
+        Return the supported "create batch" workflows.
+        """
+        enum = self.app.enum
+        if self.batch_mode == enum.PURCHASE_BATCH_MODE_ORDERING:
+            return self.batch_handler.supported_ordering_workflows()
+        elif self.batch_mode == enum.PURCHASE_BATCH_MODE_RECEIVING:
+            return self.batch_handler.supported_receiving_workflows()
+        elif self.batch_mode == enum.PURCHASE_BATCH_MODE_COSTING:
+            return self.batch_handler.supported_costing_workflows()
+        raise ValueError("unknown batch mode")
+
+    def allow_any_vendor(self):
+        """
+        Return boolean indicating whether creating a batch for "any"
+        vendor is allowed, vs. only supported vendors.
+        """
+        enum = self.app.enum
+
+        if self.batch_mode == enum.PURCHASE_BATCH_MODE_ORDERING:
+            return self.batch_handler.allow_ordering_any_vendor()
+
+        elif self.batch_mode == enum.PURCHASE_BATCH_MODE_RECEIVING:
+            value = self.config.get_bool('rattail.batch.purchase.allow_receiving_any_vendor')
+            if value is not None:
+                return value
+            value = self.config.get_bool('rattail.batch.purchase.supported_vendors_only')
+            if value is not None:
+                warnings.warn("setting rattail.batch.purchase.supported_vendors_only is deprecated; "
+                              "please use rattail.batch.purchase.allow_receiving_any_vendor instead",
+                              DeprecationWarning)
+                # nb. must negate this setting
+                return not value
+            return False
+
+        raise ValueError("unknown batch mode")
+
+    def get_supported_vendors(self):
+        """
+        Return the supported vendors for creating a batch.
+        """
+        return []
+
+    def create(self, form=None, **kwargs):
+        """
+        Custom view for creating a new batch.  We split the process
+        into two steps, 1) choose workflow and 2) create batch.  This
+        is because the specific form details for creating a batch will
+        depend on which "type" of batch creation is to be done, and
+        it's much easier to keep conditional logic for that in the
+        server instead of client-side etc.
+        """
+        model = self.app.model
+        enum = self.app.enum
+        route_prefix = self.get_route_prefix()
+
+        workflows = self.get_supported_workflows()
+        valid_workflows = [workflow['workflow_key']
+                           for workflow in workflows]
+
+        # if user has already identified their desired workflow, then
+        # we can just farm out to the default logic.  we will of
+        # course configure our form differently, based on workflow,
+        # but this create() method at least will not need
+        # customization for that.
+        if self.request.matched_route.name.endswith('create_workflow'):
+
+            redirect = self.redirect(self.request.route_url(f'{route_prefix}.create'))
+
+            # however we do have one more thing to check - the workflow
+            # requested must of course be valid!
+            workflow_key = self.request.matchdict['workflow_key']
+            if workflow_key not in valid_workflows:
+                self.request.session.flash(f"Not a supported workflow: {workflow_key}", 'error')
+                raise redirect
+
+            # also, we require vendor to be correctly identified.  if
+            # someone e.g. navigates to a URL by accident etc. we want
+            # to gracefully handle and redirect
+            uuid = self.request.matchdict['vendor_uuid']
+            vendor = self.Session.get(model.Vendor, uuid)
+            if not vendor:
+                self.request.session.flash("Invalid vendor selection.  "
+                                           "Please choose an existing vendor.",
+                                           'warning')
+                raise redirect
+
+            # okay now do the normal thing, per workflow
+            return super().create(**kwargs)
+
+        # on the other hand, if caller provided a form, that means we are in
+        # the middle of some other custom workflow, e.g. "add child to truck
+        # dump parent" or some such.  in which case we also defer to the normal
+        # logic, so as to not interfere with that.
+        if form:
+            return super().create(form=form, **kwargs)
+
+        # okay, at this point we need the user to select a vendor and workflow
+        self.creating = True
+        context = {}
+
+        # form to accept user choice of vendor/workflow
+        schema = colander.Schema()
+        schema.add(colander.SchemaNode(colander.String(), name='vendor'))
+        schema.add(colander.SchemaNode(colander.String(), name='workflow',
+                                       validator=colander.OneOf(valid_workflows)))
+        factory = self.get_form_factory()
+        form = factory(schema=schema, request=self.request)
+
+        # configure vendor field
+        vendor_handler = self.app.get_vendor_handler()
+        if self.allow_any_vendor():
+            # user may choose *any* available vendor
+            use_dropdown = vendor_handler.choice_uses_dropdown()
+            if use_dropdown:
+                vendors = self.Session.query(model.Vendor)\
+                                      .order_by(model.Vendor.id)\
+                                      .all()
+                vendor_values = [(vendor.uuid, f"({vendor.id}) {vendor.name}")
+                                 for vendor in vendors]
+                form.set_widget('vendor', dfwidget.SelectWidget(values=vendor_values))
+                if len(vendors) == 1:
+                    form.set_default('vendor', vendors[0].uuid)
+            else:
+                vendor_display = ""
+                if self.request.method == 'POST':
+                    if self.request.POST.get('vendor'):
+                        vendor = self.Session.get(model.Vendor, self.request.POST['vendor'])
+                        if vendor:
+                            vendor_display = str(vendor)
+                vendors_url = self.request.route_url('vendors.autocomplete')
+                form.set_widget('vendor', forms.widgets.JQueryAutocompleteWidget(
+                    field_display=vendor_display, service_url=vendors_url))
+        else: # only "supported" vendors allowed
+            vendors = self.get_supported_vendors()
+            vendor_values = [(vendor.uuid, vendor_handler.render_vendor(vendor))
+                             for vendor in vendors]
+            form.set_widget('vendor', dfwidget.SelectWidget(values=vendor_values))
+        form.set_validator('vendor', self.valid_vendor_uuid)
+
+        # configure workflow field
+        values = [(workflow['workflow_key'], workflow['display'])
+                  for workflow in workflows]
+        form.set_widget('workflow',
+                        dfwidget.SelectWidget(values=values))
+        if len(workflows) == 1:
+            form.set_default('workflow', workflows[0]['workflow_key'])
+
+        form.submit_label = "Continue"
+        form.cancel_url = self.get_index_url()
+
+        # if form validates, that means user has chosen a creation
+        # type, so we just redirect to the appropriate "new batch of
+        # type X" page
+        if form.validate():
+            workflow_key = form.validated['workflow']
+            vendor_uuid = form.validated['vendor']
+            url = self.request.route_url(f'{route_prefix}.create_workflow',
+                                         workflow_key=workflow_key,
+                                         vendor_uuid=vendor_uuid)
+            raise self.redirect(url)
+
+        context['form'] = form
+        if hasattr(form, 'make_deform_form'):
+            context['dform'] = form.make_deform_form()
+        return self.render_to_response('create', context)
+
     def query(self, session):
         model = self.model
         return session.query(model.PurchaseBatch)\
@@ -226,20 +398,40 @@ class PurchasingBatchView(BatchMasterView):
 
     def configure_form(self, f):
         super().configure_form(f)
-        model = self.model
+        model = self.app.model
+        enum = self.app.enum
+        route_prefix = self.get_route_prefix()
+
+        today = self.app.today()
         batch = f.model_instance
-        app = self.get_rattail_app()
-        today = app.localtime().date()
+        workflow = self.request.matchdict.get('workflow_key')
+        vendor_handler = self.app.get_vendor_handler()
 
         # mode
-        f.set_enum('mode', self.enum.PURCHASE_BATCH_MODE)
+        f.set_enum('mode', enum.PURCHASE_BATCH_MODE)
+
+        # workflow
+        if self.creating:
+            if workflow:
+                f.set_widget('workflow', dfwidget.HiddenWidget())
+                f.set_default('workflow', workflow)
+                f.set_hidden('workflow')
+                # nb. show readonly '_workflow'
+                f.insert_after('workflow', '_workflow')
+                f.set_readonly('_workflow')
+                f.set_renderer('_workflow', self.render_workflow)
+            else:
+                f.set_readonly('workflow')
+                f.set_renderer('workflow', self.render_workflow)
+        else:
+            f.remove('workflow')
 
         # store
-        single_store = self.rattail_config.single_store()
+        single_store = self.config.single_store()
         if self.creating:
             f.replace('store', 'store_uuid')
             if single_store:
-                store = self.rattail_config.get_store(self.Session())
+                store = self.config.get_store(self.Session())
                 f.set_widget('store_uuid', dfwidget.HiddenWidget())
                 f.set_default('store_uuid', store.uuid)
                 f.set_hidden('store_uuid')
@@ -263,7 +455,6 @@ class PurchasingBatchView(BatchMasterView):
         if self.creating:
             f.replace('vendor', 'vendor_uuid')
             f.set_label('vendor_uuid', "Vendor")
-            vendor_handler = app.get_vendor_handler()
             use_dropdown = vendor_handler.choice_uses_dropdown()
             if use_dropdown:
                 vendors = self.Session.query(model.Vendor)\
@@ -313,7 +504,7 @@ class PurchasingBatchView(BatchMasterView):
                         if buyer:
                             buyer_display = str(buyer)
                 elif self.creating:
-                    buyer = app.get_employee(self.request.user)
+                    buyer = self.app.get_employee(self.request.user)
                     if buyer:
                         buyer_display = str(buyer)
                         f.set_default('buyer_uuid', buyer.uuid)
@@ -324,6 +515,30 @@ class PurchasingBatchView(BatchMasterView):
                     field_display=buyer_display, service_url=buyers_url))
                 f.set_label('buyer_uuid', "Buyer")
 
+        # order_file
+        if self.creating:
+            f.set_type('order_file', 'file', required=False)
+        else:
+            f.set_readonly('order_file')
+            f.set_renderer('order_file', self.render_downloadable_file)
+
+        # order_parser_key
+        if self.creating:
+            kwargs = {}
+            if 'vendor_uuid' in self.request.matchdict:
+                vendor = self.Session.get(model.Vendor,
+                                          self.request.matchdict['vendor_uuid'])
+                if vendor:
+                    kwargs['vendor'] = vendor
+            parsers = vendor_handler.get_supported_order_parsers(**kwargs)
+            parser_values = [(p.key, p.title) for p in parsers]
+            if len(parsers) == 1:
+                f.set_default('order_parser_key', parsers[0].key)
+            f.set_widget('order_parser_key', dfwidget.SelectWidget(values=parser_values))
+            f.set_label('order_parser_key', "Order Parser")
+        else:
+            f.remove_field('order_parser_key')
+
         # invoice_file
         if self.creating:
             f.set_type('invoice_file', 'file', required=False)
@@ -341,7 +556,7 @@ class PurchasingBatchView(BatchMasterView):
                 if vendor:
                     kwargs['vendor'] = vendor
 
-            parsers = self.handler.get_supported_invoice_parsers(**kwargs)
+            parsers = self.batch_handler.get_supported_invoice_parsers(**kwargs)
             parser_values = [(p.key, p.display) for p in parsers]
             if len(parsers) == 1:
                 f.set_default('invoice_parser_key', parsers[0].key)
@@ -400,6 +615,35 @@ class PurchasingBatchView(BatchMasterView):
                             'vendor_contact',
                             'status_code')
 
+        # tweak some things if we are in "step 2" of creating new batch
+        if self.creating and workflow:
+
+            # display vendor but do not allow changing
+            vendor = self.Session.get(model.Vendor, self.request.matchdict['vendor_uuid'])
+            if not vendor:
+                raise ValueError(f"vendor not found: {self.request.matchdict['vendor_uuid']}")
+            f.set_readonly('vendor_uuid')
+            f.set_default('vendor_uuid', str(vendor))
+
+            # cancel should take us back to choosing a workflow
+            f.cancel_url = self.request.route_url(f'{route_prefix}.create')
+
+    def render_workflow(self, batch, field):
+        key = self.request.matchdict['workflow_key']
+        info = self.get_workflow_info(key)
+        if info:
+            return info['display']
+
+    def get_workflow_info(self, key):
+        enum = self.app.enum
+        if self.batch_mode == enum.PURCHASE_BATCH_MODE_ORDERING:
+            return self.batch_handler.ordering_workflow_info(key)
+        elif self.batch_mode == enum.PURCHASE_BATCH_MODE_RECEIVING:
+            return self.batch_handler.receiving_workflow_info(key)
+        elif self.batch_mode == enum.PURCHASE_BATCH_MODE_COSTING:
+            return self.batch_handler.costing_workflow_info(key)
+        raise ValueError("unknown batch mode")
+
     def render_store(self, batch, field):
         store = batch.store
         if not store:
@@ -515,10 +759,12 @@ class PurchasingBatchView(BatchMasterView):
 
     def get_batch_kwargs(self, batch, **kwargs):
         kwargs = super().get_batch_kwargs(batch, **kwargs)
-        model = self.model
+        model = self.app.model
 
         kwargs['mode'] = self.batch_mode
+        kwargs['workflow'] = self.request.POST['workflow']
         kwargs['truck_dump'] = batch.truck_dump
+        kwargs['order_parser_key'] = batch.order_parser_key
         kwargs['invoice_parser_key'] = batch.invoice_parser_key
 
         if batch.store:
@@ -536,6 +782,11 @@ class PurchasingBatchView(BatchMasterView):
         elif batch.vendor_uuid:
             kwargs['vendor_uuid'] = batch.vendor_uuid
 
+        # must pull vendor from URL if it was not in form data
+        if 'vendor_uuid' not in kwargs and 'vendor' not in kwargs:
+            if 'vendor_uuid' in self.request.matchdict:
+                kwargs['vendor_uuid'] = self.request.matchdict['vendor_uuid']
+
         if batch.department:
             kwargs['department'] = batch.department
         elif batch.department_uuid:
@@ -793,8 +1044,8 @@ class PurchasingBatchView(BatchMasterView):
         factory = self.get_grid_factory()
 
         g = factory(
-            key='{}.row_credits'.format(route_prefix),
-            request=self.request,
+            self.request,
+            key=f'{route_prefix}.row_credits',
             data=[],
             columns=[
                 'credit_type',
@@ -919,6 +1170,25 @@ class PurchasingBatchView(BatchMasterView):
 #         # otherwise just view batch again
 #         return self.get_action_url('view', batch)
 
+    @classmethod
+    def defaults(cls, config):
+        cls._purchase_batch_defaults(config)
+        cls._batch_defaults(config)
+        cls._defaults(config)
+
+    @classmethod
+    def _purchase_batch_defaults(cls, config):
+        route_prefix = cls.get_route_prefix()
+        url_prefix = cls.get_url_prefix()
+        permission_prefix = cls.get_permission_prefix()
+
+        # new batch using workflow X
+        config.add_route(f'{route_prefix}.create_workflow',
+                         f'{url_prefix}/new/{{workflow_key}}/{{vendor_uuid}}')
+        config.add_view(cls, attr='create',
+                        route_name=f'{route_prefix}.create_workflow',
+                        permission=f'{permission_prefix}.create')
+
 
 class NewProduct(colander.Schema):
 
diff --git a/tailbone/views/purchasing/ordering.py b/tailbone/views/purchasing/ordering.py
index 2e24eebb..c7cc7bfc 100644
--- a/tailbone/views/purchasing/ordering.py
+++ b/tailbone/views/purchasing/ordering.py
@@ -2,7 +2,7 @@
 ################################################################################
 #
 #  Rattail -- Retail Software Framework
-#  Copyright © 2010-2023 Lance Edgar
+#  Copyright © 2010-2024 Lance Edgar
 #
 #  This file is part of Rattail.
 #
@@ -28,14 +28,10 @@ import os
 import json
 
 import openpyxl
-from sqlalchemy import orm
 
-from rattail.db import model, api
 from rattail.core import Object
-from rattail.time import localtime
-
-from webhelpers2.html import tags
 
+from tailbone.db import Session
 from tailbone.views.purchasing import PurchasingBatchView
 
 
@@ -51,6 +47,8 @@ class OrderingBatchView(PurchasingBatchView):
     rows_editable = True
     has_worksheet = True
     default_help_url = 'https://rattailproject.org/docs/rattail-manual/features/purchasing/ordering/index.html'
+    downloadable = True
+    configurable = True
 
     labels = {
         'po_total_calculated': "PO Total",
@@ -59,9 +57,14 @@ class OrderingBatchView(PurchasingBatchView):
     form_fields = [
         'id',
         'store',
-        'buyer',
         'vendor',
+        'description',
+        'workflow',
+        'order_file',
+        'order_parser_key',
+        'buyer',
         'department',
+        'params',
         'purchase',
         'vendor_email',
         'vendor_fax',
@@ -132,15 +135,26 @@ class OrderingBatchView(PurchasingBatchView):
         return self.enum.PURCHASE_BATCH_MODE_ORDERING
 
     def configure_form(self, f):
-        super(OrderingBatchView, self).configure_form(f)
+        super().configure_form(f)
         batch = f.model_instance
+        workflow = self.request.matchdict.get('workflow_key')
 
         # purchase
         if self.creating or not batch.executed or not batch.purchase:
             f.remove_field('purchase')
 
+        # now that all fields are setup, some final tweaks based on workflow
+        if self.creating and workflow:
+
+            if workflow == 'from_scratch':
+                f.remove('order_file',
+                         'order_parser_key')
+
+            elif workflow == 'from_file':
+                f.set_required('order_file')
+
     def get_batch_kwargs(self, batch, **kwargs):
-        kwargs = super(OrderingBatchView, self).get_batch_kwargs(batch, **kwargs)
+        kwargs = super().get_batch_kwargs(batch, **kwargs)
         kwargs['ship_method'] = batch.ship_method
         kwargs['notes_to_vendor'] = batch.notes_to_vendor
         return kwargs
@@ -155,7 +169,7 @@ class OrderingBatchView(PurchasingBatchView):
         * ``cases_ordered``
         * ``units_ordered``
         """
-        super(OrderingBatchView, self).configure_row_form(f)
+        super().configure_row_form(f)
 
         # when editing, only certain fields should allow changes
         if self.editing:
@@ -308,7 +322,7 @@ class OrderingBatchView(PurchasingBatchView):
         title = self.get_instance_title(batch)
         order_date = batch.date_ordered
         if not order_date:
-            order_date = localtime(self.rattail_config).date()
+            order_date = self.app.today()
 
         return self.render_to_response('worksheet', {
             'batch': batch,
@@ -369,6 +383,7 @@ class OrderingBatchView(PurchasingBatchView):
         of being updated.  If a matching row is not found, it will not be
         created.
         """
+        model = self.app.model
         batch = self.get_instance()
 
         try:
@@ -478,13 +493,75 @@ class OrderingBatchView(PurchasingBatchView):
         return self.file_response(path)
 
     def get_execute_success_url(self, batch, result, **kwargs):
+        model = self.app.model
         if isinstance(result, model.Purchase):
             return self.request.route_url('purchases.view', uuid=result.uuid)
-        return super(OrderingBatchView, self).get_execute_success_url(batch, result, **kwargs)
+        return super().get_execute_success_url(batch, result, **kwargs)
+
+    def configure_get_simple_settings(self):
+        return [
+
+            # workflows
+            {'section': 'rattail.batch',
+             'option': 'purchase.allow_ordering_from_scratch',
+             'type': bool,
+             'default': True},
+            {'section': 'rattail.batch',
+             'option': 'purchase.allow_ordering_from_file',
+             'type': bool,
+             'default': True},
+
+            # vendors
+            {'section': 'rattail.batch',
+             'option': 'purchase.allow_ordering_any_vendor',
+             'type': bool,
+             'default': True,
+             },
+        ]
+
+    def configure_get_context(self):
+        context = super().configure_get_context()
+        vendor_handler = self.app.get_vendor_handler()
+
+        Parsers = vendor_handler.get_all_order_parsers()
+        Supported = vendor_handler.get_supported_order_parsers()
+        context['order_parsers'] = Parsers
+        context['order_parsers_data'] = dict([(Parser.key, Parser in Supported)
+                                                for Parser in Parsers])
+
+        return context
+
+    def configure_gather_settings(self, data):
+        settings = super().configure_gather_settings(data)
+        vendor_handler = self.app.get_vendor_handler()
+
+        supported = []
+        for Parser in vendor_handler.get_all_order_parsers():
+            name = f'order_parser_{Parser.key}'
+            if data.get(name) == 'true':
+                supported.append(Parser.key)
+        settings.append({'name': 'rattail.vendors.supported_order_parsers',
+                         'value': ', '.join(supported)})
+
+        return settings
+
+    def configure_remove_settings(self):
+        super().configure_remove_settings()
+
+        names = [
+            'rattail.vendors.supported_order_parsers',
+        ]
+
+        # nb. using thread-local session here; we do not use
+        # self.Session b/c it may not point to Rattail
+        session = Session()
+        for name in names:
+            self.app.delete_setting(session, name)
 
     @classmethod
     def defaults(cls, config):
         cls._ordering_defaults(config)
+        cls._purchase_batch_defaults(config)
         cls._batch_defaults(config)
         cls._defaults(config)
 
diff --git a/tailbone/views/purchasing/receiving.py b/tailbone/views/purchasing/receiving.py
index be15c1a8..01858c98 100644
--- a/tailbone/views/purchasing/receiving.py
+++ b/tailbone/views/purchasing/receiving.py
@@ -25,22 +25,22 @@ Views for 'receiving' (purchasing) batches
 """
 
 import os
-import re
 import decimal
 import logging
 from collections import OrderedDict
 
-import humanize
+# import humanize
 
 from rattail import pod
-from rattail.util import prettify, simple_error
+from rattail.util import simple_error
 
 import colander
 from deform import widget as dfwidget
 from webhelpers2.html import tags, HTML
 
-from tailbone import forms, grids
-from tailbone.util import get_form_data
+from wuttaweb.util import get_form_data
+
+from tailbone import forms
 from tailbone.views.purchasing import PurchasingBatchView
 
 
@@ -108,7 +108,7 @@ class ReceivingBatchView(PurchasingBatchView):
         'store',
         'vendor',
         'description',
-        'receiving_workflow',
+        'workflow',
         'truck_dump',
         'truck_dump_children_first',
         'truck_dump_children',
@@ -235,135 +235,18 @@ class ReceivingBatchView(PurchasingBatchView):
         if not self.handler.allow_truck_dump_receiving():
             g.remove('truck_dump')
 
-    def create(self, form=None, **kwargs):
-        """
-        Custom view for creating a new receiving batch.  We split the process
-        into two steps, 1) choose and 2) create.  This is because the specific
-        form details for creating a batch will depend on which "type" of batch
-        creation is to be done, and it's much easier to keep conditional logic
-        for that in the server instead of client-side etc.
-
-        See also
-        :meth:`tailbone.views.purchasing.costing:CostingBatchView.create()`
-        which uses similar logic.
-        """
-        model = self.model
-        route_prefix = self.get_route_prefix()
-        workflows = self.handler.supported_receiving_workflows()
-        valid_workflows = [workflow['workflow_key']
-                           for workflow in workflows]
-
-        # if user has already identified their desired workflow, then we can
-        # just farm out to the default logic.  we will of course configure our
-        # form differently, based on workflow, but this create() method at
-        # least will not need customization for that.
-        if self.request.matched_route.name.endswith('create_workflow'):
-
-            redirect = self.redirect(self.request.route_url('{}.create'.format(route_prefix)))
-
-            # however we do have one more thing to check - the workflow
-            # requested must of course be valid!
-            workflow_key = self.request.matchdict['workflow_key']
-            if workflow_key not in valid_workflows:
-                self.request.session.flash(
-                    "Not a supported workflow: {}".format(workflow_key),
-                    'error')
-                raise redirect
-
-            # also, we require vendor to be correctly identified.  if
-            # someone e.g. navigates to a URL by accident etc. we want
-            # to gracefully handle and redirect
-            uuid = self.request.matchdict['vendor_uuid']
-            vendor = self.Session.get(model.Vendor, uuid)
-            if not vendor:
-                self.request.session.flash("Invalid vendor selection.  "
-                                           "Please choose an existing vendor.",
-                                           'warning')
-                raise redirect
-
-            # okay now do the normal thing, per workflow
-            return super().create(**kwargs)
-
-        # on the other hand, if caller provided a form, that means we are in
-        # the middle of some other custom workflow, e.g. "add child to truck
-        # dump parent" or some such.  in which case we also defer to the normal
-        # logic, so as to not interfere with that.
-        if form:
-            return super().create(form=form, **kwargs)
-
-        # okay, at this point we need the user to select a vendor and workflow
-        self.creating = True
-        context = {}
-
-        # form to accept user choice of vendor/workflow
-        schema = NewReceivingBatch().bind(valid_workflows=valid_workflows)
-        form = forms.Form(schema=schema, request=self.request)
-
-        # configure vendor field
-        app = self.get_rattail_app()
-        vendor_handler = app.get_vendor_handler()
-        if self.rattail_config.getbool('rattail.batch', 'purchase.supported_vendors_only'):
-            # only show vendors for which we have dedicated invoice parsers
-            vendors = {}
-            for parser in self.batch_handler.get_supported_invoice_parsers():
-                if parser.vendor_key:
-                    vendor = vendor_handler.get_vendor(self.Session(),
-                                                       parser.vendor_key)
-                    if vendor:
-                        vendors[vendor.uuid] = vendor
-            vendors = sorted(vendors.values(), key=lambda v: v.name)
-            vendor_values = [(vendor.uuid, vendor_handler.render_vendor(vendor))
-                             for vendor in vendors]
-            form.set_widget('vendor', dfwidget.SelectWidget(values=vendor_values))
-        else:
-            # user may choose *any* available vendor
-            use_dropdown = vendor_handler.choice_uses_dropdown()
-            if use_dropdown:
-                vendors = self.Session.query(model.Vendor)\
-                                      .order_by(model.Vendor.id)\
-                                      .all()
-                vendor_values = [(vendor.uuid, "({}) {}".format(vendor.id, vendor.name))
-                                 for vendor in vendors]
-                form.set_widget('vendor', dfwidget.SelectWidget(values=vendor_values))
-                if len(vendors) == 1:
-                    form.set_default('vendor', vendors[0].uuid)
-            else:
-                vendor_display = ""
-                if self.request.method == 'POST':
-                    if self.request.POST.get('vendor'):
-                        vendor = self.Session.get(model.Vendor, self.request.POST['vendor'])
-                        if vendor:
-                            vendor_display = str(vendor)
-                vendors_url = self.request.route_url('vendors.autocomplete')
-                form.set_widget('vendor', forms.widgets.JQueryAutocompleteWidget(
-                    field_display=vendor_display, service_url=vendors_url))
-        form.set_validator('vendor', self.valid_vendor_uuid)
-
-        # configure workflow field
-        values = [(workflow['workflow_key'], workflow['display'])
-                  for workflow in workflows]
-        form.set_widget('workflow',
-                        dfwidget.SelectWidget(values=values))
-        if len(workflows) == 1:
-            form.set_default('workflow', workflows[0]['workflow_key'])
-
-        form.submit_label = "Continue"
-        form.cancel_url = self.get_index_url()
-
-        # if form validates, that means user has chosen a creation type, so we
-        # just redirect to the appropriate "new batch of type X" page
-        if form.validate():
-            workflow_key = form.validated['workflow']
-            vendor_uuid = form.validated['vendor']
-            url = self.request.route_url('{}.create_workflow'.format(route_prefix),
-                                         workflow_key=workflow_key,
-                                         vendor_uuid=vendor_uuid)
-            raise self.redirect(url)
-
-        context['form'] = form
-        if hasattr(form, 'make_deform_form'):
-            context['dform'] = form.make_deform_form()
-        return self.render_to_response('create', context)
+    def get_supported_vendors(self):
+        """ """
+        vendor_handler = self.app.get_vendor_handler()
+        vendors = {}
+        for parser in self.batch_handler.get_supported_invoice_parsers():
+            if parser.vendor_key:
+                vendor = vendor_handler.get_vendor(self.Session(),
+                                                   parser.vendor_key)
+                if vendor:
+                    vendors[vendor.uuid] = vendor
+        vendors = sorted(vendors.values(), key=lambda v: v.name)
+        return vendors
 
     def row_deletable(self, row):
 
@@ -404,13 +287,7 @@ class ReceivingBatchView(PurchasingBatchView):
             # cancel should take us back to choosing a workflow
             f.cancel_url = self.request.route_url('{}.create'.format(route_prefix))
 
-        # receiving_workflow
-        if self.creating and workflow:
-            f.set_readonly('receiving_workflow')
-            f.set_renderer('receiving_workflow', self.render_receiving_workflow)
-        else:
-            f.remove('receiving_workflow')
-
+        # TODO: remove this
         # batch_type
         if self.creating:
             f.set_widget('batch_type', dfwidget.HiddenWidget())
@@ -525,7 +402,7 @@ class ReceivingBatchView(PurchasingBatchView):
 
         # multiple invoice files (if applicable)
         if (not self.creating
-            and batch.get_param('receiving_workflow') == 'from_multi_invoice'):
+            and batch.get_param('workflow') == 'from_multi_invoice'):
 
             if 'invoice_files' not in f:
                 f.insert_before('invoice_file', 'invoice_files')
@@ -624,12 +501,6 @@ class ReceivingBatchView(PurchasingBatchView):
             items.append(HTML.tag('li', c=[link]))
         return HTML.tag('ul', c=items)
 
-    def render_receiving_workflow(self, batch, field):
-        key = self.request.matchdict['workflow_key']
-        info = self.handler.receiving_workflow_info(key)
-        if info:
-            return info['display']
-
     def get_visible_params(self, batch):
         params = super().get_visible_params(batch)
 
@@ -654,42 +525,40 @@ class ReceivingBatchView(PurchasingBatchView):
 
     def get_batch_kwargs(self, batch, **kwargs):
         kwargs = super().get_batch_kwargs(batch, **kwargs)
-        batch_type = self.request.POST['batch_type']
 
         # must pull vendor from URL if it was not in form data
         if 'vendor_uuid' not in kwargs and 'vendor' not in kwargs:
             if 'vendor_uuid' in self.request.matchdict:
                 kwargs['vendor_uuid'] = self.request.matchdict['vendor_uuid']
 
-        # TODO: ugh should just have workflow and no batch_type
-        kwargs['receiving_workflow'] = batch_type
-        if batch_type == 'from_scratch':
+        workflow = kwargs['workflow']
+        if workflow == 'from_scratch':
             kwargs.pop('truck_dump_batch', None)
             kwargs.pop('truck_dump_batch_uuid', None)
-        elif batch_type == 'from_invoice':
+        elif workflow == 'from_invoice':
             pass
-        elif batch_type == 'from_multi_invoice':
+        elif workflow == 'from_multi_invoice':
             pass
-        elif batch_type == 'from_po':
+        elif workflow == 'from_po':
             # TODO: how to best handle this field?  this doesn't seem flexible
             kwargs['purchase_key'] = batch.purchase_uuid
-        elif batch_type == 'from_po_with_invoice':
+        elif workflow == 'from_po_with_invoice':
             # TODO: how to best handle this field?  this doesn't seem flexible
             kwargs['purchase_key'] = batch.purchase_uuid
-        elif batch_type == 'truck_dump_children_first':
+        elif workflow == 'truck_dump_children_first':
             kwargs['truck_dump'] = True
             kwargs['truck_dump_children_first'] = True
             kwargs['order_quantities_known'] = True
             # TODO: this makes sense in some cases, but all?
             # (should just omit that field when not relevant)
             kwargs['date_ordered'] = None
-        elif batch_type == 'truck_dump_children_last':
+        elif workflow == 'truck_dump_children_last':
             kwargs['truck_dump'] = True
             kwargs['truck_dump_ready'] = True
             # TODO: this makes sense in some cases, but all?
             # (should just omit that field when not relevant)
             kwargs['date_ordered'] = None
-        elif batch_type.startswith('truck_dump_child'):
+        elif workflow.startswith('truck_dump_child'):
             truck_dump = self.get_instance()
             kwargs['store'] = truck_dump.store
             kwargs['vendor'] = truck_dump.vendor
@@ -774,8 +643,10 @@ class ReceivingBatchView(PurchasingBatchView):
             breakdown = self.make_po_vs_invoice_breakdown(batch)
             factory = self.get_grid_factory()
 
-            g = factory('batch_po_vs_invoice_breakdown', [],
-                columns=['title', 'count'])
+            g = factory(self.request,
+                        key='batch_po_vs_invoice_breakdown',
+                        data=[],
+                        columns=['title', 'count'])
             g.set_click_handler('title', "autoFilterPoVsInvoice(props.row)")
             kwargs['po_vs_invoice_breakdown_data'] = breakdown
             kwargs['po_vs_invoice_breakdown_grid'] = HTML.literal(
@@ -1031,14 +902,16 @@ class ReceivingBatchView(PurchasingBatchView):
         if batch.is_truck_dump_parent():
             permission_prefix = self.get_permission_prefix()
             if self.request.has_perm('{}.edit_row'.format(permission_prefix)):
-                transform = grids.GridAction('transform',
+                transform = self.make_action('transform',
                                              icon='shuffle',
                                              label="Transform to Unit",
                                              url=self.transform_unit_url)
-                g.more_actions.append(transform)
-                if g.main_actions and g.main_actions[-1].key == 'delete':
-                    delete = g.main_actions.pop()
-                    g.more_actions.append(delete)
+                if g.actions and g.actions[-1].key == 'delete':
+                    delete = g.actions.pop()
+                    g.actions.append(transform)
+                    g.actions.append(delete)
+                else:
+                    g.actions.append(transform)
 
         # truck_dump_status
         if not batch.is_truck_dump_parent():
@@ -1111,7 +984,7 @@ class ReceivingBatchView(PurchasingBatchView):
             and self.row_editable(row)):
 
             # add the Un-Declare action
-            g.main_actions.append(self.make_action(
+            g.actions.append(self.make_action(
                 'remove', label="Un-Declare",
                 url='#', icon='trash',
                 link_class='has-text-danger',
@@ -1982,6 +1855,12 @@ class ReceivingBatchView(PurchasingBatchView):
              'type': bool},
 
             # vendors
+            {'section': 'rattail.batch',
+             'option': 'purchase.allow_receiving_any_vendor',
+             'type': bool},
+            # TODO: deprecated; can remove this once all live config
+            # is updated.  but for now it remains so this setting is
+            # auto-deleted
             {'section': 'rattail.batch',
              'option': 'purchase.supported_vendors_only',
              'type': bool},
@@ -2032,6 +1911,7 @@ class ReceivingBatchView(PurchasingBatchView):
     @classmethod
     def defaults(cls, config):
         cls._receiving_defaults(config)
+        cls._purchase_batch_defaults(config)
         cls._batch_defaults(config)
         cls._defaults(config)
 
@@ -2039,17 +1919,11 @@ class ReceivingBatchView(PurchasingBatchView):
     def _receiving_defaults(cls, config):
         rattail_config = config.registry.settings.get('rattail_config')
         route_prefix = cls.get_route_prefix()
-        url_prefix = cls.get_url_prefix()
         instance_url_prefix = cls.get_instance_url_prefix()
         model_key = cls.get_model_key()
         model_title = cls.get_model_title()
         permission_prefix = cls.get_permission_prefix()
 
-        # new receiving batch using workflow X
-        config.add_route('{}.create_workflow'.format(route_prefix), '{}/new/{{workflow_key}}/{{vendor_uuid}}'.format(url_prefix))
-        config.add_view(cls, attr='create', route_name='{}.create_workflow'.format(route_prefix),
-                        permission='{}.create'.format(permission_prefix))
-
         # row-level receiving
         config.add_route('{}.receive_row'.format(route_prefix), '{}/rows/{{row_uuid}}/receive'.format(instance_url_prefix))
         config.add_view(cls, attr='receive_row', route_name='{}.receive_row'.format(route_prefix),
@@ -2102,33 +1976,6 @@ class ReceivingBatchView(PurchasingBatchView):
                         permission='{}.auto_receive'.format(permission_prefix))
 
 
-@colander.deferred
-def valid_workflow(node, kw):
-    """
-    Deferred validator for ``workflow`` field, for new batches.
-    """
-    valid_workflows = kw['valid_workflows']
-
-    def validate(node, value):
-        # we just need to provide possible values, and let stock validator
-        # handle the rest
-        oneof = colander.OneOf(valid_workflows)
-        return oneof(node, value)
-
-    return validate
-
-
-class NewReceivingBatch(colander.Schema):
-    """
-    Schema for choosing which "type" of new receiving batch should be created.
-    """
-    vendor = colander.SchemaNode(colander.String(),
-                                 label="Vendor")
-
-    workflow = colander.SchemaNode(colander.String(),
-                                   validator=valid_workflow)
-
-
 class ReceiveRowForm(colander.MappingSchema):
 
     mode = colander.SchemaNode(colander.String(),
diff --git a/tailbone/views/reports.py b/tailbone/views/reports.py
index aedda61c..099224be 100644
--- a/tailbone/views/reports.py
+++ b/tailbone/views/reports.py
@@ -308,7 +308,8 @@ class ReportOutputView(ExportMasterView):
         route_prefix = self.get_route_prefix()
         factory = self.get_grid_factory()
         g = factory(
-            key='{}.params'.format(route_prefix),
+            self.request,
+            key=f'{route_prefix}.params',
             data=params,
             columns=['key', 'value'],
             labels={'key': "Name"},
@@ -705,9 +706,12 @@ class ProblemReportView(MasterView):
         return ', '.join(recips)
 
     def render_days(self, report_info, field):
-        g = self.get_grid_factory()('days', [],
-                                    columns=['weekday_name', 'enabled'],
-                                    labels={'weekday_name': "Weekday"})
+        factory = self.get_grid_factory()
+        g = factory(self.request,
+                    key='days',
+                    data=[],
+                    columns=['weekday_name', 'enabled'],
+                    labels={'weekday_name': "Weekday"})
         return HTML.literal(g.render_table_element(data_prop='weekdaysData'))
 
     def template_kwargs_view(self, **kwargs):
diff --git a/tailbone/views/roles.py b/tailbone/views/roles.py
index 0316ea87..e8a6d8a2 100644
--- a/tailbone/views/roles.py
+++ b/tailbone/views/roles.py
@@ -30,7 +30,6 @@ from sqlalchemy import orm
 from openpyxl.styles import Font, PatternFill
 
 from rattail.db.model import Role
-from rattail.db.auth import administrator_role, guest_role, authenticated_role
 from rattail.excel import ExcelWriter
 
 import colander
@@ -107,8 +106,11 @@ class RoleView(PrincipalMasterView):
         if role.node_type and role.node_type != self.rattail_config.node_type():
             return False
 
+        app = self.get_rattail_app()
+        auth = app.get_auth_handler()
+
         # only "root" can edit Administrator
-        if role is administrator_role(self.Session()):
+        if role is auth.get_role_administrator(self.Session()):
             return self.request.is_root
 
         # only "admin" can edit "admin-ish" roles
@@ -116,11 +118,11 @@ class RoleView(PrincipalMasterView):
             return self.request.is_admin
 
         # can edit Authenticated only if user has permission
-        if role is authenticated_role(self.Session()):
+        if role is auth.get_role_authenticated(self.Session()):
             return self.has_perm('edit_authenticated')
 
         # can edit Guest only if user has permission
-        if role is guest_role(self.Session()):
+        if role is auth.get_role_anonymous(self.Session()):
             return self.has_perm('edit_guest')
 
         # current user can edit their own roles, only if they have permission
@@ -139,11 +141,14 @@ class RoleView(PrincipalMasterView):
         if role.node_type and role.node_type != self.rattail_config.node_type():
             return False
 
-        if role is administrator_role(self.Session()):
+        app = self.get_rattail_app()
+        auth = app.get_auth_handler()
+
+        if role is auth.get_role_administrator(self.Session()):
             return False
-        if role is authenticated_role(self.Session()):
+        if role is auth.get_role_authenticated(self.Session()):
             return False
-        if role is guest_role(self.Session()):
+        if role is auth.get_role_anonymous(self.Session()):
             return False
 
         # only "admin" can delete "admin-ish" roles
@@ -186,17 +191,17 @@ class RoleView(PrincipalMasterView):
 
         # session_timeout
         f.set_renderer('session_timeout', self.render_session_timeout)
-        if self.editing and role is guest_role(self.Session()):
+        if self.editing and role is auth.get_role_anonymous(self.Session()):
             f.set_readonly('session_timeout')
 
         # sync_me, node_type
         if not self.creating:
             include = True
-            if role is administrator_role(self.Session()):
+            if role is auth.get_role_administrator(self.Session()):
                 include = False
-            elif role is authenticated_role(self.Session()):
+            elif role is auth.get_role_authenticated(self.Session()):
                 include = False
-            elif role is guest_role(self.Session()):
+            elif role is auth.get_role_anonymous(self.Session()):
                 include = False
             if not include:
                 f.remove('sync_me', 'sync_users', 'node_type')
@@ -227,7 +232,7 @@ class RoleView(PrincipalMasterView):
             for groupkey in self.tailbone_permissions:
                 for key in self.tailbone_permissions[groupkey]['perms']:
                     if auth.has_permission(self.Session(), role, key,
-                                           include_guest=False,
+                                           include_anonymous=False,
                                            include_authenticated=False):
                         granted.append(key)
             f.set_default('permissions', granted)
@@ -235,12 +240,14 @@ class RoleView(PrincipalMasterView):
             f.remove_field('permissions')
 
     def render_users(self, role, field):
+        app = self.get_rattail_app()
+        auth = app.get_auth_handler()
 
-        if role is guest_role(self.Session()):
+        if role is auth.get_role_anonymous(self.Session()):
             return ("The guest role is implied for all anonymous users, "
                     "i.e. when not logged in.")
 
-        if role is authenticated_role(self.Session()):
+        if role is auth.get_role_authenticated(self.Session()):
             return ("The authenticated role is implied for all users, "
                     "but only when logged in.")
 
@@ -248,8 +255,8 @@ class RoleView(PrincipalMasterView):
         permission_prefix = self.get_permission_prefix()
         factory = self.get_grid_factory()
         g = factory(
-            key='{}.users'.format(route_prefix),
-            request=self.request,
+            self.request,
+            key=f'{route_prefix}.users',
             data=[],
             columns=[
                 'full_name',
@@ -262,9 +269,9 @@ class RoleView(PrincipalMasterView):
         )
 
         if self.request.has_perm('users.view'):
-            g.main_actions.append(self.make_action('view', icon='eye'))
+            g.actions.append(self.make_action('view', icon='eye'))
         if self.request.has_perm('users.edit'):
-            g.main_actions.append(self.make_action('edit', icon='edit'))
+            g.actions.append(self.make_action('edit', icon='edit'))
 
         return HTML.literal(
             g.render_table_element(data_prop='usersData'))
@@ -280,8 +287,8 @@ class RoleView(PrincipalMasterView):
         if the current user is an admin; otherwise it will be the "subset" of
         permissions which the current user has been granted.
         """
-        # fetch full set of permissions registered in the app
-        permissions = self.request.registry.settings.get('tailbone_permissions', {})
+        # get all known permissions from settings cache
+        permissions = self.request.registry.settings.get('wutta_permissions', {})
 
         # admin user gets to manage all permissions
         if self.request.is_admin:
@@ -308,7 +315,9 @@ class RoleView(PrincipalMasterView):
         return available
 
     def render_session_timeout(self, role, field):
-        if role is guest_role(self.Session()):
+        app = self.get_rattail_app()
+        auth = app.get_auth_handler()
+        if role is auth.get_role_anonymous(self.Session()):
             return "(not applicable)"
         if role.session_timeout is None:
             return ""
@@ -347,23 +356,26 @@ class RoleView(PrincipalMasterView):
                     auth.revoke_permission(role, pkey)
 
     def template_kwargs_view(self, **kwargs):
+        app = self.get_rattail_app()
+        auth = app.get_auth_handler()
         model = self.model
         role = kwargs['instance']
         if role.users:
             users = sorted(role.users, key=lambda u: u.username)
             actions = [
-                grids.GridAction('view', icon='zoomin',
+                self.make_action('view', icon='zoomin',
                                  url=lambda r, i: self.request.route_url('users.view', uuid=r.uuid))
             ]
-            kwargs['users'] = grids.Grid(None, users, ['username', 'active'],
-                                         request=self.request,
+            kwargs['users'] = grids.Grid(self.request,
+                                         data=users,
+                                         columns=['username', 'active'],
                                          model_class=model.User,
-                                         main_actions=actions)
+                                         actions=actions)
         else:
             kwargs['users'] = None
 
-        kwargs['guest_role'] = guest_role(self.Session())
-        kwargs['authenticated_role'] = authenticated_role(self.Session())
+        kwargs['guest_role'] = auth.get_role_anonymous(self.Session())
+        kwargs['authenticated_role'] = auth.get_role_authenticated(self.Session())
 
         role = kwargs['instance']
         if role not in (kwargs['guest_role'], kwargs['authenticated_role']):
@@ -384,9 +396,11 @@ class RoleView(PrincipalMasterView):
         return kwargs
 
     def before_delete(self, role):
-        admin = administrator_role(self.Session())
-        guest = guest_role(self.Session())
-        authenticated = authenticated_role(self.Session())
+        app = self.get_rattail_app()
+        auth = app.get_auth_handler()
+        admin = auth.get_role_administrator(self.Session())
+        guest = auth.get_role_anonymous(self.Session())
+        authenticated = auth.get_role_authenticated(self.Session())
         if role in (admin, guest, authenticated):
             self.request.session.flash("You may not delete the {} role.".format(role.name), 'error')
             return self.redirect(self.request.get_referrer(default=self.request.route_url('roles')))
@@ -402,7 +416,7 @@ class RoleView(PrincipalMasterView):
                            .options(orm.joinedload(model.Role._permissions))
         roles = []
         for role in all_roles:
-            if auth.has_permission(session, role, permission, include_guest=False):
+            if auth.has_permission(session, role, permission, include_anonymous=False):
                 roles.append(role)
         return roles
 
@@ -475,7 +489,7 @@ class RoleView(PrincipalMasterView):
                 # and show an 'X' for any role which has this perm
                 for col, role in enumerate(roles, 2):
                     if auth.has_permission(self.Session(), role, key,
-                                           include_guest=False):
+                                           include_anonymous=False):
                         sheet.cell(row=writing_row, column=col, value="X")
 
                 writing_row += 1
diff --git a/tailbone/views/settings.py b/tailbone/views/settings.py
index cce5e53d..10a0c2eb 100644
--- a/tailbone/views/settings.py
+++ b/tailbone/views/settings.py
@@ -24,213 +24,167 @@
 Settings Views
 """
 
-import os
-import re
-import subprocess
-import sys
-from collections import OrderedDict
-
 import json
+import re
+
+import colander
 
 from rattail.db.model import Setting
 from rattail.settings import Setting as AppSetting
 from rattail.util import import_module_path
 
-import colander
-
-from tailbone import forms
+from tailbone import forms, grids
 from tailbone.db import Session
 from tailbone.views import MasterView, View
-from tailbone.util import get_libver, get_liburl
+from wuttaweb.util import get_libver, get_liburl
+from wuttaweb.views.settings import AppInfoView as WuttaAppInfoView
 
 
-class AppInfoView(MasterView):
-    """
-    Master view for the overall app, to show/edit config etc.
-    """
-    route_prefix = 'appinfo'
-    model_key = 'UNUSED'
-    model_title = "UNUSED"
-    model_title_plural = "App Details"
-    creatable = False
-    viewable = False
-    editable = False
-    deletable = False
-    filterable = False
-    pageable = False
-    configurable = True
+class AppInfoView(WuttaAppInfoView):
+    """ """
+    Session = Session
+    weblib_config_prefix = 'tailbone'
 
-    grid_columns = [
-        'name',
-        'version',
-        'editable_project_location',
-    ]
-
-    def get_index_title(self):
-        return "{} for {}".format(self.get_model_title_plural(),
-                                  self.rattail_config.app_title())
-
-    def get_data(self, session=None):
-        pip = os.path.join(sys.prefix, 'bin', 'pip')
-        output = subprocess.check_output([pip, 'list', '--format=json'])
-        data = json.loads(output.decode('utf_8').strip())
-
-        for pkg in data:
-            pkg.setdefault('editable_project_location', '')
-
-        return data
+    # TODO: for now we override to get tailbone searchable grid
+    def make_grid(self, **kwargs):
+        """ """
+        return grids.Grid(self.request, **kwargs)
 
     def configure_grid(self, g):
+        """ """
         super().configure_grid(g)
 
-        g.sorters['name'] = g.make_simple_sorter('name', foldcase=True)
-        g.set_sort_defaults('name')
+        # name
         g.set_searchable('name')
 
-        g.sorters['version'] = g.make_simple_sorter('version', foldcase=True)
-
-        g.sorters['editable_project_location'] = g.make_simple_sorter(
-            'editable_project_location', foldcase=True)
+        # editable_project_location
         g.set_searchable('editable_project_location')
 
-    def template_kwargs_index(self, **kwargs):
-        kwargs = super().template_kwargs_index(**kwargs)
-        kwargs['configure_button_title'] = "Configure App"
-        return kwargs
-
     def configure_get_context(self, **kwargs):
+        """ """
         context = super().configure_get_context(**kwargs)
+        simple_settings = context['simple_settings']
+        weblibs = context['weblibs']
 
-        weblibs = OrderedDict([
-            ('vue', "Vue"),
-            ('vue_resource', "vue-resource"),
-            ('buefy', "Buefy"),
-            ('buefy.css', "Buefy CSS"),
-            ('fontawesome', "FontAwesome"),
-            ('bb_vue', "(BB) vue"),
-            ('bb_oruga', "(BB) @oruga-ui/oruga-next"),
-            ('bb_oruga_bulma', "(BB) @oruga-ui/theme-bulma (JS)"),
-            ('bb_oruga_bulma_css', "(BB) @oruga-ui/theme-bulma (CSS)"),
-            ('bb_fontawesome_svg_core', "(BB) @fortawesome/fontawesome-svg-core"),
-            ('bb_free_solid_svg_icons', "(BB) @fortawesome/free-solid-svg-icons"),
-            ('bb_vue_fontawesome', "(BB) @fortawesome/vue-fontawesome"),
-        ])
+        for weblib in weblibs:
+            key = weblib['key']
 
-        for key in weblibs:
-            title = weblibs[key]
-            weblibs[key] = {
-                'key': key,
-                'title': title,
+            # TODO: this is only needed to migrate legacy settings to
+            # use the newer wuttaweb setting names
+            url = simple_settings[f'wuttaweb.liburl.{key}']
+            if not url and weblib['configured_url']:
+                simple_settings[f'wuttaweb.liburl.{key}'] = weblib['configured_url']
 
-                # nb. these values are exactly as configured, and are
-                # used for editing the settings
-                'configured_version': get_libver(self.request, key, fallback=False),
-                'configured_url': get_liburl(self.request, key, fallback=False),
-
-                # these are for informational purposes only
-                'default_version': get_libver(self.request, key, default_only=True),
-                'live_url': get_liburl(self.request, key),
-            }
-
-        context['weblibs'] = list(weblibs.values())
         return context
 
+    # nb. these email settings require special handling below
+    configure_profile_key_mismatches = [
+        'default.subject',
+        'default.to',
+        'default.cc',
+        'default.bcc',
+        'feedback.subject',
+        'feedback.to',
+    ]
+
     def configure_get_simple_settings(self):
-        return [
+        """ """
+        simple_settings = super().configure_get_simple_settings()
 
-            # basics
-            {'section': 'rattail',
-             'option': 'app_title'},
-            {'section': 'rattail',
-             'option': 'node_type'},
-            {'section': 'rattail',
-             'option': 'node_title'},
-            {'section': 'rattail',
-             'option': 'production',
-             'type': bool},
-            {'section': 'rattail',
-             'option': 'running_from_source',
-             'type': bool},
-            {'section': 'rattail',
-             'option': 'running_from_source.rootpkg'},
+        # TODO:
+        # there are several email config keys which differ between
+        # wuttjamaican and rattail.  basically all of the "profile" keys
+        # have a different prefix.
 
-            # display
-            {'section': 'tailbone',
-             'option': 'background_color'},
+        # after wuttaweb has declared its settings, we examine each and
+        # overwrite the value if one is defined with rattail config key.
+        # (nb. this happens even if wuttjamaican key has a value!)
 
-            # grids
-            {'section': 'tailbone',
-             'option': 'grid.default_pagesize',
-             # TODO: seems like should enforce this, but validation is
-             # not setup yet
-             # 'type': int
-            },
+        # note that we *do* declare the profile mismatch keys for
+        # rattail, as part of simple settings.  this ensures the
+        # parent logic will always remove them when saving.  however
+        # we must also include them in gather_settings() to ensure
+        # they are saved to match wuttjamaican values.
 
-            # web libs
-            {'section': 'tailbone',
-             'option': 'libver.vue'},
-            {'section': 'tailbone',
-             'option': 'liburl.vue'},
-            {'section': 'tailbone',
-             'option': 'libver.vue_resource'},
-            {'section': 'tailbone',
-             'option': 'liburl.vue_resource'},
-            {'section': 'tailbone',
-             'option': 'libver.buefy'},
-            {'section': 'tailbone',
-             'option': 'liburl.buefy'},
-            {'section': 'tailbone',
-             'option': 'libver.buefy.css'},
-            {'section': 'tailbone',
-             'option': 'liburl.buefy.css'},
-            {'section': 'tailbone',
-             'option': 'libver.fontawesome'},
-            {'section': 'tailbone',
-             'option': 'liburl.fontawesome'},
+        # there are also a couple of flags where rattail's default is the
+        # opposite of wuttjamaican.  so we overwrite those too as needed.
 
-            {'section': 'tailbone',
-             'option': 'libver.bb_vue'},
-            {'section': 'tailbone',
-             'option': 'liburl.bb_vue'},
+        for setting in simple_settings:
 
-            {'section': 'tailbone',
-             'option': 'libver.bb_oruga'},
-            {'section': 'tailbone',
-             'option': 'liburl.bb_oruga'},
+            # nb. the update home page redirect setting is off by
+            # default for wuttaweb, but on for tailbone
+            if setting['name'] == 'wuttaweb.home_redirect_to_login':
+                value = self.config.get_bool('wuttaweb.home_redirect_to_login')
+                if value is None:
+                    value = self.config.get_bool('tailbone.login_is_home', default=True)
+                setting['value'] = value
 
-            {'section': 'tailbone',
-             'option': 'libver.bb_oruga_bulma'},
-            {'section': 'tailbone',
-             'option': 'liburl.bb_oruga_bulma'},
+            # nb. sending email is off by default for wuttjamaican,
+            # but on for rattail
+            elif setting['name'] == 'rattail.mail.send_emails':
+                value = self.config.get_bool('rattail.mail.send_emails', default=True)
+                setting['value'] = value
 
-            {'section': 'tailbone',
-             'option': 'libver.bb_oruga_bulma_css'},
-            {'section': 'tailbone',
-             'option': 'liburl.bb_oruga_bulma_css'},
+            # nb. this one is even more special, key is entirely different
+            elif setting['name'] == 'rattail.email.default.sender':
+                value = self.config.get('rattail.email.default.sender')
+                if value is None:
+                    value = self.config.get('rattail.mail.default.from')
+                setting['value'] = value
 
-            {'section': 'tailbone',
-             'option': 'libver.bb_fontawesome_svg_core'},
-            {'section': 'tailbone',
-             'option': 'liburl.bb_fontawesome_svg_core'},
+            else:
 
-            {'section': 'tailbone',
-             'option': 'libver.bb_free_solid_svg_icons'},
-            {'section': 'tailbone',
-             'option': 'liburl.bb_free_solid_svg_icons'},
+                # nb. fetch alternate value for profile key mismatch
+                for key in self.configure_profile_key_mismatches:
+                    if setting['name'] == f'rattail.email.{key}':
+                        value = self.config.get(f'rattail.email.{key}')
+                        if value is None:
+                            value = self.config.get(f'rattail.mail.{key}')
+                        setting['value'] = value
+                        break
 
-            {'section': 'tailbone',
-             'option': 'libver.bb_vue_fontawesome'},
-            {'section': 'tailbone',
-             'option': 'liburl.bb_vue_fontawesome'},
+        # nb. these are no longer used (deprecated), but we keep
+        # them defined here so the tool auto-deletes them
 
-            # nb. these are no longer used (deprecated), but we keep
-            # them defined here so the tool auto-deletes them
-            {'section': 'tailbone',
-             'option': 'buefy_version'},
-            {'section': 'tailbone',
-             'option': 'vue_version'},
+        simple_settings.extend([
+            {'name': 'tailbone.login_is_home'},
+            {'name': 'tailbone.buefy_version'},
+            {'name': 'tailbone.vue_version'},
+        ])
 
-        ]
+        simple_settings.append({'name': 'rattail.mail.default.from'})
+        for key in self.configure_profile_key_mismatches:
+            simple_settings.append({'name': f'rattail.mail.{key}'})
+
+        for key in self.get_weblibs():
+            simple_settings.extend([
+                {'name': f'tailbone.libver.{key}'},
+                {'name': f'tailbone.liburl.{key}'},
+            ])
+
+        return simple_settings
+
+    def configure_gather_settings(self, data, simple_settings=None):
+        """ """
+        settings = super().configure_gather_settings(data, simple_settings=simple_settings)
+
+        # nb. must add legacy rattail profile settings to match new ones
+        for setting in list(settings):
+
+            if setting['name'] == 'rattail.email.default.sender':
+                value = setting['value']
+                settings.append({'name': 'rattail.mail.default.from',
+                                 'value': value})
+
+            else:
+                for key in self.configure_profile_key_mismatches:
+                    if setting['name'] == f'rattail.email.{key}':
+                        value = setting['value']
+                        settings.append({'name': f'rattail.mail.{key}',
+                                         'value': value})
+                        break
+
+        return settings
 
 
 class SettingView(MasterView):
diff --git a/tailbone/views/tempmon/appliances.py b/tailbone/views/tempmon/appliances.py
index c523ae78..4ce52009 100644
--- a/tailbone/views/tempmon/appliances.py
+++ b/tailbone/views/tempmon/appliances.py
@@ -2,7 +2,7 @@
 ################################################################################
 #
 #  Rattail -- Retail Software Framework
-#  Copyright © 2010-2023 Lance Edgar
+#  Copyright © 2010-2024 Lance Edgar
 #
 #  This file is part of Rattail.
 #
@@ -24,11 +24,9 @@
 Views for tempmon appliances
 """
 
-from __future__ import unicode_literals, absolute_import
-
+import io
 import os
 
-import six
 from PIL import Image
 
 from rattail_tempmon.db import model as tempmon
@@ -68,7 +66,7 @@ class TempmonApplianceView(MasterView):
     ]
 
     def configure_grid(self, g):
-        super(TempmonApplianceView, self).configure_grid(g)
+        super().configure_grid(g)
 
         # name
         g.set_sort_defaults('name')
@@ -94,7 +92,7 @@ class TempmonApplianceView(MasterView):
         return HTML.tag('div', class_='image-frame', c=[helper, image])
 
     def configure_form(self, f):
-        super(TempmonApplianceView, self).configure_form(f)
+        super().configure_form(f)
 
         # name
         f.set_validator('name', self.unique_name)
@@ -122,7 +120,7 @@ class TempmonApplianceView(MasterView):
             f.remove_field('probes')
 
     def template_kwargs_view(self, **kwargs):
-        kwargs = super(TempmonApplianceView, self).template_kwargs_view(**kwargs)
+        kwargs = super().template_kwargs_view(**kwargs)
         appliance = kwargs['instance']
 
         kwargs['probes_data'] = self.normalize_probes(appliance.probes)
@@ -176,13 +174,13 @@ class TempmonApplianceView(MasterView):
                 im = Image.open(f)
 
                 im.thumbnail((600, 600), Image.ANTIALIAS)
-                data = six.BytesIO()
+                data = io.BytesIO()
                 im.save(data, 'JPEG')
                 appliance.image_normal = data.getvalue()
                 data.close()
 
                 im.thumbnail((150, 150), Image.ANTIALIAS)
-                data = six.BytesIO()
+                data = io.BytesIO()
                 im.save(data, 'JPEG')
                 appliance.image_thumbnail = data.getvalue()
                 data.close()
diff --git a/tailbone/views/tempmon/core.py b/tailbone/views/tempmon/core.py
index d551d6e6..7540abbe 100644
--- a/tailbone/views/tempmon/core.py
+++ b/tailbone/views/tempmon/core.py
@@ -77,8 +77,8 @@ class MasterView(views.MasterView):
 
         factory = self.get_grid_factory()
         g = factory(
-            key='{}.probes'.format(route_prefix),
-            request=self.request,
+            self.request,
+            key=f'{route_prefix}.probes',
             data=[],
             columns=[
                 'description',
@@ -96,7 +96,7 @@ class MasterView(views.MasterView):
                 'critical_temp_max': "Crit. Max",
             },
             linked_columns=['description'],
-            main_actions=actions,
+            actions=actions,
         )
         return HTML.literal(
             g.render_table_element(data_prop='probesData'))
diff --git a/tailbone/views/trainwreck/base.py b/tailbone/views/trainwreck/base.py
index 59a42301..d5f077aa 100644
--- a/tailbone/views/trainwreck/base.py
+++ b/tailbone/views/trainwreck/base.py
@@ -246,16 +246,17 @@ class TransactionView(MasterView):
         factory = self.get_grid_factory()
 
         g = factory(
-            key='{}.custorder_xref_markers'.format(route_prefix),
+            self.request,
+            key=f'{route_prefix}.custorder_xref_markers',
             data=[],
-            columns=['custorder_xref', 'custorder_item_xref'],
-            request=self.request)
+            columns=['custorder_xref', 'custorder_item_xref'])
 
         return HTML.literal(
             g.render_table_element(data_prop='custorderXrefMarkersData'))
 
     def template_kwargs_view(self, **kwargs):
         kwargs = super().template_kwargs_view(**kwargs)
+        config = self.rattail_config
 
         form = kwargs['form']
         if 'custorder_xref_markers' in form:
@@ -268,8 +269,32 @@ class TransactionView(MasterView):
                 })
             kwargs['custorder_xref_markers_data'] = markers
 
+        # collapse header
+        kwargs['main_form_title'] = "Transaction Header"
+        kwargs['main_form_collapsible'] = True
+        kwargs['main_form_autocollapse'] = config.get_bool(
+            'tailbone.trainwreck.view_txn.autocollapse_header',
+            default=False)
+
         return kwargs
 
+    def get_xref_buttons(self, txn):
+        app = self.get_rattail_app()
+        clientele = app.get_clientele_handler()
+        buttons = super().get_xref_buttons(txn)
+
+        if txn.customer_id:
+            customer = clientele.locate_customer_for_key(Session(), txn.customer_id)
+            if customer:
+                person = app.get_person(customer)
+                if person:
+                    url = self.request.route_url('people.view_profile', uuid=person.uuid)
+                    buttons.append(self.make_xref_button(text=str(person),
+                                                         url=url,
+                                                         internal=True))
+
+        return buttons
+
     def get_row_data(self, transaction):
         return self.Session.query(self.model_row_class)\
                            .filter(self.model_row_class.transaction == transaction)
@@ -330,11 +355,11 @@ class TransactionView(MasterView):
         factory = self.get_grid_factory()
 
         g = factory(
-            key='{}.discounts'.format(route_prefix),
+            self.request,
+            key=f'{route_prefix}.discounts',
             data=[],
             columns=['discount_type', 'description', 'amount'],
-            labels={'discount_type': "Type"},
-            request=self.request)
+            labels={'discount_type': "Type"})
 
         return HTML.literal(
             g.render_table_element(data_prop='discountsData'))
@@ -402,6 +427,11 @@ class TransactionView(MasterView):
     def configure_get_simple_settings(self):
         return [
 
+            # display
+            {'section': 'tailbone',
+             'option': 'trainwreck.view_txn.autocollapse_header',
+             'type': bool},
+
             # rotation
             {'section': 'trainwreck',
              'option': 'use_rotation',
diff --git a/tailbone/views/upgrades.py b/tailbone/views/upgrades.py
index a281062e..ffa88032 100644
--- a/tailbone/views/upgrades.py
+++ b/tailbone/views/upgrades.py
@@ -147,10 +147,11 @@ class UpgradeView(MasterView):
 
     def template_kwargs_view(self, **kwargs):
         kwargs = super().template_kwargs_view(**kwargs)
+        app = self.get_rattail_app()
         model = self.model
         upgrade = kwargs['instance']
 
-        kwargs['system_title'] = self.rattail_config.app_title()
+        kwargs['system_title'] = app.get_title()
         if upgrade.system:
             system = self.upgrade_handler.get_system(upgrade.system)
             if system:
@@ -347,56 +348,27 @@ class UpgradeView(MasterView):
     commit_hash_pattern = re.compile(r'^.{40}$')
 
     def get_changelog_projects(self):
-        projects = {
-            'rattail': {
-                'commit_url': 'https://kallithea.rattailproject.org/rattail-project/rattail/changelog/{new_version}/?size=10',
-                'release_url': 'https://kallithea.rattailproject.org/rattail-project/rattail/files/v{new_version}/CHANGES.rst',
-            },
-            'Tailbone': {
-                'commit_url': 'https://kallithea.rattailproject.org/rattail-project/tailbone/changelog/{new_version}/?size=10',
-                'release_url': 'https://kallithea.rattailproject.org/rattail-project/tailbone/files/v{new_version}/CHANGES.rst',
-            },
-            'pyCOREPOS': {
-                'commit_url': 'https://kallithea.rattailproject.org/rattail-project/pycorepos/changelog/{new_version}/?size=10',
-                'release_url': 'https://kallithea.rattailproject.org/rattail-project/pycorepos/files/v{new_version}/CHANGES.rst',
-            },
-            'rattail_corepos': {
-                'commit_url': 'https://kallithea.rattailproject.org/rattail-project/rattail-corepos/changelog/{new_version}/?size=10',
-                'release_url': 'https://kallithea.rattailproject.org/rattail-project/rattail-corepos/files/v{new_version}/CHANGES.rst',
-            },
-            'tailbone_corepos': {
-                'commit_url': 'https://kallithea.rattailproject.org/rattail-project/tailbone-corepos/changelog/{new_version}/?size=10',
-                'release_url': 'https://kallithea.rattailproject.org/rattail-project/tailbone-corepos/files/v{new_version}/CHANGES.rst',
-            },
-            'onager': {
-                'commit_url': 'https://kallithea.rattailproject.org/rattail-restricted/onager/changelog/{new_version}/?size=10',
-                'release_url': 'https://kallithea.rattailproject.org/rattail-restricted/onager/files/v{new_version}/CHANGES.rst',
-            },
-            'rattail-onager': {
-                'commit_url': 'https://kallithea.rattailproject.org/rattail-restricted/rattail-onager/changelog/{new_version}/?size=10',
-                'release_url': 'https://kallithea.rattailproject.org/rattail-restricted/rattail-onager/files/v{new_version}/CHANGELOG.md',
-            },
-            'rattail_tempmon': {
-                'commit_url': 'https://kallithea.rattailproject.org/rattail-project/rattail-tempmon/changelog/{new_version}/?size=10',
-                'release_url': 'https://kallithea.rattailproject.org/rattail-project/rattail-tempmon/files/v{new_version}/CHANGES.rst',
-            },
-            'tailbone-onager': {
-                'commit_url': 'https://kallithea.rattailproject.org/rattail-restricted/tailbone-onager/changelog/{new_version}/?size=10',
-                'release_url': 'https://kallithea.rattailproject.org/rattail-restricted/tailbone-onager/files/v{new_version}/CHANGELOG.md',
-            },
-            'rattail_woocommerce': {
-                'commit_url': 'https://kallithea.rattailproject.org/rattail-project/rattail-woocommerce/changelog/{new_version}/?size=10',
-                'release_url': 'https://kallithea.rattailproject.org/rattail-project/rattail-woocommerce/files/v{new_version}/CHANGES.rst',
-            },
-            'tailbone_woocommerce': {
-                'commit_url': 'https://kallithea.rattailproject.org/rattail-project/tailbone-woocommerce/changelog/{new_version}/?size=10',
-                'release_url': 'https://kallithea.rattailproject.org/rattail-project/tailbone-woocommerce/files/v{new_version}/CHANGES.rst',
-            },
-            'tailbone_theo': {
-                'commit_url': 'https://kallithea.rattailproject.org/rattail-project/theo/changelog/{new_version}/?size=10',
-                'release_url': 'https://kallithea.rattailproject.org/rattail-project/theo/files/v{new_version}/CHANGES.rst',
-            },
+        project_map = {
+            'onager': 'onager',
+            'pyCOREPOS': 'pycorepos',
+            'rattail': 'rattail',
+            'rattail_corepos': 'rattail-corepos',
+            'rattail-onager': 'rattail-onager',
+            'rattail_tempmon': 'rattail-tempmon',
+            'rattail_woocommerce': 'rattail-woocommerce',
+            'Tailbone': 'tailbone',
+            'tailbone_corepos': 'tailbone-corepos',
+            'tailbone-onager': 'tailbone-onager',
+            'tailbone_theo': 'theo',
+            'tailbone_woocommerce': 'tailbone-woocommerce',
         }
+
+        projects = {}
+        for name, repo in project_map.items():
+            projects[name] = {
+                'commit_url': f'https://forgejo.wuttaproject.org/rattail/{repo}/compare/{{old_version}}...{{new_version}}',
+                'release_url': f'https://forgejo.wuttaproject.org/rattail/{repo}/src/tag/v{{new_version}}/CHANGELOG.md',
+            }
         return projects
 
     def get_changelog_url(self, project, old_version, new_version):
diff --git a/tailbone/views/users.py b/tailbone/views/users.py
index dd3f7f7b..dfed0a11 100644
--- a/tailbone/views/users.py
+++ b/tailbone/views/users.py
@@ -28,8 +28,6 @@ import sqlalchemy as sa
 from sqlalchemy import orm
 
 from rattail.db.model import User, UserEvent
-from rattail.db.auth import (administrator_role, guest_role,
-                             authenticated_role, set_user_password)
 
 import colander
 from deform import widget as dfwidget
@@ -46,9 +44,6 @@ class UserView(PrincipalMasterView):
     Master view for the User model.
     """
     model_class = User
-    has_rows = True
-    rows_title = "User Events"
-    model_row_class = UserEvent
     has_versions = True
     touchable = True
     mergeable = True
@@ -79,6 +74,11 @@ class UserView(PrincipalMasterView):
         'permissions',
     ]
 
+    has_rows = True
+    model_row_class = UserEvent
+    rows_title = "User Events"
+    rows_viewable = False
+
     row_grid_columns = [
         'type_code',
         'occurred',
@@ -210,9 +210,13 @@ class UserView(PrincipalMasterView):
                             person_display = str(person)
                 elif self.editing:
                     person_display = str(user.person or '')
-                people_url = self.request.route_url('people.autocomplete')
-                f.set_widget('person_uuid', forms.widgets.JQueryAutocompleteWidget(
-                    field_display=person_display, service_url=people_url))
+                try:
+                    people_url = self.request.route_url('people.autocomplete')
+                except KeyError:
+                    pass        # TODO: wutta compat
+                else:
+                    f.set_widget('person_uuid', forms.widgets.JQueryAutocompleteWidget(
+                        field_display=person_display, service_url=people_url))
                 f.set_validator('person_uuid', self.valid_person)
                 f.set_label('person_uuid', "Person")
 
@@ -278,10 +282,10 @@ class UserView(PrincipalMasterView):
         # fs.confirm_password.attrs(autocomplete='new-password')
 
         if self.viewing:
-            permissions = self.request.registry.settings.get('tailbone_permissions', {})
+            permissions = self.request.registry.settings.get('wutta_permissions', {})
             f.set_renderer('permissions', PermissionsRenderer(request=self.request,
                                                               permissions=permissions,
-                                                              include_guest=True,
+                                                              include_anonymous=True,
                                                               include_authenticated=True))
         else:
             f.remove('permissions')
@@ -295,11 +299,11 @@ class UserView(PrincipalMasterView):
 
         factory = self.get_grid_factory()
         g = factory(
-            request=self.request,
-            key='{}.api_tokens'.format(route_prefix),
+            self.request,
+            key=f'{route_prefix}.api_tokens',
             data=[],
             columns=['description', 'created'],
-            main_actions=[
+            actions=[
                 self.make_action('delete', icon='trash',
                                  click_handler="$emit('api-token-delete', props.row)")])
 
@@ -360,17 +364,19 @@ class UserView(PrincipalMasterView):
         return tokens
 
     def get_possible_roles(self):
-        model = self.model
+        app = self.get_rattail_app()
+        auth = app.get_auth_handler()
+        model = app.model
 
         # some roles should never have users "belong" to them
         excluded = [
-            guest_role(self.Session()).uuid,
-            authenticated_role(self.Session()).uuid,
+            auth.get_role_anonymous(self.Session()).uuid,
+            auth.get_role_authenticated(self.Session()).uuid,
         ]
 
         # only allow "root" user to change true admin role membership
         if not self.request.is_root:
-            excluded.append(administrator_role(self.Session()).uuid)
+            excluded.append(auth.get_role_administrator(self.Session()).uuid)
 
         # basic list, minus exclusions so far
         roles = self.Session.query(model.Role)\
@@ -385,7 +391,9 @@ class UserView(PrincipalMasterView):
         return roles.order_by(model.Role.name)
 
     def objectify(self, form, data=None):
-        model = self.model
+        app = self.get_rattail_app()
+        auth = app.get_auth_handler()
+        model = app.model
 
         # create/update user as per normal
         if data is None:
@@ -420,7 +428,7 @@ class UserView(PrincipalMasterView):
 
         # maybe set user password
         if 'set_password' in form and data['set_password']:
-            set_user_password(user, data['set_password'])
+            auth.set_user_password(user, data['set_password'])
 
         # update roles for user
         self.update_roles(user, data)
@@ -433,10 +441,12 @@ class UserView(PrincipalMasterView):
         if 'roles' not in data:
             return
 
-        model = self.model
+        app = self.get_rattail_app()
+        auth = app.get_auth_handler()
+        model = app.model
         old_roles = set([r.uuid for r in user.roles])
         new_roles = data['roles']
-        admin = administrator_role(self.Session())
+        admin = auth.get_role_administrator(self.Session())
 
         # add any new roles for the user, taking care not to add the admin role
         # unless acting as root
@@ -506,7 +516,6 @@ class UserView(PrincipalMasterView):
         g.set_sort_defaults('occurred', 'desc')
         g.set_enum('type_code', self.enum.USER_EVENT)
         g.set_label('type_code', "Event Type")
-        g.main_actions = []
 
     def get_version_child_classes(self):
         model = self.model
@@ -792,4 +801,8 @@ def defaults(config, **kwargs):
 
 
 def includeme(config):
-    defaults(config)
+    wutta_config = config.registry.settings['wutta_config']
+    if wutta_config.get_bool('tailbone.use_wutta_views', default=False, usedb=False):
+        config.include('tailbone.views.wutta.users')
+    else:
+        defaults(config)
diff --git a/tailbone/views/vendors/core.py b/tailbone/views/vendors/core.py
index 8b9361b7..addf153c 100644
--- a/tailbone/views/vendors/core.py
+++ b/tailbone/views/vendors/core.py
@@ -2,7 +2,7 @@
 ################################################################################
 #
 #  Rattail -- Retail Software Framework
-#  Copyright © 2010-2022 Lance Edgar
+#  Copyright © 2010-2024 Lance Edgar
 #
 #  This file is part of Rattail.
 #
@@ -24,10 +24,6 @@
 Vendor Views
 """
 
-from __future__ import unicode_literals, absolute_import
-
-import six
-
 from rattail.db import model
 
 from webhelpers2.html import tags
@@ -158,7 +154,7 @@ class VendorView(MasterView):
         person = vendor.contact
         if not person:
             return ""
-        text = six.text_type(person)
+        text = str(person)
         url = self.request.route_url('people.view', uuid=person.uuid)
         return tags.link_to(text, url)
 
@@ -198,7 +194,7 @@ class VendorView(MasterView):
             data, **kwargs)
 
         supported_vendor_settings = self.configure_get_supported_vendor_settings()
-        for setting in six.itervalues(supported_vendor_settings):
+        for setting in supported_vendor_settings.values():
             name = 'rattail.vendor.{}'.format(setting['key'])
             settings.append({'name': name,
                              'value': data[name]})
@@ -211,7 +207,7 @@ class VendorView(MasterView):
         names = []
 
         supported_vendor_settings = self.configure_get_supported_vendor_settings()
-        for setting in six.itervalues(supported_vendor_settings):
+        for setting in supported_vendor_settings.values():
             names.append('rattail.vendor.{}'.format(setting['key']))
 
         if names:
@@ -236,7 +232,7 @@ class VendorView(MasterView):
             settings[key] = {
                 'key': key,
                 'value': vendor.uuid if vendor else None,
-                'label': six.text_type(vendor) if vendor else None,
+                'label': str(vendor) if vendor else None,
             }
 
         return settings
diff --git a/tailbone/views/workorders.py b/tailbone/views/workorders.py
index a53037bc..d8094e4b 100644
--- a/tailbone/views/workorders.py
+++ b/tailbone/views/workorders.py
@@ -2,7 +2,7 @@
 ################################################################################
 #
 #  Rattail -- Retail Software Framework
-#  Copyright © 2010-2023 Lance Edgar
+#  Copyright © 2010-2024 Lance Edgar
 #
 #  This file is part of Rattail.
 #
@@ -83,12 +83,12 @@ class WorkOrderView(MasterView):
     ]
 
     def __init__(self, request):
-        super(WorkOrderView, self).__init__(request)
+        super().__init__(request)
         app = self.get_rattail_app()
         self.workorder_handler = app.get_workorder_handler()
 
     def configure_grid(self, g):
-        super(WorkOrderView, self).configure_grid(g)
+        super().configure_grid(g)
         model = self.model
 
         # customer
@@ -113,7 +113,7 @@ class WorkOrderView(MasterView):
             return 'warning'
 
     def configure_form(self, f):
-        super(WorkOrderView, self).configure_form(f)
+        super().configure_form(f)
         model = self.model
         SelectWidget = forms.widgets.JQuerySelectWidget
 
@@ -208,7 +208,7 @@ class WorkOrderView(MasterView):
         return event.workorder
 
     def configure_row_grid(self, g):
-        super(WorkOrderView, self).configure_row_grid(g)
+        super().configure_row_grid(g)
         g.set_enum('type_code', self.enum.WORKORDER_EVENT)
         g.set_sort_defaults('occurred')
 
@@ -353,7 +353,7 @@ class WorkOrderView(MasterView):
 class StatusFilter(grids.filters.AlchemyIntegerFilter):
 
     def __init__(self, *args, **kwargs):
-        super(StatusFilter, self).__init__(*args, **kwargs)
+        super().__init__(*args, **kwargs)
 
         from drild import enum
 
@@ -369,14 +369,14 @@ class StatusFilter(grids.filters.AlchemyIntegerFilter):
 
     @property
     def verb_labels(self):
-        labels = dict(super(StatusFilter, self).verb_labels)
+        labels = dict(super().verb_labels)
         labels['is_active'] = "Is Active"
         labels['not_active'] = "Is Not Active"
         return labels
 
     @property
     def valueless_verbs(self):
-        verbs = list(super(StatusFilter, self).valueless_verbs)
+        verbs = list(super().valueless_verbs)
         verbs.extend([
             'is_active',
             'not_active',
@@ -385,7 +385,11 @@ class StatusFilter(grids.filters.AlchemyIntegerFilter):
 
     @property
     def default_verbs(self):
-        verbs = list(super(StatusFilter, self).default_verbs)
+        verbs = super().default_verbs
+        if callable(verbs):
+            verbs = verbs()
+
+        verbs = list(verbs or [])
         verbs.insert(0, 'is_active')
         verbs.insert(1, 'not_active')
         return verbs
diff --git a/tailbone/views/wutta/__init__.py b/tailbone/views/wutta/__init__.py
new file mode 100644
index 00000000..e69de29b
diff --git a/tailbone/views/wutta/people.py b/tailbone/views/wutta/people.py
new file mode 100644
index 00000000..bd96bd4d
--- /dev/null
+++ b/tailbone/views/wutta/people.py
@@ -0,0 +1,143 @@
+# -*- coding: utf-8; -*-
+################################################################################
+#
+#  Rattail -- Retail Software Framework
+#  Copyright © 2010-2024 Lance Edgar
+#
+#  This file is part of Rattail.
+#
+#  Rattail is free software: you can redistribute it and/or modify it under the
+#  terms of the GNU General Public License as published by the Free Software
+#  Foundation, either version 3 of the License, or (at your option) any later
+#  version.
+#
+#  Rattail is distributed in the hope that it will be useful, but WITHOUT ANY
+#  WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
+#  FOR A PARTICULAR PURPOSE.  See the GNU General Public License for more
+#  details.
+#
+#  You should have received a copy of the GNU General Public License along with
+#  Rattail.  If not, see <http://www.gnu.org/licenses/>.
+#
+################################################################################
+"""
+Person Views
+"""
+
+import colander
+import sqlalchemy as sa
+from webhelpers2.html import HTML
+
+from wuttaweb.views import people as wutta
+from tailbone.views import people as tailbone
+from tailbone.db import Session
+from rattail.db.model import Person
+from tailbone.grids import Grid
+
+
+class PersonView(wutta.PersonView):
+    """
+    This is the first attempt at blending newer Wutta views with
+    legacy Tailbone config.
+
+    So, this is a Wutta-based view but it should be included by a
+    Tailbone app configurator.
+    """
+    model_class = Person
+    Session = Session
+
+    labels = {
+        'display_name': "Full Name",
+    }
+
+    grid_columns = [
+        'display_name',
+        'first_name',
+        'last_name',
+        'phone',
+        'email',
+        'merge_requested',
+    ]
+
+    filter_defaults = {
+        'display_name': {'active': True, 'verb': 'contains'},
+    }
+    sort_defaults = 'display_name'
+
+    form_fields = [
+        'first_name',
+        'middle_name',
+        'last_name',
+        'display_name',
+        'phone',
+        'email',
+        # TODO
+        # 'address',
+    ]
+
+    ##############################
+    # CRUD methods
+    ##############################
+
+    # TODO: must use older grid for now, to render filters correctly
+    def make_grid(self, **kwargs):
+        """ """
+        return Grid(self.request, **kwargs)
+
+    def configure_grid(self, g):
+        """ """
+        super().configure_grid(g)
+
+        # display_name
+        g.set_link('display_name')
+
+        # merge_requested
+        g.set_label('merge_requested', "MR")
+        g.set_renderer('merge_requested', self.render_merge_requested)
+
+    def configure_form(self, f):
+        """ """
+        super().configure_form(f)
+
+        # email
+        if self.creating or self.editing:
+            f.remove('email')
+        else:
+            # nb. avoid colanderalchemy
+            f.set_node('email', colander.String())
+
+        # phone
+        if self.creating or self.editing:
+            f.remove('phone')
+        else:
+            # nb. avoid colanderalchemy
+            f.set_node('phone', colander.String())
+
+    ##############################
+    # support methods
+    ##############################
+
+    def render_merge_requested(self, person, key, value, session=None):
+        """ """
+        model = self.app.model
+        session = session or self.Session()
+        merge_request = session.query(model.MergePeopleRequest)\
+                               .filter(sa.or_(
+                                   model.MergePeopleRequest.removing_uuid == person.uuid,
+                                   model.MergePeopleRequest.keeping_uuid == person.uuid))\
+                               .filter(model.MergePeopleRequest.merged == None)\
+                               .first()
+        if merge_request:
+            return HTML.tag('span',
+                            class_='has-text-danger has-text-weight-bold',
+                            title="A merge has been requested for this person.",
+                            c="MR")
+
+
+def defaults(config, **kwargs):
+    kwargs.setdefault('PersonView', PersonView)
+    tailbone.defaults(config, **kwargs)
+
+
+def includeme(config):
+    defaults(config)
diff --git a/setup.py b/tailbone/views/wutta/users.py
similarity index 51%
rename from setup.py
rename to tailbone/views/wutta/users.py
index 5645ddff..3c3f8d52 100644
--- a/setup.py
+++ b/tailbone/views/wutta/users.py
@@ -2,7 +2,7 @@
 ################################################################################
 #
 #  Rattail -- Retail Software Framework
-#  Copyright © 2010-2023 Lance Edgar
+#  Copyright © 2010-2024 Lance Edgar
 #
 #  This file is part of Rattail.
 #
@@ -21,9 +21,37 @@
 #
 ################################################################################
 """
-Setup script for Tailbone
+User Views
 """
 
-from setuptools import setup
+from wuttaweb.views import users as wutta
+from tailbone.views import users as tailbone
+from tailbone.db import Session
+from rattail.db.model import User
+from tailbone.grids import Grid
 
-setup()
+
+class UserView(wutta.UserView):
+    """
+    This is the first attempt at blending newer Wutta views with
+    legacy Tailbone config.
+
+    So, this is a Wutta-based view but it should be included by a
+    Tailbone app configurator.
+    """
+    model_class = User
+    Session = Session
+
+    # TODO: must use older grid for now, to render filters correctly
+    def make_grid(self, **kwargs):
+        """ """
+        return Grid(self.request, **kwargs)
+
+
+def defaults(config, **kwargs):
+    kwargs.setdefault('UserView', UserView)
+    tailbone.defaults(config, **kwargs)
+
+
+def includeme(config):
+    defaults(config)
diff --git a/tailbone/webapi.py b/tailbone/webapi.py
index 1c2fa106..d0edb412 100644
--- a/tailbone/webapi.py
+++ b/tailbone/webapi.py
@@ -85,21 +85,34 @@ def make_pyramid_config(settings):
         provider.configure_db_sessions(rattail_config, pyramid_config)
 
     # add some permissions magic
-    pyramid_config.add_directive('add_tailbone_permission_group', 'tailbone.auth.add_permission_group')
-    pyramid_config.add_directive('add_tailbone_permission', 'tailbone.auth.add_permission')
+    pyramid_config.add_directive('add_wutta_permission_group',
+                                 'wuttaweb.auth.add_permission_group')
+    pyramid_config.add_directive('add_wutta_permission',
+                                 'wuttaweb.auth.add_permission')
+    # TODO: deprecate / remove these
+    pyramid_config.add_directive('add_tailbone_permission_group',
+                                 'wuttaweb.auth.add_permission_group')
+    pyramid_config.add_directive('add_tailbone_permission',
+                                 'wuttaweb.auth.add_permission')
 
     return pyramid_config
 
 
-def main(global_config, **settings):
+def main(global_config, views='tailbone.api', **settings):
     """
     This function returns a Pyramid WSGI application.
     """
     rattail_config = make_rattail_config(settings)
     pyramid_config = make_pyramid_config(settings)
 
-    # bring in some Tailbone
-    pyramid_config.include('tailbone.subscribers')
-    pyramid_config.include('tailbone.api')
+    # event hooks
+    pyramid_config.add_subscriber('tailbone.subscribers.new_request',
+                                  'pyramid.events.NewRequest')
+    # TODO: is this really needed?
+    pyramid_config.add_subscriber('tailbone.subscribers.context_found',
+                                  'pyramid.events.ContextFound')
+
+    # views
+    pyramid_config.include(views)
 
     return pyramid_config.make_wsgi_app()
diff --git a/tasks.py b/tasks.py
index fba0b699..6983dbea 100644
--- a/tasks.py
+++ b/tasks.py
@@ -30,19 +30,19 @@ import shutil
 from invoke import task
 
 
-here = os.path.abspath(os.path.dirname(__file__))
-exec(open(os.path.join(here, 'tailbone', '_version.py')).read())
-
-
 @task
-def release(c, tests=False):
+def release(c, skip_tests=False):
     """
     Release a new version of 'Tailbone'.
     """
-    if tests:
-        c.run('tox')
+    if not skip_tests:
+        c.run('pytest')
 
+    if os.path.exists('dist'):
+        shutil.rmtree('dist')
     if os.path.exists('Tailbone.egg-info'):
         shutil.rmtree('Tailbone.egg-info')
+
     c.run('python -m build --sdist')
-    c.run(f'twine upload dist/tailbone-{__version__}.tar.gz')
+
+    c.run('twine upload dist/*')
diff --git a/tests/__init__.py b/tests/__init__.py
index 7dec63f0..40d8071f 100644
--- a/tests/__init__.py
+++ b/tests/__init__.py
@@ -12,9 +12,6 @@ class TestCase(unittest.TestCase):
 
     def setUp(self):
         self.config = testing.setUp()
-        # TODO: this probably shouldn't (need to) be here
-        self.config.add_directive('add_tailbone_permission_group', 'tailbone.auth.add_permission_group')
-        self.config.add_directive('add_tailbone_permission', 'tailbone.auth.add_permission')
 
     def tearDown(self):
         testing.tearDown()
diff --git a/tests/forms/__init__.py b/tests/forms/__init__.py
new file mode 100644
index 00000000..e69de29b
diff --git a/tests/forms/test_core.py b/tests/forms/test_core.py
new file mode 100644
index 00000000..894d2302
--- /dev/null
+++ b/tests/forms/test_core.py
@@ -0,0 +1,153 @@
+# -*- coding: utf-8; -*-
+
+from unittest.mock import patch
+
+import deform
+from pyramid import testing
+
+from tailbone.forms import core as mod
+from tests.util import WebTestCase
+
+
+class TestForm(WebTestCase):
+
+    def setUp(self):
+        self.setup_web()
+        self.config.setdefault('rattail.web.menus.handler_spec', 'tests.util:NullMenuHandler')
+
+    def make_form(self, **kwargs):
+        kwargs.setdefault('request', self.request)
+        return mod.Form(**kwargs)
+
+    def test_basic(self):
+        form = self.make_form()
+        self.assertIsInstance(form, mod.Form)
+
+    def test_vue_tagname(self):
+
+        # default
+        form = self.make_form()
+        self.assertEqual(form.vue_tagname, 'tailbone-form')
+
+        # can override with param
+        form = self.make_form(vue_tagname='something-else')
+        self.assertEqual(form.vue_tagname, 'something-else')
+
+        # can still pass old param
+        form = self.make_form(component='legacy-name')
+        self.assertEqual(form.vue_tagname, 'legacy-name')
+
+    def test_vue_component(self):
+
+        # default
+        form = self.make_form()
+        self.assertEqual(form.vue_component, 'TailboneForm')
+
+        # can override with param
+        form = self.make_form(vue_tagname='something-else')
+        self.assertEqual(form.vue_component, 'SomethingElse')
+
+        # can still pass old param
+        form = self.make_form(component='legacy-name')
+        self.assertEqual(form.vue_component, 'LegacyName')
+
+    def test_component(self):
+
+        # default
+        form = self.make_form()
+        self.assertEqual(form.component, 'tailbone-form')
+
+        # can override with param
+        form = self.make_form(vue_tagname='something-else')
+        self.assertEqual(form.component, 'something-else')
+
+        # can still pass old param
+        form = self.make_form(component='legacy-name')
+        self.assertEqual(form.component, 'legacy-name')
+
+    def test_component_studly(self):
+
+        # default
+        form = self.make_form()
+        self.assertEqual(form.component_studly, 'TailboneForm')
+
+        # can override with param
+        form = self.make_form(vue_tagname='something-else')
+        self.assertEqual(form.component_studly, 'SomethingElse')
+
+        # can still pass old param
+        form = self.make_form(component='legacy-name')
+        self.assertEqual(form.component_studly, 'LegacyName')
+
+    def test_button_label_submit(self):
+        form = self.make_form()
+
+        # default
+        self.assertEqual(form.button_label_submit, "Submit")
+
+        # can set submit_label
+        with patch.object(form, 'submit_label', new="Submit Label", create=True):
+            self.assertEqual(form.button_label_submit, "Submit Label")
+
+        # can set save_label
+        with patch.object(form, 'save_label', new="Save Label"):
+            self.assertEqual(form.button_label_submit, "Save Label")
+
+        # can set button_label_submit
+        form.button_label_submit = "New Label"
+        self.assertEqual(form.button_label_submit, "New Label")
+
+    def test_get_deform(self):
+        model = self.app.model
+
+        # sanity check
+        form = self.make_form(model_class=model.Setting)
+        dform = form.get_deform()
+        self.assertIsInstance(dform, deform.Form)
+
+    def test_render_vue_tag(self):
+        model = self.app.model
+
+        # sanity check
+        form = self.make_form(model_class=model.Setting)
+        html = form.render_vue_tag()
+        self.assertIn('<tailbone-form', html)
+
+    def test_render_vue_template(self):
+        self.pyramid_config.include('tailbone.views.common')
+        model = self.app.model
+
+        # sanity check
+        form = self.make_form(model_class=model.Setting)
+        html = form.render_vue_template(session=self.session)
+        self.assertIn('<form ', html)
+
+    def test_get_vue_field_value(self):
+        model = self.app.model
+        form = self.make_form(model_class=model.Setting)
+
+        # TODO: yikes what a hack (?)
+        dform = form.get_deform()
+        dform.set_appstruct({'name': 'foo', 'value': 'bar'})
+
+        # null for missing field
+        value = form.get_vue_field_value('doesnotexist')
+        self.assertIsNone(value)
+
+        # normal value is returned
+        value = form.get_vue_field_value('name')
+        self.assertEqual(value, 'foo')
+
+        # but not if we remove field from deform
+        # TODO: what is the use case here again?
+        dform.children.remove(dform['name'])
+        value = form.get_vue_field_value('name')
+        self.assertIsNone(value)
+
+    def test_render_vue_field(self):
+        model = self.app.model
+
+        # sanity check
+        form = self.make_form(model_class=model.Setting)
+        html = form.render_vue_field('name', session=self.session)
+        self.assertIn('<b-field ', html)
diff --git a/tests/grids/__init__.py b/tests/grids/__init__.py
new file mode 100644
index 00000000..e69de29b
diff --git a/tests/grids/test_core.py b/tests/grids/test_core.py
new file mode 100644
index 00000000..4d143c85
--- /dev/null
+++ b/tests/grids/test_core.py
@@ -0,0 +1,579 @@
+# -*- coding: utf-8; -*-
+
+from unittest.mock import MagicMock, patch
+
+from sqlalchemy import orm
+
+from tailbone.grids import core as mod
+from tests.util import WebTestCase
+
+
+class TestGrid(WebTestCase):
+
+    def setUp(self):
+        self.setup_web()
+        self.config.setdefault('rattail.web.menus.handler_spec', 'tests.util:NullMenuHandler')
+
+    def make_grid(self, key=None, data=[], **kwargs):
+        return mod.Grid(self.request, key=key, data=data, **kwargs)
+
+    def test_basic(self):
+        grid = self.make_grid('foo')
+        self.assertIsInstance(grid, mod.Grid)
+
+    def test_deprecated_params(self):
+
+        # component
+        grid = self.make_grid()
+        self.assertEqual(grid.vue_tagname, 'tailbone-grid')
+        grid = self.make_grid(component='blarg')
+        self.assertEqual(grid.vue_tagname, 'blarg')
+
+        # default_sortkey, default_sortdir
+        grid = self.make_grid()
+        self.assertEqual(grid.sort_defaults, [])
+        grid = self.make_grid(default_sortkey='name')
+        self.assertEqual(grid.sort_defaults, [mod.SortInfo('name', 'asc')])
+        grid = self.make_grid(default_sortdir='desc')
+        self.assertEqual(grid.sort_defaults, [])
+        grid = self.make_grid(default_sortkey='name', default_sortdir='desc')
+        self.assertEqual(grid.sort_defaults, [mod.SortInfo('name', 'desc')])
+
+        # pageable
+        grid = self.make_grid()
+        self.assertFalse(grid.paginated)
+        grid = self.make_grid(pageable=True)
+        self.assertTrue(grid.paginated)
+
+        # default_pagesize
+        grid = self.make_grid()
+        self.assertEqual(grid.pagesize, 20)
+        grid = self.make_grid(default_pagesize=15)
+        self.assertEqual(grid.pagesize, 15)
+
+        # default_page
+        grid = self.make_grid()
+        self.assertEqual(grid.page, 1)
+        grid = self.make_grid(default_page=42)
+        self.assertEqual(grid.page, 42)
+
+        # searchable
+        grid = self.make_grid()
+        self.assertEqual(grid.searchable_columns, set())
+        grid = self.make_grid(searchable={'foo': True})
+        self.assertEqual(grid.searchable_columns, {'foo'})
+
+    def test_vue_tagname(self):
+
+        # default
+        grid = self.make_grid('foo')
+        self.assertEqual(grid.vue_tagname, 'tailbone-grid')
+
+        # can override with param
+        grid = self.make_grid('foo', vue_tagname='something-else')
+        self.assertEqual(grid.vue_tagname, 'something-else')
+
+        # can still pass old param
+        grid = self.make_grid('foo', component='legacy-name')
+        self.assertEqual(grid.vue_tagname, 'legacy-name')
+
+    def test_vue_component(self):
+
+        # default
+        grid = self.make_grid('foo')
+        self.assertEqual(grid.vue_component, 'TailboneGrid')
+
+        # can override with param
+        grid = self.make_grid('foo', vue_tagname='something-else')
+        self.assertEqual(grid.vue_component, 'SomethingElse')
+
+        # can still pass old param
+        grid = self.make_grid('foo', component='legacy-name')
+        self.assertEqual(grid.vue_component, 'LegacyName')
+
+    def test_component(self):
+
+        # default
+        grid = self.make_grid('foo')
+        self.assertEqual(grid.component, 'tailbone-grid')
+
+        # can override with param
+        grid = self.make_grid('foo', vue_tagname='something-else')
+        self.assertEqual(grid.component, 'something-else')
+
+        # can still pass old param
+        grid = self.make_grid('foo', component='legacy-name')
+        self.assertEqual(grid.component, 'legacy-name')
+
+    def test_component_studly(self):
+
+        # default
+        grid = self.make_grid('foo')
+        self.assertEqual(grid.component_studly, 'TailboneGrid')
+
+        # can override with param
+        grid = self.make_grid('foo', vue_tagname='something-else')
+        self.assertEqual(grid.component_studly, 'SomethingElse')
+
+        # can still pass old param
+        grid = self.make_grid('foo', component='legacy-name')
+        self.assertEqual(grid.component_studly, 'LegacyName')
+
+    def test_actions(self):
+
+        # default
+        grid = self.make_grid('foo')
+        self.assertEqual(grid.actions, [])
+
+        # main actions
+        grid = self.make_grid('foo', main_actions=['foo'])
+        self.assertEqual(grid.actions, ['foo'])
+
+        # more actions
+        grid = self.make_grid('foo', main_actions=['foo'], more_actions=['bar'])
+        self.assertEqual(grid.actions, ['foo', 'bar'])
+
+    def test_set_label(self):
+        model = self.app.model
+        grid = self.make_grid(model_class=model.Setting, filterable=True)
+        self.assertEqual(grid.labels, {})
+
+        # basic
+        grid.set_label('name', "NAME COL")
+        self.assertEqual(grid.labels['name'], "NAME COL")
+
+        # can replace label
+        grid.set_label('name', "Different")
+        self.assertEqual(grid.labels['name'], "Different")
+        self.assertEqual(grid.get_label('name'), "Different")
+
+        # can update only column, not filter
+        self.assertEqual(grid.labels, {'name': "Different"})
+        self.assertIn('name', grid.filters)
+        self.assertEqual(grid.filters['name'].label, "Different")
+        grid.set_label('name', "COLUMN ONLY", column_only=True)
+        self.assertEqual(grid.get_label('name'), "COLUMN ONLY")
+        self.assertEqual(grid.filters['name'].label, "Different")
+
+    def test_get_view_click_handler(self):
+        model = self.app.model
+        grid = self.make_grid(model_class=model.Setting)
+
+        grid.actions.append(
+            mod.GridAction(self.request, 'view',
+                           click_handler='clickHandler(props.row)'))
+
+        handler = grid.get_view_click_handler()
+        self.assertEqual(handler, 'clickHandler(props.row)')
+
+    def test_set_action_urls(self):
+        model = self.app.model
+        grid = self.make_grid(model_class=model.Setting)
+
+        grid.actions.append(
+            mod.GridAction(self.request, 'view', url='/blarg'))
+
+        setting = {'name': 'foo', 'value': 'bar'}
+        grid.set_action_urls(setting, setting, 0)
+        self.assertEqual(setting['_action_url_view'], '/blarg')
+
+    def test_default_sortkey(self):
+        grid = self.make_grid()
+        self.assertEqual(grid.sort_defaults, [])
+        self.assertIsNone(grid.default_sortkey)
+        grid.default_sortkey = 'name'
+        self.assertEqual(grid.sort_defaults, [mod.SortInfo('name', 'asc')])
+        self.assertEqual(grid.default_sortkey, 'name')
+        grid.default_sortkey = 'value'
+        self.assertEqual(grid.sort_defaults, [mod.SortInfo('value', 'asc')])
+        self.assertEqual(grid.default_sortkey, 'value')
+
+    def test_default_sortdir(self):
+        grid = self.make_grid()
+        self.assertEqual(grid.sort_defaults, [])
+        self.assertIsNone(grid.default_sortdir)
+        self.assertRaises(ValueError, setattr, grid, 'default_sortdir', 'asc')
+        grid.sort_defaults = [mod.SortInfo('name', 'asc')]
+        grid.default_sortdir = 'desc'
+        self.assertEqual(grid.sort_defaults, [mod.SortInfo('name', 'desc')])
+        self.assertEqual(grid.default_sortdir, 'desc')
+
+    def test_pageable(self):
+        grid = self.make_grid()
+        self.assertFalse(grid.paginated)
+        grid.pageable = True
+        self.assertTrue(grid.paginated)
+        grid.paginated = False
+        self.assertFalse(grid.pageable)
+
+    def test_get_pagesize_options(self):
+        grid = self.make_grid()
+
+        # default
+        options = grid.get_pagesize_options()
+        self.assertEqual(options, [5, 10, 20, 50, 100, 200])
+
+        # override default
+        options = grid.get_pagesize_options(default=[42])
+        self.assertEqual(options, [42])
+
+        # from legacy config
+        self.config.setdefault('tailbone.grid.pagesize_options', '1 2 3')
+        grid = self.make_grid()
+        options = grid.get_pagesize_options()
+        self.assertEqual(options, [1, 2, 3])
+
+        # from new config
+        self.config.setdefault('wuttaweb.grids.default_pagesize_options', '4, 5, 6')
+        grid = self.make_grid()
+        options = grid.get_pagesize_options()
+        self.assertEqual(options, [4, 5, 6])
+
+    def test_get_pagesize(self):
+        grid = self.make_grid()
+
+        # default
+        size = grid.get_pagesize()
+        self.assertEqual(size, 20)
+
+        # override default
+        size = grid.get_pagesize(default=42)
+        self.assertEqual(size, 42)
+
+        # override default options
+        self.config.setdefault('wuttaweb.grids.default_pagesize_options', '10 15 30')
+        grid = self.make_grid()
+        size = grid.get_pagesize()
+        self.assertEqual(size, 10)
+
+        # from legacy config
+        self.config.setdefault('tailbone.grid.default_pagesize', '12')
+        grid = self.make_grid()
+        size = grid.get_pagesize()
+        self.assertEqual(size, 12)
+
+        # from new config
+        self.config.setdefault('wuttaweb.grids.default_pagesize', '15')
+        grid = self.make_grid()
+        size = grid.get_pagesize()
+        self.assertEqual(size, 15)
+
+    def test_set_sorter(self):
+        model = self.app.model
+        grid = self.make_grid(model_class=model.Setting,
+                              sortable=True, sort_on_backend=True)
+
+        # passing None will remove sorter
+        self.assertIn('name', grid.sorters)
+        grid.set_sorter('name', None)
+        self.assertNotIn('name', grid.sorters)
+
+        # can recreate sorter with just column name
+        grid.set_sorter('name')
+        self.assertIn('name', grid.sorters)
+        grid.remove_sorter('name')
+        self.assertNotIn('name', grid.sorters)
+        grid.set_sorter('name', 'name')
+        self.assertIn('name', grid.sorters)
+
+        # can recreate sorter with model property
+        grid.remove_sorter('name')
+        self.assertNotIn('name', grid.sorters)
+        grid.set_sorter('name', model.Setting.name)
+        self.assertIn('name', grid.sorters)
+
+        # extra kwargs are ignored
+        grid.remove_sorter('name')
+        self.assertNotIn('name', grid.sorters)
+        grid.set_sorter('name', model.Setting.name, foo='bar')
+        self.assertIn('name', grid.sorters)
+
+        # passing multiple args will invoke make_filter() directly
+        grid.remove_sorter('name')
+        self.assertNotIn('name', grid.sorters)
+        with patch.object(grid, 'make_sorter') as make_sorter:
+            make_sorter.return_value = 42
+            grid.set_sorter('name', 'foo', 'bar')
+            make_sorter.assert_called_once_with('foo', 'bar')
+            self.assertEqual(grid.sorters['name'], 42)
+
+    def test_make_simple_sorter(self):
+        model = self.app.model
+        grid = self.make_grid(model_class=model.Setting,
+                              sortable=True, sort_on_backend=True)
+
+        # delegates to grid.make_sorter()
+        with patch.object(grid, 'make_sorter') as make_sorter:
+            make_sorter.return_value = 42
+            sorter = grid.make_simple_sorter('name', foldcase=True)
+            make_sorter.assert_called_once_with('name', foldcase=True)
+            self.assertEqual(sorter, 42)
+
+    def test_load_settings(self):
+        model = self.app.model
+
+        # nb. first use a paging grid
+        grid = self.make_grid(key='foo', paginated=True, paginate_on_backend=True,
+                              pagesize=20, page=1)
+
+        # settings are loaded, applied, saved
+        self.assertEqual(grid.page, 1)
+        self.assertNotIn('grid.foo.page', self.request.session)
+        self.request.GET = {'pagesize': '10', 'page': '2'}
+        grid.load_settings()
+        self.assertEqual(grid.page, 2)
+        self.assertEqual(self.request.session['grid.foo.page'], 2)
+
+        # can skip the saving step
+        self.request.GET = {'pagesize': '10', 'page': '3'}
+        grid.load_settings(store=False)
+        self.assertEqual(grid.page, 3)
+        self.assertEqual(self.request.session['grid.foo.page'], 2)
+
+        # no error for non-paginated grid
+        grid = self.make_grid(key='foo', paginated=False)
+        grid.load_settings()
+        self.assertFalse(grid.paginated)
+
+        # nb. next use a sorting grid
+        grid = self.make_grid(key='settings', model_class=model.Setting,
+                              sortable=True, sort_on_backend=True)
+
+        # settings are loaded, applied, saved
+        self.assertEqual(grid.sort_defaults, [])
+        self.assertFalse(hasattr(grid, 'active_sorters'))
+        self.request.GET = {'sort1key': 'name', 'sort1dir': 'desc'}
+        grid.load_settings()
+        self.assertEqual(grid.active_sorters, [{'key': 'name', 'dir': 'desc'}])
+        self.assertEqual(self.request.session['grid.settings.sorters.length'], 1)
+        self.assertEqual(self.request.session['grid.settings.sorters.1.key'], 'name')
+        self.assertEqual(self.request.session['grid.settings.sorters.1.dir'], 'desc')
+
+        # can skip the saving step
+        self.request.GET = {'sort1key': 'name', 'sort1dir': 'asc'}
+        grid.load_settings(store=False)
+        self.assertEqual(grid.active_sorters, [{'key': 'name', 'dir': 'asc'}])
+        self.assertEqual(self.request.session['grid.settings.sorters.length'], 1)
+        self.assertEqual(self.request.session['grid.settings.sorters.1.key'], 'name')
+        self.assertEqual(self.request.session['grid.settings.sorters.1.dir'], 'desc')
+
+        # no error for non-sortable grid
+        grid = self.make_grid(key='foo', sortable=False)
+        grid.load_settings()
+        self.assertFalse(grid.sortable)
+
+        # with sort defaults
+        grid = self.make_grid(model_class=model.Setting, sortable=True,
+                              sort_on_backend=True, sort_defaults='name')
+        self.assertFalse(hasattr(grid, 'active_sorters'))
+        grid.load_settings()
+        self.assertEqual(grid.active_sorters, [{'key': 'name', 'dir': 'asc'}])
+
+        # with multi-column sort defaults
+        grid = self.make_grid(model_class=model.Setting, sortable=True,
+                              sort_on_backend=True)
+        grid.sort_defaults = [
+            mod.SortInfo('name', 'asc'),
+            mod.SortInfo('value', 'desc'),
+        ]
+        self.assertFalse(hasattr(grid, 'active_sorters'))
+        grid.load_settings()
+        self.assertEqual(grid.active_sorters, [{'key': 'name', 'dir': 'asc'}])
+
+        # load settings from session when nothing is in request
+        self.request.GET = {}
+        self.request.session.invalidate()
+        self.assertNotIn('grid.settings.sorters.length', self.request.session)
+        self.request.session['grid.settings.sorters.length'] = 1
+        self.request.session['grid.settings.sorters.1.key'] = 'name'
+        self.request.session['grid.settings.sorters.1.dir'] = 'desc'
+        grid = self.make_grid(key='settings', model_class=model.Setting,
+                              sortable=True, sort_on_backend=True,
+                              paginated=True, paginate_on_backend=True)
+        self.assertFalse(hasattr(grid, 'active_sorters'))
+        grid.load_settings()
+        self.assertEqual(grid.active_sorters, [{'key': 'name', 'dir': 'desc'}])
+
+    def test_persist_settings(self):
+        model = self.app.model
+
+        # nb. start out with paginated-only grid
+        grid = self.make_grid(key='foo', paginated=True, paginate_on_backend=True)
+
+        # invalid dest
+        self.assertRaises(ValueError, grid.persist_settings, {}, dest='doesnotexist')
+
+        # nb. no error if empty settings, but it saves null values
+        grid.persist_settings({}, dest='session')
+        self.assertIsNone(self.request.session['grid.foo.page'])
+
+        # provided values are saved
+        grid.persist_settings({'pagesize': 15, 'page': 3}, dest='session')
+        self.assertEqual(self.request.session['grid.foo.page'], 3)
+
+        # nb. now switch to sortable-only grid
+        grid = self.make_grid(key='settings', model_class=model.Setting,
+                              sortable=True, sort_on_backend=True)
+
+        # no error if empty settings; does not save values
+        grid.persist_settings({}, dest='session')
+        self.assertNotIn('grid.settings.sorters.length', self.request.session)
+
+        # provided values are saved
+        grid.persist_settings({'sorters.length': 2,
+                               'sorters.1.key': 'name',
+                               'sorters.1.dir': 'desc',
+                               'sorters.2.key': 'value',
+                               'sorters.2.dir': 'asc'},
+                              dest='session')
+        self.assertEqual(self.request.session['grid.settings.sorters.length'], 2)
+        self.assertEqual(self.request.session['grid.settings.sorters.1.key'], 'name')
+        self.assertEqual(self.request.session['grid.settings.sorters.1.dir'], 'desc')
+        self.assertEqual(self.request.session['grid.settings.sorters.2.key'], 'value')
+        self.assertEqual(self.request.session['grid.settings.sorters.2.dir'], 'asc')
+
+        # old values removed when new are saved
+        grid.persist_settings({'sorters.length': 1,
+                               'sorters.1.key': 'name',
+                               'sorters.1.dir': 'desc'},
+                              dest='session')
+        self.assertEqual(self.request.session['grid.settings.sorters.length'], 1)
+        self.assertEqual(self.request.session['grid.settings.sorters.1.key'], 'name')
+        self.assertEqual(self.request.session['grid.settings.sorters.1.dir'], 'desc')
+        self.assertNotIn('grid.settings.sorters.2.key', self.request.session)
+        self.assertNotIn('grid.settings.sorters.2.dir', self.request.session)
+
+    def test_sort_data(self):
+        model = self.app.model
+        sample_data = [
+            {'name': 'foo1', 'value': 'ONE'},
+            {'name': 'foo2', 'value': 'two'},
+            {'name': 'foo3', 'value': 'ggg'},
+            {'name': 'foo4', 'value': 'ggg'},
+            {'name': 'foo5', 'value': 'ggg'},
+            {'name': 'foo6', 'value': 'six'},
+            {'name': 'foo7', 'value': 'seven'},
+            {'name': 'foo8', 'value': 'eight'},
+            {'name': 'foo9', 'value': 'nine'},
+        ]
+        for setting in sample_data:
+            self.app.save_setting(self.session, setting['name'], setting['value'])
+        self.session.commit()
+        sample_query = self.session.query(model.Setting)
+
+        grid = self.make_grid(model_class=model.Setting,
+                              sortable=True, sort_on_backend=True,
+                              sort_defaults=('name', 'desc'))
+        grid.load_settings()
+
+        # can sort a simple list of data
+        sorted_data = grid.sort_data(sample_data)
+        self.assertIsInstance(sorted_data, list)
+        self.assertEqual(len(sorted_data), 9)
+        self.assertEqual(sorted_data[0]['name'], 'foo9')
+        self.assertEqual(sorted_data[-1]['name'], 'foo1')
+
+        # can also sort a data query
+        sorted_query = grid.sort_data(sample_query)
+        self.assertIsInstance(sorted_query, orm.Query)
+        sorted_data = sorted_query.all()
+        self.assertEqual(len(sorted_data), 9)
+        self.assertEqual(sorted_data[0]['name'], 'foo9')
+        self.assertEqual(sorted_data[-1]['name'], 'foo1')
+
+        # cannot sort data if sorter missing in overrides
+        sorted_data = grid.sort_data(sample_data, sorters=[])
+        # nb. sorted data is in same order as original sample (not sorted)
+        self.assertEqual(sorted_data[0]['name'], 'foo1')
+        self.assertEqual(sorted_data[-1]['name'], 'foo9')
+
+        # multi-column sorting for list data
+        sorted_data = grid.sort_data(sample_data, sorters=[{'key': 'value', 'dir': 'asc'},
+                                                           {'key': 'name', 'dir': 'asc'}])
+        self.assertEqual(dict(sorted_data[0]), {'name': 'foo8', 'value': 'eight'})
+        self.assertEqual(dict(sorted_data[1]), {'name': 'foo3', 'value': 'ggg'})
+        self.assertEqual(dict(sorted_data[3]), {'name': 'foo5', 'value': 'ggg'})
+        self.assertEqual(dict(sorted_data[-1]), {'name': 'foo2', 'value': 'two'})
+
+        # multi-column sorting for query
+        sorted_query = grid.sort_data(sample_query, sorters=[{'key': 'value', 'dir': 'asc'},
+                                                             {'key': 'name', 'dir': 'asc'}])
+        self.assertEqual(dict(sorted_data[0]), {'name': 'foo8', 'value': 'eight'})
+        self.assertEqual(dict(sorted_data[1]), {'name': 'foo3', 'value': 'ggg'})
+        self.assertEqual(dict(sorted_data[3]), {'name': 'foo5', 'value': 'ggg'})
+        self.assertEqual(dict(sorted_data[-1]), {'name': 'foo2', 'value': 'two'})
+
+        # cannot sort data if sortfunc is missing for column
+        grid.remove_sorter('name')
+        sorted_data = grid.sort_data(sample_data, sorters=[{'key': 'value', 'dir': 'asc'},
+                                                           {'key': 'name', 'dir': 'asc'}])
+        # nb. sorted data is in same order as original sample (not sorted)
+        self.assertEqual(sorted_data[0]['name'], 'foo1')
+        self.assertEqual(sorted_data[-1]['name'], 'foo9')
+
+    def test_render_vue_tag(self):
+        model = self.app.model
+
+        # standard
+        grid = self.make_grid('settings', model_class=model.Setting)
+        html = grid.render_vue_tag()
+        self.assertIn('<tailbone-grid', html)
+        self.assertNotIn('@deleteActionClicked', html)
+
+        # with delete hook
+        master = MagicMock(deletable=True, delete_confirm='simple')
+        master.has_perm.return_value = True
+        grid = self.make_grid('settings', model_class=model.Setting)
+        html = grid.render_vue_tag(master=master)
+        self.assertIn('<tailbone-grid', html)
+        self.assertIn('@deleteActionClicked', html)
+
+    def test_render_vue_template(self):
+        # self.pyramid_config.include('tailbone.views.common')
+        model = self.app.model
+
+        # sanity check
+        grid = self.make_grid('settings', model_class=model.Setting)
+        html = grid.render_vue_template(session=self.session)
+        self.assertIn('<b-table', html)
+
+    def test_get_vue_columns(self):
+        model = self.app.model
+
+        # sanity check
+        grid = self.make_grid('settings', model_class=model.Setting, sortable=True)
+        columns = grid.get_vue_columns()
+        self.assertEqual(len(columns), 2)
+        self.assertEqual(columns[0]['field'], 'name')
+        self.assertTrue(columns[0]['sortable'])
+        self.assertEqual(columns[1]['field'], 'value')
+        self.assertTrue(columns[1]['sortable'])
+
+    def test_get_vue_data(self):
+        model = self.app.model
+
+        # sanity check
+        grid = self.make_grid('settings', model_class=model.Setting)
+        data = grid.get_vue_data()
+        self.assertEqual(data, [])
+
+        # calling again returns same data
+        data2 = grid.get_vue_data()
+        self.assertIs(data2, data)
+
+
+class TestGridAction(WebTestCase):
+
+    def test_constructor(self):
+
+        # null by default
+        action = mod.GridAction(self.request, 'view')
+        self.assertIsNone(action.target)
+        self.assertIsNone(action.click_handler)
+
+        # but can set them
+        action = mod.GridAction(self.request, 'view',
+                                target='_blank',
+                                click_handler='doSomething(props.row)')
+        self.assertEqual(action.target, '_blank')
+        self.assertEqual(action.click_handler, 'doSomething(props.row)')
diff --git a/tests/test_app.py b/tests/test_app.py
index 2523c424..f49f6b13 100644
--- a/tests/test_app.py
+++ b/tests/test_app.py
@@ -3,14 +3,11 @@
 import os
 from unittest import TestCase
 
-from sqlalchemy import create_engine
+from pyramid.config import Configurator
 
-from rattail.config import RattailConfig
 from rattail.exceptions import ConfigurationError
-from rattail.db import Session as RattailSession
-
-from tailbone import app
-from tailbone.db import Session as TailboneSession
+from rattail.testing import DataTestCase
+from tailbone import app as mod
 
 
 class TestRattailConfig(TestCase):
@@ -18,15 +15,34 @@ class TestRattailConfig(TestCase):
     config_path = os.path.abspath(
         os.path.join(os.path.dirname(__file__), 'data', 'tailbone.conf'))
 
-    def tearDown(self):
-        # may or may not be necessary depending on test
-        TailboneSession.remove()
-
     def test_settings_arg_must_include_config_path_by_default(self):
         # error raised if path not provided
-        self.assertRaises(ConfigurationError, app.make_rattail_config, {})
+        self.assertRaises(ConfigurationError, mod.make_rattail_config, {})
         # get a config object if path provided
-        result = app.make_rattail_config({'rattail.config': self.config_path})
+        result = mod.make_rattail_config({'rattail.config': self.config_path})
         # nb. cannot test isinstance(RattailConfig) b/c now uses wrapper!
         self.assertIsNotNone(result)
         self.assertTrue(hasattr(result, 'get'))
+
+
+class TestMakePyramidConfig(DataTestCase):
+
+    def make_config(self, **kwargs):
+        myconf = self.write_file('web.conf', """
+[rattail.db]
+default.url = sqlite://
+""")
+
+        self.settings = {
+            'rattail.config': myconf,
+            'mako.directories': 'tailbone:templates',
+        }
+        return mod.make_rattail_config(self.settings)
+
+    def test_basic(self):
+        model = self.app.model
+        model.Base.metadata.create_all(bind=self.config.appdb_engine)
+
+        # sanity check
+        pyramid_config = mod.make_pyramid_config(self.settings)
+        self.assertIsInstance(pyramid_config, Configurator)
diff --git a/tests/test_auth.py b/tests/test_auth.py
new file mode 100644
index 00000000..4519e152
--- /dev/null
+++ b/tests/test_auth.py
@@ -0,0 +1,3 @@
+# -*- coding: utf-8; -*-
+
+from tailbone import auth as mod
diff --git a/tests/test_config.py b/tests/test_config.py
new file mode 100644
index 00000000..0cd1938c
--- /dev/null
+++ b/tests/test_config.py
@@ -0,0 +1,12 @@
+# -*- coding: utf-8; -*-
+
+from tailbone import config as mod
+from tests.util import DataTestCase
+
+
+class TestConfigExtension(DataTestCase):
+
+    def test_basic(self):
+        # sanity / coverage check
+        ext = mod.ConfigExtension()
+        ext.configure(self.config)
diff --git a/tests/test_db.py b/tests/test_db.py
new file mode 100644
index 00000000..88cb9d41
--- /dev/null
+++ b/tests/test_db.py
@@ -0,0 +1,7 @@
+# -*- coding: utf-8; -*-
+
+# TODO: add real tests at some point but this at least gives us basic
+# coverage when running this "test" module alone
+
+from tailbone import db
+
diff --git a/tests/test_subscribers.py b/tests/test_subscribers.py
new file mode 100644
index 00000000..81bc2869
--- /dev/null
+++ b/tests/test_subscribers.py
@@ -0,0 +1,58 @@
+# -*- coding: utf-8; -*-
+
+from unittest.mock import MagicMock
+
+from pyramid import testing
+
+from tailbone import subscribers as mod
+from tests.util import DataTestCase
+
+
+class TestNewRequest(DataTestCase):
+
+    def setUp(self):
+        self.setup_db()
+        self.request = self.make_request()
+        self.pyramid_config = testing.setUp(request=self.request, settings={
+            'wutta_config': self.config,
+        })
+
+    def tearDown(self):
+        self.teardown_db()
+        testing.tearDown()
+
+    def make_request(self, **kwargs):
+        return testing.DummyRequest(**kwargs)
+
+    def make_event(self):
+        return MagicMock(request=self.request)
+
+    def test_continuum_remote_addr(self):
+        event = self.make_event()
+
+        # nothing happens
+        mod.new_request(event, session=self.session)
+        self.assertFalse(hasattr(self.session, 'continuum_remote_addr'))
+
+        # unless request has client_addr
+        self.request.client_addr = '127.0.0.1'
+        mod.new_request(event, session=self.session)
+        self.assertEqual(self.session.continuum_remote_addr, '127.0.0.1')
+
+    def test_register_component(self):
+        event = self.make_event()
+
+        # function added
+        self.assertFalse(hasattr(self.request, 'register_component'))
+        mod.new_request(event, session=self.session)
+        self.assertTrue(callable(self.request.register_component))
+
+        # call function
+        self.request.register_component('tailbone-datepicker', 'TailboneDatepicker')
+        self.assertEqual(self.request._tailbone_registered_components,
+                         {'tailbone-datepicker': 'TailboneDatepicker'})
+
+        # duplicate registration ignored
+        self.request.register_component('tailbone-datepicker', 'TailboneDatepicker')
+        self.assertEqual(self.request._tailbone_registered_components,
+                         {'tailbone-datepicker': 'TailboneDatepicker'})
diff --git a/tests/test_util.py b/tests/test_util.py
new file mode 100644
index 00000000..46684f0c
--- /dev/null
+++ b/tests/test_util.py
@@ -0,0 +1,39 @@
+# -*- coding: utf-8; -*-
+
+from unittest import TestCase
+
+from pyramid import testing
+
+from rattail.config import RattailConfig
+
+from tailbone import util
+
+
+class TestGetFormData(TestCase):
+
+    def setUp(self):
+        self.config = RattailConfig()
+
+    def make_request(self, **kwargs):
+        kwargs.setdefault('wutta_config', self.config)
+        kwargs.setdefault('rattail_config', self.config)
+        kwargs.setdefault('is_xhr', None)
+        kwargs.setdefault('content_type', None)
+        kwargs.setdefault('POST', {'foo1': 'bar'})
+        kwargs.setdefault('json_body', {'foo2': 'baz'})
+        return testing.DummyRequest(**kwargs)
+
+    def test_default(self):
+        request = self.make_request()
+        data = util.get_form_data(request)
+        self.assertEqual(data, {'foo1': 'bar'})
+
+    def test_is_xhr(self):
+        request = self.make_request(POST=None, is_xhr=True)
+        data = util.get_form_data(request)
+        self.assertEqual(data, {'foo2': 'baz'})
+
+    def test_content_type(self):
+        request = self.make_request(POST=None, content_type='application/json')
+        data = util.get_form_data(request)
+        self.assertEqual(data, {'foo2': 'baz'})
diff --git a/tests/util.py b/tests/util.py
new file mode 100644
index 00000000..4277a7c3
--- /dev/null
+++ b/tests/util.py
@@ -0,0 +1,77 @@
+# -*- coding: utf-8; -*-
+
+from unittest.mock import MagicMock
+
+from pyramid import testing
+
+from tailbone import subscribers
+from wuttaweb.menus import MenuHandler
+# from wuttaweb.subscribers import new_request_set_user
+from rattail.testing import DataTestCase
+
+
+class WebTestCase(DataTestCase):
+    """
+    Base class for test suites requiring a full (typical) web app.
+    """
+
+    def setUp(self):
+        self.setup_web()
+
+    def setup_web(self):
+        self.setup_db()
+        self.request = self.make_request()
+        self.pyramid_config = testing.setUp(request=self.request, settings={
+            'wutta_config': self.config,
+            'rattail_config': self.config,
+            'mako.directories': ['tailbone:templates', 'wuttaweb:templates'],
+            # 'pyramid_deform.template_search_path': 'wuttaweb:templates/deform',
+        })
+
+        # init web
+        # self.pyramid_config.include('pyramid_deform')
+        self.pyramid_config.include('pyramid_mako')
+        self.pyramid_config.add_directive('add_wutta_permission_group',
+                                          'wuttaweb.auth.add_permission_group')
+        self.pyramid_config.add_directive('add_wutta_permission',
+                                          'wuttaweb.auth.add_permission')
+        self.pyramid_config.add_directive('add_tailbone_permission_group',
+                                          'wuttaweb.auth.add_permission_group')
+        self.pyramid_config.add_directive('add_tailbone_permission',
+                                          'wuttaweb.auth.add_permission')
+        self.pyramid_config.add_directive('add_tailbone_index_page',
+                                          'tailbone.app.add_index_page')
+        self.pyramid_config.add_directive('add_tailbone_model_view',
+                                          'tailbone.app.add_model_view')
+        self.pyramid_config.add_directive('add_tailbone_config_page',
+                                          'tailbone.app.add_config_page')
+        self.pyramid_config.add_subscriber('tailbone.subscribers.before_render',
+                                           'pyramid.events.BeforeRender')
+        self.pyramid_config.include('tailbone.static')
+
+        # setup new request w/ anonymous user
+        event = MagicMock(request=self.request)
+        subscribers.new_request(event, session=self.session)
+        # def user_getter(request, **kwargs): pass
+        # new_request_set_user(event, db_session=self.session,
+        #                      user_getter=user_getter)
+
+    def tearDown(self):
+        self.teardown_web()
+
+    def teardown_web(self):
+        testing.tearDown()
+        self.teardown_db()
+
+    def make_request(self, **kwargs):
+        kwargs.setdefault('rattail_config', self.config)
+        # kwargs.setdefault('wutta_config', self.config)
+        return testing.DummyRequest(**kwargs)
+
+
+class NullMenuHandler(MenuHandler):
+    """
+    Dummy menu handler for testing.
+    """
+    def make_menus(self, request, **kwargs):
+        return []
diff --git a/tests/views/test_master.py b/tests/views/test_master.py
new file mode 100644
index 00000000..0e459e7d
--- /dev/null
+++ b/tests/views/test_master.py
@@ -0,0 +1,66 @@
+# -*- coding: utf-8; -*-
+
+from unittest.mock import patch, MagicMock
+
+from tailbone.views import master as mod
+from wuttaweb.grids import GridAction
+from tests.util import WebTestCase
+
+
+class TestMasterView(WebTestCase):
+
+    def make_view(self):
+        return mod.MasterView(self.request)
+
+    def test_make_form_kwargs(self):
+        self.pyramid_config.add_route('settings.view', '/settings/{name}')
+        model = self.app.model
+        setting = model.Setting(name='foo', value='bar')
+        self.session.add(setting)
+        self.session.commit()
+        with patch.multiple(mod.MasterView, create=True,
+                            model_class=model.Setting):
+            view = self.make_view()
+
+            # sanity / coverage check
+            kw = view.make_form_kwargs(model_instance=setting)
+            self.assertIsNotNone(kw['action_url'])
+
+    def test_make_action(self):
+        model = self.app.model
+        with patch.multiple(mod.MasterView, create=True,
+                            model_class=model.Setting):
+            view = self.make_view()
+            action = view.make_action('view')
+            self.assertIsInstance(action, GridAction)
+
+    def test_index(self):
+        self.pyramid_config.include('tailbone.views.common')
+        self.pyramid_config.include('tailbone.views.auth')
+        model = self.app.model
+
+        # mimic view for /settings
+        with patch.object(mod, 'Session', return_value=self.session):
+            with patch.multiple(mod.MasterView, create=True,
+                                model_class=model.Setting,
+                                Session=MagicMock(return_value=self.session),
+                                get_index_url=MagicMock(return_value='/settings/'),
+                                get_help_url=MagicMock(return_value=None)):
+
+                # basic
+                view = self.make_view()
+                response = view.index()
+                self.assertEqual(response.status_code, 200)
+
+                # then again with data, to include view action url
+                data = [{'name': 'foo', 'value': 'bar'}]
+                with patch.object(view, 'get_data', return_value=data):
+                    response = view.index()
+                    self.assertEqual(response.status_code, 200)
+                    self.assertEqual(response.content_type, 'text/html')
+
+                    # then once more as 'partial' - aka. data only
+                    self.request.GET = {'partial': '1'}
+                    response = view.index()
+                    self.assertEqual(response.status_code, 200)
+                    self.assertEqual(response.content_type, 'application/json')
diff --git a/tests/views/test_people.py b/tests/views/test_people.py
new file mode 100644
index 00000000..f85577e7
--- /dev/null
+++ b/tests/views/test_people.py
@@ -0,0 +1,17 @@
+# -*- coding: utf-8; -*-
+
+from tailbone.views import users as mod
+from tests.util import WebTestCase
+
+
+class TestPersonView(WebTestCase):
+
+    def make_view(self):
+        return mod.PersonView(self.request)
+
+    def test_includeme(self):
+        self.pyramid_config.include('tailbone.views.people')
+
+    def test_includeme_wutta(self):
+        self.config.setdefault('tailbone.use_wutta_views', 'true')
+        self.pyramid_config.include('tailbone.views.people')
diff --git a/tests/views/test_principal.py b/tests/views/test_principal.py
new file mode 100644
index 00000000..2b31531c
--- /dev/null
+++ b/tests/views/test_principal.py
@@ -0,0 +1,29 @@
+# -*- coding: utf-8; -*-
+
+from unittest.mock import patch, MagicMock
+
+from tailbone.views import principal as mod
+from tests.util import WebTestCase
+
+
+class TestPrincipalMasterView(WebTestCase):
+
+    def make_view(self):
+        return mod.PrincipalMasterView(self.request)
+
+    def test_find_by_perm(self):
+        model = self.app.model
+        self.config.setdefault('rattail.web.menus.handler_spec', 'tests.util:NullMenuHandler')
+        self.pyramid_config.include('tailbone.views.common')
+        self.pyramid_config.include('tailbone.views.auth')
+        self.pyramid_config.add_route('roles', '/roles/')
+        with patch.multiple(mod.PrincipalMasterView, create=True,
+                            model_class=model.Role,
+                            get_help_url=MagicMock(return_value=None),
+                            get_help_markdown=MagicMock(return_value=None),
+                            can_edit_help=MagicMock(return_value=False)):
+
+            # sanity / coverage check
+            view = self.make_view()
+            response = view.find_by_perm()
+            self.assertEqual(response.status_code, 200)
diff --git a/tests/views/test_roles.py b/tests/views/test_roles.py
new file mode 100644
index 00000000..0cdc724e
--- /dev/null
+++ b/tests/views/test_roles.py
@@ -0,0 +1,80 @@
+# -*- coding: utf-8; -*-
+
+from unittest.mock import patch
+
+from tailbone.views import roles as mod
+from tests.util import WebTestCase
+
+
+class TestRoleView(WebTestCase):
+
+    def make_view(self):
+        return mod.RoleView(self.request)
+
+    def test_includeme(self):
+        self.pyramid_config.include('tailbone.views.roles')
+
+    def get_permissions(self):
+        return {
+            'widgets': {
+                'label': "Widgets",
+                'perms': {
+                    'widgets.list': {
+                        'label': "List widgets",
+                    },
+                    'widgets.polish': {
+                        'label': "Polish the widgets",
+                    },
+                    'widgets.view': {
+                        'label': "View widget",
+                    },
+                },
+            },
+        }
+
+    def test_get_available_permissions(self):
+        model = self.app.model
+        auth = self.app.get_auth_handler()
+        blokes = model.Role(name="Blokes")
+        auth.grant_permission(blokes, 'widgets.list')
+        self.session.add(blokes)
+        barney = model.User(username='barney')
+        barney.roles.append(blokes)
+        self.session.add(barney)
+        self.session.commit()
+        view = self.make_view()
+        all_perms = self.get_permissions()
+        self.request.registry.settings['wutta_permissions'] = all_perms
+
+        def has_perm(perm):
+            if perm == 'widgets.list':
+                return True
+            return False
+
+        with patch.object(self.request, 'has_perm', new=has_perm, create=True):
+
+            # sanity check; current request has 1 perm
+            self.assertTrue(self.request.has_perm('widgets.list'))
+            self.assertFalse(self.request.has_perm('widgets.polish'))
+            self.assertFalse(self.request.has_perm('widgets.view'))
+
+            # when editing, user sees only the 1 perm
+            with patch.object(view, 'editing', new=True):
+                perms = view.get_available_permissions()
+                self.assertEqual(list(perms), ['widgets'])
+                self.assertEqual(list(perms['widgets']['perms']), ['widgets.list'])
+
+            # but when viewing, same user sees all perms
+            with patch.object(view, 'viewing', new=True):
+                perms = view.get_available_permissions()
+                self.assertEqual(list(perms), ['widgets'])
+                self.assertEqual(list(perms['widgets']['perms']),
+                                 ['widgets.list', 'widgets.polish', 'widgets.view'])
+
+            # also, when admin user is editing, sees all perms
+            self.request.is_admin = True
+            with patch.object(view, 'editing', new=True):
+                perms = view.get_available_permissions()
+                self.assertEqual(list(perms), ['widgets'])
+                self.assertEqual(list(perms['widgets']['perms']),
+                                 ['widgets.list', 'widgets.polish', 'widgets.view'])
diff --git a/tests/views/test_settings.py b/tests/views/test_settings.py
new file mode 100644
index 00000000..b8523729
--- /dev/null
+++ b/tests/views/test_settings.py
@@ -0,0 +1,10 @@
+# -*- coding: utf-8; -*-
+
+from tailbone.views import settings as mod
+from tests.util import WebTestCase
+
+
+class TestSettingView(WebTestCase):
+
+    def test_includeme(self):
+        self.pyramid_config.include('tailbone.views.settings')
diff --git a/tests/views/test_users.py b/tests/views/test_users.py
new file mode 100644
index 00000000..4b94caf2
--- /dev/null
+++ b/tests/views/test_users.py
@@ -0,0 +1,33 @@
+# -*- coding: utf-8; -*-
+
+from unittest.mock import patch, MagicMock
+
+from tailbone.views import users as mod
+from tailbone.views.principal import PermissionsRenderer
+from tests.util import WebTestCase
+
+
+class TestUserView(WebTestCase):
+
+    def make_view(self):
+        return mod.UserView(self.request)
+
+    def test_includeme(self):
+        self.pyramid_config.include('tailbone.views.users')
+
+    def test_configure_form(self):
+        self.pyramid_config.include('tailbone.views.users')
+        model = self.app.model
+        barney = model.User(username='barney')
+        self.session.add(barney)
+        self.session.commit()
+        view = self.make_view()
+
+        # must use mock configure when making form
+        def configure(form): pass
+        form = view.make_form(instance=barney, configure=configure)
+
+        with patch.object(view, 'viewing', new=True):
+            self.assertNotIn('permissions', form.renderers)
+            view.configure_form(form)
+            self.assertIsInstance(form.renderers['permissions'], PermissionsRenderer)
diff --git a/tests/views/wutta/__init__.py b/tests/views/wutta/__init__.py
new file mode 100644
index 00000000..e69de29b
diff --git a/tests/views/wutta/test_people.py b/tests/views/wutta/test_people.py
new file mode 100644
index 00000000..31aeb501
--- /dev/null
+++ b/tests/views/wutta/test_people.py
@@ -0,0 +1,87 @@
+# -*- coding: utf-8; -*-
+
+from unittest.mock import patch
+
+from sqlalchemy import orm
+
+from tailbone.views.wutta import people as mod
+from tests.util import WebTestCase
+
+
+class TestPersonView(WebTestCase):
+
+    def make_view(self):
+        return mod.PersonView(self.request)
+
+    def test_includeme(self):
+        self.pyramid_config.include('tailbone.views.wutta.people')
+
+    def test_get_query(self):
+        view = self.make_view()
+
+        # sanity / coverage check
+        query = view.get_query(session=self.session)
+        self.assertIsInstance(query, orm.Query)
+
+    def test_configure_grid(self):
+        model = self.app.model
+        barney = model.User(username='barney')
+        self.session.add(barney)
+        self.session.commit()
+        view = self.make_view()
+
+        # sanity / coverage check
+        grid = view.make_grid(model_class=model.Person)
+        self.assertNotIn('first_name', grid.linked_columns)
+        view.configure_grid(grid)
+        self.assertIn('first_name', grid.linked_columns)
+
+    def test_configure_form(self):
+        model = self.app.model
+        barney = model.Person(display_name="Barney Rubble")
+        self.session.add(barney)
+        self.session.commit()
+        view = self.make_view()
+
+        # email field remains when viewing
+        with patch.object(view, 'viewing', new=True):
+            form = view.make_form(model_instance=barney,
+                                  fields=view.get_form_fields())
+            self.assertIn('email', form.fields)
+            view.configure_form(form)
+            self.assertIn('email', form)
+
+        # email field removed when editing
+        with patch.object(view, 'editing', new=True):
+            form = view.make_form(model_instance=barney,
+                                  fields=view.get_form_fields())
+            self.assertIn('email', form.fields)
+            view.configure_form(form)
+            self.assertNotIn('email', form)
+
+    def test_render_merge_requested(self):
+        model = self.app.model
+        barney = model.Person(display_name="Barney Rubble")
+        self.session.add(barney)
+        user = model.User(username='user')
+        self.session.add(user)
+        self.session.commit()
+        view = self.make_view()
+
+        # null by default
+        html = view.render_merge_requested(barney, 'merge_requested', None,
+                                           session=self.session)
+        self.assertIsNone(html)
+
+        # unless a merge request exists
+        barney2 = model.Person(display_name="Barney Rubble")
+        self.session.add(barney2)
+        self.session.commit()
+        mr = model.MergePeopleRequest(removing_uuid=barney2.uuid,
+                                      keeping_uuid=barney.uuid,
+                                      requested_by=user)
+        self.session.add(mr)
+        self.session.commit()
+        html = view.render_merge_requested(barney, 'merge_requested', None,
+                                           session=self.session)
+        self.assertIn('<span ', html)
diff --git a/tox.ini b/tox.ini
index ea833b39..3896befb 100644
--- a/tox.ini
+++ b/tox.ini
@@ -1,21 +1,12 @@
 
 [tox]
-envlist = py36, py37, py38, py39, py310, py311
-
-# TODO: can remove this when we drop py36 support
-# nb. need this for testing older python versions
-# https://tox.wiki/en/latest/faq.html#testing-end-of-life-python-versions
-requires = virtualenv<20.22.0
+envlist = py38, py39, py310, py311
 
 [testenv]
 deps = rattail-tempmon
 extras = tests
 commands = pytest {posargs}
 
-[testenv:py37]
-# nb. Chameleon 4.3 requires Python 3.9+
-deps = Chameleon<4.3
-
 [testenv:coverage]
 basepython = python3
 extras = tests