diff --git a/CHANGELOG.md b/CHANGELOG.md
index 8ed82c5d..c974b3a6 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -5,6 +5,58 @@ 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
diff --git a/docs/conf.py b/docs/conf.py
index 52e384f5..ade4c92a 100644
--- a/docs/conf.py
+++ b/docs/conf.py
@@ -27,10 +27,10 @@ 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://rattailproject.org/docs/wuttaweb/', None),
-    'wuttjamaican': ('https://rattailproject.org/docs/wuttjamaican/', None),
+    'wuttaweb': ('https://docs.wuttaproject.org/wuttaweb/', None),
+    'wuttjamaican': ('https://docs.wuttaproject.org/wuttjamaican/', None),
 }
 
 # allow todo entries to show up
diff --git a/pyproject.toml b/pyproject.toml
index b928ec9b..a7214a8e 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -6,7 +6,7 @@ build-backend = "hatchling.build"
 
 [project]
 name = "Tailbone"
-version = "0.22.0"
+version = "0.22.7"
 description = "Backoffice Web Application for Rattail"
 readme = "README.md"
 authors = [{name = "Lance Edgar", email = "lance@edbob.org"}]
@@ -53,13 +53,13 @@ dependencies = [
         "pyramid_mako",
         "pyramid_retry",
         "pyramid_tm",
-        "rattail[db,bouncer]>=0.18.5",
+        "rattail[db,bouncer]>=0.20.1",
         "sa-filters",
         "simplejson",
         "transaction",
         "waitress",
         "WebHelpers2",
-        "WuttaWeb>=0.14.0",
+        "WuttaWeb>=0.21.0",
         "zope.sqlalchemy>=1.5",
 ]
 
diff --git a/tailbone/api/master.py b/tailbone/api/master.py
index 2d17339e..551d6428 100644
--- a/tailbone/api/master.py
+++ b/tailbone/api/master.py
@@ -26,7 +26,6 @@ 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
@@ -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
diff --git a/tailbone/app.py b/tailbone/app.py
index b7262866..d2d0c5ef 100644
--- a/tailbone/app.py
+++ b/tailbone/app.py
@@ -62,6 +62,17 @@ def make_rattail_config(settings):
     # 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, 'appdb_engine'):
         tailbone.db.Session.configure(bind=rattail_config.appdb_engine)
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/grids/core.py b/tailbone/grids/core.py
index 73de42c6..56b97b86 100644
--- a/tailbone/grids/core.py
+++ b/tailbone/grids/core.py
@@ -235,7 +235,7 @@ class Grid(WuttaGrid):
 
         if 'pageable' in kwargs:
             warnings.warn("pageable param is deprecated for Grid(); "
-                          "please use vue_tagname param instead",
+                          "please use paginated param instead",
                           DeprecationWarning, stacklevel=2)
             kwargs.setdefault('paginated', kwargs.pop('pageable'))
 
@@ -1223,6 +1223,7 @@ class Grid(WuttaGrid):
 
     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
@@ -1239,7 +1240,10 @@ class Grid(WuttaGrid):
         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):
         """ """
diff --git a/tailbone/menus.py b/tailbone/menus.py
index 3ddee095..09d6f3f0 100644
--- a/tailbone/menus.py
+++ b/tailbone/menus.py
@@ -394,6 +394,11 @@ class TailboneMenuHandler(WuttaMenuHandler):
                     'route': 'products',
                     'perm': 'products.list',
                 },
+                {
+                    'title': "Product Costs",
+                    'route': 'product_costs',
+                    'perm': 'product_costs.list',
+                },
                 {
                     'title': "Departments",
                     'route': 'departments',
@@ -451,6 +456,11 @@ class TailboneMenuHandler(WuttaMenuHandler):
                     'route': 'vendors',
                     'perm': 'vendors.list',
                 },
+                {
+                    'title': "Product Costs",
+                    'route': 'product_costs',
+                    'perm': 'product_costs.list',
+                },
                 {'type': 'sep'},
                 {
                     'title': "Ordering",
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/master/view.mako b/tailbone/templates/master/view.mako
index 0a1f9c62..118c028c 100644
--- a/tailbone/templates/master/view.mako
+++ b/tailbone/templates/master/view.mako
@@ -196,6 +196,7 @@
 
                   <p class="block has-text-weight-bold">
                     {{ version.model_title }}
+                    ({{ version.operation }})
                   </p>
 
                   <table class="diff monospace is-size-7"
diff --git a/tailbone/templates/products/batch.mako b/tailbone/templates/products/batch.mako
index 9f969468..db029e5a 100644
--- a/tailbone/templates/products/batch.mako
+++ b/tailbone/templates/products/batch.mako
@@ -55,19 +55,20 @@
 </%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_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.vue_component} = {
-        template: '#${form.component}-template',
+        template: '#${form.vue_tagname}-template',
         methods: {
 
             ## TODO: deprecate / remove the latter option here
diff --git a/tailbone/templates/reports/problems/view.mako b/tailbone/templates/reports/problems/view.mako
index 00ac1503..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">
diff --git a/tailbone/views/auth.py b/tailbone/views/auth.py
index a54a19a9..eceab803 100644
--- a/tailbone/views/auth.py
+++ b/tailbone/views/auth.py
@@ -44,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):
@@ -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,7 +106,6 @@ class AuthenticationView(View):
         return {
             'form': form,
             'referrer': referrer,
-            'image_url': image_url,
             'index_title': app.get_node_title(),
             'help_url': global_help_url(self.rattail_config),
         }
@@ -181,7 +154,23 @@ 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():
             auth = self.app.get_auth_handler()
diff --git a/tailbone/views/master.py b/tailbone/views/master.py
index baf63caa..21a5e58f 100644
--- a/tailbone/views/master.py
+++ b/tailbone/views/master.py
@@ -412,7 +412,7 @@ class MasterView(View):
             session = self.Session()
         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):
         """
@@ -903,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")
@@ -939,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")
@@ -1382,7 +1382,7 @@ 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,
@@ -1710,7 +1710,7 @@ class MasterView(View):
         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):
@@ -2153,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)
@@ -2594,7 +2594,7 @@ class MasterView(View):
         """
         # nb. self.Session may differ, so use tailbone.db.Session
         session = Session()
-        model = self.model
+        model = self.app.model
         route_prefix = self.get_route_prefix()
 
         info = session.query(model.TailbonePageHelp)\
@@ -2617,7 +2617,7 @@ class MasterView(View):
         """
         # nb. self.Session may differ, so use tailbone.db.Session
         session = Session()
-        model = self.model
+        model = self.app.model
         route_prefix = self.get_route_prefix()
 
         info = session.query(model.TailbonePageHelp)\
@@ -2639,7 +2639,7 @@ class MasterView(View):
 
         # nb. self.Session may differ, so use tailbone.db.Session
         session = Session()
-        model = self.model
+        model = self.app.model
         route_prefix = self.get_route_prefix()
         schema = colander.Schema()
 
@@ -2673,7 +2673,7 @@ class MasterView(View):
 
         # nb. self.Session may differ, so use tailbone.db.Session
         session = Session()
-        model = self.model
+        model = self.app.model
         route_prefix = self.get_route_prefix()
         schema = colander.Schema()
 
@@ -5541,7 +5541,7 @@ class MasterView(View):
                                   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:
@@ -6100,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.
@@ -6127,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
@@ -6160,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/people.py b/tailbone/views/people.py
index d288b551..405b1ca3 100644
--- a/tailbone/views/people.py
+++ b/tailbone/views/people.py
@@ -564,15 +564,19 @@ class PersonView(MasterView):
         Method which must return the base query for the profile's POS
         Transactions grid data.
         """
-        app = self.get_rattail_app()
-        customer = app.get_customer(person)
+        customer = self.app.get_customer(person)
 
-        key_field = app.get_customer_key_field()
-        customer_key = getattr(customer, key_field)
-        if customer_key is not None:
-            customer_key = str(customer_key)
+        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 = app.get_trainwreck_handler()
+        trainwreck = self.app.get_trainwreck_handler()
         model = trainwreck.get_model()
         query = TrainwreckSession.query(model.Transaction)\
                                  .filter(model.Transaction.customer_id == customer_key)
diff --git a/tailbone/views/products.py b/tailbone/views/products.py
index c546a0f4..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
@@ -1857,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))
 
@@ -2668,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()
 
@@ -2677,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)