Repository: bumps/bumps (language: python)

Issue:
Webview serialize v2
Change to the structure of the serialization, and refactor serialize/deserialize 

## Structure changes
### Old schema:

- one root object, which defines the serialized object.
- python class is identified with "type" keyword in an object
- possibly-unique instances (e.g. Parameter) are serialized by:
  - serialize in-place in the model the first time they are seen
  - the next time that `id` is seen in the model, use {"type": "Reference", "id": `id`} instead

(see https://github.com/bumps/bumps/discussions/132 for more details)

### New schema:
- python class is identified with `__class__` keyword instead of `type`
- root object contains three keys, 
  - "$schema": "bumps-draft-02"
  - "object": <the serialized main object, e.g. FitProblem>
  - "references": a `dict` of {`id`: <unique object>... } (full of `Parameter` objects, mostly)
- the main object tree contains `Reference` objects wherever a unique object (`Parameter`) is found, that must be dereferenced from the `references` object when deserializing

## Other changes
 - `deserialize(obj)` function changed to synchronous function (from async with `Future` placeholders for unresolved references)
 - uses `graphlib` to determine deserialization order, in case any unique objects depend on other unique objects (does not support circular references)
 - `migrate()` function added to attempt to convert old-schema models to newest schema.  A function is defined for each schema transition (one-way?  do we need to ever transition down?)
 
Issue with json-difference
Running `npm run dev` currently fails for refl1d via bumps's `ModelInspect.vue` with:
```
Uncaught SyntaxError: The requested module 'http://localhost:5173/node_modules/json-difference/dist/core/index.js?v=a89b4c51' doesn't provide an export named: 'getDiff' [ModelInspect.vue:5:9](http://localhost:5173/node_modules/bumps-webview-client/src/components/ModelInspect.vue)
```


Discussion / hints:

Fix patch:
diff --git a/MANIFEST.in b/MANIFEST.in
index ef17c207..1b4fdcab 100755
--- a/MANIFEST.in
+++ b/MANIFEST.in
@@ -14,6 +14,13 @@ include master_builder.py
 include bumps.iss
 include setup_py2exe.py
 include bin/*
-
+recursive-include bumps/webview/client/dist *.html *.js *.css *.svg *.png
+recursive-include bumps/webview/client/src *.html *.js *.mjs *.css *.svg *.png *.ts *.vue
+include bumps/webview/client/*.html
+include bumps/webview/client/*.js
+include bumps/webview/client/*.json
+include bumps/webview/client/*.txt
+include extra/*.svg extra/*.png
 # Delete files
 #prune this that
+prune bumps/webview/client/node_modules
diff --git a/bin/bumps-webview b/bin/bumps-webview
new file mode 100755
index 00000000..db577e0c
--- /dev/null
+++ b/bin/bumps-webview
@@ -0,0 +1,20 @@
+#!/usr/bin/env python
+# This program is in the public domain.
+# Authors: Paul Kienzle and James Krycka
+
+"""
+This script starts the command line interface of the Bumps
+Modeler application to process the command just entered.
+"""
+
+# ========================== Start program ==================================
+# Process the command line that has been entered.
+if __name__ == "__main__":
+    # This is necessary when running the application from a frozen image and
+    # using the --parallel option.  Note that freeze_support() has no effect
+    # when running from a python script (i.e., in a non-frozen environment).
+    import multiprocessing
+    multiprocessing.freeze_support()
+
+    import bumps.webview.server.webserver
+    bumps.webview.server.webserver.main()
diff --git a/bumps/bounds.py b/bumps/bounds.py
index bf6f92d2..034c70ef 100644
--- a/bumps/bounds.py
+++ b/bumps/bounds.py
@@ -54,6 +54,7 @@
            'Bounds', 'Unbounded', 'Bounded', 'BoundedAbove', 'BoundedBelow',
            'Distribution', 'Normal', 'BoundedNormal', 'SoftBounded']
 
+from dataclasses import dataclass, field
 import math
 from math import log, log10, sqrt, pi, ceil, floor
 
@@ -66,8 +67,24 @@
     # failure if it doesn't exist.
     pass
 
+from .util import Optional, Any, Union, Dict, Callable, Literal, Tuple, List, Literal
 
-def pm(v, plus, minus=None, limits=None):
+LimitValue = Union[float, Literal["-inf", "inf"]]
+LimitsType = Tuple[Union[float, Literal["-inf"]], Union[float, Literal["inf"]]]
+
+# TODO: should we use this in the bounds limits?
+# @dataclass(init=False)
+# class ExtendedFloat(float):
+#     __root__: Union[float, Literal["inf", "-inf"]]
+#     def __new__(cls, *args, **kw):
+#         return super().__new__(cls, *args)
+#     def __init__(self, __root__=None):
+#         pass
+#     def __repr__(self):
+#         return float.__repr__(self)
+
+
+def pm(v, plus, minus=None, limits: Optional[LimitsType]=None):
     """
     Return the tuple (~v-dv,~v+dv), where ~expr is a 'nice' number near to
     to the value of expr.  For example::
@@ -151,7 +168,7 @@ def nice_range(bounds):
         return bounds
 
 
-def init_bounds(v):
+def init_bounds(v) -> 'Bounds':
     """
     Returns a bounds object of the appropriate type given the arguments.
 
@@ -186,7 +203,7 @@ def init_bounds(v):
         return Bounded(lo, hi)
 
 
-class Bounds(object):
+class Bounds:
     """
     Bounds abstract base class.
 
@@ -201,9 +218,12 @@ class Bounds(object):
     is being optimized is also a probability, then this is an easy way to
     incorporate information from other sorts of measurements into the model.
     """
-    limits = (-inf, inf)
     # TODO: need derivatives wrt bounds transforms
 
+    @property
+    def limits(self):
+        return (-inf, inf)
+
     def get01(self, x):
         """
         Convert value into [0,1] for optimizers which are bounds constrained.
@@ -281,6 +301,19 @@ def __str__(self):
         limits = tuple(num_format(v) for v in self.limits)
         return "(%s,%s)" % limits
 
+    def satisfied(self, v) -> bool:
+        lo, hi = self.limits
+        return v >= lo and v <= hi
+
+    def penalty(self, v) -> float:
+        """
+        return a (differentiable) nonzero value when outside the bounds
+        """
+        lo, hi = self.limits
+        dlo = 0. if v >= lo else abs(v - lo)
+        dhi = 0. if v <= hi else abs(v - hi)
+        return dlo + dhi
+
     def to_dict(self):
         return dict(
             type=type(self).__name__,
@@ -302,6 +335,7 @@ def num_format(v):
         return "NaN"
 
 
+@dataclass(init=False)
 class Unbounded(Bounds):
     """
     Unbounded parameter.
@@ -312,6 +346,10 @@ class Unbounded(Bounds):
     log likelihood of P is inf everywhere.  A value inf will interfere
     with optimization routines, and so we instead choose P == 1 everywhere.
     """
+    type = "Unbounded"
+
+    def __init__(self, *args, **kw):
+        pass
 
     def random(self, n=1, target=1.0):
         scale = target + (target == 0.)
@@ -336,6 +374,7 @@ def putfull(self, v):
         return v
 
 
+@dataclass(init=True)
 class BoundedBelow(Bounds):
     """
     Semidefinite range bounded below.
@@ -354,27 +393,29 @@ class BoundedBelow(Bounds):
     is indistinguishable from values outside the range.  Instead we say
     that P = 1 in range, and 0 outside.
     """
+    base: float
+    type = "BoundedBelow"
 
-    def __init__(self, base):
-        self.limits = (base, inf)
-        self._base = base
+    @property
+    def limits(self):
+        return (self.base, inf)
 
     def start_value(self):
-        return self._base + 1
+        return self.base + 1
 
-    def random(self, n=1, target=1.):
-        target = max(abs(target), abs(self._base))
-        scale = target + (target == 0.)
-        return self._base + abs(RNG.randn(n)*scale)
+    def random(self, n=1, target:float=1.):
+        target = max(abs(target), abs(self.base))
+        scale = target + float(target == 0.)
+        return self.base + abs(RNG.randn(n)*scale)
 
     def nllf(self, value):
-        return 0 if value >= self._base else inf
+        return 0 if value >= self.base else inf
 
     def residual(self, value):
-        return 0 if value >= self._base else -4
+        return 0 if value >= self.base else -4
 
     def get01(self, x):
-        m, e = math.frexp(x - self._base)
+        m, e = math.frexp(x - self.base)
         if m >= 0 and e <= _E_MAX:
             v = (e + m) / (2. * _E_MAX)
             return v
@@ -385,18 +426,19 @@ def put01(self, v):
         v = v * 2 * _E_MAX
         e = int(v)
         m = v - e
-        x = math.ldexp(m, e) + self._base
+        x = math.ldexp(m, e) + self.base
         return x
 
     def getfull(self, x):
-        v = x - self._base
+        v = x - self.base
         return v if v >= 1 else 2 - 1. / v
 
     def putfull(self, v):
         x = v if v >= 1 else 1. / (2 - v)
-        return x + self._base
+        return x + self.base
 
 
+@dataclass(init=True)
 class BoundedAbove(Bounds):
     """
     Semidefinite range bounded above.
@@ -413,27 +455,28 @@ class BoundedAbove(Bounds):
     is indistinguishable from values outside the range.  Instead we say
     that P = 1 in range, and 0 outside.
     """
+    base: float
 
-    def __init__(self, base):
-        self.limits = (-inf, base)
-        self._base = base
+    @property
+    def limits(self):
+        return (-inf, self.base)
 
     def start_value(self):
-        return self._base - 1
+        return self.base - 1
 
-    def random(self, n=1, target=1.0):
-        target = max(abs(self._base), abs(target))
-        scale = target + (target == 0.)
-        return self._base - abs(RNG.randn(n)*scale)
+    def random(self, n=1, target:float=1.0):
+        target = max(abs(self.base), abs(target))
+        scale = target + float(target == 0.)
+        return self.base - abs(RNG.randn(n)*scale)
 
     def nllf(self, value):
-        return 0 if value <= self._base else inf
+        return 0 if value <= self.base else inf
 
     def residual(self, value):
-        return 0 if value <= self._base else 4
+        return 0 if value <= self.base else 4
 
     def get01(self, x):
-        m, e = math.frexp(self._base - x)
+        m, e = math.frexp(self.base - x)
         if m >= 0 and e <= _E_MAX:
             v = (e + m) / (2. * _E_MAX)
             return 1 - v
@@ -444,18 +487,19 @@ def put01(self, v):
         v = (1 - v) * 2 * _E_MAX
         e = int(v)
         m = v - e
-        x = -(math.ldexp(m, e) - self._base)
+        x = -(math.ldexp(m, e) - self.base)
         return x
 
     def getfull(self, x):
-        v = x - self._base
+        v = x - self.base
         return v if v <= -1 else -2 - 1. / v
 
     def putfull(self, v):
         x = v if v <= -1 else -1. / (v + 2)
-        return x + self._base
+        return x + self.base
 
 
+@dataclass(init=True)
 class Bounded(Bounds):
     """
     Bounded range.
@@ -468,24 +512,33 @@ class Bounded(Bounds):
     and for a more natural mapping between nllf and chisq, we instead
     set the probability to 0.  This choice will not affect the fits.
     """
+    lo: float = field(metadata={"description": "lower end of bounds"})
+    hi: float = field(metadata={"description": "upper end of bounds"})
+
+    # @classmethod
+    # def from_dict(cls, limits=None):
+    #     lo, hi = limits
+    #     return cls(lo, hi)
 
-    def __init__(self, lo, hi):
-        self.limits = (lo, hi)
-        self._nllf_scale = log(hi - lo)
+    # def __init__(self, lo, hi):
+    #     self.lo = lo
+    #     self.hi = hi
+    #     self._nllf_scale = log(hi - lo)
+
+    @property
+    def limits(self):
+        return (self.lo, self.hi)
 
     def random(self, n=1, target=1.0):
-        lo, hi = self.limits
         #print("= uniform",lo,hi)
-        return RNG.uniform(lo, hi, size=n)
+        return RNG.uniform(self.lo, self.hi, size=n)
 
     def nllf(self, value):
-        lo, hi = self.limits
-        return 0 if lo <= value <= hi else inf
+        return 0 if self.lo <= value <= self.hi else inf
         # return self._nllf_scale if lo<=value<=hi else inf
 
     def residual(self, value):
-        lo, hi = self.limits
-        return -4 if lo > value else (4 if hi < value else 0)
+        return -4 if self.lo > value else (4 if self.hi < value else 0)
 
     def get01(self, x):
         lo, hi = self.limits
@@ -502,6 +555,7 @@ def putfull(self, v):
         return self.put01(_get01_inf(v))
 
 
+
 class Distribution(Bounds):
     """
     Parameter is pulled from a distribution.
@@ -510,6 +564,8 @@ class Distribution(Bounds):
     In particular, it should define methods rvs, nnlf, cdf and ppf and
     attributes args and dist.name.
     """
+    #dist: DistProtocol = None
+    #dist: Any = None
 
     def __init__(self, dist):
         self.dist = dist
@@ -556,6 +612,7 @@ def to_dict(self):
             )
 
 
+@dataclass(init=False, frozen=True)
 class Normal(Distribution):
     """
     Parameter is pulled from a normal distribution.
@@ -567,11 +624,16 @@ class Normal(Distribution):
 
     *mean* is the expected value of the parameter and *std* is the 1-sigma
     standard deviation.
+
+    class is 'frozen' because a new object should be created if 
+    `mean` or `std` are changed.
     """
+    mean: float = 0.0
+    std: float = 1.0
 
-    def __init__(self, mean=0, std=1):
+    def __init__(self, mean:float=0, std:float=1):
         Distribution.__init__(self, normal_distribution(mean, std))
-        self._nllf_scale = log(sqrt(2 * pi * std ** 2))
+        self._nllf_scale = log(2 * pi * std ** 2)/2
 
     def nllf(self, value):
         # P(v) = exp(-0.5*(v-mean)**2/std**2)/sqrt(2*pi*std**2)
@@ -592,18 +654,36 @@ def __setstate__(self, state):
         self.__init__(mean=mean, std=std)
 
 
+@dataclass(init=False, frozen=True)
 class BoundedNormal(Bounds):
     """
     truncated normal bounds
     """
+    mean: float = 0.0
+    std: float = 1.0
+    lo: Union[float, Literal["-inf"]]
+    hi: Union[float, Literal["inf"]]
+
+    def __init__(self, mean:float=0, std:float=1, limits=(-inf, inf), hi="inf", lo="-inf"):
+        if limits is not None:
+            # for backward compatibility:
+            lo, hi = limits
+        limits = (
+            -inf if lo is None else float(lo),
+            inf if hi is None else float(hi)
+        )
+        object.__setattr__(self, 'lo', limits[0])
+        object.__setattr__(self, 'hi', limits[1])
+        object.__setattr__(self, 'mean', mean)
+        object.__setattr__(self, 'std', std)
 
-    def __init__(self, mean=0, std=1, limits=(-inf, inf)):
-        self.limits = limits
-        self.mean, self.std = mean, std
+        object.__setattr__(self, '_left', normal_distribution.cdf((limits[0]-mean)/std))
+        object.__setattr__(self, '_delta', normal_distribution.cdf((limits[1]-mean)/std) - self._left)
+        object.__setattr__(self, '_nllf_scale', log(2 * pi * std ** 2)/2 + log(self._delta))
 
-        self._left = normal_distribution.cdf((limits[0]-mean)/std)
-        self._delta = normal_distribution.cdf((limits[1]-mean)/std) - self._left
-        self._nllf_scale = log(sqrt(2 * pi * std ** 2)) + log(self._delta)
+    @property
+    def limits(self):
+        return (self.lo, self.hi)
 
     def get01(self, x):
         """
@@ -680,6 +760,7 @@ def __str__(self):
         return "(%s,%s), norm(%s,%s)" % tuple(num_format(v) for v in vals)
 
 
+@dataclass(init=False, frozen=True)
 class SoftBounded(Bounds):
     """
     Parameter is pulled from a stretched normal distribution.
@@ -695,41 +776,48 @@ class SoftBounded(Bounds):
     into the range [0,1] for each parameter we don't need to use soft
     constraints, and this acts just like the rectangular distribution.
     """
+    lo: float = 0.0
+    hi: float = 1.0
+    std: float = 1.0
 
-    def __init__(self, lo, hi, std=None):
-        self._lo, self._hi, self._std = lo, hi, std
+    def __init__(self, lo, hi, std=1.0):
+        self.lo, self.hi, self.std = lo, hi, std
         self._nllf_scale = log(hi - lo + sqrt(2 * pi * std))
 
+    @property
+    def limits(self):
+        return (self.lo, self.hi)
+
     def random(self, n=1, target=1.0):
-        return RNG.uniform(self._lo, self._hi, size=n)
+        return RNG.uniform(self.lo, self.hi, size=n)
 
     def nllf(self, value):
         # To turn f(x) = 1 if x in [lo,hi] else G(tail)
         # into a probability p, we need to normalize by \int{f(x)dx},
         # which is just hi-lo + sqrt(2*pi*std**2).
-        if value < self._lo:
-            z = self._lo - value
-        elif value > self._hi:
-            z = value - self._hi
+        if value < self.lo:
+            z = self.lo - value
+        elif value > self.hi:
+            z = value - self.hi
         else:
             z = 0
-        return (z / self._std) ** 2 / 2 + self._nllf_scale
+        return (z / self.std) ** 2 / 2 + self._nllf_scale
 
     def residual(self, value):
-        if value < self._lo:
-            z = self._lo - value
-        elif value > self._hi:
-            z = value - self._hi
+        if value < self.lo:
+            z = self.lo - value
+        elif value > self.hi:
+            z = value - self.hi
         else:
             z = 0
-        return z / self._std
+        return z / self.std
 
     def get01(self, x):
-        v = float(x - self._lo) / (self._hi - self._lo)
+        v = float(x - self.lo) / (self.hi - self.lo)
         return v if 0 <= v <= 1 else (0 if v < 0 else 1)
 
     def put01(self, v):
-        return v * (self._hi - self._lo) + self._lo
+        return v * (self.hi - self.lo) + self.lo
 
     def getfull(self, x):
         return x
@@ -738,7 +826,7 @@ def putfull(self, v):
         return v
 
     def __str__(self):
-        return "box_norm(%g,%g,sigma=%g)" % (self._lo, self._hi, self._std)
+        return "box_norm(%g,%g,sigma=%g)" % (self.lo, self.hi, self.std)
 
 
 _E_MIN = -1023
@@ -784,3 +872,5 @@ def _put01_inf(v):
     x = math.ldexp(s * m, e + _E_MIN)
     # print "< x,e,m,s,v",x,e+_e_min,s*m,s,v
     return x
+
+BoundsType = Union[Unbounded, Bounded, BoundedAbove, BoundedBelow, BoundedNormal, SoftBounded, Normal]
\ No newline at end of file
diff --git a/bumps/dream/corrplot.py b/bumps/dream/corrplot.py
index a52e89ee..ae16736a 100644
--- a/bumps/dream/corrplot.py
+++ b/bumps/dream/corrplot.py
@@ -50,14 +50,15 @@ def __getitem__(self, key):
         i, j = key
         return self.hists[i, j]
 
-    def plot(self, title=None):
+    def plot(self, title=None, fig=None):
         """
         Plot the correlation histograms on the specified figure
         """
         import pylab
 
-        pylab.clf()
-        fig = pylab.gcf()
+        if fig is None:
+            pylab.clf()
+            fig = pylab.gcf()
         if title is not None:
             fig.text(0.5, 0.95, title,
                      horizontalalignment='center',
diff --git a/bumps/dream/state.py b/bumps/dream/state.py
index c0a8faef..07fefed5 100644
--- a/bumps/dream/state.py
+++ b/bumps/dream/state.py
@@ -1091,6 +1091,13 @@ def __init__(self, state, vars=None, portion=None, selection=None, thin=1):
             self.integers = state._integer_vars[vars] if vars else None
         else:
             self.integers = None
+        self._argsort_indices = {}
+
+    # cache the argsort indices for each variable
+    def get_argsort_indices(self, var: int):
+        if var not in self._argsort_indices:
+            self._argsort_indices[var] = np.argsort(self.points[:, var].flatten())
+        return self._argsort_indices[var]
 
 
 def _sample(state, portion, vars, selection, thin):
diff --git a/bumps/dream/stats.py b/bumps/dream/stats.py
index a999a66e..c05abf74 100644
--- a/bumps/dream/stats.py
+++ b/bumps/dream/stats.py
@@ -36,11 +36,13 @@ def _var_stats_one(draw, var):
 
     best_idx = np.argmax(draw.logp)
     best = values[best_idx]
+    x_sort_index = draw.get_argsort_indices(var)
 
     # Choose the interval for the histogram
     #credible_interval = shortest_credible_interval
     p95, p68, p0 = credible_interval(x=values, weights=weights,
-                                     ci=[0.95, ONE_SIGMA, 0.0])
+                                     ci=[0.95, ONE_SIGMA, 0.0],
+                                     x_sort_index=x_sort_index)
 
     ## reporting uncertainty on credible intervals?
     ## might be nice to pair sd on credible intervals
@@ -54,7 +56,7 @@ def _var_stats_one(draw, var):
     #     % (p68,p95,p0,(min(values),max(values))))
     #if p0[0] != p0[1]: raise RuntimeError("wrong median %s"%(str(p0),))
 
-    mean, std = stats(x=values, weights=weights)
+    mean, std = stats(x=values, weights=weights, x_sort_index=x_sort_index)
 
     vstats = VarStats(label=draw.labels[var], index=var+1,
                       p95=p95, p95_range=(p95[0], p95[1]+integer*0.9999999999),
@@ -158,7 +160,7 @@ def parse_var(line):
         return None
 
 
-def stats(x, weights=None):
+def stats(x, weights=None, x_sort_index=None):
     """
     Find mean and standard deviation of a set of weighted samples.
 
@@ -167,7 +169,9 @@ def stats(x, weights=None):
     in the sample), but this is good enough when the sample size is large.
     """
     if weights is None:
-        x = np.sort(x)
+        if x_sort_index is None:
+            x_sort_index = np.argsort(x)
+        x = x[x_sort_index]
         mean, std = np.mean(x), np.std(x, ddof=1)
     else:
         mean = np.mean(x*weights)/np.sum(weights)
@@ -178,7 +182,7 @@ def stats(x, weights=None):
     return mean, std
 
 
-def credible_interval(x, ci, weights=None):
+def credible_interval(x, ci, weights=None, x_sort_index=None):
     r"""
     Find the credible interval covering the portion *ci* of the data.
 
@@ -205,14 +209,15 @@ def credible_interval(x, ci, weights=None):
     n = x.size
     ci = np.asarray(ci, 'd')
     target = (1 + np.vstack((-ci, +ci))).T/2
+    if x_sort_index is None:
+        x_sort_index = np.argsort(x)
 
     if weights is None:
         cdf = np.linspace(0.5/n, 1-0.5/n, n)
         #cdf = np.linspace(1, n, n)/(n+1)
-        result = np.interp(target, cdf, np.sort(x))
+        result = np.interp(target, cdf, x[x_sort_index])
     else:
-        index = np.argsort(x)
-        x, weights = x[index], weights[index]
+        x, weights = x[x_sort_index], weights[x_sort_index]
         # convert weights to cdf
         cdf = np.cumsum(weights)
         cdf /= cdf[-1]
diff --git a/bumps/dream/varplot.py b/bumps/dream/varplot.py
index d27038dc..17941dc4 100644
--- a/bumps/dream/varplot.py
+++ b/bumps/dream/varplot.py
@@ -34,13 +34,14 @@ def var_plot_size(n):
     figheight = (TILE_H+V_SPACE)*nrow + T_MARGIN + B_MARGIN
     return figwidth, figheight
 
-def _make_var_axes(n):
+def _make_var_axes(n, fig=None):
     """
     Build a figure with one axis per parameter,
     and one axis (the last one) to contain the colorbar.
     Use to make the vars histogram figure.
     """
-    fig = plt.gcf()
+    if fig is None:
+        fig = plt.gcf()
     fig.clf()
     total_width, total_height = fig.get_size_inches()
 
@@ -96,36 +97,39 @@ def tile_axes_square(n):
     return cols, rows
 
 
-def plot_vars(draw, all_vstats, **kw):
+def plot_vars(draw, all_vstats, fig=None, **kw):
     n = len(all_vstats)
-    fig = _make_var_axes(n)
-    cbar = _make_fig_colorbar(draw.logp)
+    fig = _make_var_axes(n, fig=fig)
+    cbar = _make_fig_colorbar(draw.logp, fig=fig)
     for k, vstats in enumerate(all_vstats):
-        fig.sca(fig.axes[k])
-        plot_var(draw, vstats, k, cbar, **kw)
+        axes = fig.axes[k]
+        plot_var(draw, vstats, k, cbar, axes=axes, **kw)
+        fig.canvas.draw()
 
 
-def plot_var(draw, vstats, var, cbar, nbins=30):
+def plot_var(draw, vstats, var, cbar, nbins=30, axes=None):
     values = draw.points[:, var].flatten()
     bin_range = vstats.p95_range
     #bin_range = np.min(values), np.max(values)
+    import pylab
+    if axes is None:
+        axes = pylab.gca()
+
     _make_logp_histogram(values, draw.logp, nbins, bin_range,
-                         draw.weights, cbar)
-    _decorate_histogram(vstats)
+                         draw.weights, cbar, axes)
+    _decorate_histogram(vstats, axes)
 
 
-def _decorate_histogram(vstats):
-    import pylab
+def _decorate_histogram(vstats, axes):
     from matplotlib.transforms import blended_transform_factory as blend
 
     l95, h95 = vstats.p95_range
     l68, h68 = vstats.p68_range
 
     # Shade things inside 1-sigma
-    pylab.axvspan(l68, h68, color='gold', alpha=0.5, zorder=-1)
+    axes.axvspan(l68, h68, color='gold', alpha=0.5, zorder=-1)
     # build transform with x=data, y=axes(0,1)
-    ax = pylab.gca()
-    transform = blend(ax.transData, ax.transAxes)
+    transform = blend(axes.transData, axes.transAxes)
 
     def marker(symbol, position):
         if position < l95:
@@ -134,23 +138,23 @@ def marker(symbol, position):
             symbol, position, ha = '>'+symbol, h95, 'right'
         else:
             symbol, position, ha = symbol, position, 'center'
-        pylab.text(position, 0.95, symbol, va='top', ha=ha,
+        axes.text(position, 0.95, symbol, va='top', ha=ha,
                    transform=transform, zorder=3, color='g')
-        #pylab.axvline(v)
+        #axes.axvline(v)
 
     marker('|', vstats.median)
     marker('E', vstats.mean)
     marker('*', vstats.best)
 
-    pylab.text(0.01, 0.95, vstats.label, zorder=2,
+    axes.text(0.01, 0.95, vstats.label, zorder=2,
                backgroundcolor=(1, 1, 0, 0.2),
                verticalalignment='top',
                horizontalalignment='left',
-               transform=pylab.gca().transAxes)
-    ax.set_yticklabels([])
+               transform=axes.transAxes)
+    axes.set_yticklabels([])
 
 
-def _make_fig_colorbar(logp):
+def _make_fig_colorbar(logp, fig=None):
     import matplotlib as mpl
     import pylab
 
@@ -164,7 +168,8 @@ def _make_fig_colorbar(logp):
     # Option 3: full range
     #vmin,vmax = -max(logp),-min(logp)
 
-    fig = pylab.gcf()
+    if fig is None:
+        fig = pylab.gcf()
     ax = fig.axes[-1]
     cmap = mpl.cm.copper
 
@@ -182,7 +187,10 @@ def __init__(self, low, high):
             self.delta = high - low
 
         def __call__(self, x, pos=None):
-            return format_value(x, self.delta)
+            # TODO: where did format_value come from?
+            # it does not exist anywhere in the project.
+            # return format_value(x, self.delta)
+            return '{:.3G}'.format(x)
 
     ticks = ()  #(vmin, vmax)
     formatter = MinDigitsFormatter(vmin, vmax)
@@ -203,7 +211,7 @@ def __call__(self, x, pos=None):
     return cbar
 
 
-def _make_logp_histogram(values, logp, nbins, ci, weights, cbar):
+def _make_logp_histogram(values, logp, nbins, ci, weights, cbar, axes):
     from numpy import (ones_like, searchsorted, linspace, cumsum, diff,
                        unique, argsort, array, hstack, exp)
     if weights is None:
@@ -218,7 +226,6 @@ def _make_logp_histogram(values, logp, nbins, ci, weights, cbar):
     #weightsum = cumsum(weights)
     #heights = diff(weightsum[idx])/weightsum[-1]  # normalized weights
 
-    import pylab
     edgecolors = None
     cmap = cbar.cmap
     cmap_edges = linspace(0, 1, cmap.N+1)[1:-1]
@@ -274,7 +281,7 @@ def _make_logp_histogram(values, logp, nbins, ci, weights, cbar):
         tops = unique(hstack((change_point, len(pv)-1)))
         y = hstack((0, y_top[tops]))
         z = pv[tops][:, None]
-        pylab.pcolormesh(
+        axes.pcolormesh(
             x, y, z, norm=cbar.norm, cmap=cmap, edgecolors=edgecolors)
 
         # centerpoint, histogram height, maximum likelihood for each bin
@@ -291,7 +298,7 @@ def _make_logp_histogram(values, logp, nbins, ci, weights, cbar):
     ml_peak = np.max(maxlikelihood)
     if ml_peak > hist_peak*1.3:
         maxlikelihood *= hist_peak*1.3/ml_peak
-    pylab.plot(centers, maxlikelihood, '-g')
+    axes.plot(centers, maxlikelihood, '-g')
 
     ## plot marginal gaussian approximation along with histogram
     #def G(x, mean, std):
diff --git a/bumps/dream/views.py b/bumps/dream/views.py
index 7aded468..a6f1456a 100644
--- a/bumps/dream/views.py
+++ b/bumps/dream/views.py
@@ -84,9 +84,9 @@ def plot_all(state, portion=1.0, figfile=None):
         if figfile is not None:
             savefig(figfile+"-parcor"+figext)
 
-def plot_corrmatrix(draw, nbins=50):
+def plot_corrmatrix(draw, nbins=50, fig=None):
     c = corrplot.Corr2d(draw.points.T, bins=nbins, labels=draw.labels)
-    c.plot()
+    c.plot(fig=fig)
     #print "Correlation matrix\n",c.R()
 
 
diff --git a/bumps/errplot.py b/bumps/errplot.py
index 485eb7c7..80ca1438 100644
--- a/bumps/errplot.py
+++ b/bumps/errplot.py
@@ -123,10 +123,10 @@ def calc_errors(problem, points):
     return ret
 
 
-def show_errors(errs):
+def show_errors(errs, fig=None):
     """
     Display the confidence regions returned by :func:`calc_errors`.
 
     The content of *errs* depends on the active plugin.
     """
-    return plugin.show_errors(errs)
+    return plugin.show_errors(errs, fig=fig)
diff --git a/bumps/fitproblem.py b/bumps/fitproblem.py
index 5e430ba2..af02bd68 100644
--- a/bumps/fitproblem.py
+++ b/bumps/fitproblem.py
@@ -49,31 +49,39 @@
 # to avoid this issue.
 from __future__ import division, with_statement
 
-__all__ = ['Fitness', 'FitProblem', 'load_problem',
-           'BaseFitProblem', 'MultiFitProblem']
+__all__ = ['Fitness', 'FitProblem', 'load_problem']
 
 import sys
 import os
 import traceback
 import logging
 import warnings
+import builtins
+
+from dataclasses import dataclass, field
+from typing import Optional
 
 import numpy as np
-from numpy import inf, isnan, NaN
+from numpy import inf, isnan, nan
 
 from . import parameter, bounds as mbounds
-from .parameter import to_dict
+from .parameter import to_dict, Parameter, Expression, Variable, tag_all
 from .formatnum import format_uncertainty
 from . import util
 
-# Abstract base class
-class Fitness(object):
+
+# Abstract base class:
+# can use "isinstance" to check if a class implements the protocol
+@util.runtime_checkable
+@dataclass(init=False)
+class Fitness(util.Protocol):
     """
     Manage parameters, data, and theory function evaluation.
 
     See :ref:`fitness` for a detailed explanation.
     """
-    def parameters(self):
+
+    def parameters(self) -> util.List[Parameter]:
         """
         return the parameters in the model.
 
@@ -143,22 +151,53 @@ def plot(self, view='linear'):
         pass
 
 
-def no_constraints():
+def no_constraints() -> float:
     """default constraints function for FitProblem"""
     return 0
 
+def fit_parameters(fitness: Fitness) -> util.List[Parameter]:
+    """
+    Return a list of fittable (non-fixed) parameters in the model
+    """
+    parameters = parameter.unique(fitness.parameters())
+    return [p for p in parameters if isinstance(getattr(p, 'slot', None), parameter.Variable) and not p.fixed]
+
 
-# TODO: refactor FitProblem definition
-# deprecate the direct use of MultiFitProblem
-def FitProblem(*args, **kw):
-    r"""
-    Return a fit problem instance for the fitness function(s).
+def chisq_str(fitness: Fitness) -> str:
+    """
+    Return a string representing the chisq equivalent of the nllf.
+    If the model has strictly gaussian independent uncertainties then the
+    negative log likelihood function will return 0.5*sum(residuals**2),
+    which is 1/2*chisq.  Since we are printing normalized chisq, we
+    multiply the model nllf by 2/DOF before displaying the value.  This
+    is different from the problem nllf function, which includes the
+    cost of the prior parameters and the cost of the penalty constraints
+    in the total nllf.  The constraint value is displayed separately.
+    """
+
+    pars = fit_parameters(fitness)
+    dof = fitness.numpoints() - len(pars)
+    if dof <= 0 or np.isnan(dof) or np.isinf(dof):
+        chisq_norm, chisq_err = 1., 0.
+    else:
+        #return 2./dof, 1./dof
+        from scipy.stats import chi2
+        npars = max(len(pars), 1)
+        chisq_norm, chisq_err = 2./dof, chi2.ppf(ONE_SIGMA, npars)/dof
+    
+    chisq = fitness.nllf() * chisq_norm
+    text = format_uncertainty(chisq, chisq_err)
+    return text
 
-    For an individual model:
+def show_parameters(fitness: Fitness, subs: util.Optional[util.Dict[util.Any, Parameter]]=None):
+    """Print the available parameters to the console as a tree."""
+    print(parameter.format(fitness.parameters(), freevars=subs))
+    print("[chisq=%s, nllf=%g]" % (chisq_str(fitness), fitness.nllf()))
 
-        *fitness* is a :class:`Fitness` instance.
 
-    For a set of models:
+@dataclass(init=False, eq=False)
+class FitProblem:
+    r"""
 
         *models* is a sequence of :class:`Fitness` instances.
 
@@ -177,7 +216,10 @@ def FitProblem(*args, **kw):
 
         *name* name of the problem
 
-        *constraints* is a function which returns the negative log likelihood
+        *constraints* is a list of Constraint objects, which have a method to
+        calculate the nllf for that constraint. 
+        Also supports an alternate form which cannot be serialized:
+        A function which returns the negative log likelihood
         of seeing the parameters independent from the fitness function.  Use
         this for example to check for feasible regions of the search space, or
         to add constraints that cannot be easily calculated per parameter.
@@ -185,11 +227,8 @@ def FitProblem(*args, **kw):
         the feasible region so that the fit will be directed toward feasible
         values.
 
-        *soft_limit* is the constraints function cutoff, beyond which the
-        *penalty_nllf* will be used and *fitness* nllf will not be calculated.
-
         *penalty_nllf* is the nllf to use for *fitness* when *constraints*
-        is greater than *soft_limit*.
+        or model parameter bounds are not satisfied.
 
     Total nllf is the sum of the parameter nllf, the constraints nllf and the
     depending on whether constraints is greater than soft_limit, either the
@@ -197,119 +236,118 @@ def FitProblem(*args, **kw):
 
     New in 0.9.0: weights are now squared when computing the sum rather than
     linear.
-    """
-    if len(args) > 0:
-        if isinstance(args[0], (list, tuple)):
-            return MultiFitProblem(args[0], *args[1:], **kw)
-        else:
-            return BaseFitProblem(*args, **kw)
-    else:
-        if 'fitness' in kw:
-            return BaseFitProblem(*args, **kw)
-        else:
-            return MultiFitProblem(*args, **kw)
+    """  
 
+    name: util.Optional[str]
+    models: util.List[Fitness]
+    freevars: util.Optional[parameter.FreeVariables]
+    weights: util.Union[util.List[float], util.Literal[None]]
+    constraints: util.Optional[util.Sequence[parameter.Constraint]]
+    penalty_nllf: util.Union[float, util.Literal["inf"]]
 
-class BaseFitProblem(object):
-    """
-    See :func:`FitProblem`
-    """
-    def __init__(self, fitness, name=None, constraints=None,
-                 penalty_nllf=np.inf, soft_limit=np.inf, partial=False):
-        self.constraints = constraints
-        self.fitness = fitness
-        self.partial = partial
-        if name is not None:
-            self.name = name
+    _constraints_function: util.Callable[..., float]
+    _models: util.List[Fitness]
+    _parameters: util.List[Parameter]
+    _parameters_by_id: util.Dict[str, Parameter]
+    _dof: float = np.nan # not a schema field, and is not used in __init__
+
+    # _all_constraints: util.List[util.Union[Parameter, Expression]]
+
+    def __init__(self, models: util.Union[Fitness, util.List[Fitness]], weights=None, name=None,
+                 constraints=None,
+                 penalty_nllf="inf",
+                 freevars=None,
+                 soft_limit:util.Optional[float]=None,  # TODO: deprecate,
+                 auto_tag=False
+                 ):
+        if not isinstance(models, (list, tuple)):
+            models = [models]
+        if callable(constraints):
+            warnings.warn("convert constraints functions to constraint expressions", DeprecationWarning)
+            self._constraints_function = constraints
+            self.constraints = []
         else:
-            try:
-                self.name = fitness.name
-            except AttributeError:
-                self.name = 'FitProblem'
+            self._constraints_function = self._null_constraints_function
+            # TODO: do we want to allow "constraints=a<b" or do we require a sequence "constraints=[a<b]"?
+            self.constraints = constraints if constraints is not None else []
+        if freevars is None:
+            names = ["M%d" % i for i, _ in enumerate(models)]
+            freevars = parameter.FreeVariables(names=names)
+        self.freevars = freevars
+        if auto_tag:
+            for index, model in enumerate(models):
+                model_name = model.name if model.name is not None else f"Model{index}"
+                tag_all(model.parameters(), model_name)
+        self._models = models
+        if weights is None:
+            weights = [1.0 for _ in models]
+        self.weights = weights
+        self.penalty_nllf = float(penalty_nllf)
+        self.set_active_model(0)  # Set the active model to model 0
+        self.model_reset() # sets self._all_constraints
+        self.name = name
 
-        self.soft_limit = soft_limit
-        self.penalty_nllf = penalty_nllf
-        self.model_reset()
+    @staticmethod
+    def _null_constraints_function():
+        return 0.
 
-    # noinspection PyAttributeOutsideInit
-    def model_reset(self):
-        """
-        Prepare for the fit.
+    @property
+    def fitness(self):
+        warnings.warn('Deprecated: use of problem.fitness will be removed at some point')
+        if len(self._models) == 1:
+            return self._models[0]
+        raise ValueError('problem.fitness is not defined')
 
-        This sets the parameters and the bounds properties that the
-        solver is expecting from the fittable object.  We also compute
-        the degrees of freedom so that we can return a normalized fit
-        likelihood.
+    @property
+    def dof(self):
+        return self._dof
 
-        If the set of fit parameters changes, then model_reset must
-        be called.
-        """
-        # print self.model_parameters()
-        all_parameters = parameter.unique(self.model_parameters())
-        # print "all_parameters",all_parameters
-        self._parameters = parameter.varying(all_parameters)
-        # print "varying",self._parameters
-        self.bounded = [p for p in all_parameters
-                        if not isinstance(p.bounds, mbounds.Unbounded)]
-        self.dof = self.model_points()
-        if not self.partial:
-            self.dof -= len(self._parameters)
-        if self.dof <= 0:
-            raise ValueError("Need more data points than fitting parameters")
-        #self.constraints = pars.constraints()
+    # TODO: make this @property\ndef models(self): ...
+    @property
+    def models(self):
+        """Iterate over models, with free parameters set from model values"""
+        try:
+            for i, f in enumerate(self._models):
+                self.freevars.set_model(i)
+                yield f
+        finally:
+            # Restore the active model after cycling, even if interrupted
+            self.freevars.set_model(self._active_model_index)
+
+    # noinspection PyAttributeOutsideInit
+    def set_active_model(self, i):
+        """Use free parameters from model *i*"""
+        self._active_model_index = i
+        self.active_model = self._models[i]
+        self.freevars.set_model(i)
 
     def model_parameters(self):
-        """
-        Parameters associated with the model.
-        """
-        return self.fitness.parameters()
+        """Return parameters from all models"""
+        pars = {}
+        pars['models'] = [f.parameters() for f in self.models]
+        free = self.freevars.parameters()
+        if free:
+            pars['freevars'] = free
+        return pars
 
     def to_dict(self):
         return {
             'type': type(self).__name__,
             'name': self.name,
-            'fitness': to_dict(self.fitness),
-            'partial': self.partial,
-            'soft_limit': self.soft_limit,
+            'models': to_dict(self._models),
+            'weights': self.weights,
             'penalty_nllf': self.penalty_nllf,
             # TODO: constraints may be a function.
             'constraints': to_dict(self.constraints),
+            'freevars': to_dict(self.freevars),
         }
 
-    def model_points(self):
-        """
-        Number of data points associated with the model.
-        """
-        return self.fitness.numpoints()
-
-    def model_update(self):
-        """
-        Update the model according to the changed parameters.
-        """
-        if hasattr(self.fitness, 'update'):
-            self.fitness.update()
-
-    def model_nllf(self):
-        """
-        Negative log likelihood of seeing data given model.
-        """
-        return self.fitness.nllf()
-
-    def simulate_data(self, noise=None):
-        """Simulate data with added noise"""
-        self.fitness.simulate_data(noise=noise)
-
-    def resynth_data(self):
-        """Resynthesize data with noise from the uncertainty estimates."""
-        self.fitness.resynth_data()
-
-    def restore_data(self):
-        """Restore original data after resynthesis."""
-        self.fitness.restore_data()
+    def __repr__(self):
+        return "FitProblem(name=%s)" % self.name
 
     def valid(self, pvec):
         """Return true if the point is in the feasible region"""
-        return all(v in p.bounds for p, v in zip(self._parameters, pvec))
+        return all(v in p.prior for p, v in zip(self._parameters, pvec))
 
     def setp(self, pvec):
         """
@@ -324,7 +362,7 @@ def setp(self, pvec):
         # WARNING: don't try to conditionally update the model
         # depending on whether any model parameters have changed.
         # For one thing, the model_update below probably calls
-        # the subclass MultiFitProblem.model_update, which signals
+        # the subclass FitProblem.model_update, which signals
         # the individual models.  Furthermore, some parameters may
         # related to others via expressions, and so a dependency
         # tree needs to be generated.  Whether this is better than
@@ -344,8 +382,8 @@ def getp(self):
         return np.array([p.value for p in self._parameters], 'd')
 
     def bounds(self):
-        """Return the bounds fore each parameter a 2 x N array"""
-        limits = [p.bounds.limits for p in self._parameters]
+        """Return the bounds for each parameter as a 2 x N array"""
+        limits = [p.prior.limits for p in self._parameters]
         return np.array(limits, 'd').T if limits else np.empty((2, 0))
 
     def randomize(self, n=None):
@@ -365,73 +403,68 @@ def randomize(self, n=None):
             self.setp(self.randomize(n=1)[0])
             return   # Not returning anything since no n is requested
 
+        # TODO: apply hard limits on parameters
         target = self.getp()
         target[~np.isfinite(target)] = 1.
-        pop = [p.bounds.random(n, target=v)
+        pop = [p.prior.random(n, target=v)
                for p, v in zip(self._parameters, target)]
         return np.array(pop).T
 
-    def parameter_nllf(self):
+    def nllf(self, pvec=None) -> float:
         """
-        Returns negative log likelihood of seeing parameters p.
-        """
-        s = sum(p.nllf() for p in self.bounded)
-        # print "; ".join("%s %g %g"%(p,p.value,p.nllf()) for p in
-        # self.bounded)
-        return s
-
-    def constraints_nllf(self):
-        """
-        Returns the cost of all constraints.
-        """
-        return self.constraints() if self.constraints else 0.
+        compute the cost function for a new parameter set p.
 
-    def parameter_residuals(self):
-        """
-        Returns negative log likelihood of seeing parameters p.
-        """
-        return [p.residual() for p in self.bounded]
+        this is not simply the sum-squared residuals, but instead is the
+        negative log likelihood of seeing the data given the model parameters
+        plus the negative log likelihood of seeing the model parameters.  the
+        value is used for a likelihood ratio test so normalization constants
+        can be ignored.  there is an additional penalty value provided by
+        the model which can be used to implement inequality constraints.  any
+        penalty should be large enough that it is effectively excluded from
+        the parameter space returned from uncertainty analysis.
 
-    @property
-    def has_residuals(self):
+        the model is not actually calculated if any of the parameters are 
+        out of bounds, or any of the constraints are not satisfied, but
+        instead are assigned a value of *penalty_nllf*.  
+        this will prevent expensive models from spending time computing 
+        values in the unfeasible region.
         """
-        True if the underlying fitness function defines residuals.
-        """
-        return hasattr(self.fitness, 'residuals')
+        if pvec is not None:
+            if self.valid(pvec):
+                self.setp(pvec)
+            else:
+                return inf
 
-    def residuals(self):
-        r"""
-        Return the model residuals.
+        pparameter, pconstraints, pmodel, failing_constraints = self._nllf_components()
+        cost = pparameter + pconstraints + pmodel
+        # print(pvec, "cost=",pparameter,"+",pconstraints,"+",pmodel,"=",cost)
+        if isnan(cost):
+            # TODO: make sure errors get back to the user
+            # print "point evaluates to nan"
+            # print parameter.summarize(self._parameters)
+            return inf
+        return cost
 
-        If the model is defined by $y = f(x) + \epsilon$ for normally
-        distributed error in the measurement $y$ equal to
-        $\epsilon \sim N(0, \sigma^2)$, then residuals will be defined by
-        $R = (y - f(x))/\sigma$.  If the measurement uncertainty is not normal,
-        then the normal equivalent residuals should be defined so that the
-        Levenberg-Marquardt fit behaves reasonably, and the plot of
-        residuals gives an indication of which points are driving the fit.
+    def parameter_nllf(self) -> util.Tuple[float, util.List[str]]:
         """
-        if not hasattr(self.fitness, 'residuals'):
-            ## Fake residuals by using single nllf value as residual
-            #return np.asarray([np.sqrt(self.nllf())])
-            raise NotImplementedError("model does not define residuals")
-        return self.fitness.residuals()
-
-    def chisq(self):
+        Returns negative log likelihood of seeing parameters p.
         """
-        Return sum squared residuals normalized by the degrees of freedom.
-
-        In the context of a composite fit, the reduced chisq on the individual
-        models only considers the points and the fitted parameters within
-        the individual model.
+        failing = []
+        nllf = 0.0
+        for p in self._bounded:
+            p_nllf = p.nllf()
+            nllf += p_nllf
+            if p_nllf == np.inf:
+                failing.append(str(p))
+        # print "; ".join("%s %g %g"%(p,p.value,p.nllf()) for p in
+        # self._bounded)
+        return nllf, failing
 
-        Note that this does not include cost factors due to constraints on
-        the parameters, such as sample_offset ~ N(0,0.01).
+    def parameter_residuals(self):
+        """
+        Returns negative log likelihood of seeing parameters p.
         """
-        if hasattr(self.fitness, 'chisq'):
-            return self.fitness.chisq()
-        return np.sum(self.residuals() ** 2) / self.dof
-        # return 2*self.nllf()/self.dof
+        return [p.residual() for p in self._bounded]
 
     def chisq_str(self):
         """
@@ -445,72 +478,41 @@ def chisq_str(self):
         cost of the prior parameters and the cost of the penalty constraints
         in the total nllf.  The constraint value is displayed separately.
         """
-        pparameter, pconstraints, pmodel = self._nllf_components()
+        pparameter, pconstraints, pmodel, failing_constraints = self._nllf_components()
         chisq_norm, chisq_err = nllf_scale(self)
         chisq = pmodel * chisq_norm
         text = format_uncertainty(chisq, chisq_err)
         constraints = pparameter + pconstraints
         if constraints > 0.:
             text += " constraints=%g" % constraints
+        if len(failing_constraints) > 0:
+            text += " failing_constraints=%s" % str(failing_constraints)
 
         return text
 
-    def nllf(self, pvec=None):
-        """
-        compute the cost function for a new parameter set p.
-
-        this is not simply the sum-squared residuals, but instead is the
-        negative log likelihood of seeing the data given the model parameters
-        plus the negative log likelihood of seeing the model parameters.  the
-        value is used for a likelihood ratio test so normalization constants
-        can be ignored.  there is an additional penalty value provided by
-        the model which can be used to implement inequality constraints.  any
-        penalty should be large enough that it is effectively excluded from
-        the parameter space returned from uncertainty analysis.
-
-        the model is not actually calculated if the parameter nllf plus the
-        constraint nllf are bigger than *soft_limit*, but instead it is
-        assigned a value of *penalty_nllf*.  this will prevent expensive
-        models from spending time computing values in the unfeasible region.
-        """
-        if pvec is not None:
-            if self.valid(pvec):
-                self.setp(pvec)
-            else:
-                return inf
-
-        pparameter, pconstraints, pmodel = self._nllf_components()
-        cost = pparameter + pconstraints + pmodel
-        # print(pvec, "cost=",pparameter,"+",pconstraints,"+",pmodel,"=",cost)
-        if isnan(cost):
-            # TODO: make sure errors get back to the user
-            # print "point evaluates to nan"
-            # print parameter.summarize(self._parameters)
-            return inf
-        return cost
-
-    def _nllf_components(self):
+    def _nllf_components(self) -> util.Tuple[float, float, float, util.List[str]]:
         try:
-            pparameter = self.parameter_nllf()
+            pparameter, failing_parameter_constraints = self.parameter_nllf()
             if isnan(pparameter):
                 # TODO: make sure errors get back to the user
                 info = ["Parameter nllf is wrong"]
-                info += ["%s %g"%(p, p.nllf()) for p in self.bounded]
+                info += ["%s %g"%(p, p.nllf()) for p in self._bounded]
                 logging.error("\n  ".join(info))
-            pconstraints = self.constraints_nllf()
+            pconstraints, failing_constraints = self.constraints_nllf()
+            failing_constraints += failing_parameter_constraints
             # Note: for hard constraints (which return inf) avoid computing
             # model even if soft_limit is inf by using strict comparison
             # since inf <= inf is True but inf < inf is False.
-            pmodel = (self.model_nllf()
-                      if pparameter + pconstraints < self.soft_limit
-                      else self.penalty_nllf)
-            return pparameter, pconstraints, pmodel
+            penalty_nllf = self.penalty_nllf if self.penalty_nllf is not None else np.inf
+            pmodel = self.model_nllf() if len(failing_constraints) == 0 else penalty_nllf
+                     
+            return pparameter, pconstraints, pmodel, failing_constraints
         except Exception:
             # TODO: make sure errors get back to the user
             info = (traceback.format_exc(),
                     parameter.summarize(self._parameters))
             logging.error("\n".join(info))
-            return NaN, NaN, NaN
+            return nan, nan, nan, []
 
     def __call__(self, pvec=None):
         """
@@ -523,157 +525,92 @@ def __call__(self, pvec=None):
         """
         return 2 * self.nllf(pvec) / self.dof
 
-    def show(self, _subs={}):
-        """Print the available parameters to the console as a tree."""
-        print(parameter.format(self.model_parameters(), freevars=_subs))
-        print("[chisq=%s, nllf=%g]" % (self.chisq_str(), self.nllf()))
-        #print(self.summarize())
-
     def summarize(self):
         """Return a table of current parameter values with range bars."""
         return parameter.summarize(self._parameters)
 
-    def labels(self):
+    def labels(self) -> util.List[str]:
         """Return the list of labels, one per fitted parameter."""
         return [p.name for p in self._parameters]
 
-    def save(self, basename):
-        """
-        Save the problem state for the current parameter set.
-
-        The underlying Fitness object *save* method is called, if it exists,
-        so that theory values can be saved in a format suitable to the problem.
-
-        Uses *basename* as the base of any files that are created.
-        """
-        if hasattr(self.fitness, 'save'):
-            self.fitness.save(basename)
-
-    def plot(self, p=None, fignum=None, figfile=None, view=None):
-        """
-        Plot the problem state for the current parameter set.
-
-        The underlying Fitness object *plot* method is called with *view*.
-        It should produce its plot on the current matplotlib figure.  This
-        method will add chisq to the plot and save it to a file.
-        """
-        if not hasattr(self.fitness, 'plot'):
-            return
-
-        import pylab
-        if fignum is not None:
-            pylab.figure(fignum)
-        if p is not None:
-            self.setp(p)
-        self.fitness.plot(view=view)
-        pylab.text(0.01, 0.01, 'chisq=%s' % self.chisq_str(),
-                   transform=pylab.gca().transAxes)
-        if figfile is not None:
-            pylab.savefig(figfile + "-model.png", format='png')
-
-    def cov(self):
-        """
-        Return the covariance matrix as computed by numdifftools from the
-        Hessian matrix for the problem at the current parameter values.
+    # noinspection PyAttributeOutsideInit
+    def model_reset(self):
         """
-        # TODO: remove from model
-        warnings.warn("use cov and stderr from FitDriver, not problem.",
-                      DeprecationWarning)
+        Prepare for the fit.
 
-        from . import lsqerror
-        H = lsqerror.hessian(self)
-        H, L = lsqerror.perturbed_hessian(H)
-        return lsqerror.chol_cov(L)
+        This sets the parameters and the bounds properties that the
+        solver is expecting from the fittable object.  We also compute
+        the degrees of freedom so that we can return a normalized fit
+        likelihood.
 
-    def stderr(self):
-        """
-        Return the 1-sigma uncertainty estimate for each parameter and the
-        correlation matrix *R* as computed from the covariance returned by
-        *cov*.
+        If the set of fit parameters changes, then model_reset must
+        be called.
         """
-        # TODO: remove from model
-        warnings.warn("use cov and stderr from FitDriver, not problem.",
-                      DeprecationWarning)
-
-        from . import lsqerror
-        c = self.cov()
-        return lsqerror.stderr(c), lsqerror.corr(c)
-
-    def __getstate__(self):
-        return (self.fitness, self.partial, self.name, self.penalty_nllf,
-                self.soft_limit, self.constraints)
-
-    def __setstate__(self, state):
-        self.fitness, self.partial, self.name, self.penalty_nllf, \
-            self.soft_limit, self.constraints = state
-        self.model_reset()
-
-class MultiFitProblem(BaseFitProblem):
-    """
-    Weighted fits for multiple models. See :func:`FitProblem` for an
-    explanation of weights.
-    """
-    def __init__(self, models, weights=None, name=None,
-                 constraints=None,
-                 soft_limit=np.inf, penalty_nllf=1e6,
-                 freevars=None):
-        self.partial = False
-        self.constraints = constraints
-        if freevars is None:
-            names = ["M%d" % i for i, _ in enumerate(models)]
-            freevars = parameter.FreeVariables(names=names)
-        self.freevars = freevars
-        self._models = [BaseFitProblem(m, partial=True) for m in models]
-        if weights is None:
-            weights = [1 for _ in models]
-        self.weights = weights
-        self.penalty_nllf = penalty_nllf
-        self.soft_limit = soft_limit
-        self.set_active_model(0)  # Set the active model to model 0
-        self.model_reset()
-        self.name = name
-
-    @property
-    def models(self):
-        """Iterate over models, with free parameters set from model values"""
-        for i, f in enumerate(self._models):
-            self.freevars.set_model(i)
-            yield f
-        # Restore the active model after cycling
-        self.freevars.set_model(self._active_model_index)
-
-    # noinspection PyAttributeOutsideInit
-    def set_active_model(self, i):
-        """Use free parameters from model *i*"""
-        self._active_model_index = i
-        self.active_model = self._models[i]
-        self.freevars.set_model(i)
-
-    def model_parameters(self):
-        """Return parameters from all models"""
-        pars = {'models': [f.model_parameters() for f in self.models]}
-        free = self.freevars.parameters()
-        if free:
-            pars['freevars'] = free
-        return pars
-
-    def to_dict(self):
-        return {
-            'type': type(self).__name__,
-            'name': self.name,
-            'models': to_dict(self._models),
-            'weights': self.weights,
-            'partial': self.partial,
-            'soft_limit': self.soft_limit,
-            'penalty_nllf': self.penalty_nllf,
-            # TODO: constraints may be a function.
-            'constraints': to_dict(self.constraints),
-            'freevars': to_dict(self.freevars),
-        }
+        # print self.model_parameters()
+        all_parameters = parameter.unique(self.model_parameters())
+        # print "all_parameters",all_parameters
+        # for p in all_parameters:
+        #     if hasattr(p, 'reset_prior'):
+        #         p.reset_prior()  # no constraints
+        #     else:
+        #         raise ValueError(f"{p} does not have prior")
+        broken = []
+        for p in all_parameters:
+            # slot = p.slot
+            # value = p.value
+            
+            # TODO: this is a shim to accomodate Expression, Calculation etc. being
+            # put into attributes that have type "Parameter" (in user scripts)
+            # Do we cause those scripts to break instead?  
+            # Or do we autoconvert to .equals()?
+            if hasattr(p, 'add_prior'):
+                p.add_prior(p.distribution, bounds=p.bounds, limits=p.limits)
+
+            # TODO: currently this logic for showing breaking constraints to the user 
+            # is in the chisq_str execution path instead of on model_reset - should it be here instead?
+
+            # While we are walking all parameters check which constraints aren't satisfied
+            # Build up a list of strings to help the user initialize the model correctly
+            if hasattr(p, 'limits'):
+                value = p.value
+                if (p.limits[0] > value) or (value > p.limits[1]):
+                    broken.append(f"{p}={value} is outside {p.limits}")
+                elif not np.isfinite(p.prior.nllf(value)):
+                    broken.append(f"{p}={value} is outside {p.prior}")
+
+        broken.extend([f"constraint {c} is unsatisfied" for c in self.constraints if float(c) == inf])
+        if self._constraints_function() == inf:
+             broken.append("user constraint function is unsatisfied")
+        if len(broken) > 0:
+            warnings.warn("Unsatisfied constraints: [%s]" % (",\n".join(broken)))
+        self.broken_constraints = broken
+
+        # TODO: shimmed to allow non-Parameter in Parameter attribute spots.
+        pars = []
+        pars_by_id = {}
+        for p in all_parameters:
+            if hasattr(p, 'id'):
+                pars_by_id[p.id] = p
+            slot = getattr(p, 'slot', None)
+            if isinstance(slot, Variable) and not p.fixed:
+                pars.append(p)
+        self._parameters = pars
+        self._parameters_by_id = pars_by_id
+        # TODO: shimmed to allow non-Parameter in Parameter attribute spots.
+        self._bounded = [p for p in all_parameters if hasattr(p, 'has_prior') and p.has_prior()]
+        self._dof = self.model_points()
+        self._dof -= len(self._parameters)
+        if self.dof <= 0:
+            warnings.warn(f"Need more data points (currently: {self.model_points()}) than fitting parameters ({len(self._parameters)})")
+        #self.constraints = pars.constraints()
+        # Find the constraints on variables and expressions that we need to compute
+        # parameter_constraints = [p.slot for p in all_parameters if isinstance(p.slot, Variable) and p.has_prior()]
+        # expression_constraints = [p.slot for p in all_parameters if isinstance(p.slot, Expression) and p.has_prior()]
+        # self._all_constraints = parameter_constraints + expression_constraints
 
     def model_points(self):
         """Return number of points in all models"""
-        return sum(f.model_points() for f in self.models)
+        return sum(f.numpoints() for f in self.models)
 
     def model_update(self):
         """Let all models know they need to be recalculated"""
@@ -685,16 +622,28 @@ def model_update(self):
         # computation is cheap before jumping to a more expensive
         # subspace.  SrFit does this.
         for f in self.models:
-            f.model_update()
+            if hasattr(f, 'update'):
+                f.update()
 
     def model_nllf(self):
         """Return cost function for all data sets"""
-        return sum(w**2*f.model_nllf() for w, f in zip(self.weights, self.models))
+        return sum(w**2*f.nllf() for w, f in zip(self.weights, self.models))
 
-    def constraints_nllf(self):
+    def constraints_nllf(self) -> util.Tuple[float, util.List[str]]:
         """Return the cost function for all constraints"""
-        return (sum(f.constraints_nllf() for f in self.models)
-                + BaseFitProblem.constraints_nllf(self))
+        failing = []
+        nllf = 0.0
+        nllf = self._constraints_function()
+        if nllf == np.inf:
+            failing.append("user constraints function")
+        for c in self.constraints:
+            # TODO: convert to list of residuals for Levenberg-Marquardt
+            c_nllf = float(c)**2
+            nllf += c_nllf
+            if c_nllf > 0:
+                failing.append(str(c))
+
+        return nllf, failing
 
     def simulate_data(self, noise=None):
         """Simulate data with added noise"""
@@ -716,7 +665,7 @@ def has_residuals(self):
         """
         True if all underlying fitness functions define residuals.
         """
-        return all(f.has_residuals for f in self.models)
+        return all(hasattr(f, 'residuals') for f in self.models)
 
     def residuals(self):
         resid = np.hstack([w * f.residuals()
@@ -725,22 +674,31 @@ def residuals(self):
 
     def save(self, basename):
         for i, f in enumerate(self.models):
-            f.save(basename + "-%d" % (i + 1))
+            if hasattr(f, 'save'):
+                f.save(basename + "-%d" % (i + 1))
 
     def show(self):
         for i, f in enumerate(self.models):
-            print("-- Model %d %s" % (i, f.name))
+            print("-- Model %d %s" % (i, getattr(f, 'name', '')))
             subs = self.freevars.get_model(i) if self.freevars else {}
-            f.show(_subs=subs)
+            show_parameters(f, subs=subs)
         print("[overall chisq=%s, nllf=%g]" % (self.chisq_str(), self.nllf()))
 
-    def plot(self, p=None, fignum=1, figfile=None, view=None):
+    def plot(self, p=None, fignum=1, figfile=None, view=None, model_indices=None):
         import pylab
         if p is not None:
             self.setp(p)
         for i, f in enumerate(self.models):
-            f.plot(fignum=i + fignum, view=view)
+            if model_indices is not None and i not in model_indices:
+                continue
+            if not hasattr(f, 'plot'):
+                continue
+            f.plot(view=view)
+            pylab.figure(i + fignum)
+            f.plot(view=view)
             pylab.suptitle('Model %d - %s' % (i, f.name))
+            pylab.text(0.01, 0.01, 'chisq=%s' % chisq_str(f),
+                   transform=pylab.gca().transAxes)
             if figfile is not None:
                 pylab.savefig(figfile + "-model%d.png" % i, format='png')
 
@@ -755,7 +713,7 @@ def __setstate__(self, state):
 
 # TODO: consider adding nllf_scale to FitProblem.
 ONE_SIGMA = 0.68268949213708585
-def nllf_scale(problem):
+def nllf_scale(problem: FitProblem):
     r"""
     Return the scale factor for reporting the problem nllf as an approximate
     normalized chisq, along with an associated "uncertainty".  The uncertainty
@@ -771,7 +729,7 @@ def nllf_scale(problem):
     measurements.  To first approximation, the uncertainty in $\chi^2_N$
     is $k/(n-k)$
     """
-    dof = getattr(problem, 'dof', np.NaN)
+    dof = problem.dof
     if dof <= 0 or np.isnan(dof) or np.isinf(dof):
         return 1., 0.
     else:
@@ -780,7 +738,7 @@ def nllf_scale(problem):
         npars = max(len(problem.getp()), 1)
         return 2./dof, chi2.ppf(ONE_SIGMA, npars)/dof
 
-def load_problem(filename, options=None):
+def load_problem(filename, options=None) -> FitProblem:
     """
     Load a problem definition from a python script file.
 
@@ -807,6 +765,10 @@ def load_problem(filename, options=None):
 
     return problem
 
+def MultiFitProblem(*args, **kwargs) -> FitProblem:
+    warnings.warn(DeprecationWarning("use FitProblem directly instead of MultiFitProblem"))
+    return FitProblem(*args, **kwargs)
+
 def test_weighting():
 
     class SimpleFitness(Fitness):
diff --git a/bumps/fitters.py b/bumps/fitters.py
index 557836ff..ebd18690 100644
--- a/bumps/fitters.py
+++ b/bumps/fitters.py
@@ -608,7 +608,7 @@ def _residuals(self, p, fjac=None):
         residuals = np.hstack(
             (self.problem.residuals().flat, self.problem.parameter_residuals()))
         # Tally costs for broken constraints
-        extra_cost = self.problem.constraints_nllf()
+        extra_cost, failing_constraints = self.problem.constraints_nllf()
         # Spread the cost over the residuals.  Since we are smoothly increasing
         # residuals as we leave the boundary, this should push us back into the
         # boundary (within tolerance) during the lm fit.
@@ -681,7 +681,8 @@ def _bounded_residuals(self, p):
         residuals = np.hstack(
             (self.problem.residuals().flat, self.problem.parameter_residuals()))
         # Tally costs for straying outside the boundaries plus other costs
-        extra_cost = stray_cost + self.problem.constraints_nllf()
+        constraints_cost, failing_constraints = self.problem.constraints_nllf()
+        extra_cost = stray_cost + constraints_cost
         # Spread the cost over the residuals.  Since we are smoothly increasing
         # residuals as we leave the boundary, this should push us back into the
         # boundary (within tolerance) during the lm fit.
diff --git a/bumps/gui/app_panel.py b/bumps/gui/app_panel.py
index 7f408a00..3455a4b7 100644
--- a/bumps/gui/app_panel.py
+++ b/bumps/gui/app_panel.py
@@ -624,7 +624,9 @@ def save_model(self, path):
         except ImportError:
             from pickle import dump
         try:
-            if hasattr(self.model, 'save_json'):
+            if hasattr(plugin, 'save_json'):
+                plugin.save_json(self.model, path)
+            elif hasattr(self.model, 'save_json'):
                 self.model.save_json(path)
             with open(path,'wb') as fid:
                 dump(self.model, fid)
@@ -711,6 +713,8 @@ def set_model(self, model):
         # Enable appropriate toolbar items.
         self.tb.EnableTool(self.tb_start.GetId(), True)
         self.tb.EnableTool(self.tb_stop.GetId(), True)
+        if hasattr(model, 'broken_constraints'):
+            signal.log_message(message="Unsatisfied constraints: \n\t%s" % (",\n\t".join(model.broken_constraints)))
         if hasattr(model, 'path'):
             signal.log_message(message="loaded "+model.path)
             self.GetTopLevelParent().SetTitle("Bumps: %s"%model.name)
diff --git a/bumps/gui/data_view.py b/bumps/gui/data_view.py
index 04a11a78..8e90d141 100755
--- a/bumps/gui/data_view.py
+++ b/bumps/gui/data_view.py
@@ -12,7 +12,7 @@
 # The Figure object is used to create backend-independent plot representations.
 from matplotlib.figure import Figure
 
-from ..fitproblem import MultiFitProblem
+from ..fitproblem import FitProblem
 
 from .util import EmbeddedPylab
 
@@ -157,7 +157,7 @@ def redraw(self, reset=False):
                     break
 
             # Preform the calculation
-            if isinstance(self.problem,MultiFitProblem):
+            if isinstance(self.problem,FitProblem):
                 #print "n=",len(self.problem.models)
                 for p in self.problem.models:
                     self._precalc(p)
@@ -179,10 +179,10 @@ def redraw(self, reset=False):
                 pylab.clf() # clear the canvas
                 #shift=20 if self.view == 'log' else 0
                 shift=0
-                if isinstance(self.problem, MultiFitProblem):
+                if isinstance(self.problem, FitProblem):
                     for i,p in enumerate(self.problem.models):
                         #if hasattr(p.fitness,'plot'):
-                        p.fitness.plot(view=self.view)
+                        p.plot(view=self.view)
                         if self._cancel_calculate: break
                     pylab.text(0.01, 0.01,
                                'chisq=%s' % self.problem.chisq_str(),
diff --git a/bumps/gui/parameter_view.py b/bumps/gui/parameter_view.py
index 80ccac4c..88b0a13d 100755
--- a/bumps/gui/parameter_view.py
+++ b/bumps/gui/parameter_view.py
@@ -31,7 +31,7 @@
 from uuid import uuid4 as uuid_generate
 import sys
 
-from ..parameter import BaseParameter
+from ..parameter import Parameter
 from .util import nice
 from . import signal
 
@@ -115,7 +115,7 @@ def IsContainer(self, item):
 
         # Return False if it is a leaf
         node = self.ItemToObject(item)
-        return not isinstance(node["value"], BaseParameter)
+        return not isinstance(node["value"], Parameter)
 
     def GetParent(self, item):
         # Return the item which is this item's parent.
@@ -161,14 +161,14 @@ def GetValue(self, item, col):
             return mapper[col]
 
 
-        elif isinstance(par, BaseParameter):
+        elif isinstance(par, Parameter):
             if par.fittable:
                 if par.fixed:
                     fitted = False
                     low, high = '', ''
                 else:
                     fitted = True
-                    low, high = (str(v) for v in par.bounds.limits)
+                    low, high = (str(v) for v in par.prior.limits)
             else:
                 fitted = False
                 low, high = '', ''
@@ -194,7 +194,7 @@ def SetValue(self, value, item, col):
 
         node = self.ItemToObject(item)
         par = node["value"]
-        if isinstance(par, BaseParameter):
+        if isinstance(par, Parameter):
             if col == 0:
                 if par.fittable:
                     par.fixed = not value
@@ -203,14 +203,14 @@ def SetValue(self, value, item, col):
             elif col == 3:
                 if value == '': return
                 low = float(value)
-                high = par.bounds.limits[1]
-                if low != par.bounds.limits[0]:
+                high = par.prior.limits[1]
+                if low != par.prior.limits[0]:
                     par.range(low, high)
             elif col == 4:
                 if value == '': return
                 high = float(value)
-                low = par.bounds.limits[0]
-                if high != par.bounds.limits[1]:
+                low = par.prior.limits[0]
+                if high != par.prior.limits[1]:
                     par.range(low, high)
 
         if col == 0:
@@ -314,14 +314,14 @@ def params_to_dict(params):
             ref[k] = params_to_dict(params[k])
     elif isinstance(params, tuple) or isinstance(params, list):
         ref = [params_to_dict(v) for v in params]
-    elif isinstance(params, BaseParameter):
+    elif isinstance(params, Parameter):
         if params.fittable:
             if params.fixed:
                 fitted = 'No'
                 low, high = '', ''
             else:
                 fitted = 'Yes'
-                low, high = (str(v) for v in params.bounds.limits)
+                low, high = (str(v) for v in params.prior.limits)
         else:
             fitted = ''
             low, high = '', ''
@@ -346,7 +346,7 @@ def params_to_list(params, parent_uuid=None, output=None, path='M', links=None):
             #output.append(new_item)
             new_id = None
             params_to_list(v, parent_uuid=new_id, output=output, path=path+"[%d]" % (i,), links=links)
-    elif isinstance(params, BaseParameter):
+    elif isinstance(params, Parameter):
         link_path = links.get(id(params), "")
         if link_path == "":
             links[id(params)] = path
diff --git a/bumps/gui/summary_view.py b/bumps/gui/summary_view.py
index c9027198..e85acbb4 100755
--- a/bumps/gui/summary_view.py
+++ b/bumps/gui/summary_view.py
@@ -173,7 +173,7 @@ def __init__(self, parent, parameter, model):
         self.parameter = parameter
         self.model = model
 
-        self.low, self.high = (v for v in self.parameter.bounds.limits)
+        self.low, self.high = (v for v in self.parameter.prior.limits)
 
         text_hbox = wx.BoxSizer(wx.HORIZONTAL)
 
@@ -204,14 +204,14 @@ def __init__(self, parent, parameter, model):
         self.update_slider()
 
     def update_slider(self):
-        slider_pos = int(self.parameter.bounds.get01(self.parameter.value)*NUMTICKS)
+        slider_pos = int(self.parameter.prior.get01(self.parameter.value)*NUMTICKS)
         # Add line below if get01 doesn't protect against values out of range.
         #slider_pos = min(max(slider_pos,0),100)
         self.slider.SetValue(slider_pos)
         self.value.SetLabel(VALUE_FORMAT.format(nice(self.parameter.value, digits=VALUE_PRECISION)))
 
         # Update new min and max range of values if changed.
-        newlow, newhigh = (v for v in self.parameter.bounds.limits)
+        newlow, newhigh = (v for v in self.parameter.prior.limits)
         if newlow != self.low:
             self.min_range.SetLabel(VALUE_FORMAT.format(newlow))
 
@@ -220,7 +220,7 @@ def update_slider(self):
 
     def OnScroll(self, event):
         value = self.slider.GetValue()
-        new_value  = self.parameter.bounds.put01(value/NUMTICKS)
+        new_value  = self.parameter.prior.put01(value/NUMTICKS)
         nice_new_value = nice(new_value, digits=VALUE_PRECISION)
         self.parameter.value = nice_new_value
         self.value.SetLabel(VALUE_FORMAT.format(nice_new_value))
diff --git a/bumps/lsqerror.py b/bumps/lsqerror.py
index edb1d04e..41d78f21 100644
--- a/bumps/lsqerror.py
+++ b/bumps/lsqerror.py
@@ -85,7 +85,6 @@ def f(p):
         # flatten since this will avoid an unnecessary copy.
         return np.reshape(problem.residuals(), -1)
     J = _jacobian_forward(f, p, bounds, eps=step)
-    #J = nd.Jacobian(problem.residuals)(p)
     problem.setp(p_init)
     return J
 
@@ -140,8 +139,6 @@ def hessian(problem, p=None, step=None):
     p = np.asarray(p)
     bounds = getattr(problem, 'bounds', lambda: None)()
     H = _hessian_forward(problem.nllf, p, bounds=bounds, eps=step)
-    #H = nd.Hessian(problem.nllf)(p)
-    #print("Hessian",H)
     problem.setp(p_init)
     return H
 
diff --git a/bumps/mapper.py b/bumps/mapper.py
index a81ac643..c757a9d4 100644
--- a/bumps/mapper.py
+++ b/bumps/mapper.py
@@ -16,6 +16,7 @@
 """
 import sys
 import os
+import signal
 
 # {{{ http://code.activestate.com/recipes/496767/ (r1)
 # Converted to use ctypes by Paul Kienzle
@@ -108,6 +109,7 @@ def _MP_setup(namespace):
     # Using MPMapper class variables to store worker globals.
     # It doesn't matter if they conflict with the controller values since
     # they are in a different process.
+    signal.signal(signal.SIGINT, signal.SIG_IGN)
     MPMapper.namespace = namespace
     nice()
 
@@ -166,8 +168,13 @@ def start_mapper(problem, modelargs=None, cpus=0):
         #MPMapper.namespace.modelargs = modelargs
 
         # Set the mapper to send problem_id/point value pairs
-        mapper = lambda points: MPMapper.pool.map(
-            _MP_run_problem, ((MPMapper.problem_id, p) for p in points))
+        def mapper(points):
+            try:
+                return MPMapper.pool.map(
+                    _MP_run_problem, ((MPMapper.problem_id, p) for p in points))
+            except KeyboardInterrupt:
+                MPMapper.stop_mapper(None)
+
         return mapper
 
     @staticmethod
diff --git a/bumps/names.py b/bumps/names.py
index c8692785..f29391c1 100644
--- a/bumps/names.py
+++ b/bumps/names.py
@@ -22,16 +22,12 @@
 - :class:`Curve <bumps.curve.Curve>` for defining models from functions
 - :class:`PoissonCurve <bumps.curve.PoissonCurve>` for modelling data with Poisson uncertainty
 - :class:`PDF <bumps.pdfwrapper.PDF>` for fitting a probability distribution directly
-- :func:`FitProblem <bumps.fitproblem.FitProblem>` for defining the fit (see
-    :class:`BaseFitProblem <bumps.fitproblem.BaseFitProblem>` or
-    :class:`MultiFitProblem <bumps.fitproblem.MultiFitProblem>` for details,
-    depending on whether you are fitting a single model or multiple models
-    simultaneously).
+- :class:`FitProblem <bumps.fitproblem.FitProblem>` for defining the fit.
 """
 
 #__all__ = [ 'sys', 'np', 'inf', 'pmath',
 #    'Parameter', 'FreeVariables', 'Distribution', 'PDF', 'Curve', 'PoissonCurve',
-#        'FitProblem', 'MultiFitProblem' ]
+#        'FitProblem' ]
 
 import sys
 import numpy as np
@@ -48,6 +44,6 @@
 from .bounds import Distribution
 from .pdfwrapper import PDF, VectorPDF, DirectProblem
 from .curve import Curve, PoissonCurve
-from .fitproblem import FitProblem, MultiFitProblem
+from .fitproblem import FitProblem, Fitness
 from .fitters import fit
 from .util import relative_import
diff --git a/bumps/parameter.py b/bumps/parameter.py
index 75bff124..e5c40adb 100644
--- a/bumps/parameter.py
+++ b/bumps/parameter.py
@@ -13,16 +13,30 @@
 #__all__ = [ 'Parameter']
 import operator
 import sys
-from six.moves import reduce
+import builtins
+from dataclasses import dataclass, field, InitVar
+from functools import reduce
 import warnings
 from copy import copy
-import math
+import uuid
 from functools import wraps
+from enum import Enum
+
+from typing import (
+    Type, TypeVar, Optional, Any, Union, Dict, Callable,
+    Tuple, List, Sequence)
+from .util import Literal
 
 import numpy as np
 from numpy import inf, isinf, isfinite
 
 from . import bounds as mbounds
+from . import pmath
+from .util import field_desc, schema_config
+
+BoundsType = mbounds.BoundsType
+
+ValueType = Union['Expression', 'Parameter', 'Calculation', float]
 
 # TODO: avoid evaluation of subexpressions if parameters do not change.
 # This is especially important if the subexpression invokes an expensive
@@ -65,23 +79,249 @@ def to_dict(p):
         return str(p)
 
 
-class BaseParameter(object):
+@dataclass(init=False)
+class Uniform:
+    """ Uniform distribution with hard boundaries """
+
+
+@dataclass(init=False)
+class Normal:
+    """ Normal distribution (Gaussian) """
+    std: float = field_desc("standard deviation (1-sigma)")
+    mean: float = field_desc("center of the distribution")
+    def __init__(self, std: float, mean: float):
+        self.std = std
+        self.mean = mean
+
+# Leave out of schema for now.
+# TODO: determine if this is used by anyone
+#@dataclass(init=False)
+class UniformSoftBounded:
+    """ Uniform distribution with error-function PDF on boundaries """
+    std: float = field_desc("width of the edge distribution")
+
+
+DistributionType = Union[Uniform, Normal] #, UniformSoftBounded]
+
+class OperatorMixin:
     """
-    Root of the parameter class, defining arithmetic on parameters
+    The set of operations that can be performed on parameter-like objects
+    Parameter, Constant, Expression.
+
+    These include: +, -, *, /, //, **, abs, float, int
+
+    Also, numpy math functions: sin, cos, tan, ...
+
+    Much like abs(obj) => obj.__abs__(), np.sin(obj) => obj.sin()
     """
+    # float(value) is special: it returns the current value rather than
+    # becoming part of the parameter expression.
+    value: float
+    def __float__(self):
+        return float(self.value)
+    def __int__(self):
+        return int(self.value)
+    def __bool__(self):
+        # Note: __bool__ must return true or false, so we can't handle
+        # lazy constraint expressions like not a, a or b, a and b.
+        raise TypeError("use (p != 0) to test against zero")
+    ...  # operators and functions will be filled in later
 
-    # Parameters are fixed unless told otherwise
-    fixed = True
-    fittable = False
-    discrete = False
-    _bounds = mbounds.Unbounded()
-    name = None
-    value = None # value is an attribute of the derived class
+class ValueProtocol(OperatorMixin):
+    """
+    Values can be combined to form expressions
+    Provide a suite of operators for creating parameter expressions.
+    """
+    fittable: bool = False
+    fixed: bool = True
+    value: float
+    # TODO: Do values have names? Or do the names belong to the model parameter?
+    #name: str
+    # TODO: are priors on the parameter or on the value?
+    #bounds: Optional[BoundsType] = None
+    def parameters(self) -> List["Parameter"]:
+        # default implementation:
+        return []
+
+
+@dataclass(init=False)
+class Calculation(ValueProtocol): # the name Function is taken (though deprecated)
+    """
+    A Parameter with a model-specific, calculated value.
+    The function used to calculate this value should be well-documented in the 
+    description field, e.g.
+    Stack.thickness: description = "a sum of the thicknesses of all layers in the stack"
+
+    """
+    description: str
+    _function: Callable[[], float]  # added by the model; not serialized
+    def __init__(self, description: str=""):
+        self.description = description
+    @property
+    def value(self):
+        return self._function()
+    def __float__(self):
+        return self.value
+    def set_function(self, function):
+        self._function = function
+
+
+class SupportsPrior:
+    prior: Optional[BoundsType]
+    limits: Tuple[float, float]
+    distribution: DistributionType
+    bounds: BoundsType
+
+    def reset_prior(self):
+        self.prior = None
+
+    def has_prior(self):
+        return (
+            self.prior is not None
+            and not isinstance(self.prior, mbounds.Unbounded)
+            and self.prior.limits != (-np.inf, np.inf))
+    
+    def add_prior(self, distribution: Optional[DistributionType]=None, bounds: Optional[BoundsType]=None, limits: Optional[Tuple[float, float]]=None):
+        # use self values if they are found:
+        if distribution is None and self.distribution is not None:
+            distribution = self.distribution
+        if bounds is None and self.bounds is not None:
+            bounds = self.bounds
+        if limits is None:
+            if self.limits is not None:
+                limits = self.limits
+            else:
+                limits = (-inf, inf)
 
+        if bounds is not None:
+            # get the intersection of the limits here.
+            limits = (
+                np.clip(limits[0], *bounds),
+                np.clip(limits[1], *bounds)
+            )
+        if isinstance(distribution, Normal):
+            if limits == (-inf, inf):
+                prior = mbounds.BoundedNormal(mean=distribution.mean, std=distribution.std, limits=limits)
+            else:
+                prior = mbounds.Normal(mean=distribution.mean, std=distribution.std)
+        elif isinstance(distribution, UniformSoftBounded):
+            lo, hi = limits
+            prior = mbounds.SoftBounded(lo=lo, hi=hi, std=distribution.std)
+        elif isinstance(distribution, Uniform):
+            lo, hi = limits
+            if isinf(lo) and isinf(hi):
+                prior = mbounds.Unbounded()
+            elif isinf(lo):
+                prior = mbounds.BoundedAbove(hi)
+            elif isinf(hi):
+                prior = mbounds.BoundedBelow(lo)
+            else:
+                prior = mbounds.Bounded(lo, hi)
+        else:
+            raise ValueError("no distribution found matching %s" % (str(distribution)))
+        
+        self.prior = prior
+
+
+@schema_config()
+@dataclass(init=False)
+class Parameter(ValueProtocol, SupportsPrior):
+    """
+    A parameter is a container for a symbolic value.
+
+    Parameters have a prior probability, as set by a bounds constraint:
+
+        import numpy as np
+        from scipy.stats.distributions import lognorm
+        from bumps.parameter import Parameter
+
+        p = Parameter(3)
+        p.pmp(10)               # 3 +/- 10% uniform
+        p.pmp(-5,10)            # 3 in [2.85, 3.30] uniform
+        p.pm(2)                 # 3 +/- 2 uniform
+        p.pm(-1,2)              # 3 in [2,5] uniform
+        p.range(0,5)            # 3 in [0,5] uniform
+        p.dev(2)                # 3 +/- 2 gaussian
+        p.soft_range(2,5,2)     # 3 in [2,5] uniform with gauss wings
+        p.dev(2, limits=(0,6))  # 3 +/- 2 truncated gaussian
+        p.pdf(lognorm(3, 1))    # lognormal centered on 3, width 1.
+
+    Parameters have hard limits on the possible values, dictated by the model.
+    These bounds apply in addition to any other bounds.
+
+    Parameters can be constrained to be equal to another parameter or
+    parameter expression:
+
+        a, b = Parameter(3), Parameter(4)
+        p = Parameter(limits=(6, 10))
+        p.equals(a+b)
+        assert p.nllf() == 0.   # within the bounds
+        a.value = 20
+        assert np.isinf(p.nllf()) # out of bounds
+
+    Constraints on the computed value follow from the constraints on the
+    underlying parameters in addition to any hard limits on the parameter
+    value given by the model.
+
+    **Inputs**
+
+    *value* can be a constant, a variable, an expression or a link to
+    another parameter.
+
+    *bounds* are user-supplied limits on the parameter value within the model.
+    If bounds are supplied then the parameter defaults to fittable.
+
+    *distribution* is one of Uniform, Normal or UniformSoftBounded classes
+
+    *fixed* is True if the parameter is fixed, even if bounds are supplied.
+
+    *name* is the label associated with the parameter in plots. The names
+    need not be unique, but it will be confusing if there are duplicates.
+    The name will usually correspond to the role of the parameter in the
+    model. For models with sequences (e.g., layer numbers), try using a
+    layer name (e.g., based on the material in the layer) rather than a layer
+    number for parameters in that layer. This will make it easier for the
+    user to associate the parameters displayed at the end of the the fit
+    with the layer in the model. Also, when exploring the space of models,
+    the parameter names will be preserved even if a new layer is introduced
+    before the existing layers. That will allow the parameters from the
+    previous fit to be easily used as a seed for the fit to the new model.
+
+    *id* must be a unique identifier associated with the parameter. This
+    is used to link parameters on save and reload.
+
+    *limits* are hard limits on the parameter value within the model. Separate
+    from the prior distribution on a random variable provided by the user,
+    the hard limits are restrictions on the value imposed by the model.
+    For example, the thickness of a layer must be zero or more.
+
+    Any additional keyword arguments are preserved as properties of the
+    parameter. For example, *tip* and *units* for decorating an input form
+    in the GUI:
+
+         p = Parameter(10, name="width", units="cm", tip="Width of sample")
+
+    """
     # Parameters may be dependent on other parameters, and the
     # fit engine will need to access them.
+    #prior: Optional[BoundsType]
+    id: str = field(metadata={"format": "uuid"})
+    name: Optional[str] = field(default=None, init=False)
+    fixed: bool = True
+    slot: Union['Variable', ValueType]
+    limits: Tuple[Union[float, Literal["-inf"]], Union[float, Literal["inf"]]] = (-inf, inf)
+    bounds: Optional[Tuple[Union[float, Literal["-inf"]], Union[float, Literal["inf"]]]] = None
+    distribution: DistributionType = field(default_factory=Uniform)
+    discrete: bool = False
+    tags: List[str] = field(default_factory=list)
+    
+    _fixed: bool
+
     def parameters(self):
-        return [self]
+        pars = [self]
+        if hasattr(self.slot, 'parameters'):
+            pars += self.slot.parameters()
+        return pars
 
     def pmp(self, plus, minus=None, limits=None):
         """
@@ -100,7 +340,8 @@ def pmp(self, plus, minus=None, limits=None):
         The resulting range is converted to "nice" numbers.
         """
         bounds = mbounds.pmp(self.value, plus, minus, limits=limits)
-        self.bounds = mbounds.Bounded(*bounds)
+        self.bounds = bounds
+        self.fixed = False
         return self
 
     def pm(self, plus, minus=None, limits=None):
@@ -120,7 +361,8 @@ def pm(self, plus, minus=None, limits=None):
         The resulting range is converted to "nice" numbers.
         """
         bounds = mbounds.pm(self.value, plus, minus, limits=limits)
-        self.bounds = mbounds.Bounded(*bounds)
+        self.bounds = bounds
+        self.fixed = False
         return self
 
     def dev(self, std, mean=None, limits=None, sigma=None, mu=None):
@@ -145,25 +387,29 @@ def dev(self, std, mean=None, limits=None, sigma=None, mu=None):
                 mean = mu
         if mean is None:
             mean = self.value  # Note: value is an attribute of the derived class
-        if limits is None:
-            self.bounds = mbounds.Normal(mean, std)
-        else:
-            self.bounds = mbounds.BoundedNormal(mean, std, limits)
+        self.bounds = limits if limits is not None else (-inf, inf)
+        self.distribution = Normal(mean=mean, std=std)
+        self.fixed = False
         return self
 
-    def pdf(self, dist):
-        """
-        Allow the parameter to vary according to any continuous scipy.stats
-        distribution.
-        """
-        self.bounds = mbounds.Distribution(dist)
-        return self
+    # def pdf(self, dist):
+    #     """
+    #     Allow the parameter to vary according to any continuous scipy.stats
+    #     distribution.
+    #     """
+    #     # TODO: have to make some kind of registry for distributions?
+    #     # this will not work in new system of setting priors in model_reset.
+    #     self._set_bounds((-inf, inf))
+    #     self.distribution = dist
+    #     return self
 
     def range(self, low, high):
         """
         Allow the parameter to vary within the given range.
         """
-        self.bounds = mbounds.init_bounds((low, high))
+        self.bounds = (low, high)
+        self.distribution = Uniform()
+        self.fixed = False
         return self
 
     def soft_range(self, low, high, std):
@@ -171,96 +417,66 @@ def soft_range(self, low, high, std):
         Allow the parameter to vary within the given range, or with Gaussian
         probability, stray from the range.
         """
-        self.bounds = mbounds.SoftBounded(low, high, std)
+        self.bounds = (low, high)
+        self.distribution = UniformSoftBounded(std=std)
+        self.fixed = False
         return self
 
+    # Delegate to slots
+    @property
+    def value(self):
+        return int(self.slot) if self.discrete else float(self.slot)
+    @value.setter
+    def value(self, update):
+        self.slot.value = round(update) if self.discrete else update
+    @property
+    def fittable(self):
+        return isinstance(self.slot, Variable)
     @property
-    def bounds(self):
-        """Fit bounds"""
-        # print "getting bounds for",self,self._bounds
-        return self._bounds
-
-    @bounds.setter
-    def bounds(self, b):
-        # print "setting bounds for",self
+    def fixed(self):
+        return not self.fittable or self._fixed
+    @fixed.setter
+    def fixed(self, state):
+        # Can't set fixed to false if the parameter is not fittable
         if self.fittable:
-            self.fixed = (b is None)
-        self._bounds = b
+            self._fixed = state
+        elif not state:
+            raise TypeError(f"value in {self.name} is not fittable")
+
+    ## Use the following if bounds are on the value rather than the parameter
+    #@property
+    #def bounds(self):
+    #    return getattr(self.slot, 'bounds', None)
+    #@bounds.setter
+    #def bounds(self, b):
+    #    if not hasattr(self.slot, 'bounds'):
+    #        raise TypeError(f"{self.name} is not fittable so bounds can't be set")
+    #    if self.slot.fittable:
+    #        self.slot.fixed = (b is None)
+    #    self.slot.bounds = b
 
     # Functional form of parameter value access
     def __call__(self):
         return self.value
 
-    # Parameter algebra: express relationships between parameters
-    def __gt__(self, other):
-        return Constraint(self, other, "GT", ">")
-
-    def __ge__(self, other):
-        return Constraint(self, other, "GE", ">=")
-    def __le__(self, other):
-        return Constraint(self, other, "LE", "<=")
-
-    def __lt__(self, other):
-        return Constraint(self, other, "LT", "<")
-
-    # def __eq__(self, other):
-    #     return ConstraintEQ(self, other)
-
-    # def __ne__(self, other):
-    #     return ConstraintNE(self, other)
-
-    def __add__(self, other):
-        return Operator(self, other, "add", "+")
-
-    def __sub__(self, other):
-        return Operator(self, other, "sub", "-")
-
-    def __mul__(self, other):
-        return Operator(self, other, "mul", "*")
-
-    def __div__(self, other):
-        return Operator(self, other, "truediv", "/")
-
-    def __pow__(self, other):
-        return Operator(self, other, "pow", "**")
-
-    def __radd__(self, other):
-        return Operator(other, self, "add", "+")
-
-    def __rsub__(self, other):
-        return Operator(other, self, "sub", "-")
-
-    def __rmul__(self, other):
-        return Operator(other, self, "mul", "*")
-
-    def __rdiv__(self, other):
-        return Operator(other, self, "truediv", "/")
-
-    def __rpow__(self, other):
-        return Operator(other, self, "pow", "**")
-
-    def __abs__(self):
-        return _abs(self)
-
-    def __neg__(self):
-        return self * -1
-
-    def __pos__(self):
-        return self
-
     def __float__(self):
         return float(self.value)
 
-    __truediv__ = __div__
-    __rtruediv__ = __rdiv__
-
-    def nllf(self):
+    def nllf(self) -> float:
         """
         Return -log(P) for the current parameter value.
         """
-        return self.bounds.nllf(self.value)
+        value = self.value
+        if not (self.limits[0] <= value <= self.limits[1]):
+            # quick short-circuit if not meeting own limits:
+            return np.inf
+        else:
+            logp = self.prior.nllf(value)
+            if hasattr(self.slot, 'nllf'):
+                logp += self.slot.nllf()
+            return logp
 
-    def residual(self):
+    def residual(self) -> float:
         """
         Return the z score equivalent for the current parameter value.
 
@@ -272,7 +488,7 @@ def residual(self):
         compute the cdf of value in the parameter distribution and invert
         it using the ppf from the standard normal distribution.
         """
-        return self.bounds.residual(self.value)
+        return 0.0 if self.prior is None else self.prior.residual(self.value)
 
     def valid(self):
         """
@@ -284,7 +500,7 @@ def format(self):
         """
         Format the parameter, value and range as a string.
         """
-        return "%s=%g in %s" % (self, self.value, self.bounds)
+        return "%s=%g in %s" % (self, self.value, self.prior)
 
     def __str__(self):
         name = self.name if self.name is not None else '?'
@@ -293,83 +509,19 @@ def __str__(self):
     def __repr__(self):
         return "Parameter(%s)" % self
 
-    def to_dict(self):
-        """
-        Return a dict represention of the object.
-        """
-        # When reconstructing a model from json we will need to tie parameters
-        # together that were tied before. This can be done by managing a
-        # cache of allocated parameters indexed by id, and pulling from that
-        # cache on recontruction if the id already exists, otherwise create
-        # a new entry. Conveniently, this will handle free variable references
-        # in parameter sets as well. Note that the entire parameter description
-        # will be repeated each time it occurs, but there should be few
-        # enough of these that it isn't a problem.
-        # TODO: use id that is stable from session to session.
-        # TODO: have mechanism for clearing cache between save/load.
-        return dict(
-            type=type(self).__name__,
-            id=id(self), # Warning: this will be different every session
-            name=self.name,
-            value=self.value,
-            fixed=self.fixed,
-            fittable=self.fittable,
-            bounds=to_dict(self._bounds),
-            )
-
-
-class Constant(BaseParameter):
-    """
-    An unmodifiable value.
-    """
-    fittable = False
-    fixed = True
-
-    @property
-    def value(self):
-        return self._value
-
-    def __init__(self, value, name=None):
-        self._value = value
-        self.name = name
-
-    # to_dict() can inherit from BaseParameter
-
-
-class Parameter(BaseParameter):
-    """
-    A parameter is a symbolic value.
-
-    It can be fixed or it can vary within bounds.
-
-    p = Parameter(3).pmp(10)    # 3 +/- 10%
-    p = Parameter(3).pmp(-5,10) # 3 in [2.85,3.3] rounded to 2 digits
-    p = Parameter(3).pm(2)      # 3 +/- 2
-    p = Parameter(3).pm(-1,2)   # 3 in [2,5]
-    p = Parameter(3).range(0,5) # 3 in [0,5]
-
-    It has hard limits on the possible values, and a range that should live
-    within those hard limits.  The value should lie within the range for
-    it to be valid.  Some algorithms may drive the value outside the range
-    in order to satisfy soft It has a value which should lie within the range.
-
-    Other properties can decorate the parameter, such as tip for tool tip
-    and units for units.
-    """
-    fittable = True
-
+    # TODO: deprecate
     @classmethod
-    def default(cls, value, **kw):
+    def default(
+            cls: type,
+            value: Union[float, Tuple[float, float], ValueType],
+            **kw) -> "Parameter":
         """
-        Create a new parameter with the *value* and *kw* attributes, or return
-        the existing parameter if *value* is already a parameter.
-
-        The attributes are the same as those for Parameter, or whatever
-        subclass *cls* of Parameter is being created.
+        Create a new parameter with the *value* and *kw* attributes. If value
+        is already a parameter or expression, set it to that value.
         """
         # Need to constrain the parameter to fit within fixed limits and
         # to receive a name if a name has not already been provided.
-        if isinstance(value, BaseParameter):
+        if isinstance(value, ValueProtocol):
             return value
         else:
             return cls(value, **kw)
@@ -378,23 +530,38 @@ def set(self, value):
         """
         Set a new value for the parameter, ignoring the bounds.
         """
-        self.value = value
+        self.slot.value = value
 
     def clip_set(self, value):
         """
         Set a new value for the parameter, clipping it to the bounds.
         """
-        low, high = self.bounds.limits
-        self.value = min(max(value, low), high)
-
-    def __init__(self, value=None, bounds=None, fixed=None, name=None, **kw):
-        # UI nicities:
-        # 1. check if we are started with value=range or bounds=range; if we
+        low, high = self.prior.limits
+        self.slot.value = builtins.min(builtins.max(value, low), high)
+
+    def __init__(
+            self,
+            value: Optional[Union[float, Tuple[float, float]]] = None,
+            slot: Optional[Union['Variable', ValueType]] = None,
+            #bounds: Optional[Union[BoundsType, Tuple[float, float]]]=None,
+            fixed: Optional[bool]=None,
+            name: Optional[str]=None,
+            id: Optional[str]=None,
+            limits: Optional[Tuple[Union[float, Literal[None, "-inf"]], Union[float, Literal[None, "inf"]]]]=None,
+            bounds: Optional[Tuple[Union[float, Literal["-inf"]], Union[float, Literal["inf"]]]]=None,
+            distribution: DistributionType = Uniform(),
+            discrete: bool = False,
+            tags: Optional[List[str]] = None,
+            **kw):
+        # Check if we are started with value=range or bounds=range; if we
         # are given bounds, then assume this is a fitted parameter, otherwise
         # the parameter defaults to fixed; if value is not set, use the
         # midpoint of the range.
         if bounds is None:
             try:
+                # Note: throws TypeError if not a sequence (which we want to
+                # fall through to the remainder of the function), or ValueError
+                # if the sequence is the wrong length (which we want to fail).
                 lo, hi = value
                 warnings.warn(DeprecationWarning("parameters can no longer be initialized with a fit range"))
                 bounds = lo, hi
@@ -403,86 +570,494 @@ def __init__(self, value=None, bounds=None, fixed=None, name=None, **kw):
                 pass
         if fixed is None:
             fixed = (bounds is None)
-        bounds = mbounds.init_bounds(bounds)
-        if value is None:
-            value = bounds.start_value()
-
-        # Store whatever values the user needs to associate with the parameter
-        # Models should set units and tool tips so the user interface has
-        # something to work with.
-        limits = kw.get('limits', (-inf, inf))
-        for k, v in kw.items():
-            setattr(self, k, v)
+        if slot is None:
+            if value is None:
+                value = float(bounds[0]) if bounds is not None else 0 # ? what else to do here?
+            if isinstance(value, (float, int)):
+                value = round(value) if discrete else value
+                slot = Variable(value)
+            elif isinstance(value, ValueProtocol):
+                slot = value
+            else:
+                raise TypeError("value %s: %s cannot be converted to Variable" % (str(name), str(value)))
+        assert isinstance(slot, (float, Variable, Expression, Parameter, Constant, Calculation))
 
-        # Initialize bounds, with limits clipped to the hard limits for the
-        # parameter
-        def clip(x, a, b):
-            return min(max(x, a), b)
+        self.slot = slot
+        self.name = name
+        self.id = id if id is not None else str(uuid.uuid4())
+        self.tags = tags if tags is not None else []
+        if limits is None:
+            limits = (-np.inf, np.inf)
+        self.limits  = (
+            (-np.inf if limits[0] is None else float(limits[0])),
+            (np.inf if limits[1] is None else float(limits[1])))
+        if bounds is not None:
+            bounds = (float(bounds[0]), float(bounds[1]))
         self.bounds = bounds
-        self.bounds.limits = (clip(self.bounds.limits[0], *limits),
-                              clip(self.bounds.limits[1], *limits))
-        self.value = value
+        self.distribution = distribution
+        # Note: fixed is True unless fixed=False or bounds=bounds were given
+        # as function arguments. Note that _set_bounds() will always set the
+        # fixed to False, so we need to reset it after calling _set_bounds().
         self.fixed = fixed
-        self.name = name
+        self.discrete = discrete
+
+        # Store whatever values the user needs to associate with the parameter.
+        # For example, models can set units and tool tips so the user interface
+        # has something to work with.
+        for k, v in kw.items():
+            setattr(self, k, v)
+        self.prior = None # to be filled by model_reset
 
     def randomize(self, rng=None):
         """
         Set a random value for the parameter.
         """
-        self.value = self.bounds.random(rng if rng is not None else mbounds.RNG)
+        self.value = self.prior.random(rng if rng is not None else mbounds.RNG)
 
     def feasible(self):
         """
         Value is within the limits defined by the model
         """
-        return self.bounds.limits[0] <= self.value <= self.bounds.limits[1]
+        return self.prior.limits[0] <= self.value <= self.prior.limits[1]
+
+    def equals(self, expression: ValueType):
+        """
+        Set a parameter equal to another parameter or expression.
 
-    # to_dict() can inherit from BaseParameter
+        If *expression=None* then free the parameter by giving it is own
+        slot with value equal to the present value of the expression, and
+        its bounds.
+        """
+        if isinstance(self.slot, Calculation):
+            raise TypeError("parameter is calculated by the model and cannot be changed")
+        elif expression is self:
+            # don't make a circular reference to self.
+            warnings.warn(f"{self} tried to make circular reference to self...")
+            pass
+        else:
+            self.slot = expression
+    
+    def unlink(self):
+        if isinstance(self.slot, Calculation):
+            raise TypeError("parameter is calculated by the model and cannot be changed")
+        # Replace the slot with a new variable initialized to the only variable value
+        self.slot = Variable(self.value)
+
+    def add_tag(self, tag: str):
+        if not tag in self.tags:
+            self.tags.append(tag)
+
+    def remove_tag(self, tag: Optional[str] = None):
+        if tag is None:
+            self.tags = []
+        else:
+            self.tags = [t for t in self.tags if not t == tag]
+
+    def __copy__(self):
+        """ copy will only be called when a new instance is desired, with a different id """
+        obj = type(self).__new__(self.__class__)
+        obj.__dict__.update(self.__dict__)
+        obj.id = str(uuid.uuid4())
+        return obj
+
+def tag_all(parameter_tree, tag, remove=False):
+    if isinstance(parameter_tree, dict):
+        tag_all([item for item in parameter_tree.values()], tag, remove=remove)
+    elif hasattr(parameter_tree, 'add_tag'):
+        if remove:
+            parameter_tree.remove_tag(tag)
+        else:
+            parameter_tree.add_tag(tag)
+    elif hasattr(parameter_tree, 'parameters'):
+        tag_all(parameter_tree.parameters(), tag, remove=remove)
+    elif hasattr(parameter_tree, '__iter__'):
+        for item in parameter_tree:
+            tag_all(item, tag, remove=remove)
+    else:
+        warnings.warn(f"parameter tree should have only list, object and Parameter items: {parameter_tree}")
 
+def untag_all(parameter_tree, tag: Optional[str]=None):
+    tag_all(parameter_tree, tag, remove=True)
 
-class Reference(Parameter):
+@dataclass
+class Variable(ValueProtocol):
     """
-    Create an adaptor so that a model attribute can be treated as if it
-    were a parameter.  This allows only direct access, wherein the
-    storage for the parameter value is provided by the underlying model.
+    Saved state for a random variable in the model.
+    """
+    value: float
 
-    Indirect access, wherein the storage is provided by the parameter, cannot
-    be supported since the parameter has no way to detect that the model
-    is asking for the value of the attribute.  This means that model
-    attributes cannot be assigned to parameter expressions without some
-    trigger to update the values of the attributes in the model.
+    def parameters(self):
+        return []
+
+
+@schema_config()
+@dataclass(init=True, frozen=True, eq=False)
+class Constant(ValueProtocol): # type: ignore
     """
+    Saved state for an unmodifiable value.
 
-    def __init__(self, obj, attr, **kw):
-        self.obj = obj
-        self.attr = attr
-        kw.setdefault('name', ".".join([obj.__class__.__name__, attr]))
-        Parameter.__init__(self, **kw)
+    A constant is like a fixed parameter. You can't change it's value, set
+    it equal to another parameter, or assign a prior distribution.
+    """
+
+    value: float
+    name: Optional[str] = None
+    id: str = field(metadata={"format": "uuid"}, default_factory=lambda: str(uuid.uuid4()))
+
+    fittable = False  # class property fixed across all objects
+    fixed = True # class property fixed across all objects
+
+    def parameters(self):
+        return [self]
+
+    def __str__(self):
+        return self.name
+
+# ==== Arithmetic operators ===
+class Operators(str, Enum):
+    """ Operators that can be used to construct Expressions """
+
+    # operators including abs() are defined in _build_operator_mixin()
+    # functions are defined in numpy or in UserFunction (for min/max)
+
+    # unary operator
+    neg = "neg"
+    pos = "pos"
+    # binary operator
+    add = "add"
+    sub = "sub"
+    mul = "mul"
+    truediv = "truediv"
+    div = "intdiv"
+    pow = "pow"
+    # unary functional
+    #float = "float"  => float makes values concrete
+    #int = "int"  => values must be float; use floor, trunc, ceil, round
+    abs = "abs"
+
+    # unary functions
+    exp = "exp"
+    expm1 = "expm1"
+    log = "log"
+    log10 = "log10"
+    log1p = "log1p"
+    sqrt = "sqrt"
+    degrees = "degrees"
+    radians = "radians"
+    sin = "sin"
+    cos = "cos"
+    tan = "tan"
+    arcsin = "arcsin"
+    arccos = "arccos"
+    arctan = "arctan"
+    sinh = "sinh"
+    cosh = "cosh"
+    tanh = "tanh"
+    arcsinh = "arcsinh"
+    arccosh = "arccosh"
+    arctanh = "arctanh"
+    ceil = "ceil"
+    floor = "floor"
+    trunc = "trunc"
+    rint = "rint"
+    round = "round"  # round(a) => rint(a)
+    # binary functions
+    arctan2 = "arctan2"
+
+    # n-ary
+    min = "min"  # from builtins
+    max = "max"  # from builtins
+    # TODO: support sum(seq) and prod(seq) for tuple and list
+
+# Precedence for the python operators as given in manual. Numbers start
+# from one at the bottom of the table. The value itself is "highest" precedence
+# with a value of zero.
+# https://docs.python.org/3/reference/expressions.html#operator-precedence
+VALUE_PRECEDENCE = 0
+CALL_PRECEDENCE = 2
+OPERATOR_PRECEDENCE = {
+    "pow": 4,
+    "pos": 5,
+    "neg": 5,
+    "mul": 6,
+    "div": 6,
+    "truediv": 6,
+    "intdiv": 6,
+    "add": 7,
+    "sub": 7,
+    "gt": 12,
+    "lt": 12,
+    "ge": 12,
+    "le": 12,
+    "eq": 12,
+    "ne": 12,
+}
+OPERATOR_STRING = {
+    "pow": "**",
+    "pos": "+",
+    "neg": "-",
+    "mul": "*",
+    "div": "/",
+    "truediv": "/",
+    "intdiv": "//",
+    "add": "+",
+    "sub": "-",
+    "gt": ">",
+    "lt": "<",
+    "ge": ">=",
+    "le": "<=",
+    "eq": "==",
+    "ne": "!=",
+}
+
+
+def _lookup_operator(op_name):
+    if not hasattr(Operators, op_name) and op_name not in UserFunctionRegistry:
+        raise ValueError(f"function {op_name} is not available")
+    fn = None
+    # Check plugins first so we can override lookups in operator and numpy.
+    # This is needed for min/max.
+    if fn is None: # plugin functions from UserFunctionRegistry
+        fn = UserFunctionRegistry.get(op_name, None)
+    if fn is None:
+        fn = getattr(operator, op_name, None) # operators from operators
+    if fn is None: # math functions from numpy
+        fn = getattr(np, op_name, None)
+    if fn is None:
+        raise RuntimeError(f"should not be here: {op_name} not found")
+    return fn
+
+def _precedence(obj: Any) -> int:
+    """
+    Return operator precedence according to the python parsing hierarchy.
+
+    Lower values are higher precedence. Values start at 0 for constants and
+    variables, and go up from there. Not all operators are covered.
+    """
+    if isinstance(obj, Expression):
+        return OPERATOR_PRECEDENCE.get(obj.op.name, CALL_PRECEDENCE)
+    return VALUE_PRECEDENCE
+
+@dataclass(init=False)
+class Expression(ValueProtocol):
+    """
+    Parameter expression
+    """
+    fittable = False
+    fixed = True
+
+    op: Union[Operators, 'UserFunction'] # Enumerated str type {function_name: display_name}
+    args: Sequence[ValueType]
+    _fn: Callable[..., float] # _fn(float, float, ...) -> float
+
+    def __init__(self, op: Union[str, Operators, 'UserFunction'], args):
+        op = op if (isinstance(op, Operators) or isinstance(op, UserFunction)) else getattr(Operators, op)
+        object.__setattr__(self, 'op', op)
+        object.__setattr__(self, '_fn', _lookup_operator(op.name))
+        object.__setattr__(self, 'args', args)
+
+    def parameters(self):
+        # Walk expression tree combining parameters from each subexpression
+        return sum(
+            (v.parameters() for v in self.args if hasattr(v, 'parameters')),
+            [])
 
     @property
     def value(self):
-        return getattr(self.obj, self.attr)
+        return self._fn(*(float(arg) for arg in self.args))
 
-    @value.setter
-    def value(self, value):
-        setattr(self.obj, self.attr, value)
+    @property
+    def name(self):
+        return str(self)
 
-    def to_dict(self):
-        ret = Parameter.to_dict(self)
-        ret["attr"] = self.attr
-        # TODO: another impossibility---an arbitrary python object
-        # Clearly we need a (safe??) json pickler to handle the full
-        # complexity of an arbitrary model.
-        ret["obj"] = to_dict(self.obj)
-        return ret
+    def __str__(self):
+        prec = _precedence(self)
+        vals = [str(v) for v in self.args]
+        if self.op.name in ("pos", "neg"):
+            # +- a with parens as needed
+            a = f"({vals[0]})" if prec < _precedence(self.args[0]) else vals[0]
+            return f"{OPERATOR_STRING[self.op.name]}{a}"
+        elif self.op.name in ("add", "sub", "mul", "div", "truediv", "pow"):
+            # a +-*/** b with parens as needed
+            a = f"({vals[0]})" if prec < _precedence(self.args[0]) else vals[0]
+            b = f"({vals[1]})" if prec < _precedence(self.args[1]) else vals[1]
+            return f"{a} {OPERATOR_STRING[self.op.name]} {b}"
+        else:
+            # f(a, b, ...) with no parens needed
+            return f"{self.op.name}({', '.join(v for v in vals)})"
+
+def _make_unary_op(op_name: str):
+    op = getattr(Operators, op_name)
+    # Note: self is Parameter or Expression
+    fn = lambda self: Expression(op, (self,))
+    setattr(OperatorMixin, f'__{op_name}__', fn)
+
+def _make_binary_op(op_name: str):
+    op = getattr(Operators, op_name)
+    def fn(self, other):
+        return Expression(op, (self, other))
+    setattr(OperatorMixin, f'__{op_name}__', fn)
+    def rfn(self, other):
+        return Expression(op, (other, self))
+    setattr(OperatorMixin, f'__r{op_name}__', rfn)
+
+def _make_math_fn(fn_name: str):
+    op = getattr(Operators, fn_name)
+    def fn(*args): # first of args is self
+        if any([isinstance(arg, ValueProtocol) for arg in args]):
+            return Expression(op, args)
+        else:
+            # then all the args are floats: just return a float!
+            realized_fn = _lookup_operator(op.name)
+            return realized_fn(*args)
+    # define sin, etc., in the parameter and expression so that np.sin(a)
+    # will resolve to Expression('sin', tuple(a)), etc.
+    setattr(OperatorMixin, fn_name, fn)
+    # The np.sin(a) trick only works for a limited set of functions
+    # defined by numpy itself. For arbitrary user defined functions
+    # we add them to the bumps.pmath namespace so the user can find them.
+    setattr(pmath, fn_name, fn)
+
+def _build_operator_mixin():
+    unary_op = set(("pos", "neg", "abs"))
+    binary_op = set(("add", "sub", "mul", "div", "truediv", "pow"))
+    math_fn = set(v.name for v in Operators) - unary_op - binary_op
+    for op_name in unary_op:
+        _make_unary_op(op_name)
+    for op_name in binary_op:
+        _make_binary_op(op_name)
+    # By adding the math functions to the mixin, calling np.sin(parameter) or
+    # np.sin(expression) will return the generated expression for the object.
+    for fn_name in math_fn:
+        _make_math_fn(fn_name)
+_build_operator_mixin()
+
+UserFunctionRegistry: Dict[str, Callable[..., float]] = {}
+
+# TODO: allow schema validation on user-defined functions
+@dataclass(init=False)
+class UserFunction:
+    """
+    User-defined functions.
 
+    This is a helper class for the @function decorator, which treats the
+    operator as one of the possible expression operators.
 
-class ParameterSet(object):
+    These won't be properly serialized/deserialized through the JSON schema
+    unless the function is registered in advance. The schema will not include
+    these functions as possible values even if registered, so a schema
+    validator may fail on one of these functions.
     """
-    A parameter that depends on the model.
+    name: str
+    # A function registry to remember the code associated with the name.
+    # This is a class attribute, so it is initialized with an empty dict().
+    # Ignore complaints from lint.
+    # TODO: use pmath as our registry of available functions.
+    def __init__(self, fn: Callable):
+        name = fn.__name__
+        if name in UserFunctionRegistry:
+            raise TypeError(f"Function {name} already registered in bumps.")
+        UserFunctionRegistry[name] = fn
+        self.name = name
+
+def function(fn: Callable):
+    """
+    Convert a function into a delayed evaluator.
+
+    The value of the function is computed from the values of the parameters
+    at the time that the function value is requested rather than when the
+    function is created.
     """
+    name = fn.__name__
+    op = UserFunction(fn)
+    def wrapped(*args: 'ValueType'):
+        return Expression(op, args)
+    wrapped.__name__ = fn.__name__
+    wrapped.__doc__ = fn.__doc__ if fn.__name__.endswith("d") else f"{fn.__name__}(Parameter)"
+    # Add the symbol to pmath
+    setattr(pmath, name, wrapped)
+    pmath.__all__.append(name)
+    return wrapped
+
+# min/max
+min = function(builtins.min)
+max = function(builtins.max)
+
+# Trig functions defined in degrees rather than radians.
+@function
+def cosd(v):
+    """Return the cosine of x (measured in in degrees)."""
+    return np.cos(np.radians(v))
 
-    def __init__(self, reference, names=None):
+@function
+def sind(v):
+    """Return the sine of x (measured in in degrees)."""
+    return np.sin(np.radians(v))
+
+@function
+def tand(v):
+    """Return the tangent of x (measured in in degrees)."""
+    return np.tan(np.radians(v))
+
+@function
+def arccosd(v):
+    """Return the arc cosine (measured in in degrees) of x."""
+    return np.degrees(np.arccos(v))
+
+@function
+def arcsind(v):
+    """Return the arc sine (measured in in degrees) of x."""
+    return np.degrees(np.arcsin(v))
+
+@function
+def arctand(v):
+    """Return the arc tangent (measured in in degrees) of x."""
+    return np.degrees(np.arctan(v))
+
+@function
+def arctan2d(dy, dx):
+    """Return the arc tangent (measured in in degrees) of y/x.
+    Unlike atan(y/x), the signs of both x and y are considered."""
+    return np.degrees(np.arctan2(dy, dx))
+
+# Aliases for arcsin, etc., both here in bumps.parameters and in bumps.pmath.
+pmath.asin = asin = pmath.arcsin
+pmath.acos = acos = pmath.arccos
+pmath.atan = atan = pmath.arctan
+pmath.atan2 = atan2 = pmath.arctan2
+
+pmath.asind = asind = arcsind
+pmath.acosd = acosd = arccosd
+pmath.atand = atand = arctand
+pmath.atan2d = atan2d = arctan2d
+
+pmath.asinh = asinh = pmath.arcsinh
+pmath.acosh = acosh = pmath.arccosh
+pmath.atanh = atanh = pmath.arctanh
+
+pmath.__all__.extend((
+    'asin', 'acos', 'atan', 'atan2',
+    'asind', 'acosd', 'atand', 'atan2d',
+    'asinh', 'acosh', 'atanh',
+    ))
+
+#restate these for export, now that they're all defined:
+ValueType = Union[Parameter, Expression, Calculation, float]
+
+@dataclass(init=False)
+class ParameterSet:
+    """
+    A parameter that depends on the model.
+    """
+    names: Optional[List[str]]
+    reference: Parameter
+    parameterlist: Optional[List[Parameter]]
+
+    def __init__(self,
+            reference: Parameter,
+            names: Optional[List[str]] = None,
+            parameterlist: Optional[List[Parameter]] = None
+        ):
         """
         Create a parameter set, with one parameter for each model name.
 
@@ -493,17 +1068,28 @@ def __init__(self, reference, names=None):
 
         *parameters* will be created, with one parameter per model.
         """
+        names = names if names is not None else []
         self.names = names
         self.reference = reference
         # TODO: explain better why parameters are using np.array
         # Force numpy semantics on slice operations by using an array
         # of objects rather than a list of objects
-        self.parameters = np.array([copy(reference) for _ in names])
+        if parameterlist is not None:
+            # we are being reinitialized with parameters
+            self.parameters = np.array(parameterlist)
+        else:
+            self.parameters = np.array([copy(reference) for _ in names])
         # print self.reference, self.parameters
         for p, n in zip(self.parameters, names):
             p.name = " ".join((n, p.name))
-        # Reference is no longer directly fittable
-        self.reference.fittable = False
+
+        # N.B. if the reference parameter is not referenced anywhere in the models,
+        # it will no longer show up in FitProblem.parameters
+        #self.__class__.parameterlist = property(self._get_parameterlist) #lambda self: self.parameters.tolist())
+
+    @property
+    def parameterlist(self) -> List[Parameter]:
+        return self.parameters.tolist()
 
     def to_dict(self):
         return {
@@ -598,6 +1184,38 @@ def pmp(self, *args, **kw):
             p.pmp(*args, **kw)
 
 
+class Reference(Parameter):
+    """
+    Create an adaptor so that a model attribute can be treated as if it
+    were a parameter.  This allows only direct access, wherein the
+    storage for the parameter value is provided by the underlying model.
+
+    Indirect access, wherein the storage is provided by the parameter, cannot
+    be supported since the parameter has no way to detect that the model
+    is asking for the value of the attribute.  This means that model
+    attributes cannot be assigned to parameter expressions without some
+    trigger to update the values of the attributes in the model.
+
+    NOTE: this class can not be serialized with a dataclass schema
+    TODO: can sasmodels just use Parameter directly?
+    """
+
+    def __init__(self, obj, attr, **kw):
+        self.obj = obj
+        self.attr = attr
+        kw.setdefault('name', ".".join([obj.__class__.__name__, attr]))
+        Parameter.__init__(self, **kw)
+
+    @property
+    def value(self):
+        return getattr(self.obj, self.attr)
+
+    @value.setter
+    def value(self, value):
+        setattr(self.obj, self.attr, value)
+
+
+@dataclass(init=False)
 class FreeVariables(object):
     """
     A collection of parameter sets for a group of models.
@@ -619,14 +1237,21 @@ class FreeVariables(object):
     new parameters for each model for the free parameters.  Setting up
     these copies was inconvenient.
     """
+    names: List[str]
+    parametersets: Dict[str, ParameterSet]
 
-    def __init__(self, names=None, **kw):
+    def __init__(self, names=None, parametersets=None, **kw):
         if names is None:
             raise TypeError("FreeVariables needs name=[model1, model2, ...]")
         self.names = names
-
-        # Create slots to hold the free variables
-        self._parametersets = dict((k, ParameterSet(v, names=names))
+        if parametersets is not None:
+            # assume that we are initializing with a dict of
+            # fully initialized ParameterSet objects
+            self.parametersets = parametersets
+        else:
+            # we are initializing with kw = Dict[key, (list of Parameters)]
+            # Create slots to hold the free variables
+            self.parametersets = dict((k, ParameterSet(v, names=names))
                                    for k, v in kw.items())
 
     # Shouldn't need explicit __getstate__/__setstate__ but mpi4py pickle
@@ -642,7 +1267,7 @@ def __getattr__(self, k):
         Return the parameter set for the given free parameter.
         """
         try:
-            return self._parametersets[k]
+            return self.parametersets[k]
         except KeyError:
             raise AttributeError('FreeVariables has no attribute %r' % k)
 
@@ -650,285 +1275,27 @@ def parameters(self):
         """
         Return the set of free variables for all the models.
         """
-        return dict((k, v.parameters) for k, v in self._parametersets.items())
+        return dict((k, v.parameters) for k, v in self.parametersets.items())
 
     def to_dict(self):
         return {
             'type': type(self).__name__,
             'names': self.names,
-            'parameters': to_dict(self._parametersets)
+            'parameters': to_dict(self.parametersets)
         }
 
     def set_model(self, i):
         """
         Set the reference parameters for model *i*.
         """
-        for p in self._parametersets.values():
+        for p in self.parametersets.values():
             p.set_model(i)
 
     def get_model(self, i):
         """
         Get the parameters for model *i* as {reference: substitution}
         """
-        return dict(p.get_model(i) for p in self._parametersets.values())
-
-# Current implementation computes values on the fly, so you only
-# need to plug the values into the parameters and the parameters
-# are automatically updated.
-#
-# This will not work well for wrapped models.  In those cases you
-# want to do a number of optimizations, such as only updating the
-#
-
-# ==== Comparison operators ===
-COMPARISONS = [
-    ('GT', '>'),
-    ('GE', '>='),
-    ('LE', '<='),
-    ('LT', '<'),
-    ('EQ', '=='),
-    ('NE', '!=')
-]
-
-class Constraint(object):
-    def __init__(self, a, b, op_name, op_str=""):
-        import operator
-        self.a, self.b = a, b
-        self.op_name = op_name
-        self.op = getattr(operator, op_name.lower())
-        self.op_str = op_str
-    def __bool__(self):
-        return self.op(float(self.a), float(self.b))
-    __nonzero__ = __bool__
-    def __str__(self):
-        return "(%s %s %s)" %(self.a, self.op_str, self.b)
-
-# ==== Arithmetic operators ===
-ALLOWED_OPERATORS = ["add","sub","mul","truediv","floordiv","pow"]
-
-class Operator(BaseParameter):
-    """
-    Parameter operator
-    """
-    def __init__(self, a, b, op_name, op_str):
-        import operator
-        if not op_name.lower() in ALLOWED_OPERATORS:
-            raise ValueError("Operator name %s is not in allowed operators: %s" % (op_name, str(ALLOWED_OPERATORS)))
-        self.a, self.b = a,b
-        self.op_name = op_name
-        self.op = getattr(operator, op_name.lower())
-        self.op_str = op_str
-        pars = []
-        if isinstance(a,BaseParameter): pars += a.parameters()
-        if isinstance(b,BaseParameter): pars += b.parameters()
-        self._parameters = pars
-        self.name = str(self)
-    def parameters(self):
-        return self._parameters
-    def to_dict(self):
-        return dict(
-            type="Operator",
-            op_name=self.op_name,
-            op_str=self.op_str,
-            left=to_dict(self.a),
-            right=to_dict(self.b),
-        )
-    @property
-    def value(self):
-        return self.op(float(self.a), float(self.b))
-    @property
-    def dvalue(self):
-        return float(self.a)
-    def __str__(self):
-        return "(%s %s %s)" % (self.a,self.op_str, self.b)
-
-def substitute(a):
-    """
-    Return structure a with values substituted for all parameters.
-
-    The function traverses lists, tuples and dicts recursively.  Things
-    which are not parameters are returned directly.
-    """
-    if isinstance(a, BaseParameter):
-        return float(a.value)
-    elif isinstance(a, tuple):
-        return tuple(substitute(v) for v in a)
-    elif isinstance(a, list):
-        return [substitute(v) for v in a]
-    elif isinstance(a, dict):
-        return dict((k, substitute(v)) for k, v in a.items())
-    elif isinstance(a, np.ndarray):
-        return np.array([substitute(v) for v in a])
-    else:
-        return a
-
-
-class Function(BaseParameter):
-    """
-    Delayed function evaluator.
-
-    f.value evaluates the function with the values of the
-    parameter arguments at the time f.value is referenced rather
-    than when the function was invoked.
-    """
-    __slots__ = ['op', 'args', 'kw']
-
-    def __init__(self, op, *args, **kw):
-        self.name = kw.pop('name', None)
-        self.op, self.args, self.kw = op, args, kw
-        self._parameters = self._find_parameters()
-
-    def _find_parameters(self):
-        # Figure out which arguments to the function are parameters
-        #deps = [p for p in self.args if isinstance(p,BaseParameter)]
-        args = [arg for arg in self.args if isinstance(arg, BaseParameter)]
-        kw = dict((name, arg) for name, arg in self.kw.items()
-                  if isinstance(arg, BaseParameter))
-        deps = flatten((args, kw))
-        # Find out which other parameters these parameters depend on.
-        res = []
-        for p in deps:
-            res.extend(p.parameters())
-        return res
-
-    def parameters(self):
-        return self._parameters
-
-    def _value(self):
-        # Expand args and kw, replacing instances of parameters
-        # with their values
-        return self.op(*substitute(self.args), **substitute(self.kw))
-    value = property(_value)
-
-    def to_dict(self):
-        return {
-            "type": "Function",
-            "name": self.name,
-            # TODO: function not stored properly in json
-            "op": to_dict(self.op),
-            "args": to_dict(self.args),
-            "kw": to_dict(self.kw),
-        }
-
-    def __getstate__(self):
-        return self.name, self.op, self.args, self.kw
-
-    def __setstate__(self, state):
-        self.name, self.op, self.args, self.kw = state
-        self._parameters = self._find_parameters()
-
-    def __str__(self):
-        if self.name is not None:
-            name = self.name
-        else:
-            args = [str(v) for v in self.args]
-            kw = [str(k) + "=" + str(v) for k, v in self.kw.items()]
-            name = self.op.__name__ + "(" + ", ".join(args + kw) + ")"
-        return name
-        #return "%s:%g" % (name, self.value)
-
-
-def function(op):
-    """
-    Convert a function into a delayed evaluator.
-
-    The value of the function is computed from the values of the parameters
-    at the time that the function value is requested rather than when the
-    function is created.
-    """
-    # Note: @functools.wraps(op) does not work with numpy ufuncs
-    # Note: @decorator does not work with builtins like abs
-    def function_generator(*args, **kw):
-        return Function(op, *args, **kw)
-    function_generator.__name__ = op.__name__
-    function_generator.__doc__ = op.__doc__
-    return function_generator
-_abs = function(abs)
-
-# Numpy trick: math functions from numpy delegate to the math function of
-# the class if that function exists as a class attribute.
-BaseParameter.exp = function(math.exp)
-BaseParameter.expm1 = function(math.expm1)
-BaseParameter.log = function(math.log)
-BaseParameter.log10 = function(math.log10)
-BaseParameter.log1p = function(math.log1p)
-BaseParameter.sqrt = function(math.sqrt)
-
-BaseParameter.degrees = function(math.degrees)
-BaseParameter.radians = function(math.radians)
-
-BaseParameter.sin = function(math.sin)
-BaseParameter.cos = function(math.cos)
-BaseParameter.tan = function(math.tan)
-BaseParameter.arcsin = function(math.asin)
-BaseParameter.arccos = function(math.acos)
-BaseParameter.arctan = function(math.atan)
-
-BaseParameter.sinh = function(math.sinh)
-BaseParameter.cosh = function(math.cosh)
-BaseParameter.tanh = function(math.tanh)
-BaseParameter.arcsinh = function(math.asinh)
-BaseParameter.arccosh = function(math.acosh)
-BaseParameter.arctanh = function(math.atanh)
-
-BaseParameter.ceil = function(math.ceil)
-BaseParameter.floor = function(math.floor)
-BaseParameter.trunc = function(math.trunc)
-
-def boxed_function(f):
-    box = function(f)
-    @wraps(f)
-    def wrapped(*args, **kw):
-        if any(isinstance(v, BaseParameter) for v in args):
-            return box(*args, **kw)
-        else:
-            return f(*args, **kw)
-    return wrapped
-
-# arctan2 is special since either argument can be a parameter
-arctan2 = boxed_function(math.atan2)
-
-# Trig functions defined in degrees rather than radians
-@boxed_function
-def cosd(v):
-    """Return the cosine of x (measured in in degrees)."""
-    return math.cos(math.radians(v))
-
-@boxed_function
-def sind(v):
-    """Return the sine of x (measured in in degrees)."""
-    return math.sin(math.radians(v))
-
-@boxed_function
-def tand(v):
-    """Return the tangent of x (measured in in degrees)."""
-    return math.tan(math.radians(v))
-
-@boxed_function
-def acosd(v):
-    """Return the arc cosine (measured in in degrees) of x."""
-    return math.degrees(math.acos(v))
-arccosd = acosd
-
-@boxed_function
-def asind(v):
-    """Return the arc sine (measured in in degrees) of x."""
-    return math.degrees(math.asin(v))
-arcsind = asind
-
-@boxed_function
-def atand(v):
-    """Return the arc tangent (measured in in degrees) of x."""
-    return math.degrees(math.atan(v))
-arctand = atand
-
-@boxed_function
-def atan2d(dy, dx):
-    """Return the arc tangent (measured in in degrees) of y/x.
-    Unlike atan(y/x), the signs of both x and y are considered."""
-    return math.degrees(math.atan2(dy, dx))
-arctan2d = atan2d
-
+        return dict(p.get_model(i) for p in self.parametersets.values())
 
 
 def flatten(s):
@@ -938,7 +1305,7 @@ def flatten(s):
         raise TypeError("parameter flattening cannot order sets")
     elif isinstance(s, dict):
         return reduce(lambda a, b: a + flatten(s[b]), sorted(s.keys()), [])
-    elif isinstance(s, BaseParameter):
+    elif isinstance(s, ValueProtocol):
         return [s]
     elif s is None:
         return []
@@ -946,13 +1313,14 @@ def flatten(s):
         raise TypeError("don't understand type %s for %r" % (type(s), s))
 
 
-def format(p, indent=0, freevars={}, field=None):
+def format(p, indent=0, freevars=None, field=None):
     """
     Format parameter set for printing.
 
     Note that this only says how the parameters are arranged, not how they
     relate to each other.
     """
+    freevars = {} if freevars is None else freevars
     p = freevars.get(id(p), p)
     if isinstance(p, dict) and p != {}:
         res = []
@@ -986,14 +1354,20 @@ def format(p, indent=0, freevars={}, field=None):
             s += str(p) + " = "
         s += "%g" % p.value
         if not p.fixed:
-            s += " in [%g,%g]" %  tuple(p.bounds.limits)
+            if p.prior is not None:
+                bounds = p.prior.limits
+            elif p.bounds is not None:
+                bounds = p.bounds
+            else:
+                bounds = p.limits
+            s += " in [%g,%g]" %  tuple(bounds)
         return s
 
-    elif isinstance(p, BaseParameter):
+    elif isinstance(p, Parameter):
         return "%s = %g" % (str(p), p.value)
 
     else:
-        return "None"
+        return str(p)
 
 
 def summarize(pars, sorted=False):
@@ -1005,12 +1379,12 @@ def summarize(pars, sorted=False):
     """
     output = []
     if sorted:
-        pars = sorted(pars, cmp=lambda x, y: cmp(x.name, y.name))
+        pars = sorted(pars, key=lambda x: x.name)
     for p in pars:
         if not isfinite(p.value):
             bar = ["*invalid* "]
         else:
-            position = int(p.bounds.get01(p.value) * 9.999999999)
+            position = int(p.prior.get01(p.value) * 9.999999999)
             bar = ['.'] * 10
             if position < 0:
                 bar[0] = '<'
@@ -1023,7 +1397,7 @@ def summarize(pars, sorted=False):
     return "\n".join(output)
 
 
-def unique(s):
+def unique(s) -> List[Parameter]:
     """
     Return the unique set of parameters
 
@@ -1058,10 +1432,10 @@ def fittable(s):
 
     Note that some fittable parameters may be fixed during the fit.
     """
-    return [p for p in unique(s) if not p.fittable]
+    return [p for p in unique(s) if p.fittable]
 
 
-def varying(s):
+def varying(s: List[Parameter]) -> List[Parameter]:
     """
     Return the list of fitted parameters in the model.
 
@@ -1069,32 +1443,113 @@ def varying(s):
     """
     return [p for p in unique(s) if not p.fixed]
 
+def _has_prior(p: Parameter) -> bool:
+    prior = getattr(p, 'prior', None)
+    limits = getattr(prior, 'limits', (-np.inf, np.inf))
+    return (
+        prior is not None
+        and not isinstance(prior, mbounds.Unbounded)
+        and limits != (-np.inf, np.inf))
 
-def randomize(s):
+def priors(s: List[Parameter]) -> List[Parameter]:
+    """
+    Return the list of parameters (fitted or computed) that have prior
+    probabilities associated with them. This includes all varying parameters,
+    plus expressions (including simple links), but ignoring constants and
+    fixed parameters whose probabilities won't change the fits.
+    """
+    return [p for p in unique(s) if _has_prior(p)]
+
+def randomize(s: List[Parameter]):
     """
     Set random values to the parameters in the parameter set, with
     values chosen according to the bounds.
     """
     for p in s:
-        p.value = p.bounds.random(1)[0]
+        p.value = p.prior.random(1)[0]
 
 
-def current(s):
+def current(s: List[Parameter]):
     return [p.value for p in s]
 
 # ========= trash ===================
 
+def copy_linked(has_parameters, free_names=None):
+    """
+    make a copy of an object with parameters
+     - then link all the parameters, except
+     - those with names matching "free_names"
+    """
+    assert callable(getattr(has_parameters, 'parameters', None)) == True
+    from copy import deepcopy
+    copied = deepcopy(has_parameters)
+    free_names = [] if free_names is None else free_names
+    original_pars = unique(has_parameters.parameters())
+    copied_pars = unique(copied.parameters())
+    for op, cp in zip(original_pars, copied_pars):
+        if not op.name in free_names:
+            cp.slot = op.slot
+        else:
+            cp.id = str(uuid.uuid4())
+    return copied
+
+# ==== Comparison operators ===
+class Comparisons(Enum):
+    """comparison operators"""
+
+    gt = '>'
+    ge = '>='
+    le = '<='
+    lt = '<'
+    #eq = '=='
+    #ne = '!='
+
+
+@dataclass(init=False)
+class Constraint:
+    """ Express inequality constraints between model elements """
+
+    fixed = True
+    
+    op: Comparisons
+    a: ValueType
+    b: ValueType
+
+    def __init__(self, a, b, op):
+        import operator
+        object.__setattr__(self, 'a', a)
+        object.__setattr__(self, 'b', b)
+        op_name = str(Comparisons(op).name)
+        object.__setattr__(self, 'compare', getattr(operator, op_name.lower()))
+        object.__setattr__(self, 'op', op)
+
+    # TODO: is this really necessary?  What is the reason for this trap?
+    # It seems like being able to cast with bool(Constraint) would be
+    # useful in some circumstances, like doing max(List[Parameter]), which
+    # currently fails.
+    def __bool__(self):
+        raise TypeError("failed bool")
+    __nonzero__ = __bool__
+    def __float__(self):
+        """return a float value that can be differentiated"""
+        return 0. if self.satisfied else abs(float(self.a) - float(self.b))
+    def __str__(self):
+        return "(%s %s %s)" %(self.a, self.op, self.b)
+    @property
+    def satisfied(self):
+        return self.compare(float(self.a), float(self.b))
 
-class IntegerParameter(Parameter):
-    discrete = True
 
-    def _get_value(self):
-        return self._value
+def _make_constraint(op_str: str) -> Callable[..., Constraint]:
+    return lambda self, other: Constraint(self, other, op_str)
 
-    def _set_value(self, value):
-        self._value = int(value)
-    value = property(_get_value, _set_value)
+def _build_constraints_mixin():
+    for comp_item in Comparisons:
+        op_name = comp_item.name
+        op_str = comp_item.value
+        setattr(OperatorMixin, f'__{op_name}__', _make_constraint(op_str))
 
+_build_constraints_mixin()
 
 class Alias(object):
     """
@@ -1105,7 +1560,6 @@ class Alias(object):
     parameter will have the full parameter semantics, including
     the ability to replace a fixed value with a parameter expression.
 
-    **Deprecated** :class:`Reference` does this better.
     """
 
     def __init__(self, obj, attr, p=None, name=None):
@@ -1130,10 +1584,233 @@ def to_dict(self):
             'attr': self.attr,
         }
 
+def substitute(a):
+    """
+    Return structure a with values substituted for all parameters.
+
+    The function traverses lists, tuples and dicts recursively.  Things
+    which are not parameters are returned directly.
+    """
+    if isinstance(a, ValueProtocol):
+        return float(a.value)
+    elif isinstance(a, tuple):
+        return tuple(substitute(v) for v in a)
+    elif isinstance(a, list):
+        return [substitute(v) for v in a]
+    elif isinstance(a, dict):
+        return dict((k, substitute(v)) for k, v in a.items())
+    elif isinstance(a, np.ndarray):
+        return np.array([substitute(v) for v in a])
+    else:
+        return a
+
+
+class Function(ValueProtocol):
+    """
+    **DEPRECATED**
+
+    Delayed function evaluator.
+
+    f.value evaluates the function with the values of the
+    parameter arguments at the time f.value is referenced rather
+    than when the function was invoked.
+    """
+    __slots__ = ['op', 'args', 'kw']
+    op: Callable[..., float]
+    args: Optional[Any]
+    kw: Dict[Any, Any]
+
+    def __init__(self, op, *args, **kw):
+        warnings.warn("Function no longer supported", DeprecationWarning, stacklevel=1)
+        self.name = kw.pop('name', None)
+        self.op, self.args, self.kw = op, args, kw
+        self._parameters = self._find_parameters()
+
+    def _find_parameters(self):
+        # Figure out which arguments to the function are parameters
+        #deps = [p for p in self.args if isinstance(p, ValueProtocol)]
+        args = [arg for arg in self.args if isinstance(arg, ValueProtocol)]
+        kw = dict((name, arg) for name, arg in self.kw.items()
+                  if isinstance(arg, ValueProtocol))
+        deps = flatten((args, kw))
+        # Find out which other parameters these parameters depend on.
+        res = []
+        for p in deps:
+            res.extend(p.parameters())
+        return res
+
+    def parameters(self):
+        return self._parameters
+
+    def _value(self):
+        # Expand args and kw, replacing instances of parameters
+        # with their values
+        return self.op(*substitute(self.args), **substitute(self.kw))
+    value = property(_value)
+
+    def to_dict(self):
+        return {
+            "type": "Function",
+            "name": self.name,
+            # TODO: function not stored properly in json
+            "op": to_dict(self.op),
+            "args": to_dict(self.args),
+            "kw": to_dict(self.kw),
+        }
+
+    def __getstate__(self):
+        return self.name, self.op, self.args, self.kw
+
+    def __setstate__(self, state):
+        self.name, self.op, self.args, self.kw = state
+        self._parameters = self._find_parameters()
+
+    def __str__(self):
+        if self.name is not None:
+            name = self.name
+        else:
+            args = [str(v) for v in self.args]
+            kw = [str(k) + "=" + str(v) for k, v in self.kw.items()]
+            name = self.op.__name__ + "(" + ", ".join(args + kw) + ")"
+        return name
+        #return "%s:%g" % (name, self.value)
+
+
+# ===== Tests ====
+
 def test_operator():
     a = Parameter(1, name='a')
     b = Parameter(2, name='b')
-    a_b = a + b
+    c = Parameter(3, name='c')
+    C = Constant(5, name='C')
+
+    assert a.fixed
+
+    # Check strings
+    assert str(a + b) == 'a + b'
+    assert (a+b).name == 'a + b'
+    assert str(-a) == '-a'
+    assert (-a).value == -a.value
+    assert str(a+b*c) == 'a + b * c'
+    assert str((a+b)*c) == '(a + b) * c'
+    assert str(np.sin(a+b)*c) == 'sin(a + b) * c'
+    assert str(a+C) == 'a + C'
+    assert str(a+C+3) == 'a + C + 3'
+    assert str(3 + a+C) == '3 + a + C'
+    assert str(a.sin()) == 'sin(a)'
+    assert str(atan2(a, b)) == 'arctan2(a, b)'
+    # float(expr) evaluates the expression; it doesn't build an expr with float.
+
+    # Check parameters
+    assert (a+b).parameters() == [a, b]
+    assert (np.sin(a+b)*c).parameters() == [a, b, c]
+
+    # Check values
     a.value = 3
-    assert a_b.value == 5.
-    assert a_b.name == '(a + b)'
\ No newline at end of file
+    assert (a+b).value == 5.
+    assert float(a+b) == a.value + b.value
+    assert a.sin().value == np.sin(a.value)
+    assert (3 + a+C).value == 3+3+5
+    assert np.sin(a+b).value == np.sin(a.value+b.value)
+    assert atan2(a, b).value == atan2(a.value, b.value)
+
+    # Make sure that evaluation is lazy. Capture the expression with one
+    # set of values for the parameters, update them with a new set of values,
+    # then check if the result is what you get when you call the function
+    # directly on those new values.
+    scope = locals() # record the currently available parameter handles
+    def capture_test(expr, result, **kw):
+        #print("checking", expr, "for", kw, "yields", result)
+        saved = {k: scope[k].value for k in kw}
+        for k, v in kw.items():
+            scope[k].value = float(v)
+        try:
+            assert expr.value == result, f"for {expr} expected {result} but got {expr.value}"
+        finally:
+            for k, v in saved.items():
+                scope[k].value = v
+    capture_test(np.sin(a+b), np.sin(0.5 + 3), a=0.5, b=3)
+    capture_test(np.arctan2(a, b), atan2(0.5, 3), a=0.5, b=3)
+    capture_test(np.round(a), np.round(-0.6), a=-0.6)
+    capture_test(min(a, b), builtins.min(-0.6, 3), a=-0.6, b=3)
+    capture_test(min(a, b, -2), builtins.min(-0.6, 3, -2), a=-0.6, b=3)
+    capture_test(abs(a), 2.5, a=-2.5)
+
+    # Check that symbols are defined in pmath
+    capture_test(pmath.sind(a), np.sin(np.radians(25)), a=25)
+    assert 'sind' in pmath.__all__
+
+    # TODO: can we evaluate an expression for an entire population at once?
+
+    # Check slots
+    limited = Parameter(3, name='limited', limits=[0.5, 1.5], bounds=[0, 1])
+    limited.add_prior()
+    assert np.isinf(limited.nllf())
+    assert np.isinf(limited.nllf())
+    limited.value = 0.6
+    assert limited.nllf() == 0.0
+    limited.value = 0.2
+    assert np.isinf(limited.nllf())
+
+    limited.equals(a+b)
+    assert limited.value == (a+b).value
+
+    assert np.isinf(limited.nllf())
+    a.value = b.value = 0.1
+    assert np.isinf(limited.nllf())
+    a.value = b.value = 0.3
+    assert limited.nllf() == 0.0
+    try:
+        limited.value = 5
+        failed = True
+    except Exception:
+        # TODO: define which error improper assignment should raise
+        # Currently this raises an attribute error on limited.slot.value
+        failed = False
+    if failed:
+        raise RuntimeError("failed to raise error when assigning value to expression")
+
+    # Check parameter list operations
+    s = [a, limited]
+    assert unique(s) == [a, limited, b]
+    assert fittable(s) == [a, b]
+    assert varying(s) == []
+    b.range(0, 3)
+    assert not b.fixed
+    assert varying(s) == [b]
+    assert current(s) == [a.value, limited.value]
+
+    # Check normal deviation
+    mu, sigma = 3, 2
+    b.dev(sigma, mean=mu)
+    b.value = 4
+    b.add_prior()
+    nllf_target = 0.5*((b.value-mu)/sigma)**2 + np.log(2*np.pi*sigma**2)/2
+    assert abs(b.nllf() - nllf_target)/nllf_target < 1e-12
+
+ # Check conditions
+    a.value, b.value = 3, 4
+    capture = a < b
+    assert isinstance(capture, Constraint)
+    assert capture.satisfied
+    a.value, b.value = 4, 3
+    assert not capture.satisfied
+
+    scope = locals()
+    def raises(condition_str, exception):
+        try:
+            eval(condition_str, locals=scope)
+        except exception:
+            pass
+        else:
+            raise AssertionError(f"{condition_str} does not raise {exception}")
+    raises("a < b < c", TypeError)
+    raises("a < b and b < c", TypeError)
+    raises("a < b or b < c", TypeError)
+    raises("not (a < b)", TypeError)
+    raises("not a", TypeError)
+    raises("a and b", TypeError)
+    raises("a or b", TypeError)
+
+if __name__ == "__main__":
+    test_operator()
diff --git a/bumps/pdfwrapper.py b/bumps/pdfwrapper.py
index 62d69279..e37afdd5 100644
--- a/bumps/pdfwrapper.py
+++ b/bumps/pdfwrapper.py
@@ -54,7 +54,7 @@ def __init__(self, fn, name="", plot=None, dof=1, **kw):
         # with the default value if function is defined with keyword
         # initializers; override the initializers with any keyword
         # arguments specified in the fit function constructor.
-        labels, vararg, varkw, values = inspect.getargspec(fn)
+        labels, vararg, varkw, values = inspect.getfullargspec(fn)
         if vararg or varkw:
             raise TypeError(
                 "Function cannot have *args or **kwargs in declaration")
diff --git a/bumps/plotutil.py b/bumps/plotutil.py
index d631e2e0..14fa2d0c 100644
--- a/bumps/plotutil.py
+++ b/bumps/plotutil.py
@@ -38,7 +38,7 @@ def auto_shift(offset):
 
 # ======== Color functions ========
 
-def next_color():
+def next_color(axes=None):
     """
     Return the next color in the plot color cycle.
 
@@ -52,7 +52,9 @@ def next_color():
         plt.plot(x, y, '-', color=dhsv(color, dv=-0.2))
     """
     import pylab
-    lines = pylab.gca()._get_lines
+    if axes is None:
+        axes = pylab.gca()
+    lines = axes._get_lines
     try:
         base = lines.get_next_color()
     except Exception:
@@ -118,7 +120,7 @@ def dhsv(color, dh=0., ds=0., dv=0., da=0.):
 
 # ==== Specialized plotters =====
 
-def plot_quantiles(x, y, contours, color, alpha=None):
+def plot_quantiles(x, y, contours, color, alpha=None, axes=None):
     """
     Plot quantile curves for a set of lines.
 
@@ -137,18 +139,20 @@ def plot_quantiles(x, y, contours, color, alpha=None):
     default value, alpha=2./(#contours+1), works pretty well.
     """
     _, q = form_quantiles(y, contours)
-    _plot_quantiles(x, q, color, alpha)
+    _plot_quantiles(x, q, color, alpha, axes=axes)
 
-def _plot_quantiles(x, q, color, alpha):
+def _plot_quantiles(x, q, color, alpha, axes=None):
     import pylab
     # print "p",p
     # print "q",q[:,:,0]
     # print "y",y[:,0]
+    if axes is None:
+        axes = pylab.gca()
     if alpha is None:
         alpha = 2. / (len(q) + 1)
     edgecolor = dhsv(color, ds=-(1 - alpha), dv=(1 - alpha))
     for lo, hi in q:
-        pylab.fill_between(x, lo, hi,
+        axes.fill_between(x, lo, hi,
                            facecolor=color, edgecolor=edgecolor,
                            alpha=alpha)
 
diff --git a/bumps/plugin.py b/bumps/plugin.py
index 844b1cc8..e1e94ec0 100644
--- a/bumps/plugin.py
+++ b/bumps/plugin.py
@@ -83,7 +83,7 @@ def calc_errors(problem, sample):
     return None
 
 
-def show_errors(errs):
+def show_errors(errs, fig=None):
     """
     Display the model with uncertainty on the current figure.
 
diff --git a/bumps/pmath.py b/bumps/pmath.py
index e10a286a..131055e9 100644
--- a/bumps/pmath.py
+++ b/bumps/pmath.py
@@ -1,98 +1,23 @@
 """
 Standard math functions for parameter expressions.
 """
-import math
-
-from six.moves import reduce, builtins
-
-from .parameter import function
-__all__ = [
-    'exp', 'log', 'log10', 'sqrt',
-    'sin', 'cos', 'tan', 'asin', 'acos', 'atan', 'atan2',
-    'sind', 'cosd', 'tand', 'asind', 'acosd', 'atand', 'atan2d',
-    'sinh', 'cosh', 'tanh', 'asinh', 'acosh', 'atanh',
-    'degrees', 'radians',
-    'sum', 'prod',
-]
-
-def _cosd(v):
-    """Return the cosine of x (measured in in degrees)."""
-    return math.cos(math.radians(v))
-
-
-def _sind(v):
-    """Return the sine of x (measured in in degrees)."""
-    return math.sin(math.radians(v))
-
-
-def _tand(v):
-    """Return the tangent of x (measured in in degrees)."""
-    return math.tan(math.radians(v))
-
-
-def _acosd(v):
-    """Return the arc cosine (measured in in degrees) of x."""
-    return math.degrees(math.acos(v))
-
-
-def _asind(v):
-    """Return the arc sine (measured in in degrees) of x."""
-    return math.degrees(math.asin(v))
-
-
-def _atand(v):
-    """Return the arc tangent (measured in in degrees) of x."""
-    return math.degrees(math.atan(v))
-
-
-def _atan2d(dy, dx):
-    """Return the arc tangent (measured in in degrees) of y/x.
-    Unlike atan(y/x), the signs of both x and y are considered."""
-    return math.degrees(math.atan2(dy, dx))
-
-def _prod(s):
-    """Return the product of a sequence of numbers."""
-    return reduce(lambda x, y: x * y, s, 1)
-
-exp = function(math.exp)
-log = function(math.log)
-log10 = function(math.log10)
-sqrt = function(math.sqrt)
-
-degrees = function(math.degrees)
-radians = function(math.radians)
-
-sin = function(math.sin)
-cos = function(math.cos)
-tan = function(math.tan)
-asin = function(math.asin)
-acos = function(math.acos)
-atan = function(math.atan)
-atan2 = function(math.atan2)
-
-sind = function(_sind)
-cosd = function(_cosd)
-tand = function(_tand)
-asind = function(_asind)
-acosd = function(_acosd)
-atand = function(_atand)
-atan2d = function(_atan2d)
-
-sinh = function(math.sinh)
-cosh = function(math.cosh)
-tanh = function(math.tanh)
-asinh = function(math.asinh)
-acosh = function(math.acosh)
-atanh = function(math.atanh)
-
-sum = function(builtins.sum)
-prod = function(_prod)
-
-min = function(builtins.min)
-max = function(builtins.max)
-
-# Define pickler for numpy ufuncs
-#import copy_reg
-#def udump(f): return f.__name__
-#def uload(name): return getattr(np, name)
-#copy_reg.pickle(np.ufunc, udump, uload)
+__all__ = []
+
+# Note: the symbols in this module are defined dynamically by parameter.py
+
+# TODO: need a pmath test
+# Something like:
+#
+# import numpy as np
+# from bumps.parameter import Parameter, function
+#
+# # Define a plugin function
+# @function
+# def square(x): return x*x
+#
+# # Check all pmath symbols are imported, including plugin functions. This
+# # must be done after the plugin functions are registered.
+# from bumps.pmath import *
+# a = Parameter(value=0.2)
+# assert asind(a).value == np.degrees(np.arcsin(a.value))
+# assert square(a).value = a.value**2
diff --git a/bumps/serialize.py b/bumps/serialize.py
new file mode 100644
index 00000000..5344ce35
--- /dev/null
+++ b/bumps/serialize.py
@@ -0,0 +1,283 @@
+
+
+from copy import deepcopy
+from enum import Enum
+import numpy as np
+from dataclasses import is_dataclass, fields, dataclass
+import graphlib
+import json
+from importlib import import_module
+from typing import Any, Dict, List, Literal, Optional, Tuple, TypedDict, Union
+from types import GeneratorType
+import traceback
+import warnings
+import asyncio
+from collections import defaultdict
+
+from .util import SCHEMA_ATTRIBUTE_NAME, NumpyArray
+
+DEBUG = False
+
+class SCHEMA_VERSIONS(str, Enum):
+    BUMPS_DRAFT_O1 = "bumps-draft-01"
+    BUMPS_DRAFT_02 = "bumps-draft-02"
+
+SCHEMA = SCHEMA_VERSIONS.BUMPS_DRAFT_02
+REFERENCES_KEY = "references"
+REFERENCE_IDENTIFIER = "$ref"
+MISSING = object()
+REFERENCE_TYPE_NAME = "Reference"
+REFERENCE_TYPE = Literal["Reference"]
+TYPE_KEY = "__class__"
+
+@dataclass
+class Reference:
+    id: str
+    type: REFERENCE_TYPE
+
+JSON = Union[Dict[str, "JSON"], List["JSON"],  str, int, float, bool, None]
+
+class SerializedObject(TypedDict, total=True):
+    schema: str
+    object: JSON
+    references: Dict[str, JSON]
+
+def deserialize(serialized: SerializedObject):
+    """ rehydrate all items in serialzed['references'] then 
+     - reydrate all objects in serialized['object']
+     - replacing `Reference` types with python objects from `references`
+    """
+
+    serialized_references = serialized[REFERENCES_KEY]
+    references = {}
+
+    dependency_graph = {}
+    for ref_id, ref_obj in serialized_references.items():
+        dependencies = set()
+        _find_ref_dependencies(ref_obj, dependencies)
+        dependency_graph[ref_id] = dependencies
+
+    sorter = graphlib.TopologicalSorter(dependency_graph)
+    for ref_id in sorter.static_order():
+        # deserialize and put all references into self.references
+        references[ref_id] = _rehydrate(serialized_references[ref_id], references)
+    # references is now full of deserialized objects,
+    # and we're ready to rehydrate the entire tree...
+
+    return _rehydrate(serialized['object'], references)
+
+#### deserializer helpers:
+def _rehydrate(obj, references: Dict[str, object]):
+    if isinstance(obj, dict):
+        obj = obj.copy()
+        t: str = obj.pop(TYPE_KEY, MISSING)
+        if t == REFERENCE_TYPE_NAME:
+            obj_id: str = obj.get("id", MISSING)
+            if obj_id is MISSING:
+                raise ValueError("object id is required for Reference type")
+            # requires that self.references is populated with rehydrated objects:
+            return references[obj_id]
+        elif t == 'bumps.util.NumpyArray':
+            # skip processing values list in ndarray
+            return _to_ndarray(obj)
+        else:
+            for key in obj:
+                obj[key] = _rehydrate(obj[key], references)
+            if t is MISSING:
+                # no "type" provided, so no class to instantiate: return hydrated object
+                return obj
+                # obj values are now rehydrated: instantiate the class from 'type'
+            else:
+                try:
+                    module_name, class_name = t.rsplit('.', 1)
+                    # print(module_name, class_name)
+                    klass = getattr(import_module(module_name), class_name)
+                    hydrated = _instantiate(klass, t, obj)
+                    return hydrated
+                except Exception as e:
+                    # there is a type, but it is not found...
+                    raise ValueError("type %s not found!, error: %s" % (t,e), obj)
+    elif isinstance(obj, list):
+        # rehydrate all the items
+        return [_rehydrate(v, references) for v in obj]
+    else:
+        # it's a bare value - just return
+        return obj
+
+def _instantiate(klass: type, typename: str, serialized: dict):
+    s = serialized.copy()
+    # if klass provides 'from_dict' method, use it - 
+    # otherwise use klass.__init__ directly.
+    class_factory = getattr(klass, 'from_dict', klass)
+    try:
+        hydrated = class_factory(**s)
+    except Exception as e:
+        print(class_factory, s, typename)
+        raise e            
+    return hydrated
+
+def _to_ndarray(obj: dict):
+    return np.asarray(obj['values'], dtype=np.dtype(obj.get('dtype', float)))
+
+def _find_ref_dependencies(obj, dependencies: set):
+    if isinstance(obj, dict):
+        if obj.get(TYPE_KEY, None) == REFERENCE_TYPE:
+            dependencies.add(obj['id'])
+        else:
+            for v in obj.values():
+                _find_ref_dependencies(v, dependencies)    
+    elif isinstance(obj, list):
+        for v in obj:
+            _find_ref_dependencies(v, dependencies)
+
+#### end deserializer helpers
+
+def serialize(obj, use_refs=True):
+    references = {}
+
+    def make_ref(obj_id: str):
+        return {"id": obj_id, TYPE_KEY: REFERENCE_TYPE_NAME}
+
+    def dataclass_to_dict(dclass, include=None, exclude=None):
+        all_fields = fields(dclass)
+        if include is not None:
+            all_fields = [f for f in all_fields if f.name in include]
+        elif exclude is not None:
+            all_fields = [f for f in all_fields if not f.name in exclude and not f.name.startswith('_')]
+        else:
+            all_fields = [f for f in all_fields if not f.name.startswith('_')]
+        cls = dclass.__class__
+        fqn = f"{cls.__module__}.{cls.__qualname__}"
+        output = dict([(f.name, obj_to_dict(getattr(dclass, f.name))) for f in all_fields])
+        output[TYPE_KEY] = fqn
+        return output
+
+    def obj_to_dict(obj):
+        if hasattr(obj, SCHEMA_ATTRIBUTE_NAME):
+            schema_opts = getattr(obj, SCHEMA_ATTRIBUTE_NAME)
+            include = schema_opts.get("include", None)
+            exclude = schema_opts.get("exclude", None)
+            use_ref = use_refs and hasattr(obj, 'id')
+            if (not use_ref) or (obj.id not in references):
+                # only calculate dict if it's not already in refs, or if not using refs
+                obj_dict = dataclass_to_dict(obj, include=include, exclude=exclude)
+                if use_ref:
+                    references.setdefault(obj.id, obj_dict)
+            return make_ref(obj.id) if use_ref else obj_dict
+        elif is_dataclass(obj):
+            return dataclass_to_dict(obj)
+        elif isinstance(obj, (list, tuple, GeneratorType)):
+            return list(obj_to_dict(v) for v in obj)
+        # elif isinstance(obj, GeneratorType) and issubclass(obj_type, Tuple):
+        #     return tuple(to_dict(v) for v in obj)
+        elif isinstance(obj, dict):
+            return type(obj)((obj_to_dict(k), obj_to_dict(v)) for k, v in obj.items())
+        elif isinstance(obj, np.ndarray) and obj.dtype.kind in ['f', 'i', 'U']:
+            return {TYPE_KEY: "bumps.util.NumpyArray", "dtype": str(obj.dtype), "values": obj.tolist()}
+        elif isinstance(obj, np.ndarray) and obj.dtype.kind == 'O':
+            return obj_to_dict(obj.tolist())
+        elif isinstance(obj, Enum):
+            return obj_to_dict(obj.value)
+        elif isinstance(obj, float):
+            return str(obj) if np.isinf(obj) else obj
+        elif isinstance(obj, int) or isinstance(obj, str) or obj is None:
+            return obj
+        else:
+            raise ValueError("obj %s is not serializable" % str(obj))
+
+    serialized = {
+        "$schema": SCHEMA,
+        "object": obj_to_dict(obj),
+        REFERENCES_KEY: references
+    }
+    return serialized
+
+
+def save_file(filename, problem):
+    try:
+        p = serialize(problem)
+        with open(filename, 'w') as fid:
+            json.dump(p, fid)
+    except Exception:
+        traceback.print_exc()
+        warnings.warn(f"failed to create JSON file {filename} for fit problem")
+
+def load_file(filename):
+    with open(filename, 'r') as fid:
+        serialized: SerializedObject = json.loads(fid.read())
+        final_version, migrated = migrate(serialized)
+        print("final version: ", final_version)
+        return deserialize(migrated)
+    
+#### MIGRATIONS
+def validate_version(version: str, variable_name="from_version"):
+    if version not in list(SCHEMA_VERSIONS):
+        raise ValueError(f"must choose a valid {variable_name} from this list: {[s.value for s in SCHEMA_VERSIONS]}")
+
+def migrate(serialized: dict, from_version: Optional[SCHEMA_VERSIONS] = None, to_version: Optional[SCHEMA_VERSIONS] = SCHEMA):
+    """
+    Migrate a serialized object from one version to another 
+    By default, the `from_version` is determined by inspection of the serialized object.
+    This is overriden by setting the `from_version` keyword argument to a member of `SCHEMA_VERSIONS`
+
+    Also by default, the target version is the current schema, which can be overriden with
+    the `to_version` keyword argument
+    """
+
+    if from_version is None:
+        from_version = serialized.get("$schema", SCHEMA_VERSIONS.BUMPS_DRAFT_O1) # fall back to first version if not specified
+
+    validate_version(from_version, "from_version")
+    validate_version(to_version, "to_version")
+
+    current_version = from_version
+    while current_version != to_version:
+        print(f"migrating {current_version}...")
+        current_version, serialized = MIGRATIONS[current_version](serialized)
+
+    return current_version, serialized
+
+
+def _migrate_draft_01_to_draft_02(serialized: dict):
+    references = {}
+
+    def rename_type(obj):
+        if isinstance(obj, dict):
+            t: str = obj.pop("type", obj.pop(TYPE_KEY, MISSING))
+            if t is not MISSING:
+                obj[TYPE_KEY] = t
+            for v in obj.values():
+                rename_type(v)
+        elif isinstance(obj, list):
+            for v in obj:
+                rename_type(v)
+
+    def build_references(obj):
+        if isinstance(obj, dict):
+            t: str = obj.get(TYPE_KEY, MISSING)
+            obj_id: str = obj.get("id", MISSING)
+            if obj_id is not MISSING and not t in [MISSING, REFERENCE_TYPE_NAME]:
+                if not obj_id in references:
+                    references[obj_id] = deepcopy(obj)
+                obj[TYPE_KEY] = REFERENCE_TYPE_NAME
+                for k in list(obj.keys()):
+                    if k not in [TYPE_KEY, "id"]:
+                        del obj[k]
+            for v in obj.values():
+                build_references(v)
+        elif isinstance(obj, list):
+            for v in obj:
+                build_references(v)
+
+    rename_type(serialized)
+    build_references(serialized)
+    migrated = {
+        "$schema": SCHEMA_VERSIONS.BUMPS_DRAFT_02.value,
+        "object": serialized,
+        "references": references,
+    }
+    return SCHEMA_VERSIONS.BUMPS_DRAFT_02, migrated
+
+MIGRATIONS = {
+    SCHEMA_VERSIONS.BUMPS_DRAFT_O1: _migrate_draft_01_to_draft_02
+}
\ No newline at end of file
diff --git a/bumps/util.py b/bumps/util.py
index d81346b4..752b832a 100644
--- a/bumps/util.py
+++ b/bumps/util.py
@@ -2,17 +2,16 @@
 Miscellaneous utility functions.
 """
 from __future__ import division
+import warnings
 
 __all__ = ["kbhit", "profile", "pushdir", "push_seed", "redirect_console"]
 
 import sys
 import os
 import types
+import inspect
 
-try:  # CRUFT: python 2.x
-    from cStringIO import StringIO
-except ImportError:
-    from io import StringIO
+from io import StringIO
 
 import numpy as np
 from numpy import ascontiguousarray as _dense
@@ -20,6 +19,64 @@
 # so there is no longer a need for bumps.util.erf.
 from scipy.special import erf
 
+# this can be substituted with pydantic dataclass for schema-building...
+try:
+    from typing import Literal, Protocol, runtime_checkable
+except ImportError:
+    from typing_extensions import Literal, Protocol, runtime_checkable
+from typing import Iterable, Optional, Type, TypeVar, Any, Union, Dict, Callable, Tuple, List, Sequence, TYPE_CHECKING
+
+USE_PYDANTIC = os.environ.get('BUMPS_USE_PYDANTIC', "False") == "True"
+if TYPE_CHECKING:
+    from numpy.typing import NDArray
+
+from dataclasses import dataclass, field, is_dataclass, Field
+
+def field_desc(description: str) -> Any:
+    return field(metadata={"description": description})
+
+T = TypeVar('T')
+SCHEMA_ATTRIBUTE_NAME = '__bumps_schema__'
+
+def schema_config(
+        include: Optional[List[str]] = None,
+        exclude: Optional[List[str]] = None,
+    ) -> Callable[[Type[T]], Type[T]]:
+    
+    """ 
+    Add an attribute for configuring serialization
+    (presence of SCHEMA_ATTRIBUTE_NAME attribute is used during
+    serialization of unique instances e.g. Parameter)
+    """
+    def add_schema_config(cls: Type[T]) -> Type[T]:
+        if include is not None and exclude is not None:
+            raise ValueError(f"{fqn} schema: include array and exclude array are mutually exclusive - only define one")
+
+        setattr(cls, SCHEMA_ATTRIBUTE_NAME, dict(include=include, exclude=exclude))
+        return cls
+
+    return add_schema_config
+
+def has_schema_config(cls):
+    return is_dataclass(cls) and hasattr(cls, SCHEMA_ATTRIBUTE_NAME)
+
+@dataclass(init=True)
+class NumpyArray:
+    """
+    Wrapper for numpy arrays:
+    on serialize, 
+     - array.tolist() is called and stored in 'values' attribute
+     - str(array.dtype) is stored in 'dtype' attribute
+
+     on deserialize,
+      - return new np.ndarray(values, dtype=dtype)
+    """
+    dtype: str
+    values: Sequence = field(default_factory=list)
+
+if USE_PYDANTIC:
+    from typing import TypeAlias
+    NDArray: TypeAlias = NumpyArray
 
 def parse_errfile(errfile):
     """
diff --git a/bumps/webview/__init__.py b/bumps/webview/__init__.py
new file mode 100644
index 00000000..e69de29b
diff --git a/bumps/webview/build_client.py b/bumps/webview/build_client.py
new file mode 100644
index 00000000..cad3ec55
--- /dev/null
+++ b/bumps/webview/build_client.py
@@ -0,0 +1,33 @@
+from typing import Literal
+
+def build_client(install_dependencies=True, mode: Literal["development", "production"]="development", sourcemap=False):
+    """Build the webview client."""
+    from pathlib import Path
+    import os
+    import shutil
+    # check if npm is installed
+    if not shutil.which("npm"):
+        raise RuntimeError("npm is not installed. Please install npm.")
+    client_folder = Path(__file__).parent / "client"
+    # check if the node_modules directory exists
+    node_modules = client_folder / "node_modules"
+    os.chdir(client_folder)
+    if install_dependencies or not node_modules.exists():
+        print("Installing node modules...")
+        os.system("npm install")
+    # build the client
+    print("Building the webview client...")
+    cmd = f"npm run build -m {mode}"
+    if sourcemap:
+        cmd += " -- --sourcemap"
+    os.system(cmd)
+    print("Done.")
+    
+if __name__ == "__main__":
+    import argparse
+    parser = argparse.ArgumentParser(description="Build the webview client.")
+    parser.add_argument("--install-dependencies", action="store_true", help="Install dependencies.")
+    parser.add_argument("--mode", choices=["development", "production"], default="development", help="Build mode.")
+    parser.add_argument("--sourcemap", action="store_true", help="Generate sourcemaps.")
+    args = parser.parse_args()
+    build_client(install_dependencies=args.install_dependencies, mode=args.mode, sourcemap=args.sourcemap)
\ No newline at end of file
diff --git a/bumps/webview/client/.npmignore b/bumps/webview/client/.npmignore
new file mode 100644
index 00000000..0e42ddc4
--- /dev/null
+++ b/bumps/webview/client/.npmignore
@@ -0,0 +1,2 @@
+bumps-webview-client*.tgz
+*.patch
diff --git a/bumps/webview/client/README.md b/bumps/webview/client/README.md
new file mode 100644
index 00000000..ec0a8c1c
--- /dev/null
+++ b/bumps/webview/client/README.md
@@ -0,0 +1,60 @@
+# bumps-webview-client
+
+This is the Javascript + HTML client for the webview interface in Bumps
+
+## Starting the server
+
+```sh
+python -m bumps.webview.server
+```
+
+## Recommended IDE Setup
+
+[VSCode](https://code.visualstudio.com/) + [Volar](https://marketplace.visualstudio.com/items?itemName=Vue.volar) (and disable Vetur) + [TypeScript Vue Plugin (Volar)](https://marketplace.visualstudio.com/items?itemName=Vue.vscode-typescript-vue-plugin).
+
+## Customize configuration
+
+See [Vite Configuration Reference](https://vitejs.dev/config/).
+
+## Project Setup
+
+```sh
+npm install
+```
+
+### Compile and Hot-Reload for Development
+
+```sh
+npm run dev
+```
+
+### Compile and Minify
+This will create a new folder `./dist/<client version>/` and build the client there,
+with a unique tag in the name (suitable for local testing), e.g. `./dist/<client version>/assets/index.BaQLi6ia.js`
+
+```sh
+npm run build
+```
+
+### Compile for publishing to NPM
+This will create a new folder `./dist/<client version>/` and build the client
+with reproducible artifact names, e.g. `./dist/<client version>/assets/index.js`
+(suitable for packaging and publishing to NPM)
+
+```sh
+npm run build_prod
+```
+
+# Publishing new client versions:
+(...after checking to make sure there aren't extraneous files in this folder)
+```sh
+npm version patch
+npm publish
+```
+
+and then
+```sh
+git commit package.json -m "webview client version bump"
+git pull
+git push
+```
\ No newline at end of file
diff --git a/bumps/webview/client/index.html b/bumps/webview/client/index.html
new file mode 100644
index 00000000..27a08449
--- /dev/null
+++ b/bumps/webview/client/index.html
@@ -0,0 +1,16 @@
+<!DOCTYPE html>
+<html lang="en">
+  <head>
+    <meta charset="UTF-8" />
+    <link rel="icon" href="./src/assets/bumps-icon_48x48x32.png" />
+    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
+    <script type="text/javascript" async
+      src="https://cdnjs.cloudflare.com/ajax/libs/mathjax/3.2.2/es5/tex-svg.min.js">
+    </script>
+    <title>Bumps</title>
+  </head>
+  <body>
+    <div id="app"></div>
+    <script type="module" src="/src/main.js"></script>
+  </body>
+</html>
diff --git a/bumps/webview/client/index_template.txt b/bumps/webview/client/index_template.txt
new file mode 100644
index 00000000..b8bd9683
--- /dev/null
+++ b/bumps/webview/client/index_template.txt
@@ -0,0 +1,17 @@
+<!DOCTYPE html>
+<html lang="en">
+  <head>
+    <meta charset="UTF-8" />
+    <link rel="icon" href="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAADAAAAAwCAYAAABXAvmHAAAABGdBTUEAALGPC/xhBQAAAAFzUkdCAK7OHOkAAAAgY0hSTQAAeiYAAICEAAD6AAAAgOgAAHUwAADqYAAAOpgAABdwnLpRPAAAAAZiS0dEAP8A/wD/oL2nkwAAAAlwSFlzAAAOwwAADsMBx2+oZAAADxBJREFUaN7NmluMXld5hp9vrX3455SxHceHEMdghSRKIOFUylEtJUAF4qL0hihtEVVbLrijVdWLthdtBZUoaitExRVthVS1Qq2oRKEhhwIhlAYoOD5ijw/j2B7H9pzn//dhrfV9vdj790wSoDgkwJaWNDP6Z+33fb/3/dbaa/+en+El4hyAExABQwQRuZ45/M8MvPOZmaZtu17ysjse+Pjnp/ff8+5q/n8fj6EdIuIA+7klIM5npinuue3e19/1oc88sjy57+64fe/ds3v3vnPt2OPfUNV1IP44JH7qBMbgZ/e/8s0v/8CnH7zauBur5dOxHi6kZnb73lLa/aOn5p5A3AZY/LkiMAa/68CrXn/gA5/60tUqzDbDhRR0I4u25uuwrLb3JbfnF0+vtOurRxEZAfrDJxT5qREYg7/lnre9+9b7//oLK3V7Qzta0Dat+xDXSFajNrImDzKxbXpfc+zoQRO5BNQ/BLwDfjoExGW5aYp7X/2u991y/yc+f3VtJW9Gly3EdRfSGokhyWqiVNJqrbpt2/bJ5ZXYLF49iMgqkJ6F3WOmk7M37nnRCXTgY7jpnvvev+d9f/HPS6uLEuorFtKqC7pKYkSyitbVRJ/w2jXTwdTM7vbYkSMG54HRMyppmmb3vPTltz/wt196UQmI87lpCrte854P3vzrH/3s+tqihfqKtGnNJV0nWUXShlYaoouIGYWKZE3SfGrbjF9ZapuVpScRWQbi2IZTN9/+2v2/8cmHh5M7X/piEZDxzV72y7/1+ze+508/tb66mEJ91YW0JlFXO8tYQ5CaJC0C+AS5GkWCXL1MTU7v2pg7csjgnPgsmKYwffMdr9v/wCe/PIKbmo359GIQEHHem6Z44O0f/MOd7/mjj69cvZBifcWFtCpBhySriNYQqYguIEAejUKhTFAkkbJVKydmp9urC+vtxupRTM9vv+WO1936wN98eSRuR1tdSjFV/oUlICLiMm+a4l2/+rt/PHPfRz56ZeFMDO2SD2lNQm+baA2tVAQXcAZZMsoEuXajTIJXKMhlZnJm1+LZo4/tvOMNr9x3/1/9y1DdjnZ4MQWtfLQNrmvf8f+BB3Fimu583x98YuYXf+8jly6fiKlZ9CGu98oPSdbSuoboWpxCkToCgwheIVMokpAp5MmT101qm7XH8rd++HUr1XA6jS5r0DUXbEigfmEIiIgYIs5U733/n3zaveqBD125fDqm9mrWxlWCbaCpJtIQXDe8GXmEIjqKZJ3iCTIVCgWnkFcjpvbcQ3vgLVxeuajWLEvQoUQb0lITrSZ7AcA7AxHT9Or7/+wz+qr7P3j10pFgYTVv4xopraNUJCLRNUQX8GoUsQebxsAhT5CpI1Mlj8rMbb/Exq57WFw+ryksStJaog1paIg0JN/+ZARERMzMBoMBdz/wl/+QXv7eDywtHAkpruVtXCPqOomKZC2NazFa8gjeHJkaeYI8GZl2ZPK+CznzuME0F449DurRXFyMG0QqAjXJWpKrUWl/kr2QCJgVg4n83vve/4/N4Kbf3EhtaNNansIynUdHRALRtZ3yZmSxU7zslc8V8iTkCoUKPiqDiW08feY7zD/+OcLTxxnc/koarQhU/ZrRoi7i1H6izZwHbN+9b/nU1YVTv33+0c8G6su5f9krCM0irdQka4iuJkno7LEFeGbjsHaez1NHIFeHyz1PPfkoqi312mVcqrDbbqdtVkk+YBJwamTxeRLoN4Fp14E735wk/d3aU8dSNpjIqnMnyKdL0kv2EZtVoo8EacgMsiTXlM+3dJtybB91FMlTktPEERfnvkUU8BNTxEvnKHfvodkxQ9IRzoQydK3XPR8CZkaWeSa27/zw8Kl5nHpSTFAWbHzzQdi4SFMmzJoupEEoUud531umTEJmm78X2lWjpKBOFUFbsizHmwfxhG8/xgQtLhl51K6i5q6fgHTPrGl6566b4mjlV+rRGgoOVUQcYbiGfutrlKWDlMiSkCejjJttMtNx1+lA5+bI1HX2QQjNKt3jcQYorihJly7hjp9gMp+haI2yr+jzqYADKCam3rH29MXd4iSZmagZpt3N6mNHmLh0idKV5GEzpPlWCyXIVSjUkaun0AyvEDXSNEMMEEAQnAlSDEgHDzNV1XjJyAxKk+snYGaICK4o3hmqEU6cmW15aBLBYkv7xLeZcBllNEqFPI4Vp2+Xne9z8+QqOFNCqKnjiBgDTnwHXhwewXuPrS4jx44yU87gU5er51MB9VnOcHX1bo0RM5xtefQWS1hZ0Jw9TXFhgSzP8UnxJnjrlM/UUVgHPksep0ZMNXWq2LAKDW23FojD4RADUcPnBcNjxyjWN8jJya+XgPTHHVPbd+wrvL/LktJXevwJDIfgiZqojnyPKQqy4PsVVzZtkzxF8mQGMbXUoWIYhjSxQqPie/XHkxtGckI1XGH94HeYzAb42PtZcE4ky8T1Q7xHrh0ybd0vCcDM7Lbbta0nDacmz95PCVin1ujCWbKVZcqs6DtHbxvNyDXDJ2hCzSjWbMSaKlbUMUDSTn0DZ+ANTJUUEy7LWDxzmGJ1yMANcN7nuaFqFqNpPywlzKw3PLjMi8syXJYDWLI7m9EI50WffXRjgCEgOaFtWDr2PSbcgCz6zu/mKZPDq1GnmioOqcI6dRxRhxoNCReVrcolM1SVpBHBEZuaxRNPMlVMkb3rvd89PowXL2yMTp0ejebPbayfPV2vnz41Wr94PtQrq221sYzFNIbpRMjz8kAIoSuQPRu8IXR/9/mAladOsv+2N+LdJD4pOYIzOvBhRKV193PbEGIijzk+0c9hWDKiJTQphqEohS9ZOHuE/QdeSzYxe9eBYuLuA9sn3vHWVEAsIXmobdgE1jaG6/PzzfLcqbC+cKlevrjglo5/Q4YLr9AUybJcrE+w9fbZPBM0xGW09ZDFs4c4cOfbiOsbOIEmVp3yaUQda4axIibDYonogCwmMIiaMFNUFUMR7eviHaEdMX/062RtphoF06gEj6UoRO+8TkyVcWKqLG7ee2ORveE1OCMlYdYZcx97o1lKkBe+k11AbEsd5BqNLB9w8dxRDrz0F8hwbMQRbRxRx4o61oxCgybwcUAMnjxmtCiYgRlqhpliBuNIJzN85jl/4ShZKryLpZEKTyoNzUFLiKVacoI2ZtaoWVJTV1BXT7vh0iXnvAPrYYr1U3ddCMb5N8R5qtEKF84fst1776Fq1iSkhjo2jGJNiIKkAgk5LmRYU9KEQFJFRLoYmu/s1IfCqaEYSCKLpZFKiEU3NIdUCppJXzERVMAJLgNWn6Zdu4r4nM3+L1uUl03/AoqSFwM7cfxrJ6yY2C2D6W1VqKlDS4jgQ4kmD6GEmBHE02qNSkRMUDWcOQRB0Y4QASFhOJyWkMbAC9CBoBlon0/rVVYFy6BaOkdoh+ActiXBhnQUpLOTbclFis2pGOrDi1fmvtuqMgqVhQTSq+baAYQccZPEwhFi21VXBTGHiWKiiAKmmJiJKE4sZKGAUBqWC1p24NO4f6liuLEd8cBw+TQR8OKAtMU6wvjVhF2zFmioT5rGE8CTq5fPHs937r9Xo9/hQ2mEQlwosJQTfY4VJeqFFBOqDi+dSGraV9YQzExEzCTEpj7iYiGkQjr/9+C74PTAbFNdgGrxLNrZvjeO6/r++LN2LcoxtdUR03gc+A7Io009/Ldmeelhl2Yg5EoP3jKPDQrCICPmDkvdHU0URbsmYYqhJg4RS1Vo2yfN7GQX4IGRMrkGnp6AmlyzEWJYgnZxvnsKMgHbEtjOcmYChkUN1TEzPQF8E/gP4BAQq8X5v3fqkDZ3pJyUl6RyQCpK4sQkJjlEEDxqW81phpiYpVFsm0OYfR94zKUSzLlngAeHGv1iTBdFSVi7Rlhe6JUXnvFKq1sQBLOYQn3MTOd68F8EjoCtA9TDpx/JQ/MENimW5RqLgphPEgYTaJZDVJSEiSCmiHUmcmIiFkehrQ6Z2SngK8CDzjLZvH8PfnNx6qCatSBKGq4QVpcQn13rONb16n7PYSl1yo/BPwh8n+50WUF8ShqWrx78BGVJKApiOSBMFKTMYS6CdYuXXVvX1cSZmMWNGOpDdOC/CjwKnHWKEc06S4yV7/0MitGiFjDfwvoSsVpDnO882t2mOxYybVKoDprpSeC/e/An6F5QjJOREHHra2c+NwrzX4yT210q8xRyh0nAnKI+YBp7MXvwGlZCWx00sxPAfwGPAE8BwZkyBtL9z7UgKkkb1FrMahBoqmU0bmAu7+zVfVTMUq2xPmxmc8DXgP8ETj4T/LU1QwAL1Zl/0ulJUiYIESSgYpgMwQKCXQMf2/pQP9/DPfhzQADMdfu+sfKbG7KkDUjA6A6kBCUOL2MxdPml37WZ1hrqQ71tHuuVPwlUzwUPmCaAevHov7pm/huWld4kqhlIlkGssdSYuwa+OQTMAQ/16p8H2vHcrvP4lgqgqNY9+DGBgNEQ1i6htF0zEMRM6xTrQ4ad7pV/CDgDND8Q/GYRvBm1zj/ysaKIXeZchhQOC+vmiKIalsIm+If7+Rd65a9dLqkmw8z1S3SyGiSChX4kLAXMQk/ADOcETVXqQjUG/zBwdqs6P/QyS4jzw6tzX5DL3/2sTM06802QXIzqsqjp1RDaQ32GHurnv8Sz3pUBOF96H8VJbcYwthZCqzFUllJlarWZ1qZUiLXEtYsmIKpxPYX6STrPf7UHP/9jgd8koQD1qUf/vIhXLsSZ7XlZ5KLLZ69E4wjYHPDlZ4F/ztz+zW9403v3DJrJHVMMtk0PZHp6IMWgFJ9n0qLSWCNNbLDUWvXEv0t9+dSKmR6m8/xX+nZ27rrAb1rJaWwXWZ2fm9z3ineVV44ur5984jDIHNhDwOPAZbq39j94CifsmBwUvzY9e+PbJ7fvumnb7v2zU7v3z07edOvMxO4DU8zsnGgHN2RuIpMnPvo7q5e//z+HRNwZM32kV+dC78vrA78JwQO58+5Npvo2Myvo1pBvAld+FHgAQdwUpnuBW4BZYBqYAWa9k22DzG+74cbd22/YsWvbmbljbdvUC8DXe+s8J1TPj4N4zGZAbgXzdJ1miR/g+ecS6DYzmSCZiOSI5MDAYMI0TWI21ZMaj0Xg8AsGfvNybH71IfGjvmKw5fo/APdXVpd2Y2AAAAAidEVYdENvbW1lbnQAQ3JlYXRlZCB3aXRoIEdJTVAgb24gYSBNYWOHqHdDAAAAAElFTkSuQmCC" />
+    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
+    <script type="text/javascript" async
+      src="https://cdnjs.cloudflare.com/ajax/libs/mathjax/3.2.2/es5/tex-mml-svg.min.js">
+    </script>
+    <title>Bumps</title>
+    <script type="module" crossorigin src="{cdn}/assets/index.js"></script>
+    <link rel="stylesheet" href="{cdn}/assets/index.css">
+  </head>
+  <body>
+    <div id="app"></div>
+  </body>
+</html>
diff --git a/bumps/webview/client/package.json b/bumps/webview/client/package.json
new file mode 100644
index 00000000..b6dff223
--- /dev/null
+++ b/bumps/webview/client/package.json
@@ -0,0 +1,29 @@
+{
+  "name": "bumps-webview-client",
+  "version": "0.1.16",
+  "type": "module",
+  "scripts": {
+    "dev": "vite",
+    "build": "vite build --emptyOutDir -m development",
+    "build_prod": "vite build --emptyOutDir -m production",
+    "preview": "vite preview --port 4173"
+  },
+  "dependencies": {
+    "bootstrap": "^5.2.1",
+    "date-fns": "^2.30.0",
+    "json-difference": "^1.8.2",
+    "mpld3": "^0.5.10",
+    "plotly.js": "^2.32.0",
+    "socket.io-client": "^4.5.2",
+    "uuid": "^9.0.0",
+    "vue": "^3.4.26"
+  },
+  "devDependencies": {
+    "@types/bootstrap": "^5.2.10",
+    "@types/plotly.js": "^2.12.5",
+    "@types/uuid": "^8.3.4",
+    "@vitejs/plugin-vue": "^5.0.4",
+    "vite": "^5.4.2",
+    "vue-json-pretty": "^2.4.0"
+  }
+}
diff --git a/bumps/webview/client/src/App.vue b/bumps/webview/client/src/App.vue
new file mode 100644
index 00000000..52039573
--- /dev/null
+++ b/bumps/webview/client/src/App.vue
@@ -0,0 +1,463 @@
+<script setup lang="ts">
+import { Button } from 'bootstrap';
+import { computed, onMounted, ref, shallowRef } from 'vue';
+import { io } from 'socket.io-client';
+import type { AsyncSocket } from './asyncSocket.ts';
+import './asyncSocket.ts';  // patch Socket with asyncEmit
+import {
+  active_panel,
+  active_layout,
+  fitter_settings,
+  active_fit,
+  selected_fitter,
+  fit_progress,
+  fitOptions,
+  fileBrowser,
+  FileBrowserSettings,
+  connected,
+  model_file,
+  notifications,
+  menu_items,
+  socket as socket_ref,
+  addNotification,
+  cancelNotification,
+} from './app_state.ts';
+import FitOptions from './components/FitOptions.vue';
+import PanelTabContainer from './components/PanelTabContainer.vue';
+import FileBrowser from './components/FileBrowser.vue';
+import ServerShutdown from './components/ServerShutdown.vue';
+import ServerStartup from './components/ServerStartup.vue';
+import SessionMenu from './components/SessionMenu.vue';
+
+const props = defineProps<{
+  panels: {title: string, component: any }[],
+  name?: string
+}>();
+
+type Message = {
+  timestamp: string,
+  message: object
+}
+
+const LAYOUTS = ["left-right", "top-bottom", "full"];
+const menuToggle = ref<HTMLButtonElement>();
+const nativefs = ref(false);
+
+// Create a SocketIO connection, to be passed to child components
+// so that they can do their own communications with the host.
+const queryString = window.location.search;
+const urlParams = new URLSearchParams(queryString);
+
+const sio_base_path = urlParams.get('base_path') ?? window.location.pathname;
+const sio_server = urlParams.get('server') ?? '';
+const single_panel = urlParams.get('single_panel');
+if (single_panel !== null) {
+  active_layout.value = 'full';
+  const panel_index = props.panels.findIndex(({title}) => (title.toLowerCase() == single_panel.toLowerCase()));
+  if (panel_index > -1) {
+    active_panel.value[0] = panel_index;
+  }
+  else {
+    console.error(`Panel ${single_panel} not found`);
+  }
+}
+
+const socket = io(sio_server, {
+   path: `${sio_base_path}socket.io`,
+}) as AsyncSocket;
+socket_ref.value = socket;
+
+const can_mount_local = (
+  ('mountLocal' in socket) && ('showDirectoryPicker' in window)
+)
+
+socket.on('connect', async () => {
+  console.log(socket.id);
+  connected.value = true;
+  const file_info = await socket.asyncEmit('get_shared_setting', 'model_file') as { pathlist: string[], filename: string } | undefined;
+  model_file.value = file_info;
+});
+
+socket.on('disconnect', (payload) => {
+  console.log("disconnected!", payload);
+  connected.value = false;
+})
+
+socket.on('model_file', ( file_info: { filename: string, pathlist: string[] } ) => {
+  model_file.value = file_info;
+});
+
+socket.on('active_fit', ({ fitter_id, options, num_steps }) => {
+  active_fit.value = { fitter_id, options, num_steps };
+});
+
+socket.on('fit_progress', (event) => {
+  fit_progress.value = event;
+});
+
+socket.on('add_notification', addNotification);
+socket.on('cancel_notification', cancelNotification);
+
+function disconnect() {
+  socket.disconnect();
+  connected.value = false;
+}
+
+function selectOpenFile() {
+  if (fileBrowser.value) {
+    const settings: FileBrowserSettings = {
+      title: "Load Model File",
+      callback: async (pathlist, filename) => {
+        await socket.asyncEmit("load_problem_file", pathlist, filename);
+      },
+      chosenfile_in: model_file.value?.filename ?? "",
+      show_name_input: false,
+      require_name: true,
+      show_files: true,
+      search_patterns: [".py, .pickle, .json", ".py", ".json", ".pickle"],
+    }
+    fileBrowser.value.open(settings);
+  }
+}
+
+function exportResults() {
+  if (fileBrowser.value) {
+    const settings: FileBrowserSettings = {
+      title: "Export Results",
+      callback: async (pathlist, filename) => {
+        if (filename !== "") {
+          pathlist.push(filename);
+        }
+        socket.asyncEmit("export_results", pathlist).then(() => {
+          socket?.syncFS?.();
+        })
+      },
+      show_name_input: true,
+      name_input_label: "Subdirectory",
+      require_name: false,
+      show_files: false,
+      chosenfile_in: "",
+      search_patterns: [],
+    }
+    fileBrowser.value.open(settings);
+  }
+}
+
+async function saveFileAs(ev: Event) {
+  if (fileBrowser.value) {
+    const { extension } = await socket.asyncEmit("get_serializer") as { extension: string };
+    const filename_in = model_file.value?.filename ?? "";
+    const new_filename = `${filename_in.replace(/(\.[^\.]+)$/, '')}.${extension}`;
+    const settings: FileBrowserSettings = {
+      title: "Save Problem",
+      callback: async (pathlist, filename) => {
+        saveFile(ev, {pathlist, filename});
+      },
+      show_name_input: true,
+      name_input_label: "Filename",
+      require_name: true,
+      show_files: true,
+      chosenfile_in: new_filename,
+      search_patterns: [`.${extension}`],
+    }
+    fileBrowser.value.open(settings);
+  }
+}
+
+async function saveFile(ev: Event, override?: {pathlist: string[], filename: string}) {
+  if (model_file.value === undefined) {
+    alert('no file to save');
+    return;
+  }
+  const { filename, pathlist } = override ?? model_file.value;
+  console.log('saving:', {pathlist, filename});
+  await socket.asyncEmit("save_problem_file", pathlist, filename, false, async({filename, check_overwrite}: {filename: string, check_overwrite: boolean}) => {
+    if (check_overwrite !== false) {
+      const overwrite = await confirm(`File ${filename} exists: overwrite?`);
+      if (overwrite) {
+        await socket.asyncEmit("save_problem_file", pathlist, filename, overwrite);
+      }
+    }
+    if (nativefs.value) {
+      await socket.syncFS();
+    }
+  });
+}
+
+async function reloadModel() {
+  if (model_file.value) {
+    const { filename, pathlist } = model_file.value;
+    await socket.asyncEmit("load_problem_file", pathlist, filename);
+  }
+}
+
+async function applyParameters(ev: Event) {
+  if (fileBrowser.value) {
+    const settings: FileBrowserSettings = {
+      title: "Apply Parameters",
+      callback: async (pathlist, filename) => {
+        await socket.asyncEmit("apply_parameters", pathlist, filename);
+      },
+      show_name_input: true,
+      name_input_label: "Filename",
+      require_name: true,
+      show_files: true,
+      chosenfile_in: "",
+      search_patterns: [".par"],
+    }
+    fileBrowser.value.open(settings);
+  }
+}
+
+async function saveParameters(ev: Event, override?: {pathlist: string[], filename: string}) {
+  if (fileBrowser.value) {
+    const settings: FileBrowserSettings = {
+      title: "Save Parameters",
+      callback: async (pathlist, filename) => {
+        await socket.asyncEmit("save_parameters", pathlist, filename, false, async({filename, check_overwrite}: {filename: string, check_overwrite: boolean}) => {
+          if (check_overwrite !== false) {
+          const overwrite = await confirm(`File ${filename} exists: overwrite?`);
+            if (overwrite) {
+              await socket.asyncEmit("save_parameters", pathlist, filename, overwrite);
+            }
+          }
+          if (nativefs.value) {
+            await socket.syncFS();
+          }
+        });
+      },
+      show_name_input: true,
+      name_input_label: "Filename",
+      require_name: true,
+      show_files: true,
+      search_patterns: [".par"],
+      chosenfile_in: "manual_save.par",
+    }
+    fileBrowser.value.open(settings);
+  }
+}
+
+function openFitOptions() {
+  fitOptions.value?.open();
+}
+
+async function startFit() {
+  const active = selected_fitter.value;
+  const settings = fitter_settings.value;
+  if (active && settings) {
+    const fit_args = settings[active];
+    await socket.asyncEmit("start_fit_thread", active, fit_args.settings);
+  }
+}
+
+async function stopFit() {
+  await socket.asyncEmit("stop_fit")
+}
+
+async function quit() {
+  await socket.asyncEmit("shutdown");
+}
+
+async function mountLocal() {
+  const success = (await socket.mountLocal?.()) ?? false;
+  nativefs.value = success;
+}
+
+const model_not_loaded = computed(() => model_file.value == null);
+
+menu_items.value = [
+  { text: "Load Problem", action: selectOpenFile },
+  { text: "Save Parameters", action: saveParameters, disabled: model_not_loaded },
+  { text: "Apply Parameters", action: applyParameters, disabled: model_not_loaded },
+  { text: "Save Problem", action: saveFile, disabled: model_not_loaded },
+  { text: "Save Problem As...", action: saveFileAs, disabled: model_not_loaded },
+  { text: "Export Results", action: exportResults, disabled: model_not_loaded },
+  { text: "Reload Model", action: reloadModel, disabled: model_not_loaded },
+]
+
+onMounted(() => {
+  const menuToggleButton = new Button(menuToggle.value as HTMLElement);
+});
+
+</script>
+
+<template>
+  <div class="h-100 w-100 m-0 d-flex flex-column">
+    <nav class="navbar navbar-expand-sm navbar-dark bg-dark" v-if="single_panel === null">
+      <div class="container-fluid">
+        <div class="navbar-brand">
+          <img src="./assets/bumps-icon_256x256x32.png" alt="" height="24" class="d-inline-block align-text-middle">
+          {{ name ?? "Bumps" }}
+        </div>
+        <button ref="menuToggle" class="navbar-toggler" type="button" data-bs-toggle="collapse"
+          data-bs-target="#navbarSupportedContent" aria-controls="navbarSupportedContent" aria-expanded="false"
+          aria-label="Toggle navigation">
+          <span class="navbar-toggler-icon"></span>
+        </button>
+        <div class="collapse navbar-collapse" id="navbarSupportedContent">
+          <ul class="navbar-nav me-auto mb-2 mb-lg-0">
+            <!-- <li class="nav-item dropdown">
+              <div class="nav-link dropdown-toggle"  role="button" data-bs-toggle="dropdown"
+                aria-expanded="false">
+                Session
+              </div>
+              <ul class="dropdown-menu">
+                <li><button class="btn btn-link dropdown-item"  @click="connect">New</div></li>
+                <li><button class="btn btn-link dropdown-item"  @click="disconnect">Disconnect</div></li>
+                <li><button class="btn btn-link dropdown-item"  @click="reconnect">Existing</div></li>
+              </ul>
+            </li> -->
+            <li class="nav-item dropdown">
+              <button class="btn btn-link nav-link dropdown-toggle" role="button" data-bs-toggle="dropdown"
+                aria-expanded="false">
+                File
+              </button>
+              <ul class="dropdown-menu">
+                <li><button v-if="can_mount_local" class="btn btn-link dropdown-item" @click="mountLocal">Mount Local Folder</button></li>
+                <li v-for="menu_item of menu_items" :key="menu_item.text" :title="menu_item.help">
+                  <button class="btn btn-link dropdown-item" @click="menu_item.action" :disabled="menu_item.disabled ?? false">{{ menu_item.text }}</button>
+                </li>
+                <li>
+                  <hr class="dropdown-divider">
+                </li>
+                <li><button class="btn btn-link dropdown-item" @click="quit">Quit</button></li>
+              </ul>
+            </li>
+            <SessionMenu :socket="socket" />
+            <!-- <li class="nav-item dropdown">
+              <button class="btn btn-link nav-link dropdown-toggle"  role="button" data-bs-toggle="dropdown"
+                aria-expanded="false">
+                Fitting
+              </button>
+              <ul class="dropdown-menu">
+                <li><button class="btn btn-link dropdown-item"  @click="startFit">Start</button></li>
+                <li><button class="btn btn-link dropdown-item"  @click="stopFit">Stop</button></li>
+                <li>
+                  <hr class="dropdown-divider">
+                </li>
+                <li><button class="btn btn-link dropdown-item"  @click="openFitOptions">Options...</button></li>
+              </ul>
+            </li> -->
+            <li class="nav-item dropdown">
+              <button class="btn btn-link nav-link dropdown-toggle"  role="button" data-bs-toggle="dropdown"
+                aria-expanded="false">
+                Layout
+              </button>
+              <ul class="dropdown-menu">
+                <li v-for="layout in LAYOUTS" :key="layout" :class="{layout: true, active: active_layout === layout}">
+                  <button class="btn btn-link dropdown-item" :class="{layout: true, active: active_layout === layout}" @click="active_layout = layout">{{ layout }}</button>
+                </li>
+              </ul>
+            </li>
+
+            <!-- <li class="nav-item dropdown">
+              <div class="nav-link dropdown-toggle"  role="button" data-bs-toggle="dropdown"
+                aria-expanded="false">
+                Reflectivity
+              </div>
+              <ul class="dropdown-menu">
+                <li v-for="plot_type in REFLECTIVITY_PLOTS" :key="plot_type">
+                  <div :class="{'dropdown-item': true, active: (plot_type === reflectivity_type)}" 
+                    @click="set_reflectivity(plot_type)">{{plot_type}}</div>
+                </li>
+              </ul>
+            </li> -->
+          </ul>
+          <div class="flex-grow-1 px-4 m-0">
+            <h4 class="m-0">
+              <!-- <div class="rounded p-2 bg-primary">Fitting: </div> -->
+              <div v-if="active_fit.fitter_id !== undefined" class="badge bg-secondary p-2 align-middle">
+                <div class="align-middle pt-2 pb-1 px-1 d-inline-block">
+                  <span>Fitting: {{ active_fit.fitter_id }} step {{ fit_progress?.step }} of
+                  {{ active_fit?.num_steps }}, chisq={{ fit_progress.chisq }}</span>
+                  <div class="progress mt-1" style="height:3px;">
+                    <div class="progress-bar" role="progressbar" :aria-valuenow="fit_progress?.step" 
+                      aria-valuemin="0" :aria-valuemax="active_fit?.num_steps ?? 100" :style="{width: ((fit_progress.step ?? 0) * 100 / (active_fit.num_steps ?? 1)).toFixed(1) + '%'}"></div>
+                  </div>
+                </div>
+                <button class="btn btn-danger btn-sm" @click="stopFit">Stop</button>
+              </div>
+              <div v-else class="badge bg-secondary p-1">
+                <div class="align-middle p-2 d-inline-block">
+                  <span>Fitting: </span>
+                </div>
+                <button class="btn btn-light btn-sm me-2" @click="openFitOptions">
+                  {{ selected_fitter ?? "" }}
+                  <svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-gear" viewBox="0 0 16 16">
+                    <path d="M8 4.754a3.246 3.246 0 1 0 0 6.492 3.246 3.246 0 0 0 0-6.492zM5.754 8a2.246 2.246 0 1 1 4.492 0 2.246 2.246 0 0 1-4.492 0z"/>
+                    <path d="M9.796 1.343c-.527-1.79-3.065-1.79-3.592 0l-.094.319a.873.873 0 0 1-1.255.52l-.292-.16c-1.64-.892-3.433.902-2.54 2.541l.159.292a.873.873 0 0 1-.52 1.255l-.319.094c-1.79.527-1.79 3.065 0 3.592l.319.094a.873.873 0 0 1 .52 1.255l-.16.292c-.892 1.64.901 3.434 2.541 2.54l.292-.159a.873.873 0 0 1 1.255.52l.094.319c.527 1.79 3.065 1.79 3.592 0l.094-.319a.873.873 0 0 1 1.255-.52l.292.16c1.64.893 3.434-.902 2.54-2.541l-.159-.292a.873.873 0 0 1 .52-1.255l.319-.094c1.79-.527 1.79-3.065 0-3.592l-.319-.094a.873.873 0 0 1-.52-1.255l.16-.292c.893-1.64-.902-3.433-2.541-2.54l-.292.159a.873.873 0 0 1-1.255-.52l-.094-.319zm-2.633.283c.246-.835 1.428-.835 1.674 0l.094.319a1.873 1.873 0 0 0 2.693 1.115l.291-.16c.764-.415 1.6.42 1.184 1.185l-.159.292a1.873 1.873 0 0 0 1.116 2.692l.318.094c.835.246.835 1.428 0 1.674l-.319.094a1.873 1.873 0 0 0-1.115 2.693l.16.291c.415.764-.42 1.6-1.185 1.184l-.291-.159a1.873 1.873 0 0 0-2.693 1.116l-.094.318c-.246.835-1.428.835-1.674 0l-.094-.319a1.873 1.873 0 0 0-2.692-1.115l-.292.16c-.764.415-1.6-.42-1.184-1.185l.159-.291A1.873 1.873 0 0 0 1.945 8.93l-.319-.094c-.835-.246-.835-1.428 0-1.674l.319-.094A1.873 1.873 0 0 0 3.06 4.377l-.16-.292c-.415-.764.42-1.6 1.185-1.184l.292.159a1.873 1.873 0 0 0 2.692-1.115l.094-.319z"/>
+                  </svg>
+                </button>
+                <button class="btn btn-success btn-sm" @click="startFit">Start</button>
+              </div>
+            </h4>
+          </div>
+          <div class="d-flex">
+            <div id="connection_status"
+              :class="{'btn': true, 'btn-outline-success': connected, 'btn-outline-danger': !connected}">
+              {{(connected) ? 'connected' : 'disconnected'}}</div>
+
+          </div>
+        </div>
+      </div>
+    </nav>
+    <div class="flex-grow-1 row overflow-hidden" v-if="active_layout === 'left-right'">
+      <div class="col d-flex flex-column mh-100 border-end border-success border-3">
+        <PanelTabContainer :panels="panels" :socket="socket" :active_panel="active_panel[0]" @panel_changed="(n: number) => { active_panel[0] = n }"/>
+      </div>
+      <div class="col d-flex flex-column mh-100">
+        <PanelTabContainer :panels="panels" :socket="socket" :active_panel="active_panel[1]" @panel_changed="(n: number) => { active_panel[1] = n }"/>
+      </div>
+    </div>
+    <div class="flex-grow-1 d-flex flex-column" v-if="active_layout === 'top-bottom'">
+      <div class="d-flex flex-column flex-grow-1" style="overflow-y:scroll;">
+        <PanelTabContainer :panels="panels" :socket="socket" :active_panel="active_panel[0]" @panel_changed="(n: number) => { active_panel[0] = n }"/>
+      </div>
+      <div class="d-flex flex-column flex-grow-1">
+        <PanelTabContainer :panels="panels" :socket="socket" :active_panel="active_panel[1]" @panel_changed="(n: number) => { active_panel[1] = n }"/>
+      </div>
+    </div>
+    <div class="flex-grow-1 row overflow-hidden" v-if="active_layout === 'full'">
+      <div class="col d-flex flex-column mh-100">
+        <PanelTabContainer :panels="panels" :socket="socket" :active_panel="active_panel[0]" @panel_changed="(n: number) => { active_panel[0] = n }" :hide_tabs="single_panel !== null"/>
+      </div>
+    </div>
+
+  </div>
+  <FitOptions ref="fitOptions" :socket="socket" />
+  <FileBrowser ref="fileBrowser" :socket="socket" />
+  <ServerShutdown :socket="socket" />
+  <ServerStartup :socket="socket" />
+  <div class="toast-container position-fixed bottom-0 end-0 p-3" style="z-index: 11">
+    <div v-for="notification in notifications" :key="notification.id" class="toast show" role="alert" aria-live="assertive" aria-atomic="true">
+      <div class="toast-header">
+        <strong class="me-auto">{{notification.title}}</strong>
+        <button type="button" class="btn-close" @click="cancelNotification(notification.id)" data-bs-dismiss="toast" aria-label="Close"></button>
+      </div>
+      <div class="toast-body">
+        <div v-html="notification.content"></div>
+        <div v-if="notification.spinner" class="spinner-border spinner-border-sm text-primary" role="status">
+          <span class="visually-hidden">{{ notification.title }} ongoing...</span>
+        </div>
+      </div>
+    </div>
+  </div>
+</template>
+
+<style>
+html,
+body,
+#app {
+  height: 100%;
+  width: 100%;
+  overflow-x: hidden;
+}
+
+div#connection_status {
+  pointer-events: none;
+}
+
+.dropdown-menu {
+  z-index: 2000;
+}
+</style>
diff --git a/bumps/webview/client/src/app_state.ts b/bumps/webview/client/src/app_state.ts
new file mode 100644
index 00000000..70b0f9d2
--- /dev/null
+++ b/bumps/webview/client/src/app_state.ts
@@ -0,0 +1,60 @@
+import { ref, shallowRef } from 'vue';
+import { v4 as uuidv4 } from 'uuid';
+import type { Ref } from 'vue';
+import type { AsyncSocket } from './asyncSocket.ts';
+
+interface ModalDialog {
+  open: (...args: unknown[]) => void,
+  close: () => void,
+}
+
+export class FileBrowserSettings {
+  chosenfile_in?: string
+  pathlist_in?: string[]
+  title: string
+  show_name_input: boolean
+  require_name: boolean
+  name_input_label?: string
+  show_files: boolean
+  search_patterns: string[]
+  callback: (pathlist: string[], filename: string) => Promise<void>
+};
+
+export type FitSetting = { name: string, settings: object };
+
+export const socket = ref<AsyncSocket>();
+export const connected = ref(false);
+export const fitOptions = ref<ModalDialog>();
+export const fileBrowser = ref<ModalDialog>();
+export const model_file = shallowRef<{ filename: string, pathlist: string[] }>();
+export const model_loaded = ref<string>();
+export const active_layout = ref("left-right");
+export const active_panel = ref([0, 1]);
+export const active_fit = ref<{ fitter_id?: string, options?: {}, num_steps?: number }>({});
+export const fit_progress = ref<{ chisq?: string, step?: number, value?: number }>({});
+export const fitter_settings = shallowRef<{ [fit_name: string]: FitSetting }>({});
+export const selected_fitter = ref<string>("amoeba");
+export const notifications = ref<{ title: string, content: string, id: string, spinner: boolean }[]>([]);
+export const menu_items = ref<{disabled?: Ref<boolean> | boolean, text: string, action: Function, help?: string}[]>([]);
+export const autosave_history = ref(false);
+export const autosave_history_length = ref(10);
+export const session_output_file = shallowRef<{ filename: string, pathlist: string[] }>();
+export const autosave_session = ref(false);
+export const autosave_session_interval = ref(300);
+
+export function cancelNotification(id: string) {
+  const index = notifications.value.findIndex(({id: item_id}) => (item_id === id));
+  if (index > -1) {
+    notifications.value.splice(index, 1);
+  }
+}
+
+export function addNotification({ title, content, timeout, id }: {title: string, content: string, timeout?: number, id?: string}) {
+  const has_timeout = (timeout !== undefined);
+  id = id || uuidv4();
+  notifications.value.push({title, content, id, spinner: !has_timeout});
+  if (has_timeout) {
+    setTimeout(cancelNotification, timeout, id);
+  }
+  return id;
+}
\ No newline at end of file
diff --git a/bumps/webview/client/src/assets/bumps-icon_256x256x32.png b/bumps/webview/client/src/assets/bumps-icon_256x256x32.png
new file mode 100644
index 00000000..96a6895b
Binary files /dev/null and b/bumps/webview/client/src/assets/bumps-icon_256x256x32.png differ
diff --git a/bumps/webview/client/src/assets/bumps-icon_48x48x32.png b/bumps/webview/client/src/assets/bumps-icon_48x48x32.png
new file mode 100644
index 00000000..dfaa7415
Binary files /dev/null and b/bumps/webview/client/src/assets/bumps-icon_48x48x32.png differ
diff --git a/bumps/webview/client/src/asyncSocket.ts b/bumps/webview/client/src/asyncSocket.ts
new file mode 100644
index 00000000..1e2a93df
--- /dev/null
+++ b/bumps/webview/client/src/asyncSocket.ts
@@ -0,0 +1,26 @@
+import { Socket } from 'socket.io-client';
+
+declare module 'socket.io-client' {
+  class Socket {
+    public asyncEmit(ev: string, ...args: any[]): Promise<any>;
+    public syncFS(): Promise<void>;
+  }
+}
+
+Socket.prototype.asyncEmit = async function asyncEmit(ev: string, ...args: any[]) {
+  const callback = (args[args.length-1] instanceof Function) ? args.pop() : null;
+  return new Promise((resolve, reject) => {
+    // get result of plain emit:
+    this.emit(ev, ...args, resolve);
+  }).then( async (result) =>  {
+    // execute (possibly async) callback and then return promise of result
+    if (callback !== null) {
+      await callback(result);
+    }
+    return result;
+  })
+};
+
+Socket.prototype.syncFS = async function syncFS() {}
+
+export class AsyncSocket extends Socket {};
\ No newline at end of file
diff --git a/bumps/webview/client/src/components/ConvergenceView.vue b/bumps/webview/client/src/components/ConvergenceView.vue
new file mode 100644
index 00000000..36795cc6
--- /dev/null
+++ b/bumps/webview/client/src/components/ConvergenceView.vue
@@ -0,0 +1,39 @@
+<script setup lang="ts">
+/// <reference types="@types/uuid"/>
+import { ref } from 'vue';
+import type { AsyncSocket } from '../asyncSocket.ts';
+import { setupDrawLoop } from '../setupDrawLoop';
+import * as Plotly from 'plotly.js/lib/core';
+import { SVGDownloadButton } from '../plotly_extras.mjs';
+
+const title = "Convergence";
+const plot_div = ref<HTMLDivElement>();
+const props = defineProps<{
+  socket: AsyncSocket,
+}>();
+
+setupDrawLoop('updated_convergence', props.socket, fetch_and_draw, title);
+
+async function fetch_and_draw() {
+  const payload = await props.socket.asyncEmit('get_convergence_plot') as Plotly.PlotlyDataLayoutConfig;
+  let plotdata = { ...payload };
+  // console.log({plotdata});
+  const { data, layout } = plotdata;
+  const config = { responsive: true, scrollZoom: true, modeBarButtonsToAdd: [ SVGDownloadButton ] };
+  await Plotly.react(plot_div.value as HTMLDivElement, [...data], layout, config);
+}
+
+</script>
+    
+<template>
+  <div class="container d-flex flex-grow-1 flex-column">
+    <div class="flex-grow-1" ref="plot_div">
+    </div>
+  </div>
+</template>
+
+<style scoped>
+svg {
+  width: 100%;
+}
+</style>
\ No newline at end of file
diff --git a/bumps/webview/client/src/components/CorrelationView.vue b/bumps/webview/client/src/components/CorrelationView.vue
new file mode 100644
index 00000000..af3f3b97
--- /dev/null
+++ b/bumps/webview/client/src/components/CorrelationView.vue
@@ -0,0 +1,51 @@
+<script setup lang="ts">
+/// <reference types="@types/uuid"/>
+import { ref } from 'vue';
+import type { AsyncSocket } from '../asyncSocket.ts';
+import { v4 as uuidv4 } from 'uuid';
+import { setupDrawLoop } from '../setupDrawLoop';
+import { cache } from '../plotcache';
+import * as mpld3 from 'mpld3';
+
+const title = "Correlations"
+const plot_div = ref<HTMLDivElement>();
+const plot_div_id = ref(`div-${uuidv4()}`);
+
+const props = defineProps<{
+  socket: AsyncSocket,
+}>();
+
+setupDrawLoop('updated_uncertainty', props.socket, fetch_and_draw, title);
+
+type MplD3PlotData = {
+  width?: number,
+  height?: number,
+}
+
+async function fetch_and_draw(latest_timestamp: string) {
+  let { timestamp, plotdata } = cache[title] as { timestamp: string, plotdata: MplD3PlotData }?? {};
+  if (timestamp !== latest_timestamp) {
+    console.log("fetching new correlation plot", timestamp, latest_timestamp);
+    const payload = await props.socket.asyncEmit('get_correlation_plot') as MplD3PlotData;
+    plotdata = { ...payload };
+    cache[title] = {timestamp: latest_timestamp, plotdata};
+  }
+  plotdata.width = Math.round(plot_div.value?.clientWidth ?? 640) - 16;
+  plotdata.height = Math.round(plot_div.value?.clientHeight ?? 480) - 16;
+  mpld3.draw_figure(plot_div_id.value, plotdata, false, true);
+}
+
+</script>
+    
+<template>
+  <div class="container d-flex flex-grow-1 flex-column">
+    <div class="flex-grow-1" ref="plot_div" :id="plot_div_id">
+    </div>
+  </div>
+</template>
+
+<style scoped>
+svg {
+  width: 100%;
+}
+</style>
\ No newline at end of file
diff --git a/bumps/webview/client/src/components/CorrelationViewPlotly.vue b/bumps/webview/client/src/components/CorrelationViewPlotly.vue
new file mode 100644
index 00000000..964e49df
--- /dev/null
+++ b/bumps/webview/client/src/components/CorrelationViewPlotly.vue
@@ -0,0 +1,119 @@
+<script setup lang="ts">
+/// <reference types="@types/uuid"/>
+import { ref } from 'vue';
+import type { AsyncSocket } from '../asyncSocket.ts';
+import { setupDrawLoop } from '../setupDrawLoop';
+import * as Plotly from 'plotly.js/lib/core';
+import Heatmap from 'plotly.js/lib/heatmap';
+import { SVGDownloadButton } from '../plotly_extras.mjs';
+
+// workaround to PlotlyModule not being exported as type!
+type RegisterTypes = Parameters<typeof Plotly.register>[0];
+type PlotlyModule = Exclude<RegisterTypes, any[]>;
+
+Plotly.register([
+  Heatmap as PlotlyModule
+])
+
+const title = "Correlations";
+const plot_div = ref<Plotly.PlotlyHTMLElement | HTMLDivElement>();
+const do_sort = ref(true);
+const max_rows = ref(25);
+const n_bins = ref(50);
+const callback_registered = ref(false);
+const stored_timestamp = ref("");
+const vars = ref<number[]>([]);
+// don't use the one from setupDrawLoop because we are calling
+// fetch_and_draw locally:
+const drawing_busy = ref(false);
+
+const props = defineProps<{
+  socket: AsyncSocket,
+}>();
+
+setupDrawLoop('updated_uncertainty', props.socket, fetch_and_draw, title);
+
+
+async function fetch_and_draw(latest_timestamp?: string) {
+  // use stored value of timestamp if none is provided, otherwise update stored value:
+  if (latest_timestamp !== undefined) {
+    stored_timestamp.value = latest_timestamp;
+  }
+  // send timestamp to control server-side cache
+  const timestamp = stored_timestamp.value;
+  const output_vars = (vars.value.length > 0) ? [...vars.value] : null;
+  const loading_delay = 50; // ms
+  // if the plot loads faster than the timeout, don't show spinner
+  const show_loader = setTimeout(() => {
+    drawing_busy.value = true;
+  }, loading_delay);
+  const payload = await props.socket.asyncEmit('get_correlation_plot', do_sort.value, max_rows.value, n_bins.value, output_vars, timestamp) as Plotly.PlotlyDataLayoutConfig;
+  const plotdata = { ...payload };
+  const { data, layout } = plotdata;
+  const config: Partial<Plotly.Config> = { responsive: true, scrollZoom: true, modeBarButtonsToAdd: [ SVGDownloadButton ] };
+  await Plotly.react(plot_div.value as HTMLDivElement, [...data], layout, config);
+  clearTimeout(show_loader);
+  drawing_busy.value = false;
+
+  if (!callback_registered.value) {
+    if (plot_div.value?.on) {
+      plot_div.value.on('plotly_click', (ev) => {
+        // if we're already showing only one plot, bail out:
+        if (vars.value.length === 2) {
+          return
+        }
+        // we are putting an array of numbers in customdata on the server side.
+        vars.value = (ev as Plotly.PlotMouseEvent).points[0].data.customdata as number[];
+        fetch_and_draw();
+      });
+      callback_registered.value = true;
+    }
+  }
+}
+
+function reset_vars() {
+  vars.value = [];
+  fetch_and_draw();
+}
+
+</script>
+
+<template>
+  <div class="container d-flex flex-grow-1 flex-column">
+    <div class="row g-1">
+      <div class="col-3 form-check align-items-center align-middle text-start">
+        <input class="form-check-input" type="checkbox" v-model="do_sort" id="sort-by-correlation" @change="fetch_and_draw()" />
+        <label class="form-check-label pe-2 w-auto text-wrap" for="sort-by-correlation">Sort by correlation </label>
+      </div>
+      <div class="col-5 d-flex flex-row align-items-center">
+        <label class="col-form-label w-auto text-nowrap flex-fill" for="max-rows">Max. rows</label>
+        <input class="form-control flex-fill" type="number" v-model="max_rows" id="max-rows" @change="fetch_and_draw()" />
+      </div>
+      <div class="col-4 d-flex flex-row align-items-center">
+        <label class="col-form-label w-auto" for="n-bins">Bins</label>
+        <input class="form-control flex-fill" type="number" v-model="n_bins" id="n-bins" @change="fetch_and_draw()" />
+      </div>
+    </div>
+    <button class="btn btn-primary" v-if="vars.length > 0" @click="reset_vars">Reset plot</button>
+    <div class="flex-grow-1 position-relative">
+      <div class="w-100 h-100 plot-div" ref="plot_div"></div>
+      <div class="position-absolute top-0 start-0 w-100 h-100 d-flex flex-column align-items-center justify-content-center loading" v-if="drawing_busy">
+        <span class="spinner-border text-primary"></span>
+      </div>
+    </div>
+  </div>
+</template>
+
+<style scoped>
+div.plot-div :deep(g.hovertext > path) {
+  opacity: 0.5;
+}
+
+div.loading {
+  background-color: rgba(255, 255, 255, 0.4);
+}
+span.spinner-border {
+  width: 3rem;
+  height: 3rem;
+}
+</style>
\ No newline at end of file
diff --git a/bumps/webview/client/src/components/DataView.vue b/bumps/webview/client/src/components/DataView.vue
new file mode 100644
index 00000000..e666c38e
--- /dev/null
+++ b/bumps/webview/client/src/components/DataView.vue
@@ -0,0 +1,97 @@
+<script setup lang="ts">
+/// <reference types="@types/plotly.js" />
+import { ref, onMounted, watch, onUpdated, computed, shallowRef, ssrContextKey } from 'vue';
+import * as Plotly from 'plotly.js/lib/core';
+import * as mpld3 from 'mpld3';
+import { v4 as uuidv4 } from 'uuid';
+import type { AsyncSocket } from '../asyncSocket.ts';
+import { setupDrawLoop } from '../setupDrawLoop';
+
+type ModelNameInfo = {name: string, model_index: number};
+const title = "Data";
+const plot_div = ref<HTMLDivElement | null>(null);
+const plot_div_id = ref(`div-${uuidv4()}`);
+const show_multiple = ref(false);
+const model_names = ref<string[]>([]);
+const current_models = ref<number[][]>([[0]]);
+
+// add types to mpld3
+declare global {
+  interface mpld3 {
+    draw_figure: (div_id: string, data: { width: number, height: number }, process: boolean | Function, clearElem: boolean) => void;
+  }
+  var mpld3: mpld3;
+}
+
+const props = defineProps<{
+  socket: AsyncSocket,
+}>();
+
+const { draw_requested } = setupDrawLoop('updated_parameters', props.socket, fetch_and_draw);
+
+async function get_model_names() {
+  const new_names = await props.socket.asyncEmit("get_model_names") as string[];
+  if (new_names == null) {
+    return;
+  }
+  model_names.value = new_names;
+  current_models.value = [[0]]; //Array.from({length: num_models}).map((_, i) => [i]);
+}
+
+props.socket.on('model_loaded', get_model_names);
+onMounted(async () => {
+  await get_model_names();
+});
+
+async function fetch_and_draw() {
+  const payload = await props.socket.asyncEmit('get_data_plot', current_models.value.flat());
+  let { fig_type, plotdata } = payload as { fig_type: 'plotly' | 'mpld3', plotdata: object};
+  if (fig_type === 'plotly') {
+    const { data, layout } = plotdata as Plotly.PlotlyDataLayoutConfig;
+    const config = {responsive: true}
+    await Plotly.react(plot_div.value as HTMLDivElement, [...data], layout, config);
+  }
+  else if (fig_type === 'mpld3') {
+    let mpld3_data = plotdata as { width: number, height: number };
+    mpld3_data.width = Math.round(plot_div.value?.clientWidth ?? 640) - 16;
+    mpld3_data.height = Math.round(plot_div.value?.clientHeight ?? 480) - 16;
+    mpld3.draw_figure(plot_div_id.value, mpld3_data, false, true);
+  }
+}
+
+function toggle_multiple(value) {
+  if (!show_multiple.value) {
+    // then we're toggling from multiple to single...
+    current_models.value.splice(0, current_models.value.length -1);
+    draw_requested.value = true;
+  }
+  Plotly.Plots.resize(plot_div.value);
+}
+
+</script>
+
+<template>
+  <div class="container d-flex flex-column flex-grow-1">
+    <div class="form-check">
+      <label class="form-check-label pe-2" for="multiple">Show multiple</label>
+      <input class="form-check-input" type="checkbox" v-model="show_multiple" id="multiple" @change="toggle_multiple"/>
+    </div>
+    <select v-if="show_multiple"
+      v-model="current_models"
+      @change="draw_requested = true"
+      multiple
+      >
+      <option v-for="(name, model_index) in model_names" :key="model_index" :value="[model_index]">
+        {{ model_index }}:  {{ name ?? "" }} </option>
+    </select>
+    <select v-else
+      v-model="current_models[0]"
+      @change="draw_requested = true"
+      >
+      <option v-for="(name, model_index) in model_names" :key="model_index" :value="[model_index]">
+        {{ model_index }}:  {{ name ?? "" }} </option>
+    </select>
+    <div class="flex-grow-1" ref="plot_div" :id="plot_div_id">
+    </div>
+  </div>
+</template>
diff --git a/bumps/webview/client/src/components/FileBrowser.vue b/bumps/webview/client/src/components/FileBrowser.vue
new file mode 100644
index 00000000..f660aff6
--- /dev/null
+++ b/bumps/webview/client/src/components/FileBrowser.vue
@@ -0,0 +1,304 @@
+<script setup lang="ts">
+import { format, formatDistance, formatRelative, subDays } from 'date-fns'
+import { ref, onMounted, watch, onUpdated, computed, shallowRef } from 'vue';
+import { Modal } from 'bootstrap/dist/js/bootstrap.esm.js';
+import { addNotification } from '../app_state';
+import type { AsyncSocket } from '../asyncSocket.ts';
+import type { FileBrowserSettings } from '../app_state';
+
+const props = defineProps<{
+  socket: AsyncSocket
+}>();
+
+const emit = defineEmits<{
+  (e: 'selected', pathlist: string[], filename: string): void
+}>();
+
+interface FileInfo  {
+  name: string,
+  size: number,
+  modified: number
+}
+
+type sortOrder = "name" | "size" | "modified";
+
+const UP_ARROW = "▲";
+const DOWN_ARROW = "▼";
+
+const dialog = ref<HTMLDivElement>();
+const isOpen = ref(false);
+const pathlist = shallowRef<string[]>([]);
+const subdirlist = shallowRef<FileInfo[]>([]);
+const filelist = shallowRef<FileInfo[]>([]);
+const drives = ref<string[]>([]);
+const filtered_filelist = shallowRef<FileInfo[]>([]);
+const chosenFile = ref("");
+const sortby = ref<sortOrder>("name");
+const reversed = ref(false);
+const step = ref(1);
+const active_search_pattern = ref<string | null>(null);
+const active_search_regexp = ref<RegExp | null>(null);
+const settings = ref<FileBrowserSettings>();
+
+let modal: Modal;
+onMounted(() => {
+  modal = new Modal(dialog.value as HTMLElement, { backdrop: 'static', keyboard: false });
+});
+
+function close() {
+  modal?.hide();
+  isOpen.value = false;
+}
+
+async function open(settings_in: FileBrowserSettings) {
+  settings.value = settings_in;
+  chosenFile.value = settings_in.chosenfile_in ?? "";
+  if (settings_in.pathlist_in) {
+    pathlist.value = settings_in.pathlist_in;
+  }
+  await setPath(pathlist.value ?? []);
+  if (settings_in.search_patterns && settings_in.search_patterns.length > 0) {
+    // this triggers doSorting and filtering...
+    active_search_pattern.value = settings_in.search_patterns[0];
+  }
+  modal?.show();
+  isOpen.value = true;
+}
+
+function FileInfoSorter(a: FileInfo, b: FileInfo) {
+  if (a[sortby.value] > b[sortby.value]) {
+    return step.value;
+  }
+  else if (a[sortby.value] < b[sortby.value]) {
+    return -step.value;
+  }
+  else {
+    return 0
+  }
+}
+
+function FileInfoSearch(f: FileInfo) {
+  if (active_search_regexp.value === null) {
+    return true;
+  }
+  else {
+    return active_search_regexp.value.test(f.name);
+  }
+}
+
+async function subdirClick(subdir: string) {
+  pathlist.value.push(subdir);
+  await setPath(pathlist.value);
+}
+
+interface DirListing {
+  drives: string[],
+  pathlist: string[],
+  files: FileInfo[],
+  subfolders: FileInfo[]
+}
+
+interface DirListingError {
+  error: string
+}
+
+props.socket.on("base_path", (pathlist_in: string[]) => {
+  pathlist.value = pathlist_in;
+})
+
+async function setPath(new_pathlist?: string[]) {
+  let result = await props.socket.asyncEmit("get_dirlisting", new_pathlist) as DirListing | DirListingError;
+  if ('error' in result) {
+    addNotification({ title: result.error, content: "reverting to base path", timeout: 3000 });
+    result = await props.socket.asyncEmit("get_dirlisting", null) as DirListing;
+  }
+  const { drives: drives_in, pathlist: abs_pathlist, files, subfolders } = result;
+  subdirlist.value = subfolders.sort(FileInfoSorter);
+  filelist.value = files;
+  filtered_filelist.value = files.filter(FileInfoSearch).sort(FileInfoSorter);
+  // server include absolute pathlist in response...
+  pathlist.value = abs_pathlist;
+  drives.value = drives_in;
+}
+
+const PREFIXES = ["", "k", "M", "G", "T", "P"];
+const SCALES = PREFIXES.map((_, i) => Math.pow(1024, i));
+const LOG_1024 = Math.log(1024);
+function formatSize(bytes: number) {
+  const scale = (bytes > 0) ? Math.min(5, Math.floor(Math.log(bytes) / LOG_1024)) : 0;
+  const prefix = PREFIXES[scale];
+  const reduced = bytes / SCALES[scale];
+  const extra_digits = (scale == 0) ? 0 : 1;
+  return `${reduced.toFixed(extra_digits)} ${prefix}B`;
+}
+
+async function chooseFile() {
+  await props.socket.asyncEmit("set_base_path", pathlist.value);
+  await settings.value?.callback(pathlist.value, chosenFile.value);
+  close();
+}
+
+function doSorting() {
+  subdirlist.value.sort(FileInfoSorter);
+  filtered_filelist.value = filelist.value.filter(FileInfoSearch).sort(FileInfoSorter);
+}
+
+function toggleSorting(column: sortOrder) {
+  if (sortby.value === column) {
+    // toggling direction of already-selected column
+    step.value = -step.value;
+  }
+  else {
+    sortby.value = column;
+    step.value = 1;
+  }
+  doSorting();
+}
+
+function calculateIcon(column: sortOrder) {
+  if (sortby.value === column) {
+    return (step.value > 0) ? UP_ARROW : DOWN_ARROW;
+  }
+  else {
+    return "";
+  }
+}
+
+const SUFFIX_SEARCH = /^(.*)(\.[^\.]+)$/
+function selectNotSuffix(ev: Event) {
+  const target = ev.target as HTMLInputElement;
+  const value = target.value;
+  const matches = value.match(SUFFIX_SEARCH);
+  if (matches !== null) {
+    const selection_length = matches[1].length;
+    target.setSelectionRange(0, selection_length);
+  }
+}
+
+watch(active_search_pattern, async (newval, oldval) => {
+  if (newval === null) {
+    active_search_regexp.value = null;
+  }
+  else {
+    const subexps = newval?.split(',') ?? [];
+    const re = subexps.map((s) => `(${s.trim().replace(/\./g, '\.')})`);
+    active_search_regexp.value = new RegExp(`(${re.join('|')})$`);
+  }
+  doSorting();
+})
+
+defineExpose({
+  close,
+  open
+})
+</script>
+
+<template>
+  <div ref="dialog" class="modal fade" id="fileBrowserModal" tabindex="-1" aria-labelledby="fileBrowserLabel"
+    :aria-hidden="isOpen">
+    <div class="modal-dialog modal-lg modal-dialog-scrollable">
+      <div class="modal-content">
+        <div class="modal-header">
+            <h5 class="modal-title" id="fileBrowserLabel">{{settings?.title}}</h5>
+            <button type="button" class="btn-close" @click="close" aria-label="Close"></button>
+        </div>
+        <div class="p-1 border-bottom">
+          <div class="container py-1 px-3" v-if="drives.length > 1">
+            <nav aria-label="breadcrumb">
+              <ol class="breadcrumb mb-0">
+                <li v-for="drive in drives" :key="drive" class="breadcrumb-item">
+                  <a href="#" @click.prevent="setPath([drive])">{{drive}}</a>
+                </li>
+              </ol>
+            </nav>
+          </div>
+          <div class="container py-1 px-3">
+            <nav aria-label="breadcrumb">
+              <ol class="breadcrumb mb-0">
+                <li v-for="(pathitem, index) in pathlist" :key="index" class="breadcrumb-item">
+                  <a href="#" @click.prevent="setPath(pathlist.slice(0, index+1))">{{pathitem}}</a>
+                </li>
+              </ol>
+            </nav>
+          </div>
+          <div v-if="settings?.show_name_input === true" class="container">
+            <div class="row align-items-center mb-1">
+              <div class="col-auto">
+                <label for="userfilename" class="col-form-label">{{settings?.name_input_label}}:</label>
+              </div>
+              <div class="col">
+                <input 
+                  type="text"
+                  id="userfilename"
+                  class="form-control"
+                  v-model="chosenFile"
+                  @focus="selectNotSuffix"
+                  @keyup.enter="chooseFile"
+                  >
+              </div>
+            </div>
+          </div>
+        </div>
+        <div class="modal-body">
+          <div class="container border-bottom">
+            <h5>Subdirectories:</h5>
+            <div class="row row-cols-3">
+              <div class="col overflow-hidden" v-for="subdir in subdirlist" :key="subdir.name">
+                <a href="#" @click.prevent="subdirClick(subdir.name)" :title="subdir.name">{{subdir.name}}</a>
+              </div>
+            </div>
+          </div>
+          <div>show files: {{ settings?.show_files }}</div>
+          <div class="container" v-if="settings?.show_files">
+            <h5>Files:</h5>
+            <table class="table table-sm">
+              <thead>
+                <tr class="sticky-top text-body bg-white">
+                  <th scope="col" @click="toggleSorting('name')">Name{{ calculateIcon('name') }}</th>
+                  <th scope="col" @click="toggleSorting('size')">Size{{ calculateIcon('size') }}</th>
+                  <th scope="col" @click="toggleSorting('modified')">Modified{{ calculateIcon('modified') }}</th>
+                </tr>
+              </thead>
+              <tbody>
+                <tr v-for="fileinfo in filtered_filelist" 
+                  :key="fileinfo.name"
+                  @click="chosenFile=fileinfo.name"
+                  :title="fileinfo.name"
+                  @dblclick="chosenFile=fileinfo.name;chooseFile()"
+                  :class="{'table-warning': fileinfo.name == chosenFile}"
+                  > 
+                  <td>{{ fileinfo.name }}</td>
+                  <td>{{ formatSize(fileinfo.size) }}</td>
+                  <td>{{ formatRelative(new Date(fileinfo.modified * 1000), new Date()) }}</td>
+                </tr>
+              </tbody>
+            </table>
+          </div>  
+        </div>
+        <div class="modal-footer">
+          <label v-if="settings?.search_patterns && settings?.search_patterns.length > 0">Search: 
+            <select v-model="active_search_pattern">
+              <option v-for="search_pattern in settings?.search_patterns ?? []" :key="search_pattern">
+                {{ search_pattern }}
+              </option>
+              <option :value="null">All files</option>
+            </select>
+          </label>
+          <button type="button" class="btn btn-secondary" @click="close">Cancel</button>
+          <button type="button" class="btn btn-primary" :class="{disabled: settings?.require_name && chosenFile == ''}"
+            @click="chooseFile">OK</button>
+        </div>
+      </div>
+    </div>
+  </div>
+</template>
+
+<style scoped>
+.active {
+  background-color: light
+}
+
+table tbody tr {
+  cursor: pointer;
+}
+</style>
\ No newline at end of file
diff --git a/bumps/webview/client/src/components/FitOptions.vue b/bumps/webview/client/src/components/FitOptions.vue
new file mode 100644
index 00000000..fcb3d035
--- /dev/null
+++ b/bumps/webview/client/src/components/FitOptions.vue
@@ -0,0 +1,226 @@
+<script setup lang="ts">
+import { ref, onMounted, computed, shallowRef } from 'vue';
+import { Modal } from 'bootstrap/dist/js/bootstrap.esm.js';
+import { fitter_settings, selected_fitter } from '../app_state.ts';
+import type { FitSetting } from '../app_state.ts';
+import type { AsyncSocket } from '../asyncSocket.ts';
+
+const props = defineProps<{socket: AsyncSocket}>();
+
+const dialog = ref<HTMLDivElement>();
+const isOpen = ref(false);
+const fitter_defaults = shallowRef<{ [fit_name: string]: FitSetting }>({});
+const selected_fitter_local = ref("amoeba");
+
+const FIT_FIELDS = {
+  'starts': ['Starts', 'integer'],
+  'steps': ['Steps', 'integer'],
+  'samples': ['Samples', 'integer'],
+  'xtol': ['x tolerance', 'float'],
+  'ftol': ['f(x) tolerance', 'float'],
+  'alpha': ['Convergence', 'float'],
+  'stop': ['Stopping criteria', 'string'],
+  'thin': ['Thinning', 'integer'],
+  'burn': ['Burn-in steps', 'integer'],
+  'pop': ['Population', 'float'],
+  'init': ['Initializer', ["eps", "lhs", "cov", "random"]],
+  'CR': ['Crossover ratio', 'float'],
+  'F': ['Scale', 'float'],
+  'nT': ['# Temperatures', 'integer'],
+  'Tmin': ['Min temperature', 'float'],
+  'Tmax': ['Max temperature', 'float'],
+  'radius': ['Simplex radius', 'float'],
+  'trim': ['Burn-in trim', 'boolean'],
+  'outliers': ['Outliers', ["none", "iqr", "grubbs", "mahal"]]
+}
+
+const OPTIONS_HELP = {
+  'dream': 'if Steps=0, Steps will be calculated as (Burn-in steps) + (Samples / (Population * Num. Fit Params))'
+}
+
+// make another working copy for editing:
+// const active_settings = ref<{ name: string, settings: object}>({name: "", settings: {}});
+const active_settings = ref({});
+
+props.socket.asyncEmit("get_fitter_defaults", (new_fitter_defaults) => {
+  console.log({new_fitter_defaults});
+  fitter_defaults.value = new_fitter_defaults;
+  fitter_settings.value = structuredClone(new_fitter_defaults);
+})
+
+props.socket.on('fitter_settings', (new_fitter_settings) => {
+  fitter_settings.value = new_fitter_settings;
+});
+
+props.socket.on('selected_fitter', (new_selected_fitter: string) => {
+  selected_fitter.value = new_selected_fitter;
+});
+
+let modal: Modal;
+
+onMounted(() => {
+  modal = new Modal(dialog.value, { backdrop: 'static', keyboard: false });
+
+});
+
+function close() {
+  modal?.hide();
+}
+
+function open() {
+  // copy the  selected_fitter_local from the server state:
+  selected_fitter_local.value = selected_fitter.value;
+  changeActiveFitter();
+  modal?.show();
+}
+
+const fit_names = computed(() => Object.keys(fitter_defaults?.value));
+
+function changeActiveFitter() {
+  active_settings.value = structuredClone(fitter_settings.value[selected_fitter_local.value]?.settings) ?? {};
+}
+
+function process_settings() {
+  return Object.fromEntries(Object.entries(active_settings.value).map(([sname, value]) => {
+    const field_type = FIT_FIELDS[sname][1];
+    let processed_value: any = value;
+    if (field_type === 'integer') {
+      processed_value = Math.round(Number(value));
+    }
+    else if (field_type === 'float') {
+      processed_value = Number(value);
+    }
+    else if (field_type === 'boolean') {
+      // probably unnecessary if it is bound to a checkbox
+      processed_value = Boolean(value);
+    }
+    return [sname, processed_value];
+  }))
+}
+
+async function save(start: boolean = false) {
+  if (anyIsInvalid.value) {
+    return;
+  }
+  const new_settings = structuredClone(fitter_settings.value);
+  new_settings[selected_fitter_local.value] = { settings: process_settings() };
+  const fitter_settings_local = process_settings();
+  new_settings[selected_fitter_local.value] = { settings: fitter_settings_local };
+  await props.socket.asyncEmit("set_shared_setting", "fitter_settings", new_settings);
+  await props.socket.asyncEmit("set_shared_setting", "selected_fitter", selected_fitter_local.value);
+  if (start) {
+    await props.socket.asyncEmit("start_fit_thread", selected_fitter_local.value, fitter_settings_local);
+  }
+  close();
+}
+
+function reset() {
+  active_settings.value = structuredClone(fitter_defaults.value[selected_fitter_local.value].settings) ?? {};
+}
+
+function validate(value, field_name) {
+  const field_type = FIT_FIELDS[field_name][1];
+  if (Array.isArray(field_type) || field_type === 'boolean') {
+    // there's no way to get an incorrect option.
+    return true;
+  }
+  const float_value = Number(value);
+  if (isNaN(float_value)) {
+    return false;
+  }
+  if (field_type === 'integer' && parseInt(value, 10) != float_value) {
+    return false;
+  }
+  return true;
+}
+
+const anyIsInvalid = computed(() => {
+  return Object.entries(active_settings.value).some(([sname, value]) => !validate(value, sname));
+})
+
+onMounted(async () => {
+  const new_selected_fitter = await props.socket.asyncEmit('get_shared_setting', 'selected_fitter');
+  const new_fitter_settings = await props.socket.asyncEmit('get_shared_setting', 'fitter_settings');
+  if (new_selected_fitter !== undefined) {
+    selected_fitter.value = new_selected_fitter;
+  }
+  if (new_fitter_settings !== undefined) {
+    fitter_settings.value = new_fitter_settings;
+  }
+});
+
+defineExpose({
+  close,
+  open,
+})
+</script>
+
+<template>
+  <div ref="dialog" class="modal fade" id="fitOptionsModal" tabindex="-1" aria-labelledby="fitOptionsLabel"
+    :aria-hidden="isOpen">
+    <div class="modal-dialog modal-lg">
+      <div class="modal-content">
+        <div class="modal-header">
+          <h5 class="modal-title" id="fitOptionsLabel">Fit Options</h5>
+          <button type="button" class="btn-close" @click="close" aria-label="Close"></button>
+        </div>
+        <div class="modal-body">
+          <div class="container">
+            <div class="row border-bottom">
+              <div class="col">
+                <div class="form-check" v-for="fname in fit_names.slice(0,3)" :key="fname">
+                  <input class="form-check-input" v-model="selected_fitter_local" type="radio" name="flexRadio"
+                    :id="fname" :value="fname" @change="changeActiveFitter">
+                  <label class="form-check-label" :for="fname">
+                    {{fitter_defaults[fname].name}}
+                    <span v-if="fname !== 'scipy.leastsq'">({{ fname }})</span>
+                  </label>
+                </div>
+              </div>
+              <div class="col">
+                <div class="form-check" v-for="fname in fit_names.slice(3)" :key="fname">
+                  <input class="form-check-input" v-model="selected_fitter_local" type="radio" name="flexRadio"
+                    :id="fname" :value="fname" @change="changeActiveFitter">
+                  <label class="form-check-label" :for="fname">
+                    {{fitter_defaults[fname].name}}
+                    <span v-if="fname !== 'scipy.leastsq'">({{ fname }})</span>
+                  </label>
+                </div>
+              </div>
+            </div>
+            <div class="row p-2">
+              <div class="row p-1 text-secondary" v-if="selected_fitter_local in OPTIONS_HELP">
+                <span><em>{{ OPTIONS_HELP[selected_fitter_local] }}</em></span>
+              </div>
+              <div class="row p-1" v-for="(value, sname, index) in active_settings" :key="sname">
+                <label class="col-sm-4 col-form-label" :for="'setting_' + index">{{FIT_FIELDS[sname][0]}}</label>
+                <div class="col-sm-8">
+                  <select v-if="Array.isArray(FIT_FIELDS[sname][1])" v-model="active_settings[sname]" class="form-select">
+                    <option v-for="opt in FIT_FIELDS[sname][1]">{{opt}}</option>
+                  </select>
+                  <input v-else-if="FIT_FIELDS[sname][1]==='boolean'" class="form-check-input m-2" type="checkbox"
+                    v-model="active_settings[sname]" />
+                  <input v-else :class="{'form-control': true, 'is-invalid': !validate(active_settings[sname], sname)}"
+                    type="text" v-model="active_settings[sname]" @keydown.enter="save" />
+                </div>
+              </div>
+            </div>
+          </div>
+        </div>
+        <div class="modal-footer">
+          <button type="button" class="btn btn-secondary" @click="reset">Reset Defaults</button>
+          <button type="button" class="btn btn-success" :class="{disabled: anyIsInvalid}" @click="save(true)">Save and Start</button>
+          <button type="button" class="btn btn-primary" :class="{disabled: anyIsInvalid}" @click="save(false)">
+            Save Changes</button>
+
+        </div>
+      </div>
+    </div>
+  </div>
+</template>
+
+<style scoped>
+.form-check label {
+  user-select: none;
+}
+</style>
\ No newline at end of file
diff --git a/bumps/webview/client/src/components/History.vue b/bumps/webview/client/src/components/History.vue
new file mode 100644
index 00000000..719b73b4
--- /dev/null
+++ b/bumps/webview/client/src/components/History.vue
@@ -0,0 +1,177 @@
+<script setup lang="ts">
+import { ref, onMounted, defineProps } from 'vue';
+import type { AsyncSocket } from '../asyncSocket';
+import { addNotification, autosave_history, autosave_history_length } from '../app_state';
+
+const title = "History";
+
+const props = defineProps<{
+  socket: AsyncSocket,
+}>();
+
+type HistoryItem = {
+  timestamp: string,
+  label: string,
+  chisq_str: string,
+  keep: boolean,
+  has_population: boolean,
+  has_uncertainty: boolean,
+};
+
+const history = ref<HistoryItem[]>([]);
+const history_label = ref('');
+const current_chisq_str = ref('');
+const warning_seen = ref(false);
+
+props.socket.on('updated_history', async () => {
+  const new_history = await props.socket.asyncEmit('get_history') as { problem_history: HistoryItem[] };
+  history.value = new_history.problem_history;
+});
+props.socket.asyncEmit('get_history', (new_history: { problem_history: HistoryItem[]}) => { history.value = new_history.problem_history });
+
+props.socket.on('updated_parameters', () => {
+  props.socket.asyncEmit('get_chisq', (chisq_str: string) => { current_chisq_str.value = `chisq: ${ chisq_str }` });
+});
+props.socket.asyncEmit('get_chisq', (chisq_str: string) => { current_chisq_str.value = `chisq: ${ chisq_str }` });
+
+props.socket.on('autosave_history', (autosave: boolean) => {
+  autosave_history.value = autosave;
+});
+
+props.socket.on('autosave_history_length', (length: number) => {
+  autosave_history_length.value = length;
+});
+
+async function remove_history_item(timestamp: string, keep: boolean) {
+  console.log('remove_history_item', timestamp);
+  if (keep) {
+    addNotification({
+      "title": "Forbidden", 
+      "content": "Cannot remove history item marked to keep",
+      "timeout": 4000 });
+    return;
+  } else {
+    await props.socket.asyncEmit('remove_history_item', timestamp);
+  }
+}
+
+async function manual_save() {
+  await props.socket.asyncEmit('save_to_history', history_label.value || 'manual save');
+  history_label.value = '';
+}
+
+async function reload_history(timestamp: string) {
+  const confirmation = warning_seen.value || confirm('Reloading overwrites current state: continue?');
+  if (confirmation) {
+    await props.socket.asyncEmit('reload_history_item', timestamp);
+    warning_seen.value = true;
+  }
+}
+
+async function toggle_keep(timestamp: string, current_keep: boolean) {
+  await props.socket.asyncEmit('set_keep_history', timestamp, !current_keep);
+}
+
+async function update_label(timestamp: string, new_label: string) {
+  await props.socket.asyncEmit('update_history_label', timestamp, new_label);
+}
+
+async function toggle_autosave_history() {
+  await props.socket.asyncEmit('set_shared_setting', 'autosave_history', !autosave_history.value);
+}
+
+async function set_autosave_history_length(value_str: string) {
+  const new_value = parseInt(value_str, 10);
+  if (!isNaN(new_value) && new_value > 0) {
+    await props.socket.asyncEmit('set_shared_setting', 'autosave_history_length', new_value);
+  }
+}
+
+onMounted(async () => {
+  autosave_history.value = await props.socket.asyncEmit('get_shared_setting', 'autosave_history');
+  autosave_history_length.value = await props.socket.asyncEmit('get_shared_setting', 'autosave_history_length');
+});
+
+</script>
+        
+<template>
+  <div class="history container d-flex flex-column flex-grow-1">
+    <div class="row p-1 align-items-center">
+      <div class="col-auto">
+        <button
+          type="button"
+          class="btn btn-primary"
+          @click="manual_save">
+          Save current state
+        </button>
+        <span>{{ current_chisq_str }}</span>
+      </div>
+      <div class="col text-end">
+        <input class="form-check-input" type="checkbox" id="auto_save" :checked="autosave_history" @click="toggle_autosave_history">
+        <label class="form-check-label" for="auto_save" title="Append problem state to history on load and at fit end">Auto append</label>
+      </div>
+      <div class="col-auto">
+        <input class="form-control-sm" type="number" id="auto_save_length" 
+          :value="autosave_history_length"
+          @change="set_autosave_history_length($event.target.value)"
+          min="1" step="1">
+        <label class="col-form-label" for="auto_save_length">history length</label>
+      </div>
+    </div>
+    <div class="px-2 flex-grow-1 flex-shrink-1 overflow-auto">
+      <table class="table table-sm flex-grow-0">
+          <thead class="border-bottom py-1 sticky-top text-white bg-secondary">
+            <tr class="text-center">
+              <th scope="col"></th>
+              <th scope="col">Label</th>
+              <th scope="col">Chisq</th>
+              <th scope="col"></th>
+              <th scope="col">Keep</th>
+            </tr>
+          </thead>
+          <tbody>
+            <tr class="py-1 align-middle" v-for="({timestamp, label, chisq_str, keep, has_population, has_uncertainty}, index) of history" :key="timestamp">
+              <td class="align-items-center">
+                <button
+                  type="button"
+                  class="btn-close"
+                  aria-label="Close"
+                  @click="remove_history_item(timestamp, keep)">
+                </button>
+              </td>
+              <td class="px-2"
+                contenteditable="true"
+                spellcheck="false"
+                plaintext-only
+                @blur="update_label(timestamp, $event.target.innerText)"
+                :title="timestamp"
+                >{{ label }}</td>
+              <td>
+                <small>{{ chisq_str }}</small>
+              </td>
+              <td class="text-nowrap">
+                <button class="btn btn-secondary btn-sm mx-1 text-nowrap" @click="reload_history(timestamp)">
+                  Load
+                </button>
+                <span v-show="has_population" class="badge bg-success" title="has population">P</span>
+                <span v-show="has_uncertainty" class="badge bg-warning" title="has uncertainty">U</span>
+              </td>
+              <td class="text-center">
+                  <input class="form-check-input" type="checkbox" :checked="keep" @click="toggle_keep(timestamp, keep)">
+              </td>
+            </tr>
+          </tbody>
+      </table>
+    </div>
+  </div>
+</template>
+    
+<style scoped>
+input#auto_save_length {
+  width: 4em;
+}
+
+.btn-close {
+  cursor: pointer;
+}
+</style>
\ No newline at end of file
diff --git a/bumps/webview/client/src/components/LogView.vue b/bumps/webview/client/src/components/LogView.vue
new file mode 100644
index 00000000..95ebb695
--- /dev/null
+++ b/bumps/webview/client/src/components/LogView.vue
@@ -0,0 +1,44 @@
+<script setup lang="ts">
+import { ref, onMounted } from 'vue';
+import type { AsyncSocket } from '../asyncSocket.ts';
+
+const title = "Log";
+
+const props = defineProps<{
+  socket: AsyncSocket,
+}>();
+
+const log_info = ref<{message: string, title?: string}[]>([]);
+
+props.socket.on('log', ({message: {message, title}}) => {
+    log_info.value.push({message, title});
+});
+props.socket.asyncEmit('get_topic_messages', 'log', (messages) => {
+  log_info.value = [...log_info.value, ...(messages.map((m) => m.message))];
+});
+
+</script>
+        
+<template>
+  <div class="log">
+    <div class="message" v-for="({message, title}, index) of log_info" :key="index">
+      <details v-if="title != null">
+        <summary>{{ title }}</summary>
+        <pre>{{ message }}</pre>
+      </details>
+      <span v-else>{{ message }}</span>
+    </div>
+  </div>
+</template>
+    
+<style scoped>
+
+div.log {
+  white-space: nowrap;
+}
+
+.message {
+  font-family: 'Courier New', Courier, monospace;
+  line-height: 1em;
+}
+</style>
\ No newline at end of file
diff --git a/bumps/webview/client/src/components/ModelInspect.vue b/bumps/webview/client/src/components/ModelInspect.vue
new file mode 100644
index 00000000..f9972418
--- /dev/null
+++ b/bumps/webview/client/src/components/ModelInspect.vue
@@ -0,0 +1,100 @@
+<script setup lang="ts">
+import { ref, onMounted } from 'vue';
+import { setupDrawLoop } from '../setupDrawLoop';
+import VueJsonPretty from 'vue-json-pretty';
+import { getDiff } from 'json-difference';
+import type { AsyncSocket } from '../asyncSocket.ts';
+import 'vue-json-pretty/lib/styles.css';
+
+
+// from https://github.com/microsoft/TypeScript/issues/1897#issuecomment-1228063688
+type json =
+  | string
+  | number
+  | boolean
+  | null
+  | json[]
+  | { [key: string]: json };
+
+const title = "Model";
+// @ts-ignore: intentionally infinite type recursion
+const modelJson = ref<json>({});
+
+const props = defineProps<{
+  socket: AsyncSocket,
+}>();
+
+setupDrawLoop('updated_parameters', props.socket, fetch_and_draw);
+
+props.socket.on('model_loaded', () => { fetch_and_draw('', true) });
+
+async function fetch_and_draw(timestamp: string = '', reset: boolean = false) {
+  const payload = await props.socket.asyncEmit('get_model') as ArrayBuffer;
+  const json_bytes = new Uint8Array(payload);
+  const json_value: json = JSON.parse(decoder.decode(json_bytes));
+  if (reset) {
+    modelJson.value = json_value;
+  }
+  else {
+    // do update of existing model:
+    const old_model: json = modelJson.value as Record<string, any>;
+    const new_model: json = json_value as Record<string, any>;
+    const diff = getDiff(old_model, new_model);
+    for (let [path, oldval, newval] of diff.edited) {
+      const { target, parent, key } = resolve_diffpath(old_model, path);
+      // trigger reactive update;
+      if (typeof (key) === 'number' && Array.isArray(parent)) {
+        parent.splice(key, 1, newval);
+      }
+      else {
+        parent[key] = newval;
+      }
+    }
+  }
+}
+
+const decoder = new TextDecoder('utf-8');
+
+onMounted(() => {
+  fetch_and_draw('', true);
+})
+
+// function* traverse(o: object, path: string[]=[]) {
+//     for (var i of Object.keys(o)) {
+//         const itemPath = path.concat(i);
+//         yield [i,o[i],itemPath,o];
+//         if (o[i] !== null && typeof(o[i])=="object") {
+//             //going one step down in the object tree!!
+//             yield* traverse(o[i],itemPath);
+//         }
+//     }
+// }
+
+// function resolve(o: object, path: string[] = []) {
+//   let target = o;
+//   for (let pfrag of path) {
+//     target = target[pfrag];
+//   }
+//   return target;
+// }
+
+const ARRAY_PATH_RE = /^([0-9]+)\[\]$/;
+function resolve_diffpath(o: object, diffpath: string) {
+  const pathitems = diffpath.split('/');
+  let target = o;
+  let parent: object = o;
+  let key: number | string = "";
+  for (let pathitem of pathitems) {
+    const array_path = pathitem.match(ARRAY_PATH_RE);
+    parent = target;
+    key = (array_path) ? Number(array_path[1]) : pathitem;
+    target = target[key];
+  }
+  return { target, parent, key };
+}
+
+</script>
+
+<template>
+  <vue-json-pretty :data="modelJson" :deep="1" :showLength="true" />
+</template>
\ No newline at end of file
diff --git a/bumps/webview/client/src/components/ModelUncertaintyView.vue b/bumps/webview/client/src/components/ModelUncertaintyView.vue
new file mode 100644
index 00000000..9bb14e8c
--- /dev/null
+++ b/bumps/webview/client/src/components/ModelUncertaintyView.vue
@@ -0,0 +1,59 @@
+<script setup lang="ts">
+/// <reference types="@types/uuid"/>
+import { ref } from 'vue';
+import type { AsyncSocket } from '../asyncSocket.ts';
+import { v4 as uuidv4 } from 'uuid';
+import { setupDrawLoop } from '../setupDrawLoop';
+import { cache } from '../plotcache';
+import * as mpld3 from 'mpld3';
+
+const title = "Model Uncertainty"
+const plot_div = ref<HTMLDivElement>();
+const plot_div_id = ref(`div-${uuidv4()}`);
+
+const props = defineProps<{
+  socket: AsyncSocket,
+}>();
+
+const { drawing_busy } = setupDrawLoop('updated_uncertainty', props.socket, fetch_and_draw, title);
+
+type MplD3PlotData = {
+  width?: number,
+  height?: number,
+}
+
+async function fetch_and_draw(latest_timestamp: string) {
+  let { timestamp, plotdata } = cache[title] as { timestamp: string, plotdata: MplD3PlotData } ?? {};
+  if (timestamp !== latest_timestamp) {
+    console.log("fetching new model uncertainty plot", timestamp, latest_timestamp);
+    const payload = await props.socket.asyncEmit('get_model_uncertainty_plot') as MplD3PlotData;
+    plotdata = { ...payload };
+    cache[title] = {timestamp: latest_timestamp, plotdata};
+  }
+  plotdata.width = Math.round(plot_div.value?.clientWidth ?? 640) - 16;
+  plotdata.height = Math.round(plot_div.value?.clientHeight ?? 480) - 16;
+  mpld3.draw_figure(plot_div_id.value, plotdata, false, true);
+}
+
+</script>
+    
+<template>
+  <div class="container d-flex flex-grow-1 flex-column">
+    <div class="flex-grow-1 position-relative">
+      <div class="w-100 h-100 plot-div" ref="plot_div" :id="plot_div_id"></div>
+      <div class="position-absolute top-0 start-0 w-100 h-100 d-flex flex-column align-items-center justify-content-center loading" v-if="drawing_busy">
+        <span class="spinner-border text-primary"></span>
+      </div>
+    </div>
+  </div>
+</template>
+
+<style scoped>
+svg {
+  width: 100%;
+}
+span.spinner-border {
+  width: 3rem;
+  height: 3rem;
+}
+</style>
\ No newline at end of file
diff --git a/bumps/webview/client/src/components/PanelTabContainer.vue b/bumps/webview/client/src/components/PanelTabContainer.vue
new file mode 100644
index 00000000..1035ed51
--- /dev/null
+++ b/bumps/webview/client/src/components/PanelTabContainer.vue
@@ -0,0 +1,51 @@
+<script setup lang="ts">
+import { ref, onMounted } from 'vue';
+import type { AsyncSocket } from '../asyncSocket.ts';
+
+// type PanelComponent = DefineComponent<{socket: Socket, visible: boolean}, any>;
+
+const tabTriggers = ref<HTMLAnchorElement[]>([]);
+
+const props = defineProps<{
+  socket: AsyncSocket,
+  panels: {title: string, component: unknown}[],
+  active_panel: number,
+  hide_tabs?: boolean,
+}>();
+
+const emit = defineEmits<{
+  (e: 'panel_changed', active_panel: number): void
+}>();
+
+
+function setActive(index: number) {
+  emit("panel_changed", index);
+}
+
+</script>
+
+<template>
+  <ul class="nav nav-tabs" v-if="!hide_tabs">
+    <li class="nav-item" v-for="(panel, index) in props.panels" :key="index">
+      <a ref="tabTriggers" :class="{'nav-link': true, active: index == active_panel}" href="#" @click="setActive(index)">{{panels[index]?.title}}</a>
+    </li>
+  </ul>
+  <div class="tab-content d-flex flex-column flex-grow-1 overflow-auto">
+    <KeepAlive>
+      <component :is="panels[active_panel].component" :socket="props.socket"></component>
+    </KeepAlive>
+  </div>
+</template>
+
+<style scoped>
+.tab-content>.tab-pane {
+  display: none;
+}
+
+.tab-content {
+  display: flex !important;
+  flex-grow: 1;
+  flex-shrink: 0;
+  flex-basis: 200px;
+}
+</style>
\ No newline at end of file
diff --git a/bumps/webview/client/src/components/ParameterTagFilter.vue b/bumps/webview/client/src/components/ParameterTagFilter.vue
new file mode 100644
index 00000000..b9575b29
--- /dev/null
+++ b/bumps/webview/client/src/components/ParameterTagFilter.vue
@@ -0,0 +1,129 @@
+<script setup lang="ts">
+import { ref, computed } from 'vue';
+
+type parameter_info = {
+  tags: string[]
+}
+
+const props = defineProps<{parameters: parameter_info[]}>();
+
+const tags_to_show = ref<string[]>([]);
+const tags_to_hide = ref<string[]>([]);
+const show_tags = ref(false);
+
+function toggle(tag: string, listname: 'show' | 'hide') {
+  const list = (listname === 'show') ? tags_to_show.value : tags_to_hide.value;
+  const tag_index = list.indexOf(tag);
+  if (tag_index > -1) {
+    list.splice(tag_index, 1);
+  }
+  else {
+    list.push(tag);
+  }
+}
+
+const tag_colors = computed(() => {
+  const tag_names = Array.from(new Set(props.parameters.map((p) => p.tags ?? []).flat()));
+  return Object.fromEntries(tag_names.map((t,i) => [t, COLORS[i%COLORS.length]]));
+});
+
+const COLORS = [
+  "blue",
+  "red",
+  "green",
+  "goldenrod",
+  "grey",
+  "orange",
+  "purple",
+  "teal",
+  "lightgreen",
+  "brown",
+  "black"
+];
+
+const filtered_parameters = computed(() => {
+  return props.parameters.filter(({tags}: {tags: string[]}) => {
+    if ((tags_to_hide.value.length > 0) && tags.some((t) => tags_to_hide.value.includes(t))) {
+      return false;
+    }
+    // then we're not specifically hiding it...
+    else if (tags_to_show.value.length > 0) {
+      if (tags.some((t) => tags_to_show.value.includes(t))) {
+        return true;
+      }
+      else {
+        return false;
+      }
+    }
+    // then we're not specifying to_show, show by default:
+    else {
+      return true;
+    }
+  });
+});
+
+defineExpose({
+  tags_to_show,
+  tags_to_hide,
+  show_tags,
+  tag_colors,
+  filtered_parameters,
+});
+
+</script>
+
+<template>
+  <details ref="tag_filters" class="filters">
+    <summary>tags</summary>
+    <div class="form-check">
+      <label class="form-check-label ps-1">
+        <input type="checkbox" class="form-check-input" v-model="show_tags" />
+        display tags
+      </label>
+    </div>
+    <h6>Filters:</h6>
+    <div class="row pb-1 ps-1">
+      <div class="col-1 text-end">include</div>
+      <div class="col">
+      <span 
+        class="badge rounded-pill me-1" 
+        :class="{checked: tags_to_show.includes(tag)}" 
+        v-for="(tag_color, tag) in tag_colors" 
+        @click="toggle(tag, 'show')"
+        :style="{color: 'white', 'background-color': tag_color}"
+        >
+        {{ tag }}
+      </span>
+      </div>
+    </div>
+    <div class="row pb-1 ps-1">
+      <div class="col-1 text-end">exclude</div>
+      <div class="col">
+        <span 
+        class="badge rounded-pill me-1" 
+        :class="{checked: tags_to_hide.includes(tag)}" 
+        v-for="(tag_color, tag, tag_index) in tag_colors" 
+        @click="toggle(tag, 'hide')"
+        :style="{color: 'white', 'background-color': tag_color}"
+        >
+        {{ tag }}
+      </span>
+      </div>
+    </div>
+  </details>
+</template>
+
+<style scoped>
+.filters {
+  user-select: none;
+  display: inline-block;
+}
+span.badge.checked {
+  opacity: 1;
+}
+
+span.badge {
+  opacity: 0.3;
+  cursor: pointer;
+}
+</style>
\ No newline at end of file
diff --git a/bumps/webview/client/src/components/ParameterTraceView.vue b/bumps/webview/client/src/components/ParameterTraceView.vue
new file mode 100644
index 00000000..a7977944
--- /dev/null
+++ b/bumps/webview/client/src/components/ParameterTraceView.vue
@@ -0,0 +1,43 @@
+<script setup lang="ts">
+/// <reference types="@types/uuid"/>
+import { ref } from 'vue';
+import { Socket } from 'socket.io-client';
+import { v4 as uuidv4 } from 'uuid';
+import { setupDrawLoop } from '../setupDrawLoop';
+import * as mpld3 from 'mpld3';
+
+const plot_div = ref<HTMLDivElement>();
+const plot_div_id = ref(`div-${uuidv4()}`);
+const props = defineProps<{
+  socket: Socket,
+}>();
+
+setupDrawLoop('updated_uncertainty', props.socket, fetch_and_draw);
+
+type MplD3PlotData = {
+  width?: number,
+  height?: number,
+}
+
+async function fetch_and_draw() {
+  const payload = await props.socket.asyncEmit('get_parameter_trace_plot') as MplD3PlotData;
+  let plotdata = { ...payload };
+  plotdata.width = Math.round(plot_div.value?.clientWidth ?? 640) - 16;
+  plotdata.height = Math.round(plot_div.value?.clientHeight ?? 480) - 16;
+  mpld3.draw_figure(plot_div_id.value, plotdata, false, true);
+}
+
+</script>
+    
+<template>
+  <div class="container d-flex flex-grow-1 flex-column">
+    <div class="flex-grow-1" ref="plot_div" :id="plot_div_id">
+    </div>
+  </div>
+</template>
+
+<style scoped>
+svg {
+  width: 100%;
+}
+</style>
\ No newline at end of file
diff --git a/bumps/webview/client/src/components/ParameterView.vue b/bumps/webview/client/src/components/ParameterView.vue
new file mode 100644
index 00000000..945f7695
--- /dev/null
+++ b/bumps/webview/client/src/components/ParameterView.vue
@@ -0,0 +1,116 @@
+<script setup lang="ts">
+import { ref } from 'vue';
+import { setupDrawLoop } from '../setupDrawLoop';
+import type { AsyncSocket } from '../asyncSocket.ts';
+import TagFilter from './ParameterTagFilter.vue'
+
+const title = "Parameters";
+const active_parameter = ref("");
+
+const props = defineProps<{
+  socket: AsyncSocket,
+}>();
+
+setupDrawLoop('updated_parameters', props.socket, fetch_and_draw);
+
+type parameter_info = {
+  name: string,
+  id: string,
+  tags: string[],
+  fittable: boolean,
+  fixed: boolean,
+  paths: string[],
+  link: string,
+  writable: boolean,
+  value_str: string,
+  min_str: string,
+  max_str: string,
+  active?: boolean,
+}
+
+const parameters = ref<parameter_info[]>([]);
+const parameters_local = ref<parameter_info[]>([]);
+const tag_filter = ref<typeof TagFilter>();
+
+async function fetch_and_draw() {
+  const payload = await props.socket.asyncEmit('get_parameters', false) as parameter_info[];
+  const pl = parameters_local.value;
+  payload.forEach((p, i) => {
+    if (!(pl[i]?.active)) {
+      pl.splice(i, 1, p);
+    }
+  });
+  pl.splice(payload.length);
+}
+
+async function editItem(ev, item_name: "min" | "max" | "value", index: number) {
+  const new_value = ev.target.innerText;
+  if (validate_numeric(new_value)) {
+    props.socket.asyncEmit('set_parameter', parameters_local.value[index].id, item_name, new_value);
+  }
+}
+
+function validate_numeric(value: string, allow_inf: boolean = false) {
+  if (allow_inf && (value === "inf" || value === "-inf")) {
+    return true
+  }
+  return !Number.isNaN(Number(value))
+}
+
+async function onInactive(param) {
+  param.active = false;
+  fetch_and_draw();
+}
+
+async function setFittable(ev, index) {
+  console.log(ev, ev.target, index, parameters_local.value[index].fixed);
+  const parameter = parameters_local.value[index];
+  props.socket.asyncEmit('set_parameter', parameter.id, "fixed", !parameter.fixed);
+}
+
+</script>
+        
+<template>
+  <TagFilter ref="tag_filter" :parameters="parameters_local"></TagFilter>
+  <table class="table">
+    <thead class="border-bottom py-1 sticky-top text-white bg-secondary">
+      <tr>
+        <th scope="col">Fit?</th>
+        <th scope="col">Name</th>
+        <th scope="col">Value</th>
+        <th scope="col">Paths</th>
+      </tr>
+    </thead>
+    <tbody>
+      <tr class="py-1" v-for="(param, index) in tag_filter?.filtered_parameters" :key="param.id">
+        <td>
+          <input class="form-check-input" v-if="param.fittable" type="checkbox" :checked="!param.fixed"
+            @click.prevent="setFittable($event, index)" />
+        </td>
+        <td>{{ param.name }}
+          <span 
+            v-if="tag_filter?.show_tags"
+            v-for="tag in param.tags"
+            class="badge rounded-pill me-1" 
+            :style="{color: 'white', 'background-color': tag_filter.tag_colors[tag]}"
+            >
+            {{ tag }}
+          </span>
+        </td>
+        <td :contenteditable="param.writable" spellcheck="false" @blur="editItem($event, 'value', index)"
+          @keydown.enter="$event?.target?.blur()">{{ param.value_str }}
+        </td>
+        <td>
+          <p class="my-0" v-for="path in param.paths" :key="path">{{ path }}</p>
+        </td>
+      </tr>
+    </tbody>
+  </table>
+</template>
+    
+<style scoped>
+
+table {
+  white-space: nowrap;
+}
+</style>
\ No newline at end of file
diff --git a/bumps/webview/client/src/components/ServerShutdown.vue b/bumps/webview/client/src/components/ServerShutdown.vue
new file mode 100644
index 00000000..8a54fee4
--- /dev/null
+++ b/bumps/webview/client/src/components/ServerShutdown.vue
@@ -0,0 +1,56 @@
+<script setup lang="ts">
+import { ref, onMounted, computed, shallowRef } from 'vue';
+import { Modal } from 'bootstrap/dist/js/bootstrap.esm.js';
+import type { AsyncSocket } from '../asyncSocket.ts';
+
+const props = defineProps<{socket:AsyncSocket}>();
+
+const dialog = ref<HTMLDivElement>();
+const isOpen = ref(false);
+const closeCancelled = ref(false);
+const shutdownTimer = ref(0);
+const CLOSE_DELAY = 2000; // try to auto-close window after 2 seconds.
+
+let modal: Modal;
+
+onMounted(() => {
+  modal = new Modal(dialog.value, { backdrop: 'static', keyboard: false });
+
+});
+
+props.socket.on('server_shutting_down', () => {
+  modal?.show();  
+  shutdownTimer.value = setTimeout(() => {
+    window.close();
+  }, CLOSE_DELAY);
+});
+
+function cancelClose() {
+  clearTimeout(shutdownTimer.value);
+  modal?.hide();
+}
+
+</script>
+
+<template>
+  <div ref="dialog" class="modal fade" id="serverShutdownModal" tabindex="-1" aria-labelledby="serverShutdownLabel"
+    :aria-hidden="isOpen">
+    <div class="modal-dialog modal-dialog-centered">
+      <div class="modal-content">
+        <div class="modal-header">
+          <h5 class="modal-title" id="serverShutdownLabel">Server Disconnected</h5>
+          <button type="button" class="btn-close" @click="cancelClose" aria-label="dismiss dialog"></button>
+        </div>
+        <div class="modal-body">
+          <h3>This client window can be closed</h3>
+        </div>
+        <div class="modal-footer">
+          <button type="button" class="btn btn-primary" @click="cancelClose">Dismiss</button>
+        </div>
+      </div>
+    </div>
+  </div>
+</template>
+
+<style scoped>
+</style>
\ No newline at end of file
diff --git a/bumps/webview/client/src/components/ServerStartup.vue b/bumps/webview/client/src/components/ServerStartup.vue
new file mode 100644
index 00000000..570a06f2
--- /dev/null
+++ b/bumps/webview/client/src/components/ServerStartup.vue
@@ -0,0 +1,71 @@
+<script setup lang="ts">
+import { ref, onMounted } from 'vue';
+import { Modal } from 'bootstrap/dist/js/bootstrap.esm.js';
+import type { AsyncSocket } from '../asyncSocket.ts';
+
+const props = defineProps<{socket:AsyncSocket}>();
+
+const loading_dialog = ref<HTMLDivElement>();
+const connected = ref(false);
+const status_string = ref<string>();
+const percent = ref<number>();
+
+let modal: Modal;
+
+onMounted(() => {
+  modal = new Modal(loading_dialog.value, { backdrop: 'static', keyboard: false });
+  loading_dialog.value?.addEventListener("shown.bs.modal", (e) => {
+    if (connected.value) {
+      modal.hide();
+    }
+  });
+  modal.show();
+});
+
+props.socket.on('connect', () => {
+  connected.value = true;
+  console.log('connected!');
+  modal?.hide();
+});
+
+interface ServerStartupStatus {
+  status: string;
+  percent: number; // 0-100
+}
+
+props.socket.on('server_startup_status', (status: ServerStartupStatus) => {
+  status_string.value = status.status;
+  percent.value = status.percent;
+});
+
+console.log('connected handler registered.');
+
+</script>
+
+<template>
+  <div ref="loading_dialog" class="modal" id="serverStartupModal" tabindex="-1" aria-labelledby="serverStartupLabel"
+    :aria-hidden="connected">
+    <div class="modal-dialog modal-dialog-centered">
+      <div class="modal-content">
+        <div class="modal-header">
+          <h5 class="modal-title" id="serverStartupLabel">
+            Server Connecting...
+            <div class="spinner-border text-primary" role="status" v-if="percent === undefined">
+              <span class="visually-hidden">Connecting...</span>
+            </div>
+          </h5>
+        </div>
+        <div class="modal-body" v-if="status_string !== undefined">
+          <div class="progress">
+            <div class="progress-bar" role="progressbar" :style="{ width: percent + '%' }" :aria-valuenow="percent"
+              aria-valuemin="0" aria-valuemax="100"></div>
+          </div>
+          <p>{{ status_string }}</p>
+        </div>
+      </div>
+    </div>
+  </div>
+</template>
+
+<style scoped>
+</style>
\ No newline at end of file
diff --git a/bumps/webview/client/src/components/SessionMenu.vue b/bumps/webview/client/src/components/SessionMenu.vue
new file mode 100644
index 00000000..b6908dfe
--- /dev/null
+++ b/bumps/webview/client/src/components/SessionMenu.vue
@@ -0,0 +1,188 @@
+<script setup lang="ts">
+import { onMounted, ref, shallowRef } from 'vue';
+import type { AsyncSocket } from '../asyncSocket';
+import { fileBrowser, active_fit, session_output_file, autosave_session, autosave_session_interval } from '../app_state';
+import type { FileBrowserSettings } from '../app_state';
+
+const title = "Session";
+const props = defineProps<{
+  socket: AsyncSocket,
+}>();
+
+function handle_file_message(payload: { filename: string, pathlist: string[] }) {
+  console.log("session_output_file", payload);
+  session_output_file.value = payload;
+}
+
+function handle_autosave_message(payload: boolean) {
+  console.log("autosave_session", payload);
+  autosave_session.value = payload;
+}
+
+function handle_autosave_interval_message(payload: number) {
+  console.log("autosave_session_interval", payload);
+  autosave_session_interval.value = payload;
+}
+
+props.socket.asyncEmit('get_shared_setting', 'session_output_file', handle_file_message);
+props.socket.asyncEmit('get_shared_setting', 'autosave_session', handle_autosave_message);
+props.socket.asyncEmit('get_shared_setting', 'autosave_session_interval', handle_autosave_interval_message);
+props.socket.on('session_output_file', handle_file_message);
+props.socket.on('autosave_session', handle_autosave_message);
+props.socket.on('autosave_session_interval', handle_autosave_interval_message);
+
+async function toggle_autosave() {
+  // if turning on autosave, but session_file is not set, then prompt for a file
+  if (!autosave_session.value && !session_output_file.value) {
+    // open the file browser to select output file, and then set autosave
+    await setOutputFile(true, false);
+    // closeMenu();
+    return;
+  }
+  await props.socket.asyncEmit('set_shared_setting', 'autosave_session', !autosave_session.value);
+  // setTimeout(closeMenu, 1000); 
+}
+
+async function set_interval(new_interval: number) {
+
+  await props.socket.asyncEmit('set_shared_setting', 'autosave_session_interval', new_interval);
+  closeMenu();
+}
+
+async function saveSession() {
+  if (session_output_file.value) {
+    await props.socket.asyncEmit("save_session");
+  } else {
+    await setOutputFile(false, true);
+  }
+}
+
+function closeMenu() {
+  const menu = document.getElementById("session-dropdown-menu");
+  if (menu) {
+    menu.classList.remove("show");
+  }
+}
+
+async function readSession(readOnly: boolean) {
+  if (active_fit.value && active_fit.value?.fitter_id) {
+    const confirmation = confirm("Loading session will stop current fit. Continue?");
+    if (!confirmation) {
+      return;
+    }
+  }
+  if (fileBrowser.value) {
+    const settings: FileBrowserSettings = {
+      title: "Read Session",
+      callback: (pathlist, filename) => {
+        props.socket.asyncEmit("load_session", pathlist, filename, readOnly);
+      },
+      show_name_input: true,
+      name_input_label: "Session Filename",
+      require_name: true,
+      show_files: true,
+      search_patterns: [".h5"],
+      chosenfile_in: "",
+      pathlist_in: session_output_file.value?.pathlist,
+    };
+    fileBrowser.value.open(settings);
+  }
+}
+
+async function saveSessionCopy() {
+  if (fileBrowser.value) {
+    const settings: FileBrowserSettings = {
+      title: "Save Session (Copy)",
+      callback: async (pathlist, filename) => {
+        await props.socket.asyncEmit("save_session_copy", pathlist, filename);
+        await props.socket.syncFS();
+      },
+      show_name_input: true,
+      name_input_label: "Filename",
+      require_name: true,
+      show_files: true,
+      search_patterns: [".h5"],
+      chosenfile_in: "",
+      pathlist_in: session_output_file.value?.pathlist,
+    };
+    fileBrowser.value.open(settings);
+  }
+}
+
+async function setOutputFile(enable_autosave = true, immediate_save = false) {
+  if (fileBrowser.value) {
+    const settings: FileBrowserSettings = {
+      title: "Set Output File",
+      callback: async (pathlist, filename) => {
+        await props.socket.asyncEmit("set_shared_setting", "session_output_file", { filename, pathlist });
+        if (enable_autosave) {
+          await props.socket.asyncEmit("set_shared_setting", "autosave_session", true);
+        }
+        if (immediate_save) {
+          await props.socket.asyncEmit("save_session");
+          await props.socket.syncFS();
+        }
+      },
+      show_name_input: true,
+      name_input_label: "Output Filename",
+      require_name: true,
+      show_files: true,
+      search_patterns: [".h5"],
+      chosenfile_in: session_output_file.value?.filename,
+      pathlist_in: session_output_file.value?.pathlist,
+    };
+    fileBrowser.value.open(settings);
+  }
+}
+
+async function unsetOutputFile() {
+  await props.socket.asyncEmit('set_shared_setting', 'session_output_file', null);
+}
+
+</script>
+
+<template>
+  <li class="nav-item dropdown">
+    <button class="btn btn-link nav-link dropdown-toggle" role="button" data-bs-toggle="dropdown"
+      aria-expanded="false">
+      Session
+      <input class="form-check-input" type="checkbox" :checked="autosave_session" readonly >
+    </button>
+    <ul class="dropdown-menu" id="session-dropdown-menu">
+      <li class="dropdown-item">
+        <div class="form-check form-switch">
+          <label class="form-check-label" for="autoSaveSessionCheckbox">Autosave</label>
+          <input class="form-check-input" type="checkbox" role="switch" id="autoSaveSessionCheckbox"
+            :checked="autosave_session" @change="toggle_autosave"
+            >
+        </div>
+      </li>
+      <li>
+        <span class="" v-if="session_output_file">
+          <span class="dropdown-item-text">{{ session_output_file.filename }}</span>
+        </span>
+      </li>
+      <li class="dropdown-item">
+        <div class="row">
+          <label for="autosaveIntervalInput" class="col col-form-label col-form-label-sm">Interval (s)</label>
+          <div class="col-auto">
+            <input type="number" class="form-control form-control" id="autosaveIntervalInput"
+            :value="autosave_session_interval" @change="set_interval($event.target.valueAsNumber)">
+          </div>
+        </div>
+      </li>
+      <li>
+        <hr class="dropdown-divider">
+      </li>
+      <li>
+        <button class="btn btn-link dropdown-item" :class="{'disabled': active_fit?.fitter_id }" @click="readSession(false)">Open Session</button>
+      </li>
+      <li>
+        <button class="btn btn-link dropdown-item" @click="saveSession">Save Session</button>
+      </li>
+      <li>
+        <button class="btn btn-link dropdown-item" @click="setOutputFile(false, true)">Save Session As</button>
+      </li>
+    </ul>
+  </li>
+</template>
diff --git a/bumps/webview/client/src/components/SummaryView.vue b/bumps/webview/client/src/components/SummaryView.vue
new file mode 100644
index 00000000..781d7120
--- /dev/null
+++ b/bumps/webview/client/src/components/SummaryView.vue
@@ -0,0 +1,133 @@
+<script setup lang="ts">
+import { ref, computed } from 'vue';
+import type { AsyncSocket } from '../asyncSocket.ts';
+import { setupDrawLoop} from '../setupDrawLoop';
+import TagFilter from './ParameterTagFilter.vue'
+
+const title = "Summary";
+
+const props = defineProps<{
+  socket: AsyncSocket,
+}>();
+
+setupDrawLoop('updated_parameters', props.socket, fetch_and_draw, title);
+
+type parameter_info = {
+  name: string,
+  id: string,
+  tags: string[],
+  value01: number,
+  value_str: string,
+  min_str: string,
+  max_str: string,
+  active?: boolean,
+}
+
+const parameters = ref<parameter_info[]>([]);
+const parameters_local01 = ref<number[]>([]);
+const parameters_localstr = ref<string[]>([]);
+const show_tags = ref(false);
+const tag_filter = ref<typeof TagFilter>();
+
+async function fetch_and_draw() {
+  const payload: parameter_info[] = await props.socket.asyncEmit('get_parameters', true) as parameter_info[];
+  const pv = parameters.value;
+  payload.forEach((p, i) => {
+    parameters_localstr.value[i] = p.value_str;
+    if (!(pv[i]?.active)) {
+      pv.splice(i, 1, p);
+      parameters_local01.value[i] = p.value01;
+    }
+  });
+  pv.splice(payload.length);
+  parameters_local01.value.splice(payload.length);
+  parameters_localstr.value.splice(payload.length);
+}
+
+async function onMove(param_index) {
+  // props.socket.volatile.emit('set_parameter01', parameters.value[param_index].name, parameters_local01.value[param_index]);
+  props.socket.asyncEmit('set_parameter', parameters.value[param_index].id, "value01", parameters_local01.value[param_index]);
+}
+
+async function editItem(ev, item_name: "min" | "max" | "value", index: number) {
+  const new_value = ev.target.innerText;
+  if (validate_numeric(new_value)) {
+    props.socket.asyncEmit('set_parameter', parameters.value[index].id, item_name, new_value);
+  }
+}
+
+function validate_numeric(value: string, allow_inf: boolean = false) {
+  if (allow_inf && (value === "inf" || value === "-inf")) {
+    return true
+  }
+  return !Number.isNaN(Number(value))
+}
+
+async function scrollParam(ev, index) {
+  const sign = Math.sign(ev.deltaY);
+  parameters_local01.value[index] -= 0.01 * sign;
+  props.socket.asyncEmit('set_parameter', parameters.value[index].id, "value01", parameters_local01.value[index]);
+}
+
+async function onInactive(param) {
+  param.active = false;
+  fetch_and_draw();
+}
+
+</script>
+        
+<template>
+  <TagFilter ref="tag_filter" :parameters="parameters"></TagFilter>
+  <table class="table table-sm">
+    <thead class="border-bottom py-1 sticky-top text-white bg-secondary">
+      <tr>
+        <th scope="col">Fit Parameter</th>
+        <th scope="col"></th>
+        <th scope="col">Value</th>
+        <th scope="col">Min</th>
+        <th scope="col">Max</th>
+      </tr>
+    </thead>
+    <tbody>
+      <tr class="py-1" v-for="(param, index) in tag_filter?.filtered_parameters" :key="param.id">
+        <td>{{ param.name }}
+          <span 
+            v-if="tag_filter?.show_tags"
+            v-for="tag in param.tags"
+            class="badge rounded-pill me-1" 
+            :style="{color: 'white', 'background-color': tag_filter.tag_colors[tag]}"
+            >
+            {{ tag }}
+          </span>
+        </td>
+        <td>
+          <input type="range" class="form-range" min="0" max="1.0" step="0.005"
+            v-model.number="parameters_local01[index]" @mousedown="param.active = true" @input="onMove(index)"
+            @change="onInactive(param)" @wheel="scrollParam($event, index)" />
+        </td>
+        <td class="editable" contenteditable="true" spellcheck="false" @blur="editItem($event, 'value', index)"
+          @keydown.enter="$event?.target?.blur()">{{ parameters_localstr[index] }}</td>
+        <td class="editable" contenteditable="true" spellcheck="false" @blur="editItem($event, 'min', index)"
+          @keydown.enter="$event?.target?.blur()">{{ param.min_str }}</td>
+        <td class="editable" contenteditable="true" spellcheck="false" @blur="editItem($event, 'max', index)"
+          @keydown.enter="$event?.target?.blur()">{{ param.max_str }}</td>
+      </tr>
+    </tbody>
+  </table>
+</template>
+    
+<style scoped>
+svg {
+  width: 100%;
+  white-space: nowrap;
+}
+
+td.editable {
+  min-width: 5em;
+}
+
+
+td > input {
+  min-width: 5em;
+}
+</style>
\ No newline at end of file
diff --git a/bumps/webview/client/src/components/UncertaintyView.vue b/bumps/webview/client/src/components/UncertaintyView.vue
new file mode 100644
index 00000000..4ff27d93
--- /dev/null
+++ b/bumps/webview/client/src/components/UncertaintyView.vue
@@ -0,0 +1,95 @@
+<script setup lang="ts">
+/// <reference types="@types/uuid"/>
+import { ref } from 'vue';
+import type { AsyncSocket } from '../asyncSocket.ts';
+import { v4 as uuidv4 } from 'uuid';
+import { setupDrawLoop } from '../setupDrawLoop';
+import { cache } from '../plotcache';
+import * as Plotly from 'plotly.js/lib/core';
+import Bar from 'plotly.js/lib/bar';
+import { SVGDownloadButton } from '../plotly_extras.mjs';
+
+// workaround to PlotlyModule not being exported as type!
+type RegisterTypes = Parameters<typeof Plotly.register>[0];
+type PlotlyModule = Exclude<RegisterTypes, any[]>;
+
+Plotly.register([
+  Bar as PlotlyModule,
+])
+
+const title = "Uncertainty";
+const plot_div = ref<HTMLDivElement>();
+const plot_div_id = ref(`div-${uuidv4()}`);
+const props = defineProps<{
+  socket: AsyncSocket,
+}>();
+const show_labels = ref(true);
+
+async function on_change_show_labels() {
+  let { timestamp, plotdata } = cache[title] as { timestamp: string, plotdata: Plotly.PlotlyDataLayoutConfig } ?? {};
+  if (plotdata) {
+    apply_label_visibility(plotdata.layout, show_labels.value);
+    await Plotly.react(plot_div_id.value, [...plotdata.data], plotdata.layout);
+  }
+}
+
+const { drawing_busy } = setupDrawLoop('updated_uncertainty', props.socket, fetch_and_draw, title);
+
+async function fetch_and_draw(latest_timestamp: string) {
+  let { timestamp, plotdata } = cache[title] as { timestamp: string, plotdata: Plotly.PlotlyDataLayoutConfig } ?? {};
+  if (timestamp !== latest_timestamp) {
+    console.log("fetching new uncertainty plot", timestamp, latest_timestamp);
+    const payload = await props.socket.asyncEmit('get_uncertainty_plot', latest_timestamp) as Plotly.PlotlyDataLayoutConfig;
+    plotdata = { ...payload };
+    cache[title] = {timestamp: latest_timestamp, plotdata};
+  }
+  const { data, layout } = plotdata;
+  delete layout?.width;
+  delete layout?.height;
+  const config = { responsive: true, scrollZoom: true, modeBarButtonsToAdd: [ SVGDownloadButton ] };
+  apply_label_visibility(layout, show_labels.value);
+  await Plotly.react(plot_div_id.value, [...data], layout, config);
+}
+
+function apply_label_visibility(layout: Plotly.Layout, visible: boolean) {
+  layout.showlegend = visible;
+  for (let item_name in layout) {
+    if (item_name.startsWith('xaxis')) {
+      layout[item_name].showticklabels = visible;
+    }
+  }
+  for (let annotation of layout?.annotations ?? []) {
+    if (annotation?.name === 'label') {
+      annotation.visible = visible;
+    }
+  }
+}
+
+</script>
+    
+<template>
+  <div class="container d-flex flex-grow-1 flex-column">
+    <div class="d-flex flex-row justify-content-between align-items-center">
+      <div class="form-check">
+        <input class="form-check-input" type="checkbox" id="show_labels" v-model="show_labels" @change="on_change_show_labels">
+        <label class="form-check-label" for="show_labels">Show labels</label>
+      </div>
+    </div>
+    <div class="flex-grow-1 position-relative">
+      <div class="w-100 h-100 plot-div" ref="plot_div" :id="plot_div_id"></div>
+      <div class="position-absolute top-0 start-0 w-100 h-100 d-flex flex-column align-items-center justify-content-center loading" v-if="drawing_busy">
+        <span class="spinner-border text-primary"></span>
+      </div>
+    </div>
+  </div>
+</template>
+
+<style scoped>
+svg {
+  width: 100%;
+}
+span.spinner-border {
+  width: 3rem;
+  height: 3rem;
+}
+</style>
\ No newline at end of file
diff --git a/bumps/webview/client/src/fitter_defaults.ts b/bumps/webview/client/src/fitter_defaults.ts
new file mode 100644
index 00000000..2f2d638a
--- /dev/null
+++ b/bumps/webview/client/src/fitter_defaults.ts
@@ -0,0 +1,62 @@
+export const FITTERS = {
+    dream: {
+      name: "DREAM",
+      settings: {
+        "samples": 10000,
+        "burn": 100,
+        "pop": 10,
+        "init": "eps",
+        "thin": 1,
+        "alpha": 0.01,
+        "outliers": "none",
+        "trim": false,
+        "steps": 0
+      }
+    },
+    lm: {
+      name: "Levenberg-Marquardt",
+      settings: {
+        "steps": 200,
+        "ftol": 1.5e-08,
+        "xtol": 1.5e-08
+      }
+    },
+    amoeba: {
+      name: "Nelder-Mead Simplex",
+      settings: {
+        "steps": 1000,
+        "starts": 1,
+        "radius": 0.15,
+        "xtol": 1e-06,
+        "ftol": 1e-08
+      }
+    },
+    de: {
+      name: "Differential Evolution",
+      settings: {
+        "steps": 1000,
+        "pop": 10,
+        "CR": 0.9,
+        "F": 2.0,
+        "ftol": 1e-08,
+        "xtol": 1e-06
+      }
+    },
+    mp: {
+      name: "MPFit",
+      settings: {
+        "steps": 200,
+        "ftol": 1e-10,
+        "xtol": 1e-10
+      }
+    },
+    newton: {
+      name: "Quasi-Newton BFGS",
+      settings: {
+        "steps": 3000,
+        "starts": 1,
+        "ftol": 1e-06,
+        "xtol": 1e-12
+      }
+    },
+  }
\ No newline at end of file
diff --git a/bumps/webview/client/src/main.js b/bumps/webview/client/src/main.js
new file mode 100644
index 00000000..777b6236
--- /dev/null
+++ b/bumps/webview/client/src/main.js
@@ -0,0 +1,7 @@
+import { createApp } from 'vue';
+import 'bootstrap/dist/css/bootstrap.min.css';
+import './style.css';
+import App from './App.vue';
+import { panels } from './panels.mjs';
+
+createApp(App, {panels}).mount('#app')
diff --git a/bumps/webview/client/src/panels.mjs b/bumps/webview/client/src/panels.mjs
new file mode 100644
index 00000000..4f811cc4
--- /dev/null
+++ b/bumps/webview/client/src/panels.mjs
@@ -0,0 +1,26 @@
+import DataView from './components/DataView.vue';
+import SummaryView from './components/SummaryView.vue';
+import ModelInspect from './components/ModelInspect.vue';
+import ParameterView from './components/ParameterView.vue';
+import LogView from './components/LogView.vue';
+import ConvergenceView from './components/ConvergenceView.vue';
+import CorrelationView from './components/CorrelationViewPlotly.vue';
+import ParameterTraceView from './components/ParameterTraceView.vue';
+import ModelUncertaintyView from './components/ModelUncertaintyView.vue';
+import UncertaintyView from './components/UncertaintyView.vue';
+import History from './components/History.vue';
+
+export const panels = [
+    {title: 'Data', component: DataView},
+    {title: 'Summary', component: SummaryView},
+    {title: 'Log', component: LogView},
+    {title: 'History', component: History},
+    {title: 'Convergence', component: ConvergenceView},
+    {title: 'Model', component: ModelInspect},
+    {title: 'Parameters', component: ParameterView},
+    {title: 'Correlations', component: CorrelationView},
+    {title: 'Trace', component: ParameterTraceView},
+    {title: 'Model Uncertainty', component: ModelUncertaintyView},
+    {title: 'Uncertainty', component: UncertaintyView},
+  ];
+  
\ No newline at end of file
diff --git a/bumps/webview/client/src/plotcache.ts b/bumps/webview/client/src/plotcache.ts
new file mode 100644
index 00000000..44b47920
--- /dev/null
+++ b/bumps/webview/client/src/plotcache.ts
@@ -0,0 +1,2 @@
+// TODO: move cache to server?  Send timestamp code to server to retrieve plottables...
+export const cache: {[plot_type: string]: {timestamp: string, plotdata: object}} = {};
\ No newline at end of file
diff --git a/bumps/webview/client/src/plotly_extras.mjs b/bumps/webview/client/src/plotly_extras.mjs
new file mode 100644
index 00000000..fb395770
--- /dev/null
+++ b/bumps/webview/client/src/plotly_extras.mjs
@@ -0,0 +1,16 @@
+import * as Plotly from 'plotly.js/lib/core';
+
+export const SVGDownloadButton = {
+  name: 'Download as SVG',
+  title: 'Download as SVG',
+  icon: Plotly.Icons.camera,
+  click: function(gd) {
+    Plotly.downloadImage(gd, {format: 'svg'})
+  }
+}
+
+export const configWithSVGDownloadButton = {
+    modeBarButtonsToAdd: [
+      SVGDownloadButton      
+    ]
+}
\ No newline at end of file
diff --git a/bumps/webview/client/src/setupDrawLoop.ts b/bumps/webview/client/src/setupDrawLoop.ts
new file mode 100644
index 00000000..414ed877
--- /dev/null
+++ b/bumps/webview/client/src/setupDrawLoop.ts
@@ -0,0 +1,63 @@
+import { ref, onActivated, onDeactivated } from 'vue';
+import type { AsyncSocket } from './asyncSocket.ts';
+
+type Message = {
+  timestamp: string,
+  message: object
+}
+
+export function setupDrawLoop(topic: string, socket: AsyncSocket, draw: Function, name: string = '') {
+  const mounted = ref(false);
+  const drawing_busy = ref(false);
+  const draw_requested = ref(false);
+  const latest_value = ref<unknown>();
+
+  const topic_callback = function(value: unknown) {
+    latest_value.value = value;
+    draw_requested.value = true;
+  }
+
+  const draw_if_needed = async function() {
+    if (!mounted.value) {
+      return;
+    }
+    if (drawing_busy.value) {
+      console.log(`drawing ${name} busy!`);
+    }
+    else if (draw_requested.value) {
+      drawing_busy.value = true;
+      draw_requested.value = false;
+      try {
+        // Need to continue the draw loop even if draw fails
+        await draw(latest_value.value);
+      }
+      catch (e) {
+        // TODO: should this notify the user?
+        console.error(`Error drawing ${name}:`, e);
+        // add sleep to avoid runaway error loop
+        await sleep(1000);
+      }
+      drawing_busy.value = false;
+    }
+    window.requestAnimationFrame(draw_if_needed);
+  }
+
+  const sleep = (ms: number) => new Promise(resolve => setTimeout(resolve, ms));
+
+  onActivated(async () => {
+    mounted.value = true;
+    socket.on(topic, topic_callback);
+    const value = await socket.asyncEmit('get_shared_setting', topic);
+    if (value !== undefined) {
+      topic_callback(value);
+    }
+    window.requestAnimationFrame(draw_if_needed);
+  });
+
+  onDeactivated(() => {
+    mounted.value = false;
+    socket.off(topic, topic_callback);
+  });
+
+  return { mounted, drawing_busy, draw_requested };
+}
diff --git a/bumps/webview/client/src/style.css b/bumps/webview/client/src/style.css
new file mode 100644
index 00000000..a2b25da4
--- /dev/null
+++ b/bumps/webview/client/src/style.css
@@ -0,0 +1,7 @@
+html, body, #app {
+    height: 100%;
+}
+
+.flex-column {
+    min-height: 0;
+}
\ No newline at end of file
diff --git a/bumps/webview/client/vite.config.js b/bumps/webview/client/vite.config.js
new file mode 100644
index 00000000..61f43172
--- /dev/null
+++ b/bumps/webview/client/vite.config.js
@@ -0,0 +1,43 @@
+import { fileURLToPath, URL } from 'node:url'
+import { join } from 'node:path'
+
+import { defineConfig } from 'vite'
+import vue from '@vitejs/plugin-vue'
+
+// https://vitejs.dev/config/
+export default ({mode}) => {
+  return defineConfig({
+    plugins: [
+      vue(),
+    ],
+    base: '',
+    resolve: {
+      alias: {
+        '@': fileURLToPath(new URL('./src', import.meta.url)),
+      }
+    },
+    define: {
+      // By default, Vite doesn't include shims for NodeJS/
+      // necessary for segment analytics lib to work
+      global: {},
+    },
+    worker: {
+      format: 'es',
+      rollupOptions: {
+        external: ["node-fetch"],
+      },
+    },
+    build: {
+      rollupOptions: {
+        output: {
+          // Default
+          dir: join('dist', process.env.npm_package_version),
+          entryFileNames: (mode == 'production') ? 'assets/[name].js' : 'assets/[name].[hash].js',
+          assetFileNames: (mode == 'production') ? 'assets/[name][extname]' : undefined,
+          // chunkFileNames: "chunk-[name].js",
+          // manualChunks: undefined,
+        }
+      }
+    }
+  })
+}
diff --git a/bumps/webview/server/__init__.py b/bumps/webview/server/__init__.py
new file mode 100644
index 00000000..e69de29b
diff --git a/bumps/webview/server/__main__.py b/bumps/webview/server/__main__.py
new file mode 100644
index 00000000..30178f66
--- /dev/null
+++ b/bumps/webview/server/__main__.py
@@ -0,0 +1,4 @@
+from .webserver import main
+
+if __name__ == '__main__':
+    main()
\ No newline at end of file
diff --git a/bumps/webview/server/api.py b/bumps/webview/server/api.py
new file mode 100644
index 00000000..e9192c6d
--- /dev/null
+++ b/bumps/webview/server/api.py
@@ -0,0 +1,954 @@
+from functools import lru_cache
+import itertools
+from types import GeneratorType
+from typing import Any, Callable, Coroutine, Dict, List, Literal, Mapping, Optional, Sequence, Union, Tuple, TypedDict, cast
+from datetime import datetime
+import numbers
+import warnings
+from threading import Event
+import numpy as np
+import asyncio
+from pathlib import Path, PurePath
+import json
+from copy import deepcopy
+import os
+import uuid
+
+from bumps.fitters import DreamFit, LevenbergMarquardtFit, SimplexFit, DEFit, MPFit, BFGSFit, FitDriver, fit, nllf_scale, format_uncertainty
+from bumps.mapper import MPMapper
+from bumps.parameter import Parameter, Variable, unique
+import bumps.cli
+import bumps.fitproblem
+import bumps.dream.views, bumps.dream.varplot, bumps.dream.stats, bumps.dream.state
+import bumps.errplot
+
+from .state_hdf5_backed import UNDEFINED, UNDEFINED_TYPE, State, serialize_problem, deserialize_problem, SERIALIZER_EXTENSIONS
+from .fit_thread import FitThread, EVT_FIT_COMPLETE, EVT_FIT_PROGRESS
+from .varplot import plot_vars
+from .logger import logger, console_handler
+
+
+REGISTRY: Dict[str, Callable] = {}
+MODEL_EXT = '.json'
+TRACE_MEMORY = False
+
+FITTERS = (DreamFit, LevenbergMarquardtFit, SimplexFit, DEFit, MPFit, BFGSFit)
+FITTERS_BY_ID = dict([(fitter.id, fitter) for fitter in FITTERS])
+FITTER_DEFAULTS = {}
+for fitter in FITTERS:
+    FITTER_DEFAULTS[fitter.id] = {
+        "name": fitter.name,
+        "settings": dict(fitter.settings)
+    }
+
+state = State()
+
+def register(fn: Callable):
+    REGISTRY[fn.__name__] = fn
+    return fn
+
+async def emit(
+    event: Any,
+    data: Optional[Any] = None,
+    to: Optional[Any] = None,
+    room: Optional[Any] = None,
+    skip_sid: Optional[Any] = None,
+    namespace: Optional[Any] = None,
+    callback: Optional[Any] = None,
+    ignore_queue: bool = False
+) -> Coroutine[Any, Any, None] :
+    # to be defined when initializing server
+    pass
+
+TopicNameType = Literal[
+    "log", # log messages
+]
+
+@register
+async def load_problem_file(pathlist: List[str], filename: str, autosave_previous: bool = True):
+    path = Path(*pathlist, filename)
+    logger.info(f'model loading: {path}')
+    await log(f'model_loading: {path}')
+    if filename.endswith(".json"):
+        with open(path, "rt") as input_file:
+            serialized = input_file.read()
+        problem = deserialize_problem(serialized, method='dataclass')
+    else:
+        from bumps.cli import load_model
+        problem = load_model(str(path))
+    assert isinstance(problem, bumps.fitproblem.FitProblem)
+    # problem_state = ProblemState(problem, pathlist, filename)
+    try:
+        _serialized_problem = serialize_problem(problem, method='dataclass')
+        state.problem.serializer = 'dataclass'
+    except Exception as exc:
+        logger.info(f"Could not serialize problem as JSON (dataclass): {exc}, switching to dill")
+        state.problem.serializer = 'dill'
+    if (state.shared.autosave_history and
+        autosave_previous and
+        state.problem is not None and
+        state.problem.fitProblem is not None):
+        await save_to_history("autosaved before loading new model")
+    state.shared.model_file = dict(filename=filename, pathlist=pathlist)
+    state.shared.model_loaded = now_string()
+    await set_problem(problem, Path(*pathlist), filename)
+
+
+@register
+async def set_serialized_problem(serialized, new_model: bool = False, name: Optional[str] = None):
+    fitProblem = deserialize_problem(serialized, method='dataclass')
+    state.problem.serializer = 'dataclass'
+    await set_problem(fitProblem, new_model=new_model, name=name)
+ 
+
+async def set_problem(problem: bumps.fitproblem.FitProblem, path: Optional[Path] = None, filename: str = "", new_model: bool = True, name: Optional[str] = None):
+    if state.problem is None or state.problem.fitProblem is None:
+        update = False
+    state.problem.fitProblem = problem
+    name = name if name is not None else problem.name
+    if name is None:
+        name = filename
+    state.shared.updated_model = now_string()
+    state.shared.updated_parameters = now_string()
+
+    if new_model:
+        pathlist = list(path.parts) if path is not None else []
+        path_string = "(no path)" if path is None else str(path / filename)
+        await log(f'model loaded: {path_string}')
+        state.shared.model_file = dict(filename=filename, pathlist=pathlist)
+        state.shared.model_loaded = now_string()
+        if (state.shared.autosave_history and
+            state.problem is not None and
+            state.problem.fitProblem is not None):
+            await save_to_history(f"Loaded model: {name}", keep=True)
+        await add_notification(content=path_string, title="Model loaded", timeout=2000)
+    state.autosave()
+
+
+@register
+async def get_history():
+    return state.get_history()
+
+
+@register
+async def remove_history_item(timestamp: str):
+    state.remove_history_item(timestamp)
+    state.shared.updated_history = now_string()
+
+
+@register
+async def save_to_history(label: str, include_population: bool = False, include_uncertainty: bool = False, keep: bool = False):
+    state.save_to_history(label, include_population=include_population, include_uncertainty=include_uncertainty, keep=keep)
+
+@register
+async def reload_history_item(timestamp: str):
+    state.reload_history_item(timestamp)
+
+
+@register
+async def set_keep_history(timestamp: str, keep: bool):
+    state.history.set_keep(timestamp, keep)
+    state.shared.updated_history = now_string()
+
+@register
+async def update_history_label(timestamp: str, label: str):
+    state.history.update_label(timestamp, label)
+    state.shared.updated_history = now_string()
+
+
+@register
+async def save_problem_file(pathlist: Optional[List[str]] = None, filename: Optional[str] = None, overwrite: bool = False):
+    problem_state = state.problem
+    if problem_state is None:
+        logger.warning("Save failed: no problem loaded.")
+        return
+    if pathlist is None:
+        pathlist = problem_state.pathlist
+    if filename is None:
+        filename = problem_state.filename
+
+    if pathlist is None or filename is None:
+        logger.warning("no filename and path provided to save")
+        return {"filename": "", "check_overwrite": False}
+
+    path = Path(*pathlist)
+    serializer = state.problem.serializer
+    extension = SERIALIZER_EXTENSIONS[serializer]
+    save_filename = f"{Path(filename).stem}.{extension}"
+    if not overwrite and Path.exists(path / save_filename):
+        #confirmation needed:
+        return {"filename": save_filename, "check_overwrite": True}
+
+    serialized = serialize_problem(problem_state.fitProblem, method=serializer)
+    with open(Path(path, save_filename), "wb") as output_file:
+        output_file.write(serialized)
+
+    await log(f'Saved: {save_filename} at path: {path}')
+    return {"filename": save_filename, "check_overwrite": False}
+
+@register
+async def save_session():
+    state.save()
+
+@register
+async def save_session_copy(pathlist: List[str], filename: str):
+    path = Path(*pathlist)
+    state.write_session_file(str(path / filename))
+
+@register
+async def load_session(pathlist: List[str], filename: str, read_only: bool = False):
+    path = Path(*pathlist)
+    state.setup_backing(filename, pathlist, read_only=read_only)
+    state.shared.updated_model = now_string()
+    state.shared.updated_parameters = now_string()
+
+@register
+async def set_session_output_file(pathlist: Optional[List[str]] = None, filename: Optional[str] = None):
+    if filename is None or pathlist is None:
+        await state.shared.set('session_output_file', None)
+    else:
+        await state.shared.set('session_output_file', dict(filename=filename, pathlist=pathlist))
+
+@register
+async def get_serializer():
+    output = {"serializer": "", "extension": ""}
+    problem_state = state.problem
+    if problem_state is not None:
+        serializer = problem_state.serializer
+        output["serializer"] = serializer
+        output["extension"] = SERIALIZER_EXTENSIONS[serializer]
+    return output
+
+@register
+async def export_results(export_path: Union[str, List[str]]=""):
+    from concurrent.futures import ThreadPoolExecutor
+
+    problem_state = state.problem
+    if problem_state is None:
+        logger.warning("Save failed: no problem loaded.")
+        return
+
+    problem = deepcopy(problem_state.fitProblem)
+    # TODO: if making a temporary copy of the uncertainty state is going to cause memory
+    # issues, we could try to copy and then fall back to just using the live object,
+    # or we could just always use the live object, which is unlikely to be changed before
+    # the export completes, anyway.
+    uncertainty_state = deepcopy(state.fitting.uncertainty_state)
+
+    if not isinstance(export_path, list):
+        export_path = [export_path]
+    path = Path(*export_path)
+    notification_id = await add_notification(content=f"<span>{str(path)}</span>", title="Export started", timeout=None)
+    try:
+        await asyncio.to_thread(_export_results, path, problem, uncertainty_state)
+    finally:
+        await emit("cancel_notification", notification_id)
+
+
+def _export_results(path: Path, problem: bumps.fitproblem.FitProblem, uncertainty_state: Optional[bumps.dream.state.MCMCDraw]):
+    from bumps.util import redirect_console
+
+    basename = problem.name if problem.name is not None else "problem"
+    # Storage directory
+    path.mkdir(parents=True, exist_ok=True)
+    output_pathstr = str( path / basename )
+
+    # Ask model to save its information
+    problem.save(output_pathstr)
+
+    # Save a snapshot of the model that can (hopefully) be reloaded
+    serializer = state.problem.serializer
+    extension = SERIALIZER_EXTENSIONS[serializer]
+    save_filename = f"{output_pathstr}.{extension}"
+    try:
+        serialized = serialize_problem(problem, serializer)
+        with open(save_filename, "wb") as output_file:
+            output_file.write(serialized)
+    except Exception as exc:
+        logger.error(f"Error exporting model: {exc}")
+
+    # Save the current state of the parameters
+    with redirect_console(str(path / f"{basename}.out")):
+        problem.show()
+
+    pardata = "".join("%s %.15g\n"%(name, value) for name, value in
+                        zip(problem.labels(), problem.getp()))
+
+    open( path / f"{basename}.par",'wt').write(pardata)
+
+    # Produce model plots
+    problem.plot(figfile = output_pathstr)
+
+    # Produce uncertainty plots
+    if uncertainty_state is not None:
+        with redirect_console( str(path / f"{basename}.err")):
+            uncertainty_state.show(figfile = output_pathstr)
+        uncertainty_state.save(output_pathstr)
+
+@register
+async def save_parameters(pathlist: List[str], filename: str, overwrite: bool = False):
+    problem_state = state.problem
+    if problem_state is None:
+        await log("Error: Can't save parameters if no problem loaded")
+        return
+    problem = problem_state.fitProblem
+    path = Path(*pathlist)
+    if not overwrite and Path.exists(path / filename):
+        #confirmation needed:
+        return {"filename": filename, "check_overwrite": True}
+
+    pardata = "".join("%s %.15g\n" % (name, value)
+                      for name, value in zip(problem.labels(), problem.getp()))
+    
+    with open( path / filename, "wt") as pars_file:
+        pars_file.write(pardata)
+    return {"filename": filename, "check_overwrite": False}
+
+@register
+async def apply_parameters(pathlist: List[str], filename: str):
+    path = Path(*pathlist)
+    fullpath = path / filename
+    try:
+        bumps.cli.load_best(state.problem.fitProblem, fullpath)
+        state.shared.updated_parameters = now_string()
+        await log(f"applied parameters from {fullpath}")
+        await add_notification(f"applied parameters from {fullpath}", title="Parameters applied", timeout=2000)
+    except Exception:
+        await log(f"unable to apply parameters from {fullpath}")
+        await add_notification(f"unable to apply parameters from {fullpath}", title="Error applying parameters", timeout=2000)
+
+
+@register
+async def start_fit(fitter_id: str="", kwargs=None):
+    kwargs = {} if kwargs is None else kwargs
+    problem_state = state.problem
+    if problem_state is None:
+        await log("Error: Can't start fit if no problem loaded")
+    else:
+        fitProblem = problem_state.fitProblem
+        mapper = MPMapper.start_mapper(fitProblem, None, cpus=state.parallel)
+        monitors = []
+        fitclass = FITTERS_BY_ID[fitter_id]
+        driver = FitDriver(fitclass=fitclass, mapper=mapper, problem=fitProblem, monitors=monitors, **kwargs)
+        x, fx = driver.fit()
+        driver.show()
+
+@register
+async def stop_fit():
+    if state.fit_thread is not None:
+        if state.fit_thread.is_alive():
+            state.fit_abort_event.set()
+            loop = getattr(state, 'calling_loop', None)
+            if loop is not None:
+                await loop.run_in_executor(None, state.fit_complete_event.wait)
+    else:
+        state.shared.active_fit = {}
+
+@register
+async def get_chisq(problem: Optional[bumps.fitproblem.FitProblem]=None, nllf=None):
+    problem = state.problem.fitProblem if problem is None else problem
+    if problem is None:
+        return ''
+    nllf = problem.nllf() if nllf is None else nllf
+    scale, err = nllf_scale(problem)
+    chisq = format_uncertainty(scale*nllf, err)
+    return chisq
+
+def get_num_steps(fitter_id: str, num_fitparams: int, options: Optional[Dict] = None):
+    options = FITTER_DEFAULTS[fitter_id] if options is None else options
+    steps = options['steps']
+    if fitter_id == 'dream':
+        if steps == 0:
+            total_pop = options['pop'] * num_fitparams
+            steps = int(options['samples'] / total_pop)
+        steps += options['burn']
+    return steps
+
+def get_running_loop():
+    try:
+        return asyncio.get_running_loop()
+    except RuntimeError:
+        return None
+
+@register
+async def start_fit_thread(fitter_id: str="", options=None, terminate_on_finish=False):
+    options = {} if options is None else options    # session_id: str = app["active_session"]
+    fitProblem = state.problem.fitProblem if state.problem is not None else None
+    if fitProblem is None:
+        await log("Error: Can't start fit if no problem loaded")
+    else:
+        state.calling_loop = get_running_loop()
+        fit_state = state.fitting
+        fitclass = FITTERS_BY_ID[fitter_id]
+        if state.fit_thread is not None:
+            # warn that fit is alread running...
+            logger.warning("fit already running...")
+            await log("Can't start fit, a fit is already running...")
+            return
+        
+        # TODO: better access to model parameters
+        num_params = len(fitProblem.getp())
+        if num_params == 0:
+            raise ValueError("Problem has no fittable parameters")
+
+        # Start a new thread worker and give fit problem to the worker.
+        # Clear abort and uncertainty state
+        # state.abort = False
+        # state.fitting.uncertainty_state = None
+        num_steps = get_num_steps(fitter_id, num_params, options)
+        state.fit_abort_event.clear()
+        state.fit_complete_event.clear()
+        state.fit_uncertainty_final.clear()
+
+        fit_thread = FitThread(
+            abort_event=state.fit_abort_event,
+            problem=fitProblem,
+            fitclass=fitclass,
+            options=options,
+            parallel=state.parallel,
+            # session_id=session_id,
+            # Number of seconds between updates to the GUI, or 0 for no updates
+            convergence_update=5,
+            uncertainty_update=state.shared.autosave_session_interval,
+            terminate_on_finish=terminate_on_finish,
+            )
+        await emit("fit_progress", {}) # clear progress
+        state.shared.active_fit = to_json_compatible_dict(dict(fitter_id=fitter_id, options=options, num_steps=num_steps))
+        await log(json.dumps(to_json_compatible_dict(options), indent=2), title = f"starting fitter {fitter_id}")
+        state.autosave()
+        fit_thread.start()
+        state.fit_thread = fit_thread
+
+async def _fit_progress_handler(event: Dict):
+    # session_id = event["session_id"]
+    if TRACE_MEMORY:
+        import tracemalloc
+        if tracemalloc.is_tracing():
+                snapshot = tracemalloc.take_snapshot()
+                top_stats = snapshot.statistics('lineno')
+                print("memory use:")
+                for stat in top_stats[:15]:
+                    print(stat)
+                
+    problem_state = state.problem
+    fitProblem = problem_state.fitProblem if problem_state is not None else None
+    if fitProblem is None:
+        raise ValueError("should never happen: fit progress reported for session in which fitProblem is undefined")
+    message = event.get("message", None)
+    if message == 'complete' or message == 'improvement':
+        fitProblem.setp(event["point"])
+        fitProblem.model_update()
+        state.shared.updated_parameters = now_string()
+        if message == 'complete':
+            state.shared.active_fit = {}
+    elif message == 'convergence_update':
+        state.fitting.population = event["pop"]
+        state.shared.updated_convergence = now_string()
+    elif message == 'progress':
+        await emit("fit_progress", to_json_compatible_dict(event))
+    elif message == 'uncertainty_update' or message == 'uncertainty_final':
+        state.fitting.uncertainty_state = cast(bumps.dream.state.MCMCDraw, event["uncertainty_state"])
+        state.shared.updated_uncertainty = now_string()
+        state.autosave()
+        if message == 'uncertainty_final':
+            # fit is not complete until uncertainty is saved.
+            state.fit_uncertainty_final.set()
+
+async def _fit_complete_handler(event):
+    message = event.get("message", None)
+    fit_thread = state.fit_thread
+    terminate = False
+    if fit_thread is not None:
+        terminate = fit_thread.terminate_on_finish
+        fit_thread.join(1) # 1 second timeout on join
+        if fit_thread.is_alive():
+            await log("fit thread failed to complete")
+    state.fit_thread = None
+    state.shared.active_fit = {}
+    if message == "error":
+        await log(event["traceback"], title=f"fit failed with error: {event.get('error_string')}")
+        logger.warning(f"fit failed with error: {event.get('error_string')}\n{event['traceback']}")
+    else:
+        problem: bumps.fitproblem.FitProblem = event["problem"]
+        chisq = nice(2*event["value"]/problem.dof)
+        problem.setp(event["point"])
+        problem.model_update()
+        state.problem.fitProblem = problem
+        if state.shared.autosave_history:
+            await save_to_history(f"fit complete: {event['fitter_id']}", include_population=True, include_uncertainty=True)
+        state.shared.updated_parameters = now_string()
+        await log(event["info"], title=f"done with chisq {chisq}")
+        logger.info(f"fit done with chisq {chisq}")
+
+    if fit_thread.fitclass.id == 'dream':
+        # print("waiting for uncertainty to complete...")
+        await asyncio.to_thread(state.fit_uncertainty_final.wait)
+
+    state.fit_complete_event.set()
+
+    if terminate:
+        await shutdown()
+
+def fit_progress_handler(event: Dict):
+    loop = getattr(state, 'calling_loop', None)
+    if loop is not None:
+        asyncio.run_coroutine_threadsafe(_fit_progress_handler(event), loop)
+
+def fit_complete_handler(event: Dict):
+    loop = getattr(state, 'calling_loop', None)
+    if loop is not None:
+        asyncio.run_coroutine_threadsafe(_fit_complete_handler(event), loop)
+
+EVT_FIT_PROGRESS.connect(fit_progress_handler, weak=True)
+EVT_FIT_COMPLETE.connect(fit_complete_handler, weak=True)
+
+async def log(message: str, title: Optional[str] = None):
+    topic = "log"
+    contents = {"message": {"message": message, "title": title}, "timestamp": now_string()}
+    # session = get_session(session_id)    
+    state.topics[topic].append(contents)
+    # if session_id == app["active_session"]:
+    await emit(topic, contents)
+
+@register
+async def get_data_plot(model_indices: Optional[List[int]] = None):
+    if state.problem is None or state.problem.fitProblem is None:
+        return None
+    fitProblem = deepcopy(state.problem.fitProblem)
+    import mpld3
+    import matplotlib
+    matplotlib.use("agg")
+    import matplotlib.pyplot as plt
+    import time
+    start_time = time.time()
+    logger.info(f'queueing new data plot... {start_time}')
+    fig = plt.figure()
+    for i, model in enumerate(fitProblem.models):
+        if model_indices is not None and i not in model_indices:
+            continue
+        model.plot()
+    plt.text(0.01, 0.01,
+             'chisq=%s' % fitProblem.chisq_str(),
+             transform=plt.gca().transAxes)
+    dfig = mpld3.fig_to_dict(fig)
+    plt.close(fig)
+    end_time = time.time()
+    logger.info(f"time to draw data plot: {end_time - start_time}")
+    return {"fig_type": "mpld3", "plotdata": dfig}
+
+@register
+async def get_model_names():
+    problem = state.problem.fitProblem
+    if problem is None:
+        return None
+    return [p.name if p.name is not None else f"model_{i}" for (i,p) in enumerate(problem.models)]
+
+@register
+async def get_model():
+    if state.problem is None or state.problem.fitProblem is None:
+        return None
+    fitProblem = state.problem.fitProblem
+    serialized = serialize_problem(fitProblem, 'dataclass') if state.problem.serializer == 'dataclass' else 'null'
+    return serialized
+
+@register
+async def get_convergence_plot():
+    if state.problem is None or state.problem.fitProblem is None:
+        return None
+    fitProblem = state.problem.fitProblem
+    population = state.fitting.population
+    if population is not None:
+        normalized_pop = 2*population/fitProblem.dof
+        best, pop = normalized_pop[:, 0], normalized_pop[:, 1:]
+
+        ni,npop = pop.shape
+        iternum = np.arange(1,ni+1)
+        tail = int(0.25*ni)
+        fig = {"data": [], "layout": {}} # go.Figure()
+        hovertemplate = "(%{{x}}, %{{y}})<br>{label}<extra></extra>"
+        x = iternum[tail:].tolist()
+        if npop==5:
+            # fig['data'].append(dict(type="scattergl", x=x, y=pop[tail:,4].tolist(), name="95%", mode="lines", line=dict(color="lightblue", width=1), showlegend=True, hovertemplate=hovertemplate.format(label="95%")))
+            fig['data'].append(dict(type="scattergl", x=x, y=pop[tail:,3].tolist(), mode="lines", line=dict(color="lightgreen", width=0), showlegend=False, hovertemplate=hovertemplate.format(label="80%")))
+            fig['data'].append(dict(type="scattergl", x=x, y=pop[tail:,1].tolist(), name="20% to 80% range", fill="tonexty", mode="lines", line=dict(color="lightgreen", width=0), hovertemplate=hovertemplate.format(label="20%")))
+            fig['data'].append(dict(type="scattergl", x=x, y=pop[tail:,2].tolist(), name="population median", mode="lines", line=dict(color="green"), opacity=0.5))
+            fig['data'].append(dict(type="scattergl", x=x, y=pop[tail:,0].tolist(), name="population best", mode="lines", line=dict(color="green", dash="dot")))
+
+        fig['data'].append(dict(type="scattergl", x=x, y=best[tail:].tolist(), name="best", line=dict(color="red", width=1), mode="lines"))
+        fig['layout'].update(dict(template="simple_white", legend=dict(x=1,y=1,xanchor="right",yanchor="top")))
+        fig['layout'].update(dict(title=dict(text="Convergence", xanchor="center", x=0.5)))
+        fig['layout'].update(dict(uirevision=1))
+        fig['layout'].update(dict(xaxis=dict(title="iteration number", showline=True, showgrid=False, zeroline=False)))
+        fig['layout'].update(dict(yaxis=dict(title="chisq", showline=True, showgrid=False, zeroline=False)))
+        return to_json_compatible_dict(fig)
+    else:
+        return None
+
+@lru_cache(maxsize=30)
+def _get_correlation_plot(sort: bool=True, max_rows: int=8, nbins: int=50, vars=None, timestamp: str=""):
+    from .corrplot import Corr2d
+    uncertainty_state = state.fitting.uncertainty_state
+
+    if isinstance(uncertainty_state, bumps.dream.state.MCMCDraw):
+        import time
+        start_time = time.time()
+        logger.info(f'queueing new correlation plot... {start_time}')
+        draw = uncertainty_state.draw(vars=vars)
+        c = Corr2d(draw.points.T, bins=nbins, labels=draw.labels)
+        fig = c.plot(sort=sort, max_rows=max_rows)
+        logger.info(f"time to render but not serialize... {time.time() - start_time}")
+        serialized = to_json_compatible_dict(fig.to_dict())
+        end_time = time.time()
+        logger.info(f"time to draw correlation plot: {end_time - start_time}")
+        return serialized
+    else:
+        return None
+
+@register
+async def get_correlation_plot(sort: bool=True, max_rows: int=8, nbins: int=50, vars=None, timestamp: str=""):
+    # need vars to be immutable (hashable) for caching based on arguments:
+    vars = tuple(vars) if vars is not None else None
+    result = await asyncio.to_thread(_get_correlation_plot, sort=sort, max_rows=max_rows, nbins=nbins, vars=vars, timestamp=timestamp)
+    return result
+
+@lru_cache(maxsize=30)
+def _get_uncertainty_plot(timestamp: str="", cbar_colors: int=8):
+    uncertainty_state = state.fitting.uncertainty_state
+    if uncertainty_state is not None:
+        import time
+        start_time = time.time()
+        logger.info(f'queueing new uncertainty plot... {start_time}')
+        draw = uncertainty_state.draw()
+        nbins = max(min(draw.points.shape[0]//20000, 100), 30)
+        stats = bumps.dream.stats.var_stats(draw)
+        fig = plot_vars(draw, stats, nbins=nbins, cbar_colors=cbar_colors)
+        logger.info(f"time to draw uncertainty plot: {time.time() - start_time}")
+        return to_json_compatible_dict(fig)
+    else:
+        return None
+
+@register
+async def get_uncertainty_plot(timestamp: str=""):
+    result = await asyncio.to_thread(_get_uncertainty_plot, timestamp=timestamp, cbar_colors=8)
+    return result
+
+@register
+async def get_model_uncertainty_plot():
+    if state.problem is None or state.problem.fitProblem is None:
+        return None
+    fitProblem = state.problem.fitProblem
+    uncertainty_state = state.fitting.uncertainty_state
+    if uncertainty_state is not None:
+        import mpld3
+        import matplotlib
+        matplotlib.use("agg")
+        import matplotlib.pyplot as plt
+        import time
+        start_time = time.time()
+        logger.info(f'queueing new model uncertainty plot... {start_time}')
+
+        fig = plt.figure()
+        errs = bumps.errplot.calc_errors_from_state(fitProblem, uncertainty_state)
+        logger.info(f'errors calculated: {time.time() - start_time}')
+        bumps.errplot.show_errors(errs, fig=fig)
+        logger.info(f"time to render but not serialize... {time.time() - start_time}")
+        fig.canvas.draw()
+        dfig = mpld3.fig_to_dict(fig)
+        plt.close(fig)
+        end_time = time.time()
+        logger.info(f"time to draw model uncertainty plot: {end_time - start_time}")
+        return dfig
+    else:
+        return None
+
+@register
+async def get_parameter_trace_plot():
+    uncertainty_state = state.fitting.uncertainty_state
+    if uncertainty_state is not None:
+        import mpld3
+        import matplotlib
+        matplotlib.use("agg")
+        import matplotlib.pyplot as plt
+        import time
+
+        start_time = time.time()
+        logger.info(f'queueing new parameter_trace plot... {start_time}')
+
+        fig = plt.figure()
+        axes = fig.add_subplot(111)
+
+        # begin plotting:
+        var = 0
+        portion = None
+        draw, points, _ = uncertainty_state.chains()
+        label = uncertainty_state.labels[var]
+        start = int((1-portion)*len(draw)) if portion else 0
+        genid = np.arange(uncertainty_state.generation-len(draw)+start, uncertainty_state.generation)+1
+        axes.plot(genid*uncertainty_state.thinning,
+             np.squeeze(points[start:, uncertainty_state._good_chains, var]))
+        axes.set_xlabel('Generation number')
+        axes.set_ylabel(label)
+        fig.canvas.draw()
+
+        logger.info(f"time to render but not serialize... {time.time() - start_time}")
+        dfig = mpld3.fig_to_dict(fig)
+        plt.close(fig)
+        end_time = time.time()
+        logger.info(f"time to draw parameter_trace plot: {end_time - start_time}")
+        return dfig
+    else:
+        return None
+    
+
+@register
+async def get_parameters(only_fittable: bool = False):
+    if state.problem is None or state.problem.fitProblem is None:
+        return []
+    fitProblem = state.problem.fitProblem
+
+    all_parameters = fitProblem.model_parameters()
+    if only_fittable:
+        parameter_infos = params_to_list(unique(all_parameters))
+        # only include params with priors:
+        parameter_infos = [pi for pi in parameter_infos if pi['fittable'] and not pi['fixed']]
+    else:
+        parameter_infos = params_to_list(all_parameters)
+        
+    return to_json_compatible_dict(parameter_infos)
+
+@register
+async def set_parameter(parameter_id: str, property: Literal["value01", "value", "min", "max"], value: Union[float, str, bool]):
+    if state.problem is None or state.problem.fitProblem is None:
+        return None
+    fitProblem = state.problem.fitProblem
+
+    parameter = fitProblem._parameters_by_id.get(parameter_id, None)
+    if parameter is None:
+        warnings.warn(f"Attempting to update parameter that doesn't exist: {parameter_id}")
+        return
+
+    if parameter.prior is None:
+        warnings.warn(f"Attempting to set prior properties on parameter without priors: {parameter}")
+        return
+
+    if property == "value01":
+        new_value  = parameter.prior.put01(value)
+        nice_new_value = nice(new_value, digits=VALUE_PRECISION)
+        parameter.clip_set(nice_new_value)
+    elif property == "value":
+        new_value = float(value)
+        nice_new_value = nice(new_value, digits=VALUE_PRECISION)
+        parameter.clip_set(nice_new_value)
+    elif property == "min":
+        lo = float(value)
+        hi = parameter.prior.limits[1]
+        parameter.range(lo, hi)
+        parameter.add_prior()
+    elif property == "max":
+        lo = parameter.prior.limits[0]
+        hi = float(value)
+        parameter.range(lo, hi)
+        parameter.add_prior()
+    elif property == "fixed":
+        if parameter.fittable:
+            parameter.fixed = bool(value)
+            fitProblem.model_reset()
+            # logger.info(f"setting parameter: {parameter}.fixed to {value}")
+            # model has been changed: setp and getp will return different values!
+            state.shared.updated_model = now_string()
+    fitProblem.model_update()
+    state.shared.updated_parameters = now_string()
+    return
+
+def now_string():
+    return f"{datetime.now().timestamp():.6f}"
+
+@register
+async def publish(topic: str, message: Any = None):
+    timestamp_str = f"{datetime.now().timestamp():.6f}"
+    contents = {"message": message, "timestamp": timestamp_str}
+    # session = get_session(session_id)    
+    state.topics[topic].append(contents)
+    # if session_id == app["active_session"]:
+    await emit(topic, contents)
+    # logger.info(f"emitted: {topic} :: {contents}")
+
+@register
+async def get_shared_setting(setting: str):
+    value = await state.shared.get(setting)
+    return to_json_compatible_dict(value)
+
+@register
+async def set_shared_setting(setting: str, value: Any):
+    await state.shared.set(setting, value)
+
+async def notify_shared_setting(setting: str, value: Any):
+    await emit(setting, to_json_compatible_dict(value))
+
+object.__setattr__(state.shared, 'notify', notify_shared_setting)
+
+@register
+async def get_topic_messages(topic: Optional[TopicNameType] = None, max_num=None) -> List[Dict]:
+    # this is a GET request in disguise -
+    # emitter must handle the response in a callback,
+    # as no separate response event is emitted.
+    if topic is None:
+        return []    
+    topics = state.topics
+    q = topics.get(topic, None)
+    if q is None:
+        raise ValueError(f"Topic: {topic} not defined")
+    elif max_num is None:
+        return list(q)
+    else:
+        q_length = len(q)
+        start = max(q_length - max_num, 0)
+        return list(itertools.islice(q, start, q_length))
+
+@register
+async def get_dirlisting(pathlist: Optional[List[str]]=None):
+    # GET request
+    # TODO: use psutil to get disk listing as well?
+    subfolders = []
+    files = []
+    path = Path(state.base_path) if (pathlist is None or len(pathlist) == 0) else Path(*pathlist)
+    abs_path = path.absolute()
+    if not abs_path.exists():
+        return { "error": f"Path does not exist: {abs_path}" }
+    for p in abs_path.iterdir():
+        if not p.exists():
+            continue
+        stat = p.stat()
+        mtime = stat.st_mtime
+        fileinfo = {
+            "name": p.name,
+            "modified": mtime
+        }
+        if p.is_dir():
+            fileinfo["size"] = len(list(p.glob('*')))
+            subfolders.append(fileinfo)
+        else:
+            # files.append(p.resolve().name)
+            fileinfo["size"] = stat.st_size
+            files.append(fileinfo)
+    # for Windows: list drives as well
+    drives = os.listdrives() if hasattr(os, 'listdrives') else []
+    return dict(drives=drives, pathlist=abs_path.parts, subfolders=subfolders, files=files)
+
+@register
+async def get_fitter_defaults(*args):
+    return FITTER_DEFAULTS
+
+@register
+async def shutdown():
+    logger.info("killing...")
+    await stop_fit()
+    state.autosave()
+    await emit("server_shutting_down")
+    shutdown_result = asyncio.gather(_shutdown(), return_exceptions=True)
+
+async def add_notification(content: str, title: str = "Notification", timeout: Optional[int] = None):
+    id = None
+    if timeout is None:
+        id = str(uuid.uuid4())
+        await emit("add_notification", {"title": title, "content": content, "id": id})
+    else:
+        await emit("add_notification", {"title": title, "content": content, "timeout": timeout})
+    return id
+
+async def _shutdown():
+    raise SystemExit(0)
+
+VALUE_PRECISION = 6
+VALUE_FORMAT = "{{:.{:d}g}}".format(VALUE_PRECISION)
+
+def nice(v, digits=4):
+    """Fix v to a value with a given number of digits of precision"""
+    from math import log10, floor
+    v = float(v)
+    if v == 0. or not np.isfinite(v):
+        return v
+    else:
+        sign = v/abs(v)
+        place = floor(log10(abs(v)))
+        scale = 10**(place-(digits-1))
+        return sign*floor(abs(v)/scale+0.5)*scale
+
+JSON_TYPE = Union[str, float, bool, None, Sequence['JSON_TYPE'], Mapping[str, 'JSON_TYPE']]
+
+def to_json_compatible_dict(obj) -> JSON_TYPE:
+    if isinstance(obj, (list, tuple)):
+        return type(obj)(to_json_compatible_dict(v) for v in obj)
+    elif isinstance(obj, GeneratorType):
+        return list(to_json_compatible_dict(v) for v in obj)
+    elif isinstance(obj, dict):
+        return type(obj)((to_json_compatible_dict(k), to_json_compatible_dict(v))
+                        for k, v in obj.items())
+    elif isinstance(obj, np.ndarray) and obj.dtype.kind in ['f', 'i']:
+        return obj.tolist()
+    elif isinstance(obj, np.ndarray) and obj.dtype.kind == 'O':
+        return to_json_compatible_dict(obj.tolist())
+    elif isinstance(obj, bool) or isinstance(obj, str) or obj is None:
+        return obj
+    elif isinstance(obj, numbers.Number):
+        return str(obj) if np.isinf(obj) else float(obj)
+    elif isinstance(obj, UNDEFINED_TYPE):
+        return None
+    else:
+        raise ValueError("obj %s is not serializable" % str(obj))
+
+
+class ParamInfo(TypedDict, total=False):
+    id: str
+    name: str
+    paths: List[str]
+    value_str: str
+    fittable: bool
+    fixed: bool
+    writable: bool
+    value01: float
+    min_str: str
+    max_str: str
+
+
+def params_to_list(params, lookup=None, pathlist=None, links=None) -> List[ParamInfo]:
+    lookup: Dict[str, ParamInfo] = {} if lookup is None else lookup
+    pathlist = [] if pathlist is None else pathlist
+    if isinstance(params,dict):
+        for k in sorted(params.keys()):
+            params_to_list(params[k], lookup=lookup, pathlist=pathlist + [k])
+    elif isinstance(params, tuple) or isinstance(params, list):
+        for i, v in enumerate(params):
+            # add index to last item in pathlist (in-place):
+            new_pathlist = pathlist.copy()
+            if len(pathlist) < 1:
+                new_pathlist.append("")
+            new_pathlist[-1] = f"{new_pathlist[-1]}[{i:d}]"
+            params_to_list(v, lookup=lookup, pathlist=new_pathlist)
+    elif isinstance(params, Parameter):
+        path = ".".join(pathlist)
+        existing = lookup.get(params.id, None)
+        if existing is not None:
+            existing["paths"].append(".".join(pathlist))
+        else:
+            value_str = VALUE_FORMAT.format(nice(params.value))
+            has_prior = params.has_prior()
+            new_item: ParamInfo = { 
+                "id": params.id,
+                "name": str(params.name),
+                "paths": [path],
+                "tags": getattr(params, 'tags', []),
+                "writable": type(params.slot) in [Variable, Parameter], 
+                "value_str": value_str, "fittable": params.fittable, "fixed": params.fixed }
+            if has_prior:
+                assert(params.prior is not None)
+                lo, hi = params.prior.limits
+                new_item['value01'] = params.prior.get01(float(params.value))
+                new_item['min_str'] = VALUE_FORMAT.format(nice(lo))
+                new_item['max_str'] = VALUE_FORMAT.format(nice(hi))
+            lookup[params.id] = new_item
+    elif callable(getattr(params, 'parameters', None)):
+        # handle Expression, Constant, etc.
+        subparams = params.parameters()
+        params_to_list(subparams, lookup=lookup, pathlist=pathlist)
+    return list(lookup.values())
diff --git a/bumps/webview/server/corrplot.py b/bumps/webview/server/corrplot.py
new file mode 100644
index 00000000..4122f08e
--- /dev/null
+++ b/bumps/webview/server/corrplot.py
@@ -0,0 +1,279 @@
+# This program is public domain
+# Authors Paul Kienzle, Brian Maranville
+"""
+2-D correlation histograms
+
+Generate 2-D correlation histograms and display them in a figure.
+
+Uses false color plots of density.
+"""
+__all__ = ['Corr2d']
+
+import numpy as np
+from numpy import inf
+from typing import List
+
+import plotly.graph_objects as go
+from plotly.subplots import make_subplots
+
+# if more than this many variables are to be plotted, put them all
+# on a single axis for efficiency (no linked axes)
+MAKE_SINGLE_BREAKPOINT = 9
+
+class Corr2d(object):
+    """
+    Generate and manage 2D correlation histograms.
+    """
+    def __init__(self, data, labels=None, **histogram2d_kw):
+        if labels is None:
+            labels = ["P"+str(i+1) for i, _ in enumerate(data)]
+        self.N = len(data)
+        self.labels = labels
+        self.data = data
+        self.histogram2d_kw = histogram2d_kw # e.g. bins=(50,50), range=[(0,1),(0,1)]
+        self._hists = {} # cache for histograms
+        self.fig = None
+        #for k, v in self.hists.items():
+        #    print k, (v[1][0], v[1][-1]), (v[2][0], v[2][-1])
+        low, high = np.min(data, axis=1), np.max(data, axis=1)
+        self.default_ranges = [(l, h) for l, h in zip(low, high)]
+
+    def R(self):
+        return np.corrcoef(self.data)
+    
+    def __getitem__(self, indices):
+        """
+        Retrieve correlation histogram for data[i] X data[j].
+
+        Returns bin i edges, bin j edges, and histogram
+        """
+        i, j = indices
+        if (i, j) not in self._hists:
+            self._hists[(i, j)] = self.calculate_histogram(i, j, range=None, **self.histogram2d_kw)
+        return self._hists[(i, j)]
+
+    def calculate_histogram(self, i, j, range=None, **histogram2d_kw):
+        """
+        Calculate the histogram for data[i] X data[j].
+
+        Returns bin i edges, bin j edges, and histogram
+        """
+        if range is None:
+            range = [self.default_ranges[i], self.default_ranges[j]]
+        return np.histogram2d(self.data[i], self.data[j], range=range, **histogram2d_kw)
+
+    def plot(self, title=None, sort=True, max_rows=25, indices=None):
+        """
+        Plot the correlation histograms on the specified figure
+
+        Use supplied indices to select parameters by index, else
+        generate indices (optionally sorted by max correlation coeff.)
+        """
+        num_to_show = min(max_rows, self.N - 1)
+        if indices is None:
+            if sort:
+                coeffs = (self.R() - np.eye(self.N))
+                max_corr = np.max(coeffs**2, axis=0)
+                indices = np.argsort(max_corr)[:-max_rows-2:-1]
+                labels = _disambiguated(self.labels)
+            else:
+                indices = np.arange(num_to_show + 1, dtype=np.int32)
+                labels = self.labels
+        if num_to_show > MAKE_SINGLE_BREAKPOINT:
+            fig = _plot_single_heatmap(self, labels, indices=indices)
+        else:
+            fig = _plot(self, labels, indices=indices)
+        if title is not None:
+            fig.update_layout(title=dict(text=title, xanchor="center", x=0.5))
+
+        return fig
+
+
+def _plot(hists, labels, indices, show_ticks=None):
+    """
+    Plot pair-wise correlation histograms
+    """
+
+    n = len(indices)
+    vmin, vmax = float('inf'), float('-inf')
+    for i, index in enumerate(indices[:-1]):
+        for cross_index in indices[i+1:]:
+            ii, jj = sorted((index, cross_index))
+            data, _, _ = hists[(ii, jj)]
+            positive = data[data > 0]
+            if len(positive) > 0:
+                vmin = min(vmin, np.amin(positive))
+                vmax = max(vmax, np.amax(positive))
+    
+    fig = make_subplots(rows=n-1, cols=n-1, horizontal_spacing=0, vertical_spacing=0, shared_yaxes=True, shared_xaxes=True)
+    COLORSCALE = ["white", "yellow", "green", "blue", "red"]
+
+    for i, index in enumerate(indices[:-1]):
+        fig.add_annotation(
+            xref="x domain",
+            yref="y domain",
+            xanchor="right",
+            yanchor="bottom",
+            x=-0.05,
+            y=0.05,
+            showarrow=False,
+            col=i+1,
+            row=n-i-1,
+            text=labels[index],
+            textangle=-90
+        )
+        for j, cross_index in enumerate(indices[i+1:], start=i+1):
+            ii, jj = sorted((index, cross_index))
+            data, x, y = hists[(ii, jj)]
+            if index > cross_index:
+                # then we have reversed the order of the axes...
+                data = data.T
+                x, y = (y, x)
+            data = np.clip(data, vmin, vmax)
+            hovertemplate = f"{labels[index]}<br>{labels[cross_index]}<extra></extra>"
+            trace = go.Heatmap(z=np.log10(data), coloraxis='coloraxis', hovertemplate=hovertemplate, customdata=[ii,jj])
+            fig.add_traces([trace], rows=n-i-1, cols=j)
+    
+    # Add annotation for last parameter:
+    fig.add_annotation(
+        xref="x domain",
+        yref="y domain",
+        xanchor="left",
+        yanchor="bottom",
+        x=0.05,
+        y=1.05,
+        showarrow=False,
+        col=i+1,
+        row=n-i-1,
+        text=labels[indices[-1]],
+        textangle=0
+    )
+
+
+    log_cbar = dict(
+        tickvals=np.arange(int(np.log10(vmax)) + 1),
+        ticktext=10 ** np.arange(int(np.log10(vmax)) + 1),
+    )
+    fig.update_layout(coloraxis={'colorscale': COLORSCALE, "cmin": np.log10(vmin), "cmax": np.log10(vmax), 'colorbar': log_cbar})
+    fig.update_layout(plot_bgcolor='rgba(0, 0, 0, 0)')
+    fig.update_layout(hoverlabel=dict(bgcolor='white', font_size=16))
+    # fig.update_layout(height=600, width=800)
+    fig.update_xaxes(showticklabels=False, showline=True, mirror=True, linewidth=1, linecolor='black')
+    fig.update_yaxes(showticklabels=False, showline=True, mirror=True, linewidth=1, linecolor='black')
+    return fig
+
+def _plot_single_heatmap(hists, labels, indices, show_ticks=None):
+    """
+    Plot pair-wise correlation histograms
+    """
+
+    n = len(indices)
+    vmin, vmax = float('inf'), float('-inf')
+    for i, index in enumerate(indices[:-1]):
+        for cross_index in indices[i+1:]:
+            ii, jj = sorted((index, cross_index))
+            data, _, _ = hists[(ii, jj)]
+            positive = data[data > 0]
+            if len(positive) > 0:
+                vmin = min(vmin, np.amin(positive))
+                vmax = max(vmax, np.amax(positive))
+
+    fig = go.Figure()
+    COLORSCALE = ["white", "yellow", "green", "blue", "red"]
+
+    for i, index in enumerate(indices[:-1]):
+        fig.add_annotation(
+            xanchor="right",
+            yanchor="bottom",
+            x=i+1,
+            y=i,
+            showarrow=False,
+            text=labels[index],
+            textangle=-90
+        )
+        for j, cross_index in enumerate(indices[i+1:], start=i+1):
+            ii, jj = sorted((index, cross_index))
+            data, x, y = hists[(ii, jj)]
+            if index > cross_index:
+                # then we have reversed the order of the axes...
+                data = data.T
+                x, y = (y, x)
+            data = np.clip(data, vmin, vmax)
+            sx, sy = data.shape
+            dx = 1.0 / sx
+            dy = 1.0 / sy
+            hovertemplate = f"{labels[index]}<br>{labels[cross_index]}<extra></extra>"
+            heatmap_trace = go.Heatmap(z=np.log10(data), y=[i, i+dx], x=[j,j+dy], coloraxis='coloraxis', hovertemplate=hovertemplate, customdata=[ii,jj])
+            border_trace = go.Scattergl(x=[j, j+1, j+1, j, j], y=[i, i, i+1, i+1, i], mode='lines', line=dict(color='black', width=1), showlegend=False, hoverinfo='skip')
+            fig.add_traces([heatmap_trace, border_trace])
+
+    # Add annotation for last parameter:
+    fig.add_annotation(
+        xanchor="left",
+        yanchor="bottom",
+        x=i+1,
+        y=i+1,
+        showarrow=False,
+        text=labels[indices[-1]],
+        textangle=0
+    )
+
+    log_cbar = dict(
+        tickvals=np.arange(int(np.log10(vmax)) + 1),
+        ticktext=10 ** np.arange(int(np.log10(vmax)) + 1),
+    )
+    fig.update_layout(coloraxis={'colorscale': COLORSCALE, "cmin": np.log10(vmin), "cmax": np.log10(vmax), 'colorbar': log_cbar})
+    fig.update_layout(plot_bgcolor='rgba(0, 0, 0, 0)')
+    fig.update_layout(hoverlabel=dict(bgcolor='white', font_size=16))
+    # fig.update_layout(height=600, width=800)
+    fig.update_xaxes(visible=False)
+    fig.update_yaxes(visible=False)
+    return fig
+
+
+def _disambiguated(labels: List[str]):
+    label_count = {}
+    output = []
+    for label in labels:
+        label_count.setdefault(label, 0)
+        count = label_count[label]
+        l = f"{label} ({count})" if count > 0 else label
+        output.append(l)
+        label_count[label] += 1
+    return output
+
+
+### NOT USED AT THE MOMENT: all below
+###
+def zoom(event, step):
+    ax = event.inaxes
+    if not hasattr(ax, 'zoomable'):
+        return
+
+    # TODO: test logscale
+    step *= 3
+
+    if ax.zoomable is not True and 'mapper' in ax.zoomable:
+        mapper = ax.zoomable['mapper']
+        if event.ydata is not None:
+            lo, hi = mapper.get_clim()
+            pt = event.ydata*(hi-lo)+lo
+            lo, hi = _rescale(lo, hi, pt, step)
+            mapper.set_clim((lo, hi))
+    if ax.zoomable is True and event.xdata is not None:
+        lo, hi = ax.get_xlim()
+        lo, hi = _rescale(lo, hi, event.xdata, step)
+        ax.set_xlim((lo, hi))
+    if ax.zoomable is True and event.ydata is not None:
+        lo, hi = ax.get_ylim()
+        lo, hi = _rescale(lo, hi, event.ydata, step)
+        ax.set_ylim((lo, hi))
+    ax.figure.canvas.draw_idle()
+
+
+def _rescale(lo, hi, pt, step):
+    scale = float(hi-lo)*step/(100 if step > 0 else 100-step)
+    bal = float(pt-lo)/(hi-lo)
+    new_lo = lo - bal*scale
+    new_hi = hi + (1-bal)*scale
+    return new_lo, new_hi
diff --git a/bumps/webview/server/fit_thread.py b/bumps/webview/server/fit_thread.py
new file mode 100644
index 00000000..c8628f6d
--- /dev/null
+++ b/bumps/webview/server/fit_thread.py
@@ -0,0 +1,261 @@
+
+from copy import deepcopy
+from threading import Thread
+from threading import Event
+import traceback
+
+from blinker import Signal
+
+import numpy as np
+from bumps import monitor
+from bumps.fitters import FitDriver, nllf_scale, format_uncertainty
+from bumps.mapper import MPMapper, SerialMapper, can_pickle
+from bumps.util import redirect_console
+from bumps.history import History
+
+#from .convergence_view import ConvergenceMonitor
+# ==============================================================================
+
+PROGRESS_DELAY = 5
+IMPROVEMENT_DELAY = 5
+
+EVT_FIT_PROGRESS = Signal()
+EVT_FIT_COMPLETE = Signal()
+
+# NOTE: GUI monitors are running in a separate thread.  They should not
+# touch the problem internals.
+
+
+class GUIProgressMonitor(monitor.TimedUpdate):
+    def __init__(self, problem, progress=None, improvement=None):
+        monitor.TimedUpdate.__init__(
+            self, progress=progress or PROGRESS_DELAY,
+            improvement=improvement or IMPROVEMENT_DELAY)
+        self.problem = problem
+
+    def show_progress(self, history):
+        scale, err = nllf_scale(self.problem)
+        chisq = format_uncertainty(scale*history.value[0], err)
+        evt = dict(
+            # problem=self.problem,
+            message="progress",
+            step=history.step[0],
+            value=history.value[0],
+            chisq=chisq,
+            point=history.point[0]+0)  # avoid race
+        EVT_FIT_PROGRESS.send(evt)
+
+    def show_improvement(self, history):
+        evt = dict(
+            message="improvement",
+            step=history.step[0],
+            value=history.value[0],
+            point=history.point[0]+0)  # avoid race
+        EVT_FIT_PROGRESS.send(evt)
+
+
+class ConvergenceMonitor(monitor.Monitor):
+    """
+    Generic GUI monitor for fitting.
+
+    Sends a convergence_update event every *rate*
+    seconds.  Gathers statistics about the best, worst, median and +/- 1 interquartile
+    range.  This will be the input for the convergence plot.
+
+    *problem* should be the fit problem handed to the fit thread, and not
+    a copy. This is because it is used for direct comparison with the current
+    fit object in the progress panels so that stray messages don't cause
+    confusion when making graphs.
+
+    *message* is a dispatch string used by the OnFitProgress event processor
+    in the app to determine which progress panel should receive the event.
+    """
+
+    def __init__(self, problem, message="convergence_update", rate=0):
+        self.time = 0
+        self.rate = rate  # rate=0 for no progress update, only final
+        self.problem = problem
+        self.message = message
+        self.pop = []
+
+
+    def config_history(self, history):
+        history.requires(population_values=1, value=1)
+        history.requires(time=1)
+        # history.requires(time=1, population_values=1, value=1)
+
+    def __call__(self, history):
+        # from old ConvergenceMonitor:
+        best = history.value[0]
+        try:
+            pop = history.population_values[0]
+            n = len(pop)
+            p = np.sort(pop)
+            QI,Qmid, = int(0.2*n),int(0.5*n)
+            self.pop.append((best, p[0],p[QI],p[Qmid],p[-1-QI],p[-1]))
+        except AttributeError:
+            self.pop.append((best, ))
+
+        if self.rate > 0 and history.time[0] >= self.time+self.rate:
+            evt = dict(
+                message=self.message,
+                pop=self.progress())
+            EVT_FIT_PROGRESS.send(evt)
+            self.time = history.time[0]
+
+    def progress(self):
+        return np.empty((0,1),'d') if not self.pop else np.array(self.pop)
+
+    def final(self):
+        """
+        Close out the monitor
+        """
+        evt = dict(
+            message=self.message,
+            pop=self.progress())
+        EVT_FIT_PROGRESS.send(evt)
+
+# Horrible hacks:
+# (1) We are grabbing uncertainty_state from the history on monitor update
+# and holding onto it for the monitor final call. Need to restructure the
+# history/monitor interaction so that object lifetimes are better controlled.
+
+
+class DreamMonitor(monitor.Monitor):
+    def __init__(self, problem, message, fitter, rate=0):
+        self.time = 0
+        self.rate = rate  # rate=0 for no progress update, only final
+        self.update_counter = 0
+        self.problem = problem
+        self.fitter = fitter
+        self.message = message
+        self.uncertainty_state = None
+        # emit None uncertainty state to start with
+        evt = dict(
+            message=message,
+            uncertainty_state=None,
+        )
+        EVT_FIT_PROGRESS.send(evt)
+
+    def config_history(self, history):
+        history.requires(time=1)
+
+    def __call__(self, history):
+        self.uncertainty_state = getattr(history, 'uncertainty_state', None)
+        self.time = history.time[0]
+        if (self.rate > 0):
+            update_counter = history.time[0] // self.rate
+            if update_counter > self.update_counter:
+                self.update_counter = update_counter
+                evt = dict(
+                    message=self.message,
+                    time = self.time,
+                    uncertainty_state=deepcopy(self.uncertainty_state),
+                )
+                EVT_FIT_PROGRESS.send(evt)
+
+    def final(self):
+        """
+        Close out the monitor
+        """
+        if self.uncertainty_state is not None:
+            # Note: win.uncertainty_state protected by win.fit_lock
+            # self.win.uncertainty_state = self.uncertainty_state
+            evt = dict(
+                message="uncertainty_final",
+                time=self.time,
+                uncertainty_state=deepcopy(self.uncertainty_state),
+            )
+            EVT_FIT_PROGRESS.send(evt)
+
+# ==============================================================================
+
+
+class FitThread(Thread):
+    """Run the fit in a separate thread from the GUI thread."""
+
+    def __init__(self, abort_event: Event, problem=None,
+                 fitclass=None, options=None, mapper=None, parallel=0,
+                 convergence_update=5, uncertainty_update=300,
+                 terminate_on_finish=False):
+        # base class initialization
+        # Process.__init__(self)
+
+        Thread.__init__(self)
+        self.abort_event = abort_event
+        self.problem = problem
+        self.fitclass = fitclass
+        self.options = options if isinstance(options, dict) else dict()
+        self.mapper = mapper
+        self.parallel = parallel
+        self.convergence_update = convergence_update
+        self.uncertainty_update = uncertainty_update
+        self.terminate_on_finish = terminate_on_finish
+
+    def abort_test(self):
+        return self.abort_event.is_set()
+
+    def run(self):
+        # TODO: we have no interlocks on changes in problem state.  What
+        # happens when the user changes the problem while a fit is being run?
+        # May want to keep a history of changes to the problem definition,
+        # along with a function to reverse them so we can handle undo.
+
+        # NOTE: Problem must be the original problem (not a copy) when used
+        # inside the GUI monitor otherwise AppPanel will not be able to
+        # recognize that it is the same problem when updating views.
+        try:
+            monitors = [GUIProgressMonitor(self.problem),
+                        ConvergenceMonitor(self.problem,
+                                        rate=self.convergence_update),
+                        # GUIMonitor(self.problem,
+                        #            message="convergence_update",
+                        #            monitor=ConvergenceMonitor(),
+                        #            rate=self.convergence_update),
+                        DreamMonitor(self.problem,
+                                    fitter=self.fitclass,
+                                    message="uncertainty_update",
+                                    rate=self.uncertainty_update),
+                        ]
+
+            mapper = self.mapper
+            if mapper is None:
+                # fallback options if no mapper is specified
+                mp_available = True
+                try:
+                    import _multiprocessing
+                except ImportError:
+                    mp_available = False
+                # Only use MPMapper if the problem can be pickled
+                # and multiprocessing is available, and parallel != 1
+                mapper = MPMapper if mp_available and self.parallel != 1 and can_pickle(self.problem) else SerialMapper
+
+            # Be safe and send a private copy of the problem to the fitting engine
+            # print "fitclass",self.fitclass
+            problem = deepcopy(self.problem)
+            # print "fitclass id",id(self.fitclass),self.fitclass,threading.current_thread()
+            driver = FitDriver(
+                self.fitclass, problem=problem,
+                monitors=monitors, abort_test=self.abort_test,
+                mapper=mapper.start_mapper(problem, [], cpus=self.parallel),
+                **self.options)
+
+            x, fx = driver.fit()
+            # Give final state message from monitors
+            for M in monitors:
+                if hasattr(M, 'final'):
+                    M.final()
+
+            with redirect_console() as fid:
+                driver.show()
+                captured_output = fid.getvalue()
+
+            evt = dict(message="complete", problem=self.problem,
+                    point=x, value=fx, info=captured_output, fitter_id = self.fitclass.id)
+            EVT_FIT_COMPLETE.send(evt)
+            self.result = evt
+
+        except Exception as exc:
+            tb = "".join(traceback.TracebackException.from_exception(exc).format())
+            evt = dict(message="error", error_string=str(exc), traceback=tb)
+            EVT_FIT_COMPLETE.send(evt)
diff --git a/bumps/webview/server/logger.py b/bumps/webview/server/logger.py
new file mode 100644
index 00000000..2c58f04e
--- /dev/null
+++ b/bumps/webview/server/logger.py
@@ -0,0 +1,23 @@
+import logging
+
+class ListHandler(logging.Handler):
+    def __init__(self):
+        super().__init__()
+        self.log_list = []
+
+    def emit(self, record):
+        self.log_list.append(self.format(record))
+
+# Create a logger
+logger = logging.getLogger('webview')
+logger.setLevel(logging.INFO)
+
+# Create and add the custom handler
+list_handler = ListHandler()
+formatter = logging.Formatter('%(asctime)s - %(levelname)s - %(message)s')
+list_handler.setFormatter(formatter)
+list_handler.setLevel(logging.INFO)
+
+console_handler = logging.StreamHandler()
+console_handler.setFormatter(formatter)
+console_handler.setLevel(logging.INFO)
\ No newline at end of file
diff --git a/bumps/webview/server/names.py b/bumps/webview/server/names.py
new file mode 100644
index 00000000..9c1b2c6d
--- /dev/null
+++ b/bumps/webview/server/names.py
@@ -0,0 +1,2 @@
+from .api import state, set_problem, load_session
+from .webserver import main, start_app, create_server_task, sio
\ No newline at end of file
diff --git a/bumps/webview/server/persistent_settings.py b/bumps/webview/server/persistent_settings.py
new file mode 100644
index 00000000..61b582ec
--- /dev/null
+++ b/bumps/webview/server/persistent_settings.py
@@ -0,0 +1,26 @@
+# persist settings to disk
+
+import sys
+from pathlib import Path
+from typing import Optional
+
+ETC_PATH = Path(sys.prefix, 'etc')
+DEFAULT_APPLICATION = 'bumps'
+
+def set_value(key: str, value: str, application: str = DEFAULT_APPLICATION):
+    settings_path = ETC_PATH / application / key
+    try:
+        settings_path.parent.mkdir(parents=True, exist_ok=True)
+        with open(settings_path, 'w') as f:
+            f.write(value)
+        return True
+    except Exception as e:
+        return False
+
+def get_value(key: str, default_value: Optional[str] = None, application: str = DEFAULT_APPLICATION):
+    settings_path = ETC_PATH / application / key
+    try:
+        with open(settings_path, 'r') as f:
+            return f.read()
+    except Exception as e:
+        return default_value
diff --git a/bumps/webview/server/state.py b/bumps/webview/server/state.py
new file mode 100644
index 00000000..fbb23c96
--- /dev/null
+++ b/bumps/webview/server/state.py
@@ -0,0 +1,89 @@
+from collections import deque
+from queue import Queue
+from typing import Dict, List, Optional, TYPE_CHECKING
+from threading import Event
+import asyncio
+
+from .state_hdf5_backed import SERIALIZERS
+
+if TYPE_CHECKING:
+    import bumps, bumps.fitproblem, bumps.dream.state
+    from .webserver import TopicNameType
+    from .fit_thread import FitThread
+
+
+SESSION_FILE_NAME = "session.h5"
+
+class ProblemState:
+    fitProblem: Optional['bumps.fitproblem.FitProblem'] = None
+    pathlist: Optional[List[str]] = None
+    serializer: Optional[SERIALIZERS] = None
+    filename: Optional[str] = None
+
+class FittingState:
+    abort: bool = False
+    population: Optional[List] = None
+    uncertainty_state: Optional['bumps.dream.state.MCMCDraw']
+
+
+class State:
+    # These attributes are ephemeral, not to be serialized/stored:
+    hostname: str
+    port: int
+    parallel: int
+    abort_queue: Queue
+    fit_thread: Optional['FitThread'] = None
+    fit_abort: Optional[Event] = None
+    fit_abort_event: Event
+    fit_complete_event: Event
+    calling_loop: Optional[asyncio.AbstractEventLoop] = None
+    fit_enabled: Event
+    session_file_name: Optional[str]
+
+    # State to be stored:
+    problem: ProblemState
+    fitting: FittingState
+    topics: Dict['TopicNameType', 'deque[Dict]']
+
+    def __init__(self, problem: Optional[ProblemState] = None, fitting: Optional[FittingState] = None):
+        self.problem = problem if problem is not None else ProblemState()
+        self.fitting = FittingState()
+        self.fit_abort_event = Event()
+        self.fit_complete_event = Event()
+        self.topics = {
+            "log": deque([]),
+            "update_parameters": deque([], maxlen=1),
+            "update_model": deque([], maxlen=1),
+            "model_loaded": deque([], maxlen=1),
+            "fit_active": deque([], maxlen=1),
+            "convergence_update": deque([], maxlen=1),
+            "uncertainty_update": deque([], maxlen=1),
+            "fitter_settings": deque([], maxlen=1),
+            "fitter_active": deque([], maxlen=1),
+        }
+
+    def setup_backing(self, session_file_name: Optional[str] = SESSION_FILE_NAME, read_only: bool = False ):
+        self.session_file_name = session_file_name
+
+    def save(self):
+        pass
+
+    def copy_session_file(self, session_copy_name: str):
+        pass
+
+    def write_session_file(self):
+        pass
+
+    def cleanup(self):
+        self._session_file.close()
+
+    def __del__(self):
+        self.cleanup()
+
+    async def async_cleanup(self):
+        self.cleanup()
+
+    def __exit__(self, exc_type, exc_value, exc_traceback):
+        self.cleanup()
+    async def cleanup(self):
+        pass
\ No newline at end of file
diff --git a/bumps/webview/server/state_hdf5_backed.py b/bumps/webview/server/state_hdf5_backed.py
new file mode 100644
index 00000000..e62fb876
--- /dev/null
+++ b/bumps/webview/server/state_hdf5_backed.py
@@ -0,0 +1,589 @@
+import asyncio
+from copy import deepcopy
+from datetime import datetime
+from typing import TYPE_CHECKING, Optional, Dict, List, NewType, Tuple, TypedDict, Any, Literal, Union, cast, IO
+from collections import deque
+from dataclasses import dataclass, fields
+import json
+import shutil
+import os
+import tempfile
+from pathlib import Path
+from typing import Dict, List, Optional, TYPE_CHECKING
+from threading import Event
+from bumps.serialize import serialize, deserialize, migrate
+import h5py
+import numpy as np
+
+from bumps.dream.state import MCMCDraw
+from .logger import logger
+
+if TYPE_CHECKING:
+    import bumps, bumps.fitproblem, bumps.dream.state
+    from .webserver import TopicNameType
+    from .fit_thread import FitThread
+    from h5py import Group, Dataset
+
+
+SESSION_FILE_NAME = "session.h5"
+MAX_PROBLEM_SIZE = 100*1024*1024 # 10 MB problem max size
+UNCERTAINTY_DTYPE = 'f'
+MAX_LABEL_LENGTH = 1024
+LABEL_DTYPE = f"|S{MAX_LABEL_LENGTH}"
+COMPRESSION = 5
+UNCERTAINTY_COMPRESSION = 5
+
+SERIALIZERS = Literal['dataclass', 'pickle', 'dill']
+SERIALIZER_EXTENSIONS = {
+    'dataclass': 'json',
+    'pickle': 'pickle',
+    'dill': 'pickle'
+}
+DEFAULT_SERIALIZER: SERIALIZERS = "dill"
+
+@dataclass(frozen=True)
+class UNDEFINED_TYPE: pass
+
+UNDEFINED = UNDEFINED_TYPE()
+
+def now_string():
+    return f"{datetime.now().timestamp():.6f}"
+
+def serialize_problem(problem: 'bumps.fitproblem.FitProblem', method: SERIALIZERS):
+    if method == 'dataclass':
+        return json.dumps(serialize(problem)).encode()
+    elif method == 'pickle':
+        import pickle
+        return pickle.dumps(problem)
+    elif method == 'dill':
+        import dill
+        return dill.dumps(problem, recurse=True)
+
+def deserialize_problem(serialized: bytes, method: SERIALIZERS):
+    if method == 'dataclass':
+        serialized_dict = json.loads(serialized)
+        final_version, migrated = migrate(serialized_dict)
+        return deserialize(migrated)
+    elif method == 'pickle':
+        import pickle
+        return pickle.loads(serialized)
+    elif method == 'dill':
+        import dill
+        return dill.loads(serialized)
+
+
+def write_bytes_data(group: 'Group', name: str, data: bytes):
+    saved_data = [data] if data is not None else []
+    return group.create_dataset(name, data=np.void(saved_data), compression=COMPRESSION)
+
+def read_bytes_data(group: 'Group', name: str):
+    if not name in group:
+        return UNDEFINED
+    raw_data = group[name][()]
+    size = raw_data.size
+    if size is not None and size > 0:
+        return raw_data.tobytes().rstrip(b'\x00')
+    else:
+        return None
+
+def write_string(group: 'Group', name: str, data: str, encoding='utf-8'):
+    saved_data = np.bytes_([data]) if data is not None else []
+    return group.create_dataset(name, data=saved_data, compression=COMPRESSION, dtype=h5py.string_dtype(encoding=encoding))
+
+def read_string(group: 'Group', name: str):
+    if not name in group:
+        return UNDEFINED
+    raw_data = group[name][()]
+    size = raw_data.size
+    if size is not None and size > 0:
+        return np.bytes_(raw_data.flat[0]).decode()
+    else:
+        return None
+
+def write_fitproblem(group: 'Group', name: str, fitProblem: 'bumps.fitproblem.FitProblem', serializer: SERIALIZERS):
+    serialized = serialize_problem(fitProblem, serializer) if fitProblem is not None else None
+    dset = write_bytes_data(group, name, serialized)
+    return dset
+
+def read_fitproblem(group: 'Group', name: str, serializer: SERIALIZERS):
+    if not name in group:
+        return UNDEFINED
+    serialized = read_bytes_data(group, name)
+    fitProblem = deserialize_problem(serialized, serializer) if serialized is not None else None
+    return fitProblem
+
+def write_json(group: 'Group', name: str, data):
+    serialized = json.dumps(data) if data is not None else None
+    dset = write_string(group, name, serialized.encode())
+    return dset
+
+def read_json(group: 'Group', name: str):
+    if not name in group:
+        return UNDEFINED
+    serialized = read_string(group, name)
+    try:
+        # if JSON fails to load, then just return None
+        result = json.loads(serialized) if serialized is not None else None
+    except Exception:
+        result = None
+    return result
+
+def write_ndarray(group: 'Group', name: str, data: Optional[np.ndarray], dtype=UNCERTAINTY_DTYPE):
+    saved_data = data if data is not None else []
+    return group.create_dataset(name, data=saved_data, dtype=dtype, compression=UNCERTAINTY_COMPRESSION)
+
+def read_ndarray(group: 'Group', name: str):
+    if not name in group:
+        return UNDEFINED
+    raw_data = group[name][()]
+    size = raw_data.size
+    if size is not None and size > 0:
+        return raw_data
+    else:
+        return None
+
+class StringAttribute:
+    @classmethod
+    def serialize(value, obj=None):
+        return json.dumps(value)
+
+    @classmethod
+    def deserialize(value, obj=None):
+        return json.loads(value) if value else None
+
+class ProblemState:
+    fitProblem: Optional['bumps.fitproblem.FitProblem'] = None
+    serializer: Optional[SERIALIZERS] = None
+
+    def write(self, parent: 'Group'):
+        group = parent.require_group('problem')
+        write_fitproblem(group, 'fitProblem', self.fitProblem, self.serializer)
+        write_string(group, 'serializer', self.serializer)
+        # write_json(group, 'pathlist', self.pathlist)
+        # write_string(group, 'filename', self.filename)
+
+    def read(self, parent: 'Group'):
+        group = parent.require_group('problem')
+        self.serializer = read_string(group, 'serializer')
+        self.fitProblem = read_fitproblem(group, 'fitProblem', self.serializer)
+        # self.pathlist = read_json(group, 'pathlist')
+        # self.filename = read_string(group, 'filename')
+               
+class HistoryItem:
+    problem: ProblemState
+    fitting: Optional['FittingState']
+    timestamp: str
+    label: str
+    chisq_str: str
+    keep: bool
+
+
+class History:
+    store: List[HistoryItem]
+
+    def __init__(self):
+        self.store = []
+
+    def write(self, parent: 'Group', include_uncertainty_state=True):
+        group = parent.require_group('problem_history')
+        for item in self.store:
+            problem = item.problem
+            fitting = item.fitting
+            name = item.timestamp
+            item_group = group.require_group(name)
+            problem.write(item_group)
+            fitting.write(item_group, include_uncertainty_state=include_uncertainty_state)
+            item_group.attrs['chisq'] = item.chisq_str
+            item_group.attrs['label'] = item.label
+            item_group.attrs['keep'] = item.keep
+
+    def read(self, parent: 'Group'):
+        group = parent.get('problem_history', [])
+        self.store = []
+        for name in group:
+            item = HistoryItem()
+            item_group = group[name]
+            item.problem = ProblemState()
+            item.fitting = FittingState()
+            item.problem.read(item_group)
+            item.fitting.read(item_group)
+            item.label = item_group.attrs['label']
+            item.chisq_str = item_group.attrs['chisq']
+            # keep is a boolean, but h5py returns np.bool_ which is not JSON serializable
+            item.keep = bool(item_group.attrs['keep'])
+            item.timestamp = name
+            self.store.append(item)
+
+    def remove_item(self, timestamp: str):
+        self.store = [item for item in self.store if item.timestamp != timestamp]
+
+    def prune(self, target_length: int):
+        # remove oldest items with keep=False until the length is target_length
+        num_to_remove = len(self.store) - target_length
+        if num_to_remove <= 0:
+            return
+        indices_to_remove = []
+        for index, item in enumerate(self.store):
+            if not item.keep:
+                indices_to_remove.append(index)
+                num_to_remove -= 1
+                if num_to_remove == 0:
+                    break
+        for index in reversed(indices_to_remove):
+            self.store.pop(index)
+
+    def add_item(self, item: HistoryItem, target_length: int):
+        self.prune(target_length)
+        self.store.append(item)
+
+    def list(self):
+        return [dict(timestamp=item.timestamp,
+                     label=item.label,
+                     chisq_str=item.chisq_str,
+                     keep=item.keep,
+                     has_population=(item.fitting.population is not None),
+                     has_uncertainty=(item.fitting.uncertainty_state is not None),
+                    ) for item in self.store]
+
+    def set_keep(self, timestamp: str, keep: bool):
+        for item in self.store:
+            if item.timestamp == timestamp:
+                item.keep = keep
+                return
+
+    def update_label(self, timestamp: str, label: str):
+        for item in self.store:
+            if item.timestamp == timestamp:
+                item.label = label
+                return
+
+class UncertaintyStateStorage:
+    AR: Optional['np.ndarray'] = None
+    gen_draws: Optional['np.ndarray'] = None
+    labels: Optional['np.ndarray'] = None
+    thin_draws: Optional['np.ndarray'] = None
+    gen_logp: Optional['np.ndarray'] = None
+    thin_logp: Optional['np.ndarray'] = None
+    thin_point: Optional['np.ndarray'] = None
+    update_CR_weight: Optional['np.ndarray'] = None
+    update_draws: Optional['np.ndarray'] = None
+    good_chains: Optional['np.ndarray'] = None
+
+    def write(self, parent: 'Group'):
+        group = parent.require_group('uncertainty_state')
+        for attrname in ['AR', 'gen_draws', 'thin_draws', 'gen_logp', 'thin_logp', 'thin_point', 'update_CR_weight', 'update_draws', 'good_chains']:
+            write_ndarray(group, attrname, getattr(self, attrname), dtype=UNCERTAINTY_DTYPE)
+        write_ndarray(group, 'labels', self.labels, dtype=LABEL_DTYPE)
+
+    def read(self, parent: 'Group'):
+        group = parent['uncertainty_state']
+        for attrname in ['AR', 'gen_draws', 'labels', 'thin_draws', 'gen_logp', 'thin_logp', 'thin_point', 'update_CR_weight', 'update_draws', 'good_chains']:
+            setattr(self, attrname, read_ndarray(group, attrname))
+
+class FittingState:
+    population: Optional[List] = None
+    uncertainty_state: Optional['bumps.dream.state.MCMCDraw'] = None
+
+    def write(self, parent: 'Group', include_uncertainty_state=True):
+        group = parent.require_group('fitting')
+        write_ndarray(group, 'population', self.population)
+        uncertainty_state_storage = UncertaintyStateStorage()
+        uncertainty_state = self.uncertainty_state
+        if uncertainty_state is not None and include_uncertainty_state:
+            write_uncertainty_state(uncertainty_state, uncertainty_state_storage)
+            uncertainty_state_storage.write(group)
+
+    def read(self, parent: 'Group'):
+        group = parent['fitting']
+        population = read_ndarray(group, 'population')
+        self.population = population
+        if 'uncertainty_state' in group:
+            uncertainty_state_storage = UncertaintyStateStorage()
+            uncertainty_state_storage.read(group)
+            self.uncertainty_state = read_uncertainty_state(uncertainty_state_storage)
+
+
+class State:
+    # These attributes are ephemeral, not to be serialized/stored:
+    hostname: str
+    port: int
+    parallel: int
+    fit_thread: Optional['FitThread'] = None
+    fit_abort: Optional[Event] = None
+    fit_abort_event: Event
+    fit_complete_event: Event
+    fit_uncertainty_final: Event
+    fit_enabled: Event
+    calling_loop: Optional[asyncio.AbstractEventLoop] = None
+    base_path: str = ''
+
+    # State to be stored:
+    problem: ProblemState
+    fitting: FittingState
+    history: History
+    topics: Dict['TopicNameType', 'deque[Dict]']
+    shared: 'SharedState'
+
+    def __init__(self):
+        self.problem = ProblemState()
+        self.fitting = FittingState()
+        self.history = History()
+        self.fit_abort_event = Event()
+        self.fit_complete_event = Event()
+        self.fit_uncertainty_final = Event()
+        self.topics = {
+            "log": deque([]),
+        }
+        self.shared = SharedState()
+
+    def __enter__(self):
+        return self
+
+    def setup_backing(self, session_file_name: str, session_pathlist: List[str], read_only: bool = False ):
+        if not read_only:
+            self.shared.session_output_file = dict(filename=session_file_name, pathlist=session_pathlist)
+        if session_file_name is not None:
+            full_path = Path(*session_pathlist) / session_file_name
+            if full_path.exists():
+                self.read_session_file(full_path)
+            else:
+                self.save()
+
+    def save_to_history(self, label: str, include_population: bool = False, include_uncertainty: bool = False, keep: bool = False):
+        if self.problem.fitProblem is None:
+            return
+        item = HistoryItem()
+        item.problem = deepcopy(self.problem)
+        item.fitting = FittingState()
+        if include_population:
+            item.fitting.population = self.fitting.population
+        if include_uncertainty:
+            # uncertainty_state is not mutated, so we can safely use it here
+            # rather than making a memory-hogging copy on first use.
+            item.fitting.uncertainty_state = self.fitting.uncertainty_state
+
+        item.timestamp = str(datetime.now())
+        item.label = label
+        item.chisq_str = item.problem.fitProblem.chisq_str()
+        item.keep = keep
+        self.history.add_item(item, self.shared.autosave_history_length - 1)
+        self.shared.updated_history = now_string()
+
+
+    def get_history(self):
+        return dict(problem_history=self.history.list())
+
+    def remove_history_item(self, timestamp: str):
+        self.history.remove_item(timestamp)
+
+    def reload_history_item(self, timestamp: str):
+        for item in self.history.store:
+            if item.timestamp == timestamp:
+                print("problem found!", timestamp)
+                problem_state = item.problem
+                print('chisq of found item: ', problem_state.fitProblem.chisq_str())
+                self.problem = deepcopy(problem_state)
+                self.shared.updated_model = now_string()
+                self.shared.updated_parameters = now_string()
+                if item.fitting.population is not None:
+                    self.fitting.population = item.fitting.population
+                    self.shared.updated_convergence = now_string()
+                if item.fitting.uncertainty_state is not None:
+                    self.fitting.uncertainty_state = item.fitting.uncertainty_state
+                    self.shared.updated_uncertainty = now_string()
+                return
+        raise ValueError(f"Could not find history item with timestamp {timestamp}")
+    
+    def autosave(self):
+        if self.shared.autosave_session:
+            self.save()
+
+    def save(self):
+        if self.shared.session_output_file not in [None, UNDEFINED]:
+            pathlist = self.shared.session_output_file['pathlist']
+            filename = self.shared.session_output_file['filename']
+            full_path = Path(*pathlist) / filename
+            self.write_session_file(full_path)
+
+    def write_session_file(self, session_fullpath: str):
+        # Session filename is assumed to be a full path
+        tmp_fd, tmp_name = tempfile.mkstemp(dir=Path(session_fullpath).parent, prefix=Path(session_fullpath).name, suffix='.tmp')
+        with os.fdopen(tmp_fd, 'w+b') as output_file:
+            with h5py.File(output_file, 'w') as root_group:
+                self.problem.write(root_group)
+                self.fitting.write(root_group)
+                self.history.write(root_group)
+                self.write_topics(root_group)
+                self.shared.write(root_group)
+        shutil.move(tmp_name, session_fullpath)
+        os.chmod(session_fullpath, 0o644)
+
+    def read_session_file(self, session_fullpath: str, read_problem: bool = True, read_fitstate: bool = True):
+        try:
+            with h5py.File(session_fullpath, 'r') as root_group:
+                if read_problem:
+                    self.problem.read(root_group)
+                if read_fitstate:
+                    self.fitting.read(root_group)
+                self.history.read(root_group)
+                self.read_topics(root_group)
+                self.shared.read(root_group)
+        except Exception as e:
+            logger.warning(f"could not load session file {session_fullpath} because of {e}")
+
+    def read_problem_from_session(self, session_fullpath: str):
+        try:
+            with h5py.File(session_fullpath, 'r') as root_group:
+                self.problem.read(root_group)
+        except Exception as e:
+            logger.warning(f"could not load fitProblem from {session_fullpath} because of {e}")
+
+    def read_fitstate_from_session(self, session_fullpath: str):
+        try:
+            with h5py.File(session_fullpath, 'r') as root_group:
+                self.fitting.read(root_group)
+        except Exception as e:
+            logger.warning(f"could not load fit state from {session_fullpath} because of {e}")
+
+    def write_topics(self, parent: 'Group'):
+        group = parent.require_group('topics')
+        for topic, messages in self.topics.items():
+            write_json(group, topic, list(messages))
+
+    def read_topics(self, parent: 'Group'):
+        group = parent.require_group('topics')
+        for topic in group:
+            topic_data = read_json(group, topic)
+            topic_data = np.array([topic_data]).flatten()
+            if topic_data is not None and topic in self.topics:
+                self.topics[topic].extend(topic_data)
+
+    def get_last_message(self, topic: 'TopicNameType'):
+        return self.topics[topic][-1] if len(self.topics[topic]) > 0 else None
+
+    def cleanup(self):
+        pass
+
+    def __del__(self):
+        self.cleanup()
+
+    async def async_cleanup(self):
+        self.cleanup()
+
+    def __exit__(self, exc_type, exc_value, exc_traceback):
+        self.cleanup()
+
+
+def write_uncertainty_state(state: 'MCMCDraw', storage: UncertaintyStateStorage):
+        # Build 2-D data structures
+        storage.gen_draws, storage.gen_logp = state.logp(full=True)
+        _, storage.AR = state.acceptance_rate()
+
+        storage.thin_draws, storage.thin_point, storage.thin_logp = state.chains()
+        storage.update_draws, storage.update_CR_weight = state.CR_weight()
+        storage.labels = np.array(state.labels, dtype=LABEL_DTYPE)
+        good_chains = state._good_chains
+        storage.good_chains = None if isinstance(good_chains, slice) else good_chains
+
+def read_uncertainty_state(loaded: UncertaintyStateStorage, skip=0, report=0, derived_vars=0):
+
+    # Guess dimensions
+    Ngen = loaded.gen_draws.shape[0]
+    thinning = 1
+    Nthin, Npop, Nvar = loaded.thin_point.shape
+    Nupdate, Ncr = loaded.update_CR_weight.shape
+    Nthin -= skip
+    good_chains = loaded.good_chains
+
+    # Create empty draw and fill it with loaded data
+    state = MCMCDraw(0, 0, 0, 0, 0, 0, thinning)
+    state.draws = Ngen * Npop
+    state.labels = [label.decode() for label in loaded.labels]
+    state.generation = Ngen
+    state._gen_index = 0
+    state._gen_draws = loaded.gen_draws
+    state._gen_acceptance_rate = loaded.AR
+    state._gen_logp = loaded.gen_logp
+    state.thinning = thinning
+    state._thin_count = Ngen//thinning
+    state._thin_index = 0
+    state._thin_draws = loaded.thin_draws
+    state._thin_logp = loaded.thin_logp
+    state._thin_point = loaded.thin_point
+    state._gen_current = state._thin_point[-1].copy()
+    state._update_count = Nupdate
+    state._update_index = 0
+    state._update_draws = loaded.update_draws
+    state._update_CR_weight = loaded.update_CR_weight
+    state._outliers = []
+
+    bestidx = np.unravel_index(np.argmax(loaded.thin_logp), loaded.thin_logp.shape)
+    state._best_logp = loaded.thin_logp[bestidx]
+    state._best_x = loaded.thin_point[bestidx]
+    state._best_gen = 0
+
+    state._good_chains = slice(None, None) if (good_chains is None or good_chains is UNDEFINED) else good_chains.astype(int)
+
+    return state
+
+class ActiveFit(TypedDict):
+    fitter_id: str
+    options: Dict[str, Any]
+    num_steps: int
+
+class FileInfo(TypedDict):
+    filename: str
+    pathlist: List[str]
+    
+Timestamp = NewType('Timestamp', str)
+
+@dataclass
+class SharedState:
+    updated_convergence: Union[UNDEFINED_TYPE, Timestamp] = UNDEFINED
+    updated_uncertainty: Union[UNDEFINED_TYPE, Timestamp] = UNDEFINED
+    updated_parameters: Union[UNDEFINED_TYPE, Timestamp] = UNDEFINED
+    updated_model: Union[UNDEFINED_TYPE, Timestamp] = UNDEFINED
+    updated_history: Union[UNDEFINED_TYPE, Timestamp] = UNDEFINED
+    selected_fitter: Union[UNDEFINED_TYPE, str] = UNDEFINED
+    fitter_settings: Union[UNDEFINED_TYPE, Dict[str, Dict]] = UNDEFINED
+    active_fit: Union[UNDEFINED_TYPE, ActiveFit] = UNDEFINED
+    model_file: Union[UNDEFINED_TYPE, FileInfo] = UNDEFINED
+    model_loaded: Union[UNDEFINED_TYPE, bool] = UNDEFINED
+    session_output_file: Union[UNDEFINED_TYPE, FileInfo] = UNDEFINED
+    autosave_session: bool = False
+    autosave_session_interval: int = 300
+    autosave_history: bool = True
+    autosave_history_length: int = 10
+
+    _not_reloaded = ["active_fit", "autosave_session", "session_output_file"]
+    
+    async def notify(self, name, value):
+        pass
+
+    def __setattr__(self, name, value):
+        super().__setattr__(name, value)
+        loop = asyncio.get_event_loop()
+        if loop.is_running():
+            loop.create_task(self.notify(name, value))
+
+    async def set(self, name, value):
+        super().__setattr__(name, value)
+        await self.notify(name, value)
+
+    async def get(self, name):
+        return getattr(self, name, UNDEFINED)
+
+    def write(self, parent: 'Group'):
+        group = parent.require_group('shared')
+        for field in fields(self):
+            value = getattr(self, field.name)
+            if value is not UNDEFINED:
+                write_json(group, field.name, value)
+
+    def read(self, parent: 'Group'):
+        group = parent.get('shared')
+        if group is None:
+            return
+        for field in fields(self):
+            if not field.name in self._not_reloaded:
+                setattr(self, field.name, read_json(group, field.name))
\ No newline at end of file
diff --git a/bumps/webview/server/varplot.py b/bumps/webview/server/varplot.py
new file mode 100644
index 00000000..49b6c061
--- /dev/null
+++ b/bumps/webview/server/varplot.py
@@ -0,0 +1,269 @@
+"""
+Build layout for histogram plots (plotly)
+"""
+
+__all__ = ['var_plot_size', 'plot_vars', 'plot_var']
+
+from math import ceil, sqrt
+import numpy as np
+
+import typing
+if typing.TYPE_CHECKING:
+    from bumps.dream.state import Draw
+    from bumps.dream.stats import VarStats
+    import plotly.graph_objects as go
+
+# TODO: improve type hinting on VarStats (dataclass with optional fields?)
+
+# # Set space between plots in horiz and vert.
+# H_SPACE = 0.2
+# V_SPACE = 0.2
+
+# # Set top, bottom, left margins.
+# T_MARGIN = 0.2
+# B_MARGIN = 0.2
+# L_MARGIN = 0.2
+# R_MARGIN = 0.4
+
+# # Set desired plot sizes.
+# TILE_W = 3.0
+# TILE_H = 2.0
+# CBAR_WIDTH = 0.75
+
+CBAR_COLORS = 64
+LINE_COLOR = "red"
+ANNOTATION_COLOR = "blue"
+HISTOGRAM_COLORMAP = "Cividis"
+
+
+def tile_axes_square(n):
+    """
+    Determine number of columns by finding the
+    next greatest square, then determine number
+    of rows needed.
+    """
+    cols = int(ceil(sqrt(n)))
+    rows = int(ceil(n/float(cols)))
+    return cols, rows
+
+
+def plot_vars(draw: 'Draw', all_vstats, cbar_colors=CBAR_COLORS, **kw):
+    from plotly.subplots import make_subplots
+    n = len(all_vstats)
+    ncol, nrow = tile_axes_square(n)
+
+    snllf = np.sort(-draw.logp)
+    vmin, vmax = snllf[0], snllf[int(0.98*(len(snllf)-1))]  # robust range
+    cbar_edges = np.linspace(vmin, vmax, cbar_colors)
+    # titles = [vstats.label for vstats in all_vstats]
+
+    # fig = make_subplots(rows=nrow, cols=ncol, subplot_titles=titles)
+    fig = make_subplots(rows=nrow, cols=ncol)
+    fig.update_yaxes(secondary_y = True)
+    fig.update_xaxes(dict(exponentformat='e'), overwrite=True)
+    fig.update_xaxes(showticklabels=True, showline=True, mirror=True, linewidth=1, linecolor='black', showgrid=False)
+    fig.update_yaxes(showticklabels=False, showline=True, mirror=True, linewidth=1, linecolor='black', showgrid=False)
+    fig.update_layout(coloraxis = {'colorscale': HISTOGRAM_COLORMAP, "cmin": vmin, "cmax": vmax})
+    fig.update_layout(height=nrow*300, width=ncol*400)
+    fig.update_layout(plot_bgcolor='rgba(0, 0, 0, 0)')
+    fig.update_layout(hoverlabel=dict(bgcolor='white', font_size=16))
+    fig.update_layout(bargap=0)
+
+    fig = fig.to_dict()
+    fig['layout'].update(shapes=[], annotations=[])
+
+    for k, vstats in enumerate(all_vstats):
+        row = (k // ncol) + 1
+        col = (k % ncol) + 1
+        subplot = k + 1
+        plot_var(fig, draw, vstats, k, cbar_edges=cbar_edges, subplot=subplot, **kw)
+
+    return fig
+
+
+def plot_var(fig: 'go.Figure', draw: 'Draw', vstats: 'VarStats', var: int, cbar_edges: np.ndarray, nbins=30, subplot=None):
+    values = draw.points[:, var].flatten()
+    bin_range = vstats.p95_range
+    sort_index = draw.get_argsort_indices(var)
+    #bin_range = np.min(values), np.max(values)
+    # showscale = (subplot == 1)
+    showscale = True
+    traces = _make_logp_histogram(values, draw.logp, nbins, bin_range,
+                         draw.weights, sort_index, cbar_edges=cbar_edges, showscale=showscale, subplot=subplot)
+
+    fig['data'].extend(traces)
+    _decorate_histogram(vstats, fig, subplot=subplot)
+
+
+def _decorate_histogram(vstats: 'VarStats', fig: dict, subplot: int = 1):
+    import plotly.graph_objects as go
+
+    xaxis, yaxis = subplot_axis_names(subplot)
+    l95, h95 = vstats.p95_range
+    l68, h68 = vstats.p68_range
+    # Shade things inside 1-sigma
+
+    shading_rect = dict(type='rect', x0=l68, x1=h68, y0=0, y1=1, fillcolor='lightblue', opacity=0.5, layer='below', line={"width": 0}, xref=xaxis, yref=f"{yaxis} domain")
+    fig['layout']['shapes'].append(shading_rect)
+
+    def marker(symbol, position, info_template):
+        if position < l95:
+            symbol, position, ha = '<'+symbol, l95, 'left'
+        elif position > h95:
+            symbol, position, ha = '>'+symbol, h95, 'right'
+        else:
+            symbol, position, ha = symbol, position, 'center'
+        new_marker = dict(
+            xref=xaxis,
+            yref=f"{yaxis} domain",
+            x=position,
+            y=0.95,
+            text=symbol,
+            showarrow=False,
+            font=dict(color=ANNOTATION_COLOR),
+            hovertext=info_template.format(position=position, label=vstats.label),
+
+        )
+        fig['layout']['annotations'].append(new_marker)
+        #pylab.axvline(v)
+
+    marker('|', vstats.median, "{label}<br>median: {position}")
+    marker('E', vstats.mean, "{label}<br>mean: {position}")
+    marker('*', vstats.best, "{label}<br>best: {position}")
+    
+    label_annotation = dict(
+        xref=f"{xaxis} domain",
+        yref=f"{yaxis} domain",
+        x = 0.0,
+        y = 1.1,
+        text = vstats.label,
+        showarrow=False,
+        name='label',
+    )
+    fig['layout']['annotations'].append(label_annotation)
+
+def _make_logp_histogram(values, logp, nbins, ci, weights, idx, cbar_edges, showscale=False, subplot=None):
+    from numpy import (ones_like, searchsorted, linspace, cumsum, diff,
+                       unique, argsort, array, hstack, exp)
+
+    if weights is None:
+        weights = ones_like(logp)
+    # use sorting index calculated during stats collection:
+    values, weights, logp = values[idx], weights[idx], logp[idx]
+    #open('/tmp/out','a').write("ci=%s, range=%s\n"
+    #                           % (ci,(min(values),max(values))))
+    edges = linspace(ci[0], ci[1], nbins+1)
+    bin_index = range(nbins)
+    idx = searchsorted(values[1:-1], edges)
+
+
+    bins = []  # marginalized maximum likelihood
+
+    # H, xedges, yedges = np.histogram2d(values, -logp, bins=(edges, cbar_edges), weights=weights)
+    # ybins = len(yedges) - 1
+    # x = (xedges[:-1] + xedges[1:]) / 2.0
+    # cbar_values = ((cbar_edges[:-1] + cbar_edges[1:]) / 2.0)[::-1]
+    # x = x.repeat(ybins) # match length of y array
+    # y = H.flatten(order="C")
+    # color = np.tile(cbar_values, nbins)
+
+    # # filter out empty y values (speeds up drawing, omitting empty rectangles):
+    # y_nonzero = (y > 0)
+    # # print(f"y_zeros: {y_nonzero}, {len([z for z in y_nonzero if z])} out of {len(y)}")
+    # x = x[y_nonzero]
+    # color = color[y_nonzero]
+    # y = y[y_nonzero]
+    # traces.append(go.Bar(x=x, y=y, showlegend=False, marker=dict(color=color, coloraxis='coloraxis', line=dict(width=0))))
+    # # traces.append(go.Bar(x=x, y=y, showlegend=False, hoverinfo='skip', marker=dict(color=color, coloraxis='coloraxis', line=dict(width=0))))
+
+    xscatt = []
+    yscatt = []
+    zscatt = []
+
+    for i, s, e, xlo, xhi \
+            in zip(bin_index, idx[:-1], idx[1:], edges[:-1], edges[1:]):
+        if s == e:
+            continue
+
+        pv = -logp[s:e]
+        # weights for samples within interval
+        pw = weights[s:e]
+        # vertical colorbar top edges is the cumulative sum of the weights
+        bin_height = pw.sum()
+
+         # parameter interval endpoints
+        x = array([xlo, xhi], 'd')
+        xav = (xlo + xhi)/2
+        # -logp values within interval, with sort index from low to high
+        pv = -logp[s:e]
+        pidx = argsort(pv)
+        pv = pv[pidx]
+        # weights for samples within interval, sorted
+        pw = weights[s:e][pidx]
+        # vertical colorbar top edges is the cumulative sum of the weights
+        y_top = cumsum(pw)
+        bin_height = y_top[-1]
+
+        change_point = searchsorted(pv[1:-1], cbar_edges)
+        tops = unique(hstack((change_point, len(pv)-1)))
+        # For plotly Bar plot, y values should be [size along y] (stacked)
+        yy = np.diff(hstack((0, y_top[tops])))
+        zz = pv[tops][:, None].flatten()
+        xx = np.ones(tops.shape, dtype=float) * xav
+        
+        xscatt.extend(xx.tolist())
+        yscatt.extend(yy.tolist())
+        zscatt.extend(zz.tolist())
+        
+        # centerpoint, histogram height, maximum likelihood for each bin
+        bin_max_likelihood = exp(cbar_edges[0] - pv[0])
+        bins.append(((xlo+xhi)/2, bin_height, bin_max_likelihood))
+
+    xaxis, yaxis = subplot_axis_names(subplot)
+    bar_trace = dict(
+        type='bar',
+        x=xscatt, y=yscatt,
+        showlegend=False, hoverinfo='skip',
+        marker=dict(color=zscatt, coloraxis='coloraxis',
+        line=dict(width=0)),
+        xaxis=xaxis,
+        yaxis=yaxis
+    )
+    # Check for broken distribution
+    if not bins:
+        return
+    centers, height, maxlikelihood = array(bins).T
+    # Normalize maximum likelihood plot so it contains the same area as the
+    # histogram, unless it is really spikey, in which case make sure it has
+    # about the same height as the histogram.
+    maxlikelihood *= np.sum(height)/np.sum(maxlikelihood)
+    hist_peak = np.max(height)
+    ml_peak = np.max(maxlikelihood)
+    if ml_peak > hist_peak*1.3:
+        maxlikelihood *= hist_peak*1.3/ml_peak
+    
+    scatter_trace = dict(
+        type='scatter',
+        x=centers, y=maxlikelihood,
+        hoverinfo='skip',
+        showlegend=False,
+        line={"color": LINE_COLOR},
+        xaxis=xaxis,
+        yaxis=yaxis,
+    )
+    # return dict(bar_trace=bar_trace, scatter_trace=scatter_trace)
+    return [bar_trace, scatter_trace]
+
+    ## plot marginal gaussian approximation along with histogram
+    #def G(x, mean, std):
+    #    return np.exp(-((x-mean)/std)**2/2)/np.sqrt(2*np.pi*std**2)
+    ## TODO: use weighted average for standard deviation
+    #mean, std = np.average(values, weights=weights), np.std(values, ddof=1)
+    #pdf = G(centers, mean, std)
+    #pylab.plot(centers, pdf*np.sum(height)/np.sum(pdf), '-b')
+
+def subplot_axis_names(subplot: int):
+    if subplot == 1:
+        return "x", "y"
+    else:
+        return f"x{subplot}", f"y{subplot}"
\ No newline at end of file
diff --git a/bumps/webview/server/webserver.py b/bumps/webview/server/webserver.py
new file mode 100644
index 00000000..52c4e568
--- /dev/null
+++ b/bumps/webview/server/webserver.py
@@ -0,0 +1,373 @@
+# from .main import setup_bumps
+from dataclasses import dataclass
+import functools
+import os
+import signal
+import socket
+from typing import Callable, Dict, Optional
+import warnings
+from aiohttp import web, ClientSession
+import asyncio
+import socketio
+from typing import Union, List
+from pathlib import Path
+import json
+import re
+import sys
+
+import matplotlib
+matplotlib.use("agg")
+
+import mimetypes
+mimetypes.add_type("text/css", ".css")
+mimetypes.add_type("text/html", ".html")
+mimetypes.add_type("application/json", ".json")
+mimetypes.add_type("text/javascript", ".js")
+mimetypes.add_type("text/javascript", ".mjs")
+mimetypes.add_type("image/png", ".png")
+mimetypes.add_type("image/svg+xml", ".svg")
+
+from . import api
+from .fit_thread import EVT_FIT_PROGRESS
+from .state_hdf5_backed import SERIALIZERS, UNDEFINED
+from .logger import logger, list_handler, console_handler
+from . import persistent_settings
+
+TRACE_MEMORY = False
+CDN_TEMPLATE = "https://cdn.jsdelivr.net/npm/bumps-webview-client@{client_version}/dist/{client_version}"
+
+# can get by name and not just by id
+
+routes = web.RouteTableDef()
+# sio = socketio.AsyncServer(cors_allowed_origins="*", serializer='msgpack')
+sio = socketio.AsyncServer(cors_allowed_origins="*")
+app = web.Application()
+CLIENT_PATH = Path(__file__).parent.parent / 'client'
+APPLICATION_NAME = "bumps"
+
+sio.attach(app)
+
+def rest_get(fn):
+    """
+    Add a REST (GET) route for the function, which can also be used for 
+    """
+    @routes.get(f"/{fn.__name__}")
+    async def handler(request: web.Request):
+        result = await fn(**request.query)
+        return web.json_response(result)
+    
+    # pass the function to the next decorator unchanged...
+    return fn
+
+    
+@sio.event
+async def connect(sid: str, environ, data=None):
+    for topic, contents in api.state.topics.items():
+        message = contents[-1] if len(contents) > 0 else None
+        if message is not None:
+            await sio.emit(topic, message, to=sid)
+    logger.info(f"connect {sid}")
+
+@sio.event
+def disconnect(sid):
+    logger.info(f"disconnect {sid}")
+
+@sio.event
+async def set_base_path(sid: str, pathlist: List[str]):
+    path = str(Path(*pathlist))
+    persistent_settings.set_value("base_path", path, application=APPLICATION_NAME)
+
+async def disconnect_all_clients():
+    # disconnect all clients:
+    clients = list(sio.manager.rooms.get('/', {None: {}}).get(None).keys())
+    for client in clients:
+        await sio.disconnect(client)
+    while clients:
+        clients = list(sio.manager.rooms.get('/', {None: {}}).get(None).keys())
+        await asyncio.sleep(0.1)
+
+async def _shutdown():
+    await disconnect_all_clients()
+    logger.info("webserver shutdown tasks complete")
+    await asyncio.sleep(0.1)
+    raise web.GracefulExit()
+
+api._shutdown = _shutdown
+
+import argparse
+
+@dataclass
+class BumpsOptions:
+    """ provide type hints for arguments """
+    filename: Optional[str] = None
+    headless: bool = True
+    external: bool = False
+    port: int = 0
+    hub: Optional[str] = None
+    fit: Optional[str] = None
+    start: bool = False
+    read_store: Optional[str] = None
+    write_store: Optional[str] = None
+    store: Optional[str] = None
+    exit: bool = False
+    serializer: SERIALIZERS = "dill"
+    trace: bool = False
+    parallel: int = 0
+    path: Optional[str] = None
+    no_auto_history: bool = False
+
+OPTIONS_CLASS = BumpsOptions
+
+def get_commandline_options(arg_defaults: Optional[Dict]=None):
+    parser = argparse.ArgumentParser()
+    parser.add_argument('filename', nargs='?', help='problem file to load, .py or .json (serialized) fitproblem')
+    # parser.add_argument('-d', '--debug', action='store_true', help='autoload modules on change')
+    parser.add_argument('-x', '--headless', action='store_true', help='do not automatically load client in browser')
+    parser.add_argument('--external', action='store_true', help='listen on all interfaces, including external (local connections only if not set)')
+    parser.add_argument('-p', '--port', default=0, type=int, help='port on which to start the server')
+    parser.add_argument('--hub', default=None, type=str, help='api address of parent hub (only used when called as subprocess)')
+    parser.add_argument('--fit', default=None, type=str, choices=list(api.FITTERS_BY_ID.keys()), help='fitting engine to use; see manual for details')
+    parser.add_argument('--start', action='store_true', help='start fit when problem loaded')
+    parser.add_argument('--store', default=None, type=str, help='set read_store and write_store to same file')
+    parser.add_argument('--read_store', default=None, type=str, help='read initial session state from file (overrides --store)')
+    parser.add_argument('--write_store', default=None, type=str, help='output file for session state (overrides --store)')
+    parser.add_argument('--exit', action='store_true', help='end process when fit complete (fit results lost unless write_store is specified)')
+    parser.add_argument('--serializer', default=OPTIONS_CLASS.serializer, type=str, choices=["pickle", "dill", "dataclass"], help='strategy for serializing problem, will use value from store if it has already been defined')
+    parser.add_argument('--trace', action='store_true', help='enable memory tracing (prints after every uncertainty update in dream)')
+    parser.add_argument('--parallel', default=0, type=int, help='run fit using multiprocessing for parallelism; use --parallel=0 for all cpus')
+    parser.add_argument('--path', default=None, type=str, help='set initial path for save and load dialogs')
+    parser.add_argument('--no_auto_history', action='store_true', help='disable auto-appending problem state to history on load and at fit end')
+    # parser.add_argument('-c', '--config-file', type=str, help='path to JSON configuration to load')
+    namespace = OPTIONS_CLASS()
+    if arg_defaults is not None:
+        logger.debug(f'arg_defaults: {arg_defaults}')
+        for k,v in arg_defaults.items():
+            setattr(namespace, k, v)
+    args = parser.parse_args(namespace=namespace)
+    return args
+
+def wrap_with_sid(function: Callable):
+    """ 
+    throw away first parameter sid: str
+    for compatibility with socket.io
+    (none of the API functions use sid value)
+    """
+    @functools.wraps(function)
+    async def with_sid(sid: str, *args, **kwargs):
+        return await function(*args, **kwargs)
+    return with_sid
+
+def setup_sio_api():
+    api.emit = sio.emit
+    for (name, action) in api.REGISTRY.items():
+        sio.on(name, handler=wrap_with_sid(action))
+        rest_get(action)
+
+def setup_app(sock: Optional[socket.socket] = None, options: OPTIONS_CLASS = OPTIONS_CLASS()):
+    # check if the locally-build site has the correct version:
+    with open(CLIENT_PATH / 'package.json', 'r') as package_json:
+        client_version = json.load(package_json)['version'].strip()
+
+    static_assets_path = CLIENT_PATH / 'dist' / client_version / 'assets'
+
+    if Path.exists(static_assets_path):
+        app.router.add_static('/assets', static_assets_path)
+
+    async def index(request):
+        """Serve the client-side application."""
+        local_client_path = CLIENT_PATH / 'dist' / client_version
+
+        if local_client_path.is_dir():
+            return web.FileResponse(local_client_path / 'index.html')
+        else:
+            CDN = CDN_TEMPLATE.format(client_version=client_version)
+            with open(CLIENT_PATH / 'index_template.txt', 'r') as index_template:
+                index_html = index_template.read().format(cdn=CDN)
+            return web.Response(body=index_html, content_type="text/html")
+        
+    app.router.add_get('/', index)
+
+    api.state.base_path = persistent_settings.get_value('base_path', str(Path().absolute()), application=APPLICATION_NAME)
+    if options.path is not None:
+        if Path(options.path).exists():
+            api.state.base_path = options.path
+        else:
+            logger.warning(f"specified path {options.path} does not exist, reverting to current directory")
+
+    if options.read_store is not None and options.store is not None:
+        warnings.warn("read_store and store are both set; read_store will be used to initialize state")
+    if options.write_store is not None and options.store is not None:
+        warnings.warn("write_store and store are both set; write_store will be used to save state")
+
+    read_store = options.read_store if options.read_store is not None else options.store
+    write_store = options.write_store if options.write_store is not None else options.store
+    if read_store is not None:
+        read_store_path = Path(read_store).absolute()
+        api.state.read_session_file(str(read_store_path))
+        if write_store is None:
+            api.state.shared.session_output_file = dict(pathlist=list(read_store_path.parent.parts), filename=read_store_path.name)
+    if write_store is not None:
+        write_store_path = Path(write_store).absolute()
+        api.state.shared.session_output_file = dict(pathlist=list(write_store_path.parent.parts), filename=write_store_path.name)
+        api.state.shared.autosave_session = True
+
+    if api.state.problem.serializer is None or api.state.problem.serializer == "":
+        api.state.problem.serializer = options.serializer
+
+    if options.no_auto_history:
+        api.state.shared.autosave_history = False
+
+    if options.trace:
+        global TRACE_MEMORY
+        TRACE_MEMORY = True
+        api.TRACE_MEMORY = True
+
+    # app.on_startup.append(lambda App: publish('', 'local_file_path', Path().absolute().parts))
+    if options.fit is not None:
+        app.on_startup.append(lambda App: api.state.shared.set('selected_fitter', options.fit))
+
+    fitter_id = options.fit
+    if fitter_id is None:
+        fitter_id = api.state.shared.selected_fitter
+    if fitter_id is None or fitter_id is UNDEFINED:
+        fitter_id = 'amoeba'
+    fitter_settings = api.FITTER_DEFAULTS[fitter_id]
+
+    api.state.parallel = options.parallel
+
+    # if args.steps is not None:
+    #     fitter_settings["steps"] = args.steps
+
+    if options.filename is not None:
+        filepath = Path(options.filename).absolute()
+        pathlist = list(filepath.parent.parts)
+        filename = filepath.name
+        logger.debug(f"fitter for filename {filename} is {fitter_id}")
+        async def load_problem(App=None):
+            await api.load_problem_file(pathlist, filename)
+        
+        app.on_startup.append(load_problem)
+
+    if options.start:
+        async def start_fit(App=None):
+            if api.state.problem is not None:
+                await api.start_fit_thread(fitter_id, fitter_settings["settings"], options.exit)
+        app.on_startup.append(start_fit)
+    else:
+        # signal that no fit is running at startup, even if a fit was
+        # interrupted and the state was saved:
+        app.on_startup.append(lambda App: api.state.shared.set('active_fit', {}))
+
+    async def notice(message: str):
+        logger.info(message)
+    app.on_cleanup.append(lambda App: notice("cleanup task"))
+    app.on_shutdown.append(lambda App: notice("shutdown task"))
+    # not sure why, but have to call shutdown twice to get it to work:
+    app.on_shutdown.append(lambda App: api.shutdown())
+    app.on_shutdown.append(lambda App: notice("shutdown complete"))
+
+    # set initial path to cwd:
+    app.add_routes(routes)
+    hostname = 'localhost' if not options.external else '0.0.0.0'
+
+    if sock is None:
+        sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
+        sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
+        sock.bind((hostname, options.port))
+    host, port = sock.getsockname()
+    api.state.hostname = host
+    api.state.port = port
+    if options.hub is not None:
+        async def register_instance(application: web.Application):
+            async with ClientSession() as client_session:
+                await client_session.post(options.hub, json={"host": hostname, "port": port})
+        app.on_startup.append(register_instance)
+    if not options.headless:
+        import webbrowser
+        async def open_browser(app: web.Application):
+            loop = asyncio.get_event_loop()
+            loop.call_later(0.5, lambda: webbrowser.open_new_tab(f"http://{hostname}:{port}/"))
+        app.on_startup.append(open_browser)
+
+    if TRACE_MEMORY:
+        import tracemalloc
+        tracemalloc.start()
+
+    return sock
+
+def main(options: Optional[OPTIONS_CLASS] = None, sock: Optional[socket.socket] = None):
+    # this entrypoint will be used to start gui, so set headless = False
+    # (other contexts e.g. jupyter notebook will directly call start_app)
+    logger.addHandler(console_handler)
+    options = get_commandline_options(arg_defaults={"headless": False}) if options is None else options
+    logger.info(dict(options=options))
+    setup_sio_api()
+    runsock = setup_app(options=options, sock=None)
+    web.run_app(app, sock=runsock)
+
+async def start_app(options: OPTIONS_CLASS = OPTIONS_CLASS(), sock: socket.socket = None, jupyter_link: bool = False):
+    # this function is called from jupyter notebook, so set headless = True
+    options.headless = True
+    # redirect logging to a list
+    logger.addHandler(list_handler)
+    setup_sio_api()
+    runsock = setup_app(options=options, sock=sock)
+    runner = web.AppRunner(app, handle_signals=False)
+    await runner.setup()
+    site = web.SockSite(runner, sock=runsock)
+    await site.start()
+    if jupyter_link:
+        return open_tab_link()
+    else:
+        url = get_server_url()
+        print(f"webserver started: {url}")
+
+def create_server_task():
+    return asyncio.create_task(start_app())
+
+
+def get_server_url():
+    from bumps.webview.server import api
+
+    port = getattr(api.state, 'port', None)
+    if port is None:
+        raise ValueError("The web server has not been started.")
+
+    # detect if running through Jupyter Hub
+    if 'JUPYTERHUB_SERVICE_PREFIX' in os.environ:
+        url = f"{os.environ['JUPYTERHUB_SERVICE_PREFIX']}/proxy/{port}/"
+    elif api.state.hostname == 'localhost': # local server
+        url = f"http://{api.state.hostname}:{port}/"
+    else: # external server, e.g. TACC
+        url = f"/proxy/{port}/"
+    return url
+
+def display_inline_jupyter(width: Union[str,int]="100%", height: Union[str, int]=600, single_panel=None) -> None:
+    """
+    Display the web server in an iframe.
+
+    This is useful for displaying the web server in a Jupyter notebook.
+
+    :param width: The width of the iframe.
+    :param height: The height of the iframe.
+    """
+    from IPython.display import display, IFrame
+
+    url = get_server_url()
+    kwargs = dict(single_panel=single_panel) if single_panel is not None else {}
+    display(IFrame(src=url, width=width, height=height, extras=['style="resize: both;"'], **kwargs))
+
+def open_tab_link(single_panel=None) -> None:
+    """
+    Open the web server in a new tab in the default web browser.
+    """
+    from IPython.display import Javascript, display, HTML
+
+    url = get_server_url()
+    if single_panel is not None:
+        url += f"?single_panel={single_panel}"
+    src = f'<h3><a href="{url}" target="_blank">Open Webview in Tab</a></h3>'
+    display(HTML(src))
+
+if __name__ == '__main__':
+    main()
diff --git a/check_fitters.py b/check_fitters.py
index 5578a710..c3fdfc22 100755
--- a/check_fitters.py
+++ b/check_fitters.py
@@ -74,11 +74,11 @@ def check_fit(fitter, store, targets):
     model_index = 0
     with open(errfiles[0]) as fid:
         for line in fid:
-            if line.startswith("[chisq="):
-                if line[7:10].lower() == 'inf':
+            if line.startswith("[overall chisq="):
+                if line[15:10].lower() == 'inf':
                     value = np.inf
                 else:
-                    value = float(line[7:].split("(")[0])
+                    value = float(line[15:].split("(")[0])
                 assert abs(value-targets[model_index]) < 1e-2, \
                     "error in %s: expected %.3f but got %.3f" \
                     % (fitter, targets[model_index], value)
diff --git a/doc/conf.py b/doc/conf.py
index aae12795..2a18d44b 100644
--- a/doc/conf.py
+++ b/doc/conf.py
@@ -62,6 +62,7 @@
 
 nitpick_ignore = [
     ('py:class', 'type'),
+    ('py:class', 'enum.Enum'),
     ('py:class', 'object'),
     ("py:class", "numpy.ndarray"),
     ("py:class", "np.ndarray"),
diff --git a/doc/guide/intro.rst b/doc/guide/intro.rst
index 7db1df23..11f8a15b 100644
--- a/doc/guide/intro.rst
+++ b/doc/guide/intro.rst
@@ -39,14 +39,14 @@ the line.
 Every fit file ends with a :func:`FitProblem <bumps.fitproblem.FitProblem>`
 definition::
 
-    problem = FitProblem(model)
+    problem = FitProblem(models)
 
 In fact, this is the only requirement of the fit file.  The Bumps engine
 loads the fit file, retieves the *problem* symbol and feeds it to one of the
 :mod:`fitters <bumps.fitters>`.  Some fit files do not even use *FitProblem* to
 define *problem*, or use *Parameter* objects for the fitted parameters, so
 long as *problem* implements the
-:class:`BaseFitProblem <bumps.fitproblem.BaseFitProblem>` interface, which
+:class:`FitProblem <bumps.fitproblem.FitProblem>` interface, which
 provides *getp* to get the existing parameter vector, *setp* to set a new
 parameter vector, *bounds* to return the parameter bounds, and *nllf* to
 to compute the negative log likelihood function.  The remaining methods are
@@ -60,7 +60,7 @@ pattern to use is::
 
     import bumps.names as bmp
     ...
-    problem = bmp.FitProblem(model)
+    problem = bmp.FitProblem(models)
 
 This documents to the reader unfamiliar with your code (such as you, dear
 reader, when looking at your model files two years from now) exactly where
diff --git a/doc/guide/parameter.rst b/doc/guide/parameter.rst
index 2142a055..262b2411 100644
--- a/doc/guide/parameter.rst
+++ b/doc/guide/parameter.rst
@@ -76,8 +76,6 @@ are helpful:
 - :meth:`soft_range <Parameter.soft_range>` is a combination of *range*
   and *dev* in that the parameter has equal probability within [*low*,*high*]
   but Gaussian probability of width *std* as it strays outside of the range.
-- :meth:`pdf <Parameter.pdf>` is like *dev* but works with any continuous
-  `scipy.stats distribution <http://docs.scipy.org/doc/scipy/reference/stats.html>`_.
 
 All these methods set the *bounds* attribute on the parameter in one way
 or another.  See :mod:`bumps.bounds` for details.  Technically, setting
diff --git a/extra/build_conda_packed.ps1 b/extra/build_conda_packed.ps1
new file mode 100644
index 00000000..24d71438
--- /dev/null
+++ b/extra/build_conda_packed.ps1
@@ -0,0 +1,36 @@
+$ENV_NAME="isolated-base"
+$PYTHON_VERSION="3.10"
+$DIRNAME="bumps"
+
+conda activate "base"
+
+conda install -y conda-pack
+conda create -n "$ENV_NAME" -q --force -y "python=$PYTHON_VERSION"
+conda-pack -n "$ENV_NAME" -f -o "$ENV_NAME.tar.gz"
+
+# unpack the new environment, that contains only python + pip
+$tmpdir="dist"
+$destdir="$tmpdir\$DIRNAME"
+$envdir = "$destdir\env"
+Remove-Item -r -Force "$destdir"
+mkdir "$envdir"
+tar -xzf "$ENV_NAME.tar.gz" -C "$envdir"
+
+# activate the unpacked environment and install pip packages
+conda deactivate
+$WORKING_DIRECTORY="$pwd"
+echo "WORKING_DIRECTORY=$WORKING_DIRECTORY"
+dir .
+dir ..
+# add our batch script:
+Copy-Item .\extra\bumps_webview.bat "$destdir"
+
+& "$envdir\python.exe" -m pip install --no-input --no-compile numba
+& "$envdir\python.exe" -m pip install --no-input --no-compile git+https://github.com/bumps/bumps@webview
+& "$envdir\python.exe" -m pip install --no-compile -r https://raw.githubusercontent.com/bumps/bumps/webview/webview-requirements
+
+$version=$(& "$envdir\python.exe" -c "import bumps; print(bumps.__version__)")
+# zip it back up
+cd $tmpdir
+Rename-Item "$DIRNAME" "$DIRNAME-$version"
+tar -czf "bumps-webview-Windows-x86_64.tar.gz" "$DIRNAME-$version"
diff --git a/extra/bumps_webview.bat b/extra/bumps_webview.bat
new file mode 100644
index 00000000..7a623cf2
--- /dev/null
+++ b/extra/bumps_webview.bat
@@ -0,0 +1,3 @@
+@echo off
+rem start "Bumps Web GUI" 
+start "Bumps Webview" "%~dp0\env\python.exe" "-m" "bumps.webview.server"
\ No newline at end of file
diff --git a/extra/sasview/sasbumps.py b/extra/sasview/sasbumps.py
index 79d44f55..16f30d71 100644
--- a/extra/sasview/sasbumps.py
+++ b/extra/sasview/sasbumps.py
@@ -7,7 +7,7 @@
 import sys
 import numpy as np
 from bumps.names import Parameter, FitProblem, FreeVariables, pmath, preview, fit
-from bumps.parameter import BaseParameter
+from bumps.parameter import Parameter
 from bumps.util import push_seed as seed
 import sans
 import sans.models
@@ -52,7 +52,7 @@ def load_model(model, name=None, **kw):
                            %(k, model, formatted_pars))
         if '.' in k and not k.endswith('.width'):
             M.setParam(k, v)
-        elif isinstance(v, BaseParameter):
+        elif isinstance(v, Parameter):
             M._bumps_pars[k] = v
         elif isinstance(v, (tuple, list)):
             low, high = v
diff --git a/pyproject.toml b/pyproject.toml
new file mode 100644
index 00000000..c09cb11d
--- /dev/null
+++ b/pyproject.toml
@@ -0,0 +1,68 @@
+[project]
+name = "bumps"
+dynamic = ["version"]
+description = "Data fitting with bayesian uncertainty analysis"
+readme = "README.rst"
+authors = [
+    { name = "Paul Kienzle", email = "paul.kienzle@nist.gov" },
+]
+license = { file = "LICENSE.txt" }
+dependencies = [
+    'typing_extensions>=3.7.4', 'dataclasses; python_version < "3.7.0"'
+]
+classifiers=[
+    'Development Status :: 4 - Beta',
+    'Environment :: Console',
+    'Intended Audience :: Science/Research',
+    'License :: Public Domain',
+    'Operating System :: OS Independent',
+    'Programming Language :: Python',
+    'Programming Language :: Python :: 3',
+    'Topic :: Scientific/Engineering',
+    'Topic :: Scientific/Engineering :: Chemistry',
+    'Topic :: Scientific/Engineering :: Physics',
+]
+requires-python = ">=3.8"
+
+[project.optional-dependencies]
+webview = [
+    'scipy',
+    'aiohttp',
+    'blinker',
+    'python-socketio',
+    'plotly',
+    'mpld3',
+    'matplotlib',
+    'nodejs',
+    'h5py',
+    'dill',
+    'graphlib_backport; python_version < "3.9"'
+]
+
+[project.urls]
+documentation = "https://bumps.readthedocs.io"
+homepage = "https://bumps.readthedocs.io"
+repository = "https://github.com/bumps/bumps"
+
+[build-system]
+requires = ["setuptools"]
+build-backend = "setuptools.build_meta"
+
+[tool.setuptools.dynamic]
+version = { attr = "bumps.__version__" }
+
+[tool.setuptools]
+packages = ['bumps', 'bumps.dream', 'bumps.webview.server']
+
+[project.scripts]
+bumps = "bumps.main:main"
+bumps-webview = "bumps.webview.server.webserver:main"
+
+[tool.pytest.ini_options]
+addopts = "--doctest-modules --doctest-glob=*.rst --cov=bumps"
+doctest_optionflags = ["ELLIPSIS", "NORMALIZE_WHITESPACE"]
+testpaths = ["bumps", "doc/getting_started", "doc/guide"]
+norecursedirs = ["gui", "webview/client"]
+python_files = ["*.py"]
+python_classes = ["NoClassTestsWillMatch"]
+python_functions = ["test", "*_test", "test_*"]
diff --git a/setup.py b/setup.py
deleted file mode 100755
index 4a81ea3a..00000000
--- a/setup.py
+++ /dev/null
@@ -1,59 +0,0 @@
-#!/usr/bin/env python
-import sys
-import os
-
-if len(sys.argv) == 1:
-    sys.argv.append('install')
-
-# Use our own nose-based test harness
-if sys.argv[1] == 'test':
-    from subprocess import call
-    sys.exit(call([sys.executable, 'test.py'] + sys.argv[2:]))
-
-#sys.dont_write_bytecode = True
-
-from setuptools import setup, find_packages
-
-sys.path.insert(0, os.path.dirname(__file__))
-import bumps
-from bumps.gui.resources import resources as gui_resources
-
-packages = find_packages(exclude=['amqp_map', 'fit_functions', 'jobqueue'])
-
-
-# TODO: write a proper dependency checker for packages which cannot be
-# installed by easy_install
-#dependency_check('numpy>=1.0', 'scipy>=0.6', 'matplotlib>=1.0', 'wx>=2.8.9')
-# print bumps.package_data()
-
-scripts = ['bin/bumps.bat'] if os.name == 'nt' else ['bin/bumps']
-#sys.dont_write_bytecode = False
-dist = setup(
-    name='bumps',
-    version=bumps.__version__,
-    author='Paul Kienzle',
-    author_email='paul.kienzle@nist.gov',
-    url='https://github.com/bumps/bumps',
-    description='Data fitting with bayesian uncertainty analysis',
-    long_description=open('README.rst').read(),
-    classifiers=[
-        'Development Status :: 4 - Beta',
-        'Environment :: Console',
-        'Intended Audience :: Science/Research',
-        'License :: Public Domain',
-        'Operating System :: OS Independent',
-        'Programming Language :: Python',
-        'Programming Language :: Python :: 3',
-        'Topic :: Scientific/Engineering',
-        'Topic :: Scientific/Engineering :: Chemistry',
-        'Topic :: Scientific/Engineering :: Physics',
-    ],
-    packages=packages,
-    package_data=gui_resources.package_data(),
-    scripts=scripts,
-    install_requires=['six'],
-    #install_requires=['six', 'numpy>=1.0', 'scipy>=0.7.0', 'matplotlib>=1.0'],
-    #install_requires = ['httplib2'],
-)
-
-# End of file
diff --git a/webview-requirements b/webview-requirements
new file mode 100644
index 00000000..efa58a32
--- /dev/null
+++ b/webview-requirements
@@ -0,0 +1,11 @@
+scipy
+aiohttp
+blinker
+python-socketio
+plotly
+mpld3
+matplotlib
+nodejs
+h5py
+dill
+graphlib_backport; python_version < '3.9'


Test patch:
diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml
index 04dba2d7..71d1159f 100644
--- a/.github/workflows/test.yml
+++ b/.github/workflows/test.yml
@@ -37,6 +37,7 @@ jobs:
         python -m pip install --upgrade pip
         python -m pip install wheel
         python -m pip install numpy scipy matplotlib scikit-learn pytest pytest-cov
+        python -m pip install -r webview-requirements
 
     - name: Build package [Linux only]
       if: ${{ runner.os == 'Linux' }}
diff --git a/pytest.ini b/pytest.ini
deleted file mode 100644
index be4e4cc7..00000000
--- a/pytest.ini
+++ /dev/null
@@ -1,8 +0,0 @@
-[pytest]
-addopts = --doctest-modules --doctest-glob=*.rst --cov=bumps
-doctest_optionflags = ELLIPSIS NORMALIZE_WHITESPACE
-testpaths = bumps doc/getting_started doc/guide
-norecursedirs = numdifftools gui
-python_files = *.py
-python_classes = NoClassTestsWillMatch
-python_functions = test *_test test_*
\ No newline at end of file