Improve app hosting management app, restructure UI and refactor REST services and data model to use an SQL database.

git-svn-id: https://svn.apache.org/repos/asf/tuscany/sca-cpp/trunk@1428193 13f79535-47bb-0310-9956-ffa450edef68
diff --git a/etc/ios-inspector b/etc/ios-inspector
new file mode 100755
index 0000000..5227908
--- /dev/null
+++ b/etc/ios-inspector
@@ -0,0 +1,35 @@
+#!/bin/bash
+
+#  Licensed to the Apache Software Foundation (ASF) under one
+#  or more contributor license agreements.  See the NOTICE file
+#  distributed with this work for additional information
+#  regarding copyright ownership.  The ASF licenses this file
+#  to you under the Apache License, Version 2.0 (the
+#  "License"); you may not use this file except in compliance
+#  with the License.  You may obtain a copy of the License at
+#  
+#    http://www.apache.org/licenses/LICENSE-2.0
+#    
+#  Unless required by applicable law or agreed to in writing,
+#  software distributed under the License is distributed on an
+#  "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+#  KIND, either express or implied.  See the License for the
+#  specific language governing permissions and limitations
+#  under the License.
+
+# Open iOS mobile Safari remote inspector
+mobsafari=$(ps x | grep "MobileSafari" | grep -v grep | awk '{ print $1 }')
+if [ "$mobsafari" == "" ]; then
+  echo "Mobile Safari.app not running."
+  exit 1
+else
+
+    # Enable remote inspector
+    cat <<EOF | gdb
+attach $mobsafari
+p (void *)[WebView _enableRemoteInspector]
+detach
+EOF
+
+fi
+
diff --git a/hosting/server/Makefile.am b/hosting/server/Makefile.am
index d6e15f8..226c69b 100644
--- a/hosting/server/Makefile.am
+++ b/hosting/server/Makefile.am
@@ -18,31 +18,42 @@
 if WANT_PYTHON
 
 moddir = $(prefix)/hosting/server
-dist_mod_SCRIPTS = start stop ssl-start mkapplinks config-backup data-backup put-auth get-auth delete-auth
+dist_mod_SCRIPTS = start stop ssl-start mkapplinks config-backup data-backup put-auth get-auth delete-auth create-tables drop-tables load-tables clean-tables load-authn pgsql imapd-start imapd-stop
 
-not_minified = htdocs/public/iframe.html htdocs/create/index.html htdocs/page/index.html htdocs/login/index.html htdocs/public/notfound/index.html htdocs/public/oops/index.html htdocs/proxy/public/oops/index.html htdocs/graph/index.html htdocs/public/notauth/index.html htdocs/account/index.html htdocs/home/index.html htdocs/index.html htdocs/public/notyet/index.html htdocs/clone/index.html htdocs/delete/index.html htdocs/stats/index.html htdocs/app/index.html htdocs/store/index.html htdocs/config.js htdocs/public/config.js htdocs/cache-template.cmf htdocs/app/cache-template.cmf htdocs/cache-template.cmf htdocs/app/cache-template.cmf 
+not_minified = htdocs/create/index.html htdocs/page/index.html htdocs/login/index.html htdocs/public/cache/index.html htdocs/public/notfound/index.html htdocs/public/oops/index.html htdocs/proxy/public/oops/index.html htdocs/proxy/public/cache/index.html htdocs/graph/index.html htdocs/public/notauth/index.html htdocs/account/index.html htdocs/home/index.html htdocs/index.html htdocs/cache/index.html htdocs/public/notyet/index.html htdocs/clone/index.html htdocs/delete/index.html htdocs/rate/index.html htdocs/search/index.html htdocs/info/index.html htdocs/app/index.html htdocs/store/index.html htdocs/config.js htdocs/public/config.js htdocs/cache/cache-template.cmf htdocs/app/cache/cache-template.cmf htdocs/public/cache/cache-template.cmf htdocs/proxy/public/cache/cache-template.cmf
 
-minified = htdocs/public/iframe-min.html htdocs/create/index-min.html htdocs/page/index-min.html htdocs/login/index-min.html htdocs/public/notfound/index-min.html htdocs/public/oops/index-min.html htdocs/proxy/public/oops/index-min.html htdocs/graph/index-min.html htdocs/public/notauth/index-min.html htdocs/account/index-min.html htdocs/home/index-min.html htdocs/index-min.html htdocs/public/notyet/index-min.html htdocs/clone/index-min.html htdocs/delete/index-min.html htdocs/stats/index-min.html htdocs/app/index-min.html htdocs/store/index-min.html htdocs/config-min.js htdocs/public/config-min.js
+minified = htdocs/create/index-min.html htdocs/page/index-min.html htdocs/login/index-min.html htdocs/public/cache/index-min.html htdocs/public/notfound/index-min.html htdocs/public/oops/index-min.html htdocs/proxy/public/oops/index-min.html htdocs/proxy/public/cache/index-min.html htdocs/graph/index-min.html htdocs/public/notauth/index-min.html htdocs/account/index-min.html htdocs/home/index-min.html htdocs/index-min.html htdocs/cache/index-min.html htdocs/public/notyet/index-min.html htdocs/clone/index-min.html htdocs/delete/index-min.html htdocs/rate/index-min.html htdocs/search/index-min.html htdocs/info/index-min.html htdocs/app/index-min.html htdocs/store/index-min.html htdocs/config-min.js htdocs/public/config-min.js
 
 js_minified = ../../modules/js/htdocs/all-min.js ../../modules/js/htdocs/ui-min.css
 
-resources = server.composite *.py data/palettes/*/palette.composite data/accounts/*/*.account data/apps/*/app.composite data/apps/*/app.stats data/apps/*/htdocs/app.html data/dashboards/*/user.apps data/store/*/store.apps htdocs/cache-manifest.cmf htdocs/app/cache-manifest.cmf htdocs/*.ico htdocs/home/*.png htdocs/home/*.b64 htdocs/*.txt htdocs/public/*.png htdocs/public/*.b64 ${minified}
+b64images = htdocs/public/app.b64 htdocs/public/img.b64 htdocs/public/rate.b64 htdocs/public/ratings.b64 htdocs/public/search.b64 htdocs/public/user.b64
+
+resources = server.composite *.py data/palettes/*/palette.composite data/accounts/*/*.account data/apps/*/app.composite data/apps/*/app.info data/apps/*/htdocs/app.html data/dashboards/*/user.apps data/store/*/store.apps htdocs/cache/cache-manifest.cmf htdocs/app/cache/cache-manifest.cmf htdocs/public/cache/cache-manifest.cmf htdocs/proxy/public/cache/cache-manifest.cmf htdocs/*.ico htdocs/*.txt htdocs/public/*.png ${b64images} ${minified}
 
 nobase_dist_mod_DATA = ${resources}
 EXTRA_DIST = ${resources} ${not_minified}
 
-SUFFIXES = -min.html -min.js
+SUFFIXES = -min.html -min.js b64
 .html-min.html:
 	../../modules/http/minify-html $< $@
 
 .js-min.js:
 	../../modules/http/minify-js $< $@
 
-htdocs/cache-manifest.cmf: htdocs/cache-template.cmf ${minified} ${js_minified}
-	../../modules/http/cache-manifest htdocs $^
+.png.b64:
+	../../modules/http/base64-encode $< $@
 
-htdocs/app/cache-manifest.cmf: htdocs/app/cache-template.cmf ${minified} ${js_minified}
-	../../modules/http/cache-manifest htdocs/app $^
+htdocs/cache/cache-manifest.cmf: htdocs/cache/cache-template.cmf ${minified} ${js_minified} ${b64images}
+	../../modules/http/cache-manifest htdocs/cache $^
+
+htdocs/app/cache/cache-manifest.cmf: htdocs/app/cache/cache-template.cmf ${minified} ${js_minified} ${b64images}
+	../../modules/http/cache-manifest htdocs/app/cache $^
+
+htdocs/public/cache/cache-manifest.cmf: htdocs/public/cache/cache-template.cmf ${minified} ${js_minified} ${b64images}
+	../../modules/http/cache-manifest htdocs/public/cache $^
+
+htdocs/proxy/public/cache/cache-manifest.cmf: htdocs/proxy/public/cache/cache-template.cmf ${minified} ${js_minified} ${b64images}
+	../../modules/http/cache-manifest htdocs/proxy/public/cache $^
 
 nuvem:
 	ln -s "../../../nuvem/nuvem-parallel/nuvem" "nuvem"
@@ -54,13 +65,16 @@
 	cd $(moddir); rm -f nuvem; ln -s "../../../nuvem/nuvem-parallel/nuvem" "nuvem"
 	cd $(moddir); rm -f lib; ln -s "../../components" "lib"
 
-CLEANFILES = ${minified} nuvem lib htdocs/cache-manifest.cmf htdocs/app/cache-manifest.cmf
+CLEANFILES = ${minified} nuvem lib htdocs/cache/cache-manifest.cmf htdocs/app/cache/cache-manifest.cmf htdocs/public/cache/cache-manifest.cmf htdocs/proxy/public/cache/cache-manifest.cmf
 
 client_test_SOURCES = client-test.cpp
-client_test_LDFLAGS = -lxml2 -lcurl -lmozjs
+client_test_LDFLAGS = -lxml2 -lcurl -ljansson
+
+patch_test_SOURCES = patch-test.cpp
+patch_test_LDFLAGS = -lxml2 -lcurl -ljansson
 
 dist_noinst_SCRIPTS = logic-test server-test test.py
-noinst_PROGRAMS = client-test
-TESTS = logic-test
+noinst_PROGRAMS = client-test patch-test
+TESTS = logic-test patch-test
 
 endif
diff --git a/hosting/server/accounts.py b/hosting/server/accounts.py
index 3587f5f..9965562 100644
--- a/hosting/server/accounts.py
+++ b/hosting/server/accounts.py
@@ -16,8 +16,9 @@
 #  under the License.
 
 # Accounts collection implementation
-from time import strftime
 from util import *
+from atomutil import *
+from sys import debug
 
 # Convert a particular user id to an account id
 def accountid(user):
@@ -25,12 +26,17 @@
 
 # Get the current user's account
 def get(id, user, cache):
+    debug('accounts.py::get::id', id)
     account = cache.get(accountid(user))
-    if isNil(account) or account is None:
-        return (("'entry", ("'title", user.get(())), ("'id", user.get(())), ("'updated", strftime('%b %d, %Y'))),)
+    if isNil(account):
+        return mkentry(user.get(()), user.get(()), user.get(()), now(), ())
     return account
 
 # Update the user's account
 def put(id, account, user, cache):
-    return cache.put(accountid(user), account)
+    debug('accounts.py::put::id', id)
+    debug('accounts.py::put::account', account)
+
+    accountentry = mkentry(title(account), user.get(()), user.get(()), now(), content(account))
+    return cache.put(accountid(user), accountentry)
 
diff --git a/hosting/server/apps.py b/hosting/server/apps.py
index 064701a..5b0c1b8 100644
--- a/hosting/server/apps.py
+++ b/hosting/server/apps.py
@@ -16,96 +16,103 @@
 #  under the License.
 
 # App collection implementation
-from time import strftime
 from util import *
+from atomutil import *
 from sys import debug
 
 # Convert an id to an app id
 def appid(id):
-    return ("apps", car(id), "app.stats")
+    return ("apps", car(id), "app.info")
 
 # Put an app into the apps db
-def put(id, app, user, cache, dashboard, store, composites, pages):
+def put(id, app, user, cache, dashboard, store, composites, pages, icons):
     debug('apps.py::put::id', id)
     debug('apps.py::put::app', app)
 
     # Update an app
-    eid = cadr(assoc("'id", car(app)))
+    eid = entryid(app)
     if car(id) == eid:
         # Check app author
-        eapp = cache.get(appid(id));
-        if (not (isNil(eapp) or eapp is None)) and (cadr(assoc("'author", car(eapp))) != user.get(())):
-            debug('apps.py::put', 'different author', cadr(assoc("'author", car(eapp))))
+        eapp = cache.get(appid(id))
+        if (not isNil(eapp)) and (author(eapp) != user.get(())):
+            debug('apps.py::put', 'different author', author(eapp))
             return False
 
         # Update the app in the apps db
-        appentry = (("'entry", assoc("'title", car(app)), ("'id", car(id)), ("'author", user.get(())), ("'updated", strftime('%b %d, %Y')), assoc("'content", car(app))),)
+        appentry = mkentry(title(app), car(id), user.get(()), now(), content(app))
         debug('apps.py::put::appentry', appentry)
         cache.put(appid(id), appentry)
         dashboard.put(id, appentry)
 
         # Create new page and composite if necessary
-        if isNil(eapp) or eapp is None:
-            comp = (("'entry", ("'title", car(id)), ("'id", car(id))),)
-            composites.put(id, comp);
-            page = (("'entry", ("'title", car(id)), ("'id", car(id))),)
-            pages.put(id, comp);
+        if isNil(eapp):
+            comp = mkentry(car(id), car(id), user.get(()), now(), ())
+            composites.put(id, comp)
+            page = mkentry(car(id), car(id), user.get(()), now(), ())
+            pages.put(id, page)
+            icon = mkentry(car(id), car(id), user.get(()), now(), ())
+            icons.put(id, icon)
             return True
         return True
 
     # Check app author
-    eapp = cache.get(appid(id));
-    if (not (isNil(eapp) or eapp is None)) and (cadr(assoc("'author", car(eapp))) != user.get(())):
-        debug('apps.py::put', 'different author', cadr(assoc("'author", car(eapp))))
+    eapp = cache.get(appid(id))
+    if (not isNil(eapp)) and (author(eapp) != user.get(())):
+        debug('apps.py::put', 'different author', author(eapp))
         return False
 
-    # Clone an app
-    appentry = (("'entry", assoc("'title", car(app)), ("'id", car(id)), ("'author", user.get(())), ("'updated", strftime('%b %d, %Y')), assoc("'content", car(app))),)
+    # Get app to clone
+    capp = cache.get(appid((eid,)))
+    if isNil(capp):
+        debug('apps.py::put', 'cloned app not found', (eid,))
+        return False
+
+    # Clone app
+    appentry = mkentry(title(app), car(id), user.get(()), now(), content(app))
     debug('apps.py::put::appentry', appentry)
     cache.put(appid(id), appentry)
     composites.put(id, composites.get((eid,)))
     pages.put(id, pages.get((eid,)))
+    icons.put(id, icons.get((eid,)))
     dashboard.put(id, appentry)
     return True
 
 # Get an app from the apps db
-def get(id, user, cache, dashboard, store, composites, pages):
+def get(id, user, cache, dashboard, store, composites, pages, icons):
     debug('apps.py::get::id', id)
     if isNil(id):
         return (("'feed", ("'title", "Apps"), ("'id", "apps")),)
 
     # Get the requested app
-    app = cache.get(appid(id));
-    if isNil(app) or app is None:
+    app = cache.get(appid(id))
+    if isNil(app):
         debug('apps.py::get', 'app not found', id)
-
-        # Return a default new app
-        return (("'entry", ("'title", car(id)), ("'id", car(id)), ("'author", user.get(())), ("'updated", strftime('%b %d, %Y')), ("'content", ("'stats", ("'description", '')))),)
+        return None
 
     # Return the app
     debug('apps.py::get::app', app)
     return app
 
 # Delete an app from the apps db
-def delete(id, user, cache, dashboard, store, composites, pages):
+def delete(id, user, cache, dashboard, store, composites, pages, icons):
     debug('apps.py::delete::id', id)
 
     # Get the requested app
-    app = cache.get(appid(id));
-    if isNil(app) or app is None:
+    app = cache.get(appid(id))
+    if isNil(app):
         debug('apps.py::delete', 'app not found', id)
         return False
 
     # Check app author
-    author = cadr(assoc("'author", car(app)))
-    if author != user.get(()):
-        debug('apps.py::delete', 'different author', author)
+    if author(app) != user.get(()):
+        debug('apps.py::delete', 'different author', author(app))
         return False
 
     # Delete the app, its composite and page
     dashboard.delete(id)
     composites.delete(id)
     pages.delete(id)
+    icons.delete(id)
     cache.delete(appid(id))
     return True
 
diff --git a/hosting/server/atomutil.py b/hosting/server/atomutil.py
new file mode 100644
index 0000000..8f01f81
--- /dev/null
+++ b/hosting/server/atomutil.py
@@ -0,0 +1,68 @@
+#  Licensed to the Apache Software Foundation (ASF) under one
+#  or more contributor license agreements.  See the NOTICE file
+#  distributed with this work for additional information
+#  regarding copyright ownership.  The ASF licenses this file
+#  to you under the Apache License, Version 2.0 (the
+#  "License"); you may not use this file except in compliance
+#  with the License.  You may obtain a copy of the License at
+#  
+#    http://www.apache.org/licenses/LICENSE-2.0
+#    
+#  Unless required by applicable law or agreed to in writing,
+#  software distributed under the License is distributed on an
+#  "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+#  KIND, either express or implied.  See the License for the
+#  specific language governing permissions and limitations
+#  under the License.
+
+# Simple ATOM handling functions
+from util import *
+from time import strftime, gmtime
+
+# Make an ATOM entry
+def mkentry(title, id, author, updated, content):
+    return (("'entry", ("'title", title), ("'id", id), ("'author", author), ("'updated", updated), ("'content",) if isNil(content) else ("'content", content)),)
+
+# Make an ATOM feed
+def mkfeed(title, id, author, updated, entries):
+    return (("'entry", ("'title", title), ("'id", id), ("'author", author), ("'updated", updated), ("'content",) if isNil(content) else ("'content", content)),)
+
+# Return ATOM attributes
+def title(e):
+    if isNil(e):
+        return ()
+    t = assoc("'title", car(e))
+    return None if isNil(t) else cadr(t)
+
+def entryid(e):
+    if isNil(e):
+        return ()
+    id = assoc("'id", car(e))
+    return None if isNil(id) else cadr(id)
+
+def author(e):
+    if isNil(e):
+        return ()
+    a = assoc("'author", car(e))
+    return None if isNil(a) else cadr(a)
+
+def updated(e):
+    if isNil(e):
+        return ()
+    u = assoc("'updated", car(e))
+    return None if isNil(u) else cadr(u)
+
+def content(e):
+    if isNil(e):
+        return ()
+    c = assoc("'content", car(e))
+    return () if isNil(c) or isNil(cdr(c)) else c[len(c) - 1]
+
+# Return the current time
+def now():
+    return strftime('%Y-%m-%dT%H:%M:%S+00:00', gmtime())
+
+# Return an (updated now) assoc
+def updatedNow():
+    return ("'updated", strftime('%Y-%m-%dT%H:%M:%S+00:00', gmtime()))
+
diff --git a/hosting/server/authn.py b/hosting/server/authn.py
index 4d4f34b..02c24a2 100644
--- a/hosting/server/authn.py
+++ b/hosting/server/authn.py
@@ -16,7 +16,6 @@
 #  under the License.
 
 # User authenticator implementation
-from time import strftime
 from util import *
 
 # Convert a particular user id to an authentication id
@@ -26,7 +25,7 @@
 # Get a user's authentication
 def get(id, cache):
     authn = cache.get(authnid(id))
-    if isNil(authn) or authn is None:
+    if isNil(authn):
         return None
     return authn
 
diff --git a/hosting/server/clean-tables b/hosting/server/clean-tables
new file mode 100755
index 0000000..76a5992
--- /dev/null
+++ b/hosting/server/clean-tables
@@ -0,0 +1,37 @@
+#!/bin/sh
+
+#  Licensed to the Apache Software Foundation (ASF) under one
+#  or more contributor license agreements.  See the NOTICE file
+#  distributed with this work for additional information
+#  regarding copyright ownership.  The ASF licenses this file
+#  to you under the Apache License, Version 2.0 (the
+#  "License"); you may not use this file except in compliance
+#  with the License.  You may obtain a copy of the License at
+#  
+#    http://www.apache.org/licenses/LICENSE-2.0
+#    
+#  Unless required by applicable law or agreed to in writing,
+#  software distributed under the License is distributed on an
+#  "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+#  KIND, either express or implied.  See the License for the
+#  specific language governing permissions and limitations
+#  under the License.
+
+here=`echo "import os; print os.path.realpath('$0')" | python`; here=`dirname $here`
+
+tmp=$1
+if [ "$tmp" = "" ]; then
+    tmp="$here/tmp"
+fi
+host=$2
+if [ "$host" = "" ]; then
+    host="localhost"
+fi
+
+# Cleanup tables
+cat >$tmp/sqldb/clean-tables.sql <<EOF
+delete from data;
+EOF
+
+$here/../../components/sqldb/pgsql $host 6432 <$tmp/sqldb/clean-tables.sql
+
diff --git a/hosting/server/composites.py b/hosting/server/composites.py
index 970bc98..9e4b40b 100644
--- a/hosting/server/composites.py
+++ b/hosting/server/composites.py
@@ -16,8 +16,8 @@
 #  under the License.
 
 # App composites collection implementation
-from time import strftime
 from util import *
+from atomutil import *
 from sys import debug
 
 # Convert an id to a composite id
@@ -30,19 +30,18 @@
     debug('composites.py::put::comp', comp)
 
     # Get the requested app
-    app = apps.get(id);
-    if isNil(app) or app is None:
+    app = apps.get(id)
+    if isNil(app):
         debug('composites.py::put', 'app not found', id)
         return False
 
     # Check app author
-    author = cadr(assoc("'author", car(app)))
-    if author != user.get(()):
-        debug('composites.py::put', 'different author', author)
+    if author(app) != user.get(()):
+        debug('composites.py::put', 'different author', author(app))
         return False
 
     # Update the composite in the composite db
-    compentry = (("'entry", assoc("'title", car(app)), ("'id", car(id)), ("'author", user.get(())), ("'updated", strftime('%b %d, %Y')), assoc("'content", car(comp))),)
+    compentry = mkentry(title(app), car(id), user.get(()), now(), content(comp))
     debug('composites.py::put::compentry', compentry)
     return cache.put(compid(id), compentry)
 
@@ -54,25 +53,22 @@
 
     # Get the requested app
     app = apps.get(id)
-    if isNil(app) or app is None:
+    if isNil(app):
         debug('composites.py::get', 'app not found', id)
 
         # Return a default new composite
-        return (("'entry", ("'title", car(id)), ("'id", car(id)), ("'author", user.get(())), ("'updated", strftime('%b %d, %Y'))),)
+        return mkentry(car(id), car(id), user.get(()), now(), ())
 
     # Get the requested composite
-    comp = cache.get(compid(id));
-    if isNil(comp) or comp is None:
+    comp = cache.get(compid(id))
+    if isNil(comp):
         debug('composites.py::get', 'composite not found', id)
 
         # Return a default new composite
-        return (("'entry", ("'title", car(id)), ("'id", car(id)), assoc("'author", car(app)), assoc("'updated", car(app))),)
+        return mkentry(title(app), car(id), author(app), now(), ())
 
     # Return the composite
-    def updated(u):
-        return assoc("'updated", car(app)) if isNil(u) or u is None else u
-
-    compentry = (("'entry", assoc("'title", car(app)), ("'id", car(id)), assoc("'author", car(app)), updated(assoc("'updated", car(comp))), assoc("'content", car(comp))),)
+    compentry = mkentry(title(app), car(id), author(app), updated(comp), content(comp))
     debug('composites.py::get::compentry', compentry)
     return compentry
 
@@ -81,15 +77,14 @@
     debug('composites.py::delete::id', id)
 
     # Get the requested app
-    app = apps.get(id);
-    if isNil(app) or app is None:
+    app = apps.get(id)
+    if isNil(app):
         debug('composites.py::delete', 'app not found', id)
         return False
 
     # Check app author
-    author = cadr(assoc("'author", car(app)))
-    if author != user.get(()):
-        debug('composites.py::delete', 'different author', author)
+    if author(app) != user.get(()):
+        debug('composites.py::delete', 'different author', author(app))
         return False
 
     # Delete the composite
diff --git a/hosting/server/config-backup b/hosting/server/config-backup
index 5e09008..f0f38f8 100755
--- a/hosting/server/config-backup
+++ b/hosting/server/config-backup
@@ -18,5 +18,5 @@
 #  under the License.
 
 cd ../../
-tar czf ../config-backup.tar.gz hosting/server/*start hosting/server/*stop hosting/server/htdocs/*.js hosting/server/htdocs/public/*.js hosting/server/htdocs/home/*.b64 hosting/server/htdocs/home/*.png
+tar czf ../config-backup.tar.gz hosting/server/*start hosting/server/*stop hosting/server/htdocs/*.js hosting/server/htdocs/public/*.js
 
diff --git a/hosting/server/create-tables b/hosting/server/create-tables
new file mode 100755
index 0000000..65f681f
--- /dev/null
+++ b/hosting/server/create-tables
@@ -0,0 +1,38 @@
+#!/bin/sh
+
+#  Licensed to the Apache Software Foundation (ASF) under one
+#  or more contributor license agreements.  See the NOTICE file
+#  distributed with this work for additional information
+#  regarding copyright ownership.  The ASF licenses this file
+#  to you under the Apache License, Version 2.0 (the
+#  "License"); you may not use this file except in compliance
+#  with the License.  You may obtain a copy of the License at
+#  
+#    http://www.apache.org/licenses/LICENSE-2.0
+#    
+#  Unless required by applicable law or agreed to in writing,
+#  software distributed under the License is distributed on an
+#  "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+#  KIND, either express or implied.  See the License for the
+#  specific language governing permissions and limitations
+#  under the License.
+
+here=`echo "import os; print os.path.realpath('$0')" | python`; here=`dirname $here`
+
+tmp=$1
+if [ "$tmp" = "" ]; then
+    tmp="$here/tmp"
+fi
+host=$2
+if [ "$host" = "" ]; then
+    host="localhost"
+fi
+
+# Create tables
+cat >$tmp/sqldb/create-tables.sql <<EOF
+create table data(key text, value text);
+create index data_index on data(key);
+EOF
+
+$here/../../components/sqldb/pgsql $host 6432 <tmp/sqldb/create-tables.sql
+
diff --git a/hosting/server/dashboards.py b/hosting/server/dashboards.py
index d6281d0..a87f1fa 100644
--- a/hosting/server/dashboards.py
+++ b/hosting/server/dashboards.py
@@ -17,6 +17,7 @@
 
 # Dashboards collection implementation
 from util import *
+from atomutil import *
 from sys import debug
 
 # Convert a particular user id to a dashboard id
@@ -27,7 +28,7 @@
 def getdashboard(id, cache):
     debug('dashboards.py::getdashboard::id', id)
     val = cache.get(id)
-    if isNil(val) or val is None:
+    if isNil(val):
         return ()
     dashboard = cdddr(car(val))
     if not isNil(dashboard) and isList(car(cadr(car(dashboard)))):
@@ -47,36 +48,57 @@
     return cache.put(id, val)
 
 # Put an app into the user's dashboard
-def put(id, app, user, cache, apps):
+def put(id, app, user, cache, apps, ratings):
     debug('dashboards.py::put::id', id)
     debug('dashboards.py::put::app', app)
 
     def putapp(id, app, dashboard):
         if isNil(dashboard):
             return app
-        if car(id) == cadr(assoc("'id", car(dashboard))):
+        if car(id) == entryid(dashboard):
             return cons(car(app), cdr(dashboard))
         return cons(car(dashboard), putapp(id, app, cdr(dashboard)))
 
-    appentry = (("'entry", assoc("'title", car(app)), ("'id", car(id)), ("'author", user.get(())), assoc("'updated", car(app)), assoc("'content", car(app))),)
+    appentry = mkentry(title(app), car(id), user.get(()), now(), content(app))
     debug('dashboards.py::put::appentry', appentry)
 
     dashboard = putapp(id, appentry, getdashboard(dashboardid(user), cache))
     return putdashboard(dashboardid(user), dashboard, cache)
 
+# Merge app info and ratings into a list of apps
+def mergeapps(entries, apps, ratings):
+    debug('store.py::mergeapps::entries', entries)
+
+    def mergeapp(entry):
+        debug('store.py::mergeapp::entry', entry)
+        id = (entryid(entry),)
+        app = apps.get(id)
+        if isNil(app):
+            return ((),)
+        info = content(app)
+        rating = ratings.get(id)
+        rates = content(rating)
+        mergedentry = mkentry(title(app), car(id), author(app), updated(app), ("'info",) + (() if isNil(info) else cdr(info)) + (() if isNil(rates) else cdr(rates)))
+        return mergedentry
+
+    mergedentries = tuple(filter(lambda e: not isNil(e), map(lambda e: car(mergeapp((e,))), entries)))
+    debug('store.py::mergeapps::mergedentries', mergedentries)
+    return mergedentries
+
 # Get apps from the user's dashboard
-def get(id, user, cache, apps):
+def get(id, user, cache, apps, ratings):
     debug('dashboards.py::get::id', id)
 
     def findapp(id, dashboard):
         if isNil(dashboard):
             return None
-        if car(id) == cadr(assoc("'id", car(dashboard))):
+        if car(id) == entryid(dashboard):
             return (car(dashboard),)
         return findapp(id, cdr(dashboard))
 
     if isNil(id):
-        dashboard = ((("'feed", ("'title", "Your Apps"), ("'id", user.get(()))) + getdashboard(dashboardid(user), cache)),)
+        dashboardapps = mergeapps(getdashboard(dashboardid(user), cache), apps, ratings)
+        dashboard = ((("'feed", ("'title", "Your Apps"), ("'id", user.get(()))) + dashboardapps),)
         debug('dashboards.py::get::dashboard', dashboard)
         return dashboard
 
@@ -85,7 +107,7 @@
     return app
 
 # Delete apps from the user's dashboard
-def delete(id, user, cache, apps):
+def delete(id, user, cache, apps, ratings):
     debug('dashboards.py::delete::id', id)
     if isNil(id):
         return cache.delete(dashboardid(user))
@@ -93,7 +115,7 @@
     def deleteapp(id, dashboard):
         if isNil(dashboard):
             return ()
-        if car(id) == cadr(assoc("'id", car(dashboard))):
+        if car(id) == entryid(dashboard):
             return cdr(dashboard)
         return cons(car(dashboard), deleteapp(id, cdr(dashboard)))
 
diff --git a/hosting/server/data/apps/me360/app.info b/hosting/server/data/apps/me360/app.info
new file mode 100644
index 0000000..febbd7d
--- /dev/null
+++ b/hosting/server/data/apps/me360/app.info
@@ -0,0 +1 @@
+((entry (title "Check my public social data") (id "me360") (author "admin@example.com") (updated "Apr 28, 2012") (content (info (description "Sample app")))))
\ No newline at end of file
diff --git a/hosting/server/data/apps/me360/app.stats b/hosting/server/data/apps/me360/app.stats
deleted file mode 100644
index 31695e5..0000000
--- a/hosting/server/data/apps/me360/app.stats
+++ /dev/null
@@ -1 +0,0 @@
-((entry (title "Check my public social data") (id "me360") (author "admin@example.com") (updated "Apr 28, 2012") (content (stats (description "Sample app")))))
\ No newline at end of file
diff --git a/hosting/server/data/apps/nearme/app.info b/hosting/server/data/apps/nearme/app.info
new file mode 100644
index 0000000..07777a0
--- /dev/null
+++ b/hosting/server/data/apps/nearme/app.info
@@ -0,0 +1 @@
+((entry (title "nearme") (id "nearme") (author "admin@example.com") (updated "Jan 01, 2012") (content (info (description "Sample app")))))
diff --git a/hosting/server/data/apps/nearme/app.stats b/hosting/server/data/apps/nearme/app.stats
deleted file mode 100644
index 5bc3a2e..0000000
--- a/hosting/server/data/apps/nearme/app.stats
+++ /dev/null
@@ -1 +0,0 @@
-((entry (title "nearme") (id "nearme") (author "admin@example.com") (updated "Jan 01, 2012") (content (stats (description "Sample app")))))
diff --git a/hosting/server/data/apps/nearme2/app.info b/hosting/server/data/apps/nearme2/app.info
new file mode 100644
index 0000000..e637eba
--- /dev/null
+++ b/hosting/server/data/apps/nearme2/app.info
@@ -0,0 +1 @@
+((entry (title "nearme2") (id "nearme2") (author "admin@example.com") (updated "Jan 01, 2012") (content (info (description "Sample app")))))
diff --git a/hosting/server/data/apps/nearme2/app.stats b/hosting/server/data/apps/nearme2/app.stats
deleted file mode 100644
index c6d9948..0000000
--- a/hosting/server/data/apps/nearme2/app.stats
+++ /dev/null
@@ -1 +0,0 @@
-((entry (title "nearme2") (id "nearme2") (author "admin@example.com") (updated "Jan 01, 2012") (content (stats (description "Sample app")))))
diff --git a/hosting/server/data/apps/new/app.info b/hosting/server/data/apps/new/app.info
new file mode 100644
index 0000000..04ab8b8
--- /dev/null
+++ b/hosting/server/data/apps/new/app.info
@@ -0,0 +1 @@
+((entry (title "An empty app template") (id "new") (author "admin@example.com") (updated "Jan 01, 2012") (content (info (description "Sample app")))))
diff --git a/hosting/server/data/apps/new/app.stats b/hosting/server/data/apps/new/app.stats
deleted file mode 100644
index 7c0571b..0000000
--- a/hosting/server/data/apps/new/app.stats
+++ /dev/null
@@ -1 +0,0 @@
-((entry (title "An empty app template") (id "new") (author "admin@example.com") (updated "Jan 01, 2012") (content (stats (description "Sample app")))))
diff --git a/hosting/server/data/apps/ourphotos/app.info b/hosting/server/data/apps/ourphotos/app.info
new file mode 100644
index 0000000..afc57e8
--- /dev/null
+++ b/hosting/server/data/apps/ourphotos/app.info
@@ -0,0 +1 @@
+((entry (title "Our photos of an event") (id "ourphotos") (author "admin@example.com") (updated "Jan 01, 2012") (content (info (description "Sample app")))))
diff --git a/hosting/server/data/apps/ourphotos/app.stats b/hosting/server/data/apps/ourphotos/app.stats
deleted file mode 100644
index 6986fbe..0000000
--- a/hosting/server/data/apps/ourphotos/app.stats
+++ /dev/null
@@ -1 +0,0 @@
-((entry (title "Our photos of an event") (id "ourphotos") (author "admin@example.com") (updated "Jan 01, 2012") (content (stats (description "Sample app")))))
diff --git a/hosting/server/data/apps/shoppingcart/app.info b/hosting/server/data/apps/shoppingcart/app.info
new file mode 100644
index 0000000..3b77112
--- /dev/null
+++ b/hosting/server/data/apps/shoppingcart/app.info
@@ -0,0 +1 @@
+((entry (title "My online store") (id "shoppingcart") (author "admin@example.com") (updated "Jan 01, 2012") (content (info (description "Sample app")))))
diff --git a/hosting/server/data/apps/shoppingcart/app.stats b/hosting/server/data/apps/shoppingcart/app.stats
deleted file mode 100644
index b4c696f..0000000
--- a/hosting/server/data/apps/shoppingcart/app.stats
+++ /dev/null
@@ -1 +0,0 @@
-((entry (title "My online store") (id "shoppingcart") (author "admin@example.com") (updated "Jan 01, 2012") (content (stats (description "Sample app")))))
diff --git a/hosting/server/data/apps/slice/app.info b/hosting/server/data/apps/slice/app.info
new file mode 100644
index 0000000..76685cd
--- /dev/null
+++ b/hosting/server/data/apps/slice/app.info
@@ -0,0 +1 @@
+((entry (title "Slice") (id "slice") (author "admin@example.com") (updated "Jan 01, 2012") (content (info (description "Sample app")))))
diff --git a/hosting/server/data/apps/slice/app.stats b/hosting/server/data/apps/slice/app.stats
deleted file mode 100644
index bebfcbb..0000000
--- a/hosting/server/data/apps/slice/app.stats
+++ /dev/null
@@ -1 +0,0 @@
-((entry (title "Slice") (id "slice") (author "admin@example.com") (updated "Jan 01, 2012") (content (stats (description "Sample app")))))
diff --git a/hosting/server/data/apps/test/app.info b/hosting/server/data/apps/test/app.info
new file mode 100644
index 0000000..b6ea414
--- /dev/null
+++ b/hosting/server/data/apps/test/app.info
@@ -0,0 +1 @@
+((entry (title "An empty test app") (id "test") (author "admin@example.com") (updated "Jan 01, 2012") (content (info (description "Sample app")))))
diff --git a/hosting/server/data/apps/test/app.stats b/hosting/server/data/apps/test/app.stats
deleted file mode 100644
index 8c9b379..0000000
--- a/hosting/server/data/apps/test/app.stats
+++ /dev/null
@@ -1 +0,0 @@
-((entry (title "An empty test app") (id "test") (author "admin@example.com") (updated "Jan 01, 2012") (content (stats (description "Sample app")))))
diff --git a/hosting/server/data/apps/testanimation/app.stats b/hosting/server/data/apps/testanimation/app.info
similarity index 76%
rename from hosting/server/data/apps/testanimation/app.stats
rename to hosting/server/data/apps/testanimation/app.info
index 0b6f8bd..a6759e4 100644
--- a/hosting/server/data/apps/testanimation/app.stats
+++ b/hosting/server/data/apps/testanimation/app.info
@@ -1 +1 @@
-((entry (title "Test animation components") (id "testanimation") (author "admin@example.com") (updated "Jan 01, 2012") (content (stats (description "Sample app")))))
+((entry (title "Test animation components") (id "testanimation") (author "admin@example.com") (updated "Jan 01, 2012") (content (info (description "Sample app")))))
diff --git a/hosting/server/data/apps/testdb/app.info b/hosting/server/data/apps/testdb/app.info
new file mode 100644
index 0000000..3f5b3de
--- /dev/null
+++ b/hosting/server/data/apps/testdb/app.info
@@ -0,0 +1 @@
+((entry (title "Test database components") (id "testdb") (author "admin@example.com") (updated "Jan 01, 2012") (content (info (description "Sample app")))))
diff --git a/hosting/server/data/apps/testdb/app.stats b/hosting/server/data/apps/testdb/app.stats
deleted file mode 100644
index e33dc22..0000000
--- a/hosting/server/data/apps/testdb/app.stats
+++ /dev/null
@@ -1 +0,0 @@
-((entry (title "Test database components") (id "testdb") (author "admin@example.com") (updated "Jan 01, 2012") (content (stats (description "Sample app")))))
diff --git a/hosting/server/data/apps/testevents/app.info b/hosting/server/data/apps/testevents/app.info
new file mode 100644
index 0000000..20191d6
--- /dev/null
+++ b/hosting/server/data/apps/testevents/app.info
@@ -0,0 +1 @@
+((entry (title "Test event components") (id "testevents") (author "admin@example.com") (updated "Jan 01, 2012") (content (info (description "Sample app")))))
diff --git a/hosting/server/data/apps/testevents/app.stats b/hosting/server/data/apps/testevents/app.stats
deleted file mode 100644
index 9c14040..0000000
--- a/hosting/server/data/apps/testevents/app.stats
+++ /dev/null
@@ -1 +0,0 @@
-((entry (title "Test event components") (id "testevents") (author "admin@example.com") (updated "Jan 01, 2012") (content (stats (description "Sample app")))))
diff --git a/hosting/server/data/apps/testhttp/app.info b/hosting/server/data/apps/testhttp/app.info
new file mode 100644
index 0000000..ef2afff
--- /dev/null
+++ b/hosting/server/data/apps/testhttp/app.info
@@ -0,0 +1 @@
+((entry (title "Test HTTP components") (id "testhttp") (author "admin@example.com") (updated "Jan 01, 2012") (content (info (description "Sample app")))))
diff --git a/hosting/server/data/apps/testhttp/app.stats b/hosting/server/data/apps/testhttp/app.stats
deleted file mode 100644
index f55f071..0000000
--- a/hosting/server/data/apps/testhttp/app.stats
+++ /dev/null
@@ -1 +0,0 @@
-((entry (title "Test HTTP components") (id "testhttp") (author "admin@example.com") (updated "Jan 01, 2012") (content (stats (description "Sample app")))))
diff --git a/hosting/server/data/apps/testlogic/app.info b/hosting/server/data/apps/testlogic/app.info
new file mode 100644
index 0000000..b4d3d9d
--- /dev/null
+++ b/hosting/server/data/apps/testlogic/app.info
@@ -0,0 +1 @@
+((entry (title "Test logic components") (id "testlogic") (author "admin@example.com") (updated "Jan 01, 2012") (content (info (description "Sample app")))))
diff --git a/hosting/server/data/apps/testlogic/app.stats b/hosting/server/data/apps/testlogic/app.stats
deleted file mode 100644
index 018a42a..0000000
--- a/hosting/server/data/apps/testlogic/app.stats
+++ /dev/null
@@ -1 +0,0 @@
-((entry (title "Test logic components") (id "testlogic") (author "admin@example.com") (updated "Jan 01, 2012") (content (stats (description "Sample app")))))
diff --git a/hosting/server/data/apps/testsearch/app.info b/hosting/server/data/apps/testsearch/app.info
new file mode 100644
index 0000000..6c959dc
--- /dev/null
+++ b/hosting/server/data/apps/testsearch/app.info
@@ -0,0 +1 @@
+((entry (title "Test search components") (id "testsearch") (author "admin@example.com") (updated "Jan 01, 2012") (content (info (description "Sample app")))))
diff --git a/hosting/server/data/apps/testsearch/app.stats b/hosting/server/data/apps/testsearch/app.stats
deleted file mode 100644
index 23679af..0000000
--- a/hosting/server/data/apps/testsearch/app.stats
+++ /dev/null
@@ -1 +0,0 @@
-((entry (title "Test search components") (id "testsearch") (author "admin@example.com") (updated "Jan 01, 2012") (content (stats (description "Sample app")))))
diff --git a/hosting/server/data/apps/testsms/app.info b/hosting/server/data/apps/testsms/app.info
new file mode 100644
index 0000000..ef2afff
--- /dev/null
+++ b/hosting/server/data/apps/testsms/app.info
@@ -0,0 +1 @@
+((entry (title "Test HTTP components") (id "testhttp") (author "admin@example.com") (updated "Jan 01, 2012") (content (info (description "Sample app")))))
diff --git a/hosting/server/data/apps/testsms/app.stats b/hosting/server/data/apps/testsms/app.stats
deleted file mode 100644
index f55f071..0000000
--- a/hosting/server/data/apps/testsms/app.stats
+++ /dev/null
@@ -1 +0,0 @@
-((entry (title "Test HTTP components") (id "testhttp") (author "admin@example.com") (updated "Jan 01, 2012") (content (stats (description "Sample app")))))
diff --git a/hosting/server/data/apps/testsocial/app.info b/hosting/server/data/apps/testsocial/app.info
new file mode 100644
index 0000000..16190d4
--- /dev/null
+++ b/hosting/server/data/apps/testsocial/app.info
@@ -0,0 +1 @@
+((entry (title "Test social components") (id "testsocial") (author "admin@example.com") (updated "Jan 01, 2012") (content (info (description "Sample app")))))
diff --git a/hosting/server/data/apps/testsocial/app.stats b/hosting/server/data/apps/testsocial/app.stats
deleted file mode 100644
index e386c05..0000000
--- a/hosting/server/data/apps/testsocial/app.stats
+++ /dev/null
@@ -1 +0,0 @@
-((entry (title "Test social components") (id "testsocial") (author "admin@example.com") (updated "Jan 01, 2012") (content (stats (description "Sample app")))))
diff --git a/hosting/server/data/apps/testtext/app.stats b/hosting/server/data/apps/testtext/app.info
similarity index 76%
rename from hosting/server/data/apps/testtext/app.stats
rename to hosting/server/data/apps/testtext/app.info
index 4b06f1d..e717015 100644
--- a/hosting/server/data/apps/testtext/app.stats
+++ b/hosting/server/data/apps/testtext/app.info
@@ -1 +1 @@
-((entry (title "Test text processing components") (id "testtext") (author "admin@example.com") (updated "Jan 01, 2012") (content (stats (description "Sample app")))))
+((entry (title "Test text processing components") (id "testtext") (author "admin@example.com") (updated "Jan 01, 2012") (content (info (description "Sample app")))))
diff --git a/hosting/server/data/apps/testurl/app.info b/hosting/server/data/apps/testurl/app.info
new file mode 100644
index 0000000..37d89b5
--- /dev/null
+++ b/hosting/server/data/apps/testurl/app.info
@@ -0,0 +1 @@
+((entry (title "Test URL components") (id "testurl") (author "admin@example.com") (updated "Jan 01, 2012") (content (info (description "Sample app")))))
diff --git a/hosting/server/data/apps/testurl/app.stats b/hosting/server/data/apps/testurl/app.stats
deleted file mode 100644
index 5683bd4..0000000
--- a/hosting/server/data/apps/testurl/app.stats
+++ /dev/null
@@ -1 +0,0 @@
-((entry (title "Test URL components") (id "testurl") (author "admin@example.com") (updated "Jan 01, 2012") (content (stats (description "Sample app")))))
diff --git a/hosting/server/data/apps/testvalues/app.info b/hosting/server/data/apps/testvalues/app.info
new file mode 100644
index 0000000..42ed01b
--- /dev/null
+++ b/hosting/server/data/apps/testvalues/app.info
@@ -0,0 +1 @@
+((entry (title "Test values and lists") (id "testvalues") (author "admin@example.com") (updated "Jan 01, 2012") (content (info (description "Sample app")))))
diff --git a/hosting/server/data/apps/testvalues/app.stats b/hosting/server/data/apps/testvalues/app.stats
deleted file mode 100644
index 88f6323..0000000
--- a/hosting/server/data/apps/testvalues/app.stats
+++ /dev/null
@@ -1 +0,0 @@
-((entry (title "Test values and lists") (id "testvalues") (author "admin@example.com") (updated "Jan 01, 2012") (content (stats (description "Sample app")))))
diff --git a/hosting/server/data/apps/testwidgets/app.info b/hosting/server/data/apps/testwidgets/app.info
new file mode 100644
index 0000000..3d8e7be
--- /dev/null
+++ b/hosting/server/data/apps/testwidgets/app.info
@@ -0,0 +1 @@
+((entry (title "Test widgets") (id "testwidgets") (author "admin@example.com") (updated "Jan 01, 2012") (content (info (description "Sample app")))))
diff --git a/hosting/server/data/apps/testwidgets/app.stats b/hosting/server/data/apps/testwidgets/app.stats
deleted file mode 100644
index f9a1181..0000000
--- a/hosting/server/data/apps/testwidgets/app.stats
+++ /dev/null
@@ -1 +0,0 @@
-((entry (title "Test widgets") (id "testwidgets") (author "admin@example.com") (updated "Jan 01, 2012") (content (stats (description "Sample app")))))
diff --git a/hosting/server/data/apps/testwidgets2/app.info b/hosting/server/data/apps/testwidgets2/app.info
new file mode 100644
index 0000000..2ba2571
--- /dev/null
+++ b/hosting/server/data/apps/testwidgets2/app.info
@@ -0,0 +1 @@
+((entry (title "Test more widgets") (id "testwidgets2") (author "admin@example.com") (updated "Jan 01, 2012") (content (info (description "Sample app")))))
diff --git a/hosting/server/data/apps/testwidgets2/app.stats b/hosting/server/data/apps/testwidgets2/app.stats
deleted file mode 100644
index e375415..0000000
--- a/hosting/server/data/apps/testwidgets2/app.stats
+++ /dev/null
@@ -1 +0,0 @@
-((entry (title "Test more widgets") (id "testwidgets2") (author "admin@example.com") (updated "Jan 01, 2012") (content (stats (description "Sample app")))))
diff --git a/hosting/server/data/apps/testwidgets3/app.stats b/hosting/server/data/apps/testwidgets3/app.info
similarity index 75%
rename from hosting/server/data/apps/testwidgets3/app.stats
rename to hosting/server/data/apps/testwidgets3/app.info
index d08847c..3d01141 100644
--- a/hosting/server/data/apps/testwidgets3/app.stats
+++ b/hosting/server/data/apps/testwidgets3/app.info
@@ -1 +1 @@
-((entry (title "Test HTML generator components") (id "testwidgets3") (author "admin@example.com") (updated "Jan 01, 2012") (content (stats (description "Sample app")))))
+((entry (title "Test HTML generator components") (id "testwidgets3") (author "admin@example.com") (updated "Jan 01, 2012") (content (info (description "Sample app")))))
diff --git a/hosting/server/data/apps/twsms/app.info b/hosting/server/data/apps/twsms/app.info
new file mode 100644
index 0000000..d870caf
--- /dev/null
+++ b/hosting/server/data/apps/twsms/app.info
@@ -0,0 +1 @@
+((entry (title "SMS send service") (id "twsms") (author "admin@example.com") (updated "Jan 01, 2012") (content (info (description "Sample app")))))
diff --git a/hosting/server/data/apps/twsms/app.stats b/hosting/server/data/apps/twsms/app.stats
deleted file mode 100644
index 4a05841..0000000
--- a/hosting/server/data/apps/twsms/app.stats
+++ /dev/null
@@ -1 +0,0 @@
-((entry (title "SMS send service") (id "twsms") (author "admin@example.com") (updated "Jan 01, 2012") (content (stats (description "Sample app")))))
diff --git a/hosting/server/drop-tables b/hosting/server/drop-tables
new file mode 100755
index 0000000..3628fd3
--- /dev/null
+++ b/hosting/server/drop-tables
@@ -0,0 +1,38 @@
+#!/bin/sh
+
+#  Licensed to the Apache Software Foundation (ASF) under one
+#  or more contributor license agreements.  See the NOTICE file
+#  distributed with this work for additional information
+#  regarding copyright ownership.  The ASF licenses this file
+#  to you under the Apache License, Version 2.0 (the
+#  "License"); you may not use this file except in compliance
+#  with the License.  You may obtain a copy of the License at
+#  
+#    http://www.apache.org/licenses/LICENSE-2.0
+#    
+#  Unless required by applicable law or agreed to in writing,
+#  software distributed under the License is distributed on an
+#  "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+#  KIND, either express or implied.  See the License for the
+#  specific language governing permissions and limitations
+#  under the License.
+
+here=`echo "import os; print os.path.realpath('$0')" | python`; here=`dirname $here`
+
+tmp=$1
+if [ "$tmp" = "" ]; then
+    tmp="$here/tmp"
+fi
+host=$2
+if [ "$host" = "" ]; then
+    host="localhost"
+fi
+
+# Drop tables
+cat >$tmp/sqldb/drop-tables.sql <<EOF
+drop index data_index;
+drop table data;
+EOF
+
+$here/../../components/sqldb/pgsql $host 6432 <tmp/sqldb/drop-tables.sql
+
diff --git a/hosting/server/htdocs/account/index.html b/hosting/server/htdocs/account/index.html
index a0c2e78..47c0ea0 100644
--- a/hosting/server/htdocs/account/index.html
+++ b/hosting/server/htdocs/account/index.html
@@ -19,19 +19,25 @@
 -->
 <div id="bodydiv" class="body">
 
-<div class="viewform">
+<div id="viewform" class="viewform">
 
 <form id="userForm">
 <table style="width: 100%;">
-<tr><tr><td><b>Photo:</b></td></tr>
-<tr><td><img id="userimg" style="width: 50px; height: 50px; vertical-align: top;"></td></tr>
-<tr><tr><td style="padding-top: 6px;"><b>Name:</b></td></tr>
-<tr><td><input type="text" id="userTitle" class="flatentry" size="30" placeholder="Enter your name" style="width: 300px;"/></td></tr>
-<tr><tr><td style="padding-top: 6px;"><b>About Me:</b></td></tr>
-<tr><td><textarea id="userDescription" class="flatentry" cols="40" rows="3" placeholder="Enter a short description of yourself" style="width: 300px;"></textarea></td></tr>
+<tr><tr><td class="label">Username:</td></tr>
+<tr><td><input type="text" id="userName" class="readentry" size="30" readonly="readonly" placeholder="Your username" style="width: 300px;"/></td></tr>
+<tr><tr><td class="label">Email:</td></tr>
+<tr><td><input type="text" id="userEmail" class="readentry" size="30" readonly="readonly" placeholder="Your email address" style="width: 300px;"/></td></tr>
+<tr><tr><td class="label">Picture:</td></tr>
+<tr><td><img id="userPicture" style="width: 50px; height: 50px; vertical-align: top;"/><input id="uploadPicture" type="button" class="lightbutton" value="Upload"/><input id="uploadFile" type="file" accept="image/*" style="display:none;"/><span id="refreshingPicture" class="refreshing" style="display:none;"/></td></tr>
+<tr><tr><td class="label">Name:</td></tr>
+<tr><td><input type="text" id="userFullname" class="flatentry" size="30" placeholder="Your name" style="width: 300px;"/></td></tr>
+<tr><tr><td class="label">Bio:</td></tr>
+<tr><td><textarea id="userDescription" class="flatentry" cols="40" rows="3" placeholder="About yourself, in fewer than 120 characters" style="width: 300px;"></textarea></td></tr>
 </table>
-
 <br/>
+
+<!--
+TODO Disabled for now
 <table style="width: 100%;">
 <tr>
 <th class="thl thr" style="padding-top: 4px; padding-bottom: 4px; padding-left: 2px; padding-right: 2px; ">Calendar</th>
@@ -66,60 +72,73 @@
 <tr><td style="padding-right: 2px;"><input type="text" id="name10" class="flatentry" size="10" placeholder="Key name" style="width: 80px;"/></td><td><input type="text" id="value10" class="flatentry" size="2048" placeholder="Key value" style="width: 200px;"/></td></tr>
 </table>
 </form>
+<br/>
+-->
 
 </div>
 
 <script type="text/javascript">
-(function() {
+(function accountbody() {
 
 /**
- * Init service references.
+ * Setup page layout.
  */
-var editorComp = sca.component("Editor");
-var user= sca.defun(sca.reference(editorComp, "user"));
-var accounts = sca.reference(editorComp, "accounts");
-
-/**
- * Set page titles.
- */
-document.title = config.windowtitle() + ' - Account';
-$('viewhead').innerHTML = '<span class="cmenu">' + username + '</span>';
+(function layout() {
+    document.title = config.windowtitle() + ' - Account';
+    $('viewhead').innerHTML = '<span class="cmenu">' + username + '</span>' +
+        '<input type="button" class="redbutton plusminus" style="position: absolute; top: 4px; left: 5px;" id="deleteUser" value="-" title="Delete your account" disabled="true"/>';
+    if (!ui.isMobile())
+        $('viewform').className = 'viewform flatscrollbars';
+    $('userName').value = username;
+})();
 
 /**
  * Set images.
  */
-$('userimg').src = ui.b64img(appcache.get('/public/user.b64'));
+$('userPicture').src = ui.b64png(appcache.get('/public/user.b64'));
+
+/**
+ * Initialize service references.
+ */
+var editorComp = sca.component("Editor");
+var user= sca.defun(sca.reference(editorComp, "user"));
+var accounts = sca.reference(editorComp, "accounts");
+var pictures = sca.reference(editorComp, "pictures");
 
 /**
  * The current account entry and corresponding saved XML content.
  */
-var accountentry;
-var savedaccountentryxml = '';
+var savedacctxml = '';
+var savedpicxml = '';
 
 /**
  * Get and display the user's account.
  */
-function getaccount() {
-    showStatus('Loading');
+(function getacct() {
+    workingstatus(true);
+    showstatus('Loading');
 
     return accounts.get('', function(doc) {
 
         // Stop now if we didn't get an account
         if (doc == null) {
-            showError('Account info not available');
+            errorstatus('Account info not available');
+            workingstatus(false);
             return false;
         }
-        showOnlineStatus();
 
-        accountentry = car(elementsToValues(atom.readATOMEntry(mklist(doc))));
-        $('userTitle').value = cadr(assoc("'title", cdr(accountentry)));
+        var acctentry = car(elementsToValues(atom.readATOMEntry(mklist(doc))));
+        $('userFullname').value = cadr(assoc("'title", acctentry));
 
-        var content = cadr(assoc("'content", cdr(accountentry)));
-        var acct = isNil(content)? mklist() : cdr(content);
+        var acct = cadr(assoc("'content", acctentry));
+
+        var email = assoc("'email", acct);
+        $('userEmail').value = isNil(email) || isNil(cdr(email))? '' : cadr(email);
 
         var desc = assoc("'description", acct);
         $('userDescription').innerHTML = isNil(desc) || isNil(cdr(desc))? '' : cadr(desc);
 
+        /* TODO disabled for now
         var cal = assoc("'calendar", acct);
         reduce(function(i, evt) {
                 var sched = assoc("'@schedule", evt);
@@ -137,27 +156,126 @@
                 $('value' + i).value = isNil(kv)? '' : cadr(kv);
                 return i + 1;
             }, 1, isNil(keys)? mklist() : cadr(cadr(keys)));
+        */
 
-        savedaccountentryxml = car(atom.writeATOMEntry(valuesToElements(mklist(accountentry))));
+        savedacctxml = car(atom.writeATOMEntry(valuesToElements(mklist(acctentry))));
         return true;
     });
+    return true;
+})();
+
+/**
+ * Get and display the user's picture.
+ */
+(function getpic() {
+    workingstatus(true);
+    showstatus('Loading');
+
+    return pictures.get('', function(doc) {
+        // Stop now if we didn't get a picture
+        if (doc == null) {
+            errorstatus('Picture not available');
+            workingstatus(false);
+            return false;
+        }
+
+        var picentry = car(elementsToValues(atom.readATOMEntry(mklist(doc))));
+        savedpicxml = car(atom.writeATOMEntry(valuesToElements(mklist(picentry))));
+        var content = assoc("'content", picentry);
+        var picture = assoc("'picture", content);
+        var img = assoc("'image", picture);
+        if (!isNil(img))
+            $('userPicture').src = cadr(img);
+
+        onlinestatus();
+        workingstatus(false);
+        return true;
+    });
+    return true;
+})();
+
+/**
+ * Refresh picture.
+ */
+var refreshingpic = false;
+function refreshpic() {
+    if (!refreshingpic)
+        return false;
+    $('refreshingPicture').style.display = 'inline-block';
+    return pictures.get('', function(doc) {
+        if (doc == null) {
+            errorstatus('Picture not available');
+            $('refreshingPicture').style.display = 'none';
+            refreshingpic = false;
+            return false;
+        }
+
+        var picentry = car(elementsToValues(atom.readATOMEntry(mklist(doc))));
+        var content = assoc("'content", picentry);
+        var picture = assoc("'picture", content);
+        var token = assoc("'token", picture);
+
+        // Update picture
+        if (isNil(token)) {
+            var entryxml = car(atom.writeATOMEntry(valuesToElements(mklist(picentry))));
+            savedpicxml = entryxml;
+            var img = assoc("'image", picture);
+            if (!isNil(img))
+                $('userPicture').src = cadr(img);
+            $('refreshingPicture').style.display = 'none';
+            refreshingpic = false;
+            return true;
+        }
+
+        // Refresh in 2 secs
+        return ui.delay(refreshpic, 2000);
+    }, 'remote');
+    return true;
 }
 
 /**
  * Save the user's account.
  */
-function save(entryxml) {
+function saveacct(entryxml) {
     if (isNil(username))
         return false;
-    showStatus('Saving');
-    savedaccountentryxml = entryxml;
-    accounts.put('', savedaccountentryxml, function(e) {
+    workingstatus(true);
+    showstatus('Saving');
+
+    savedacctxml = entryxml;
+    accounts.put('', savedacctxml, function(e) {
         if (e) {
-            showStatus('Local copy');
+            showstatus('Local copy');
+            workingstatus(false);
             return false;
         }
 
-        showStatus('Saved');
+        showstatus('Saved');
+        workingstatus(false);
+        return true;
+    });
+    return true;
+}
+
+/**
+ * Save the user's picture.
+ */
+function savepic(entryxml) {
+    if (isNil(username))
+        return false;
+    workingstatus(true);
+    showstatus('Uploading');
+
+    savedpicxml = entryxml;
+    pictures.put('', savedpicxml, function(e) {
+        if (e) {
+            showstatus('Local copy');
+            workingstatus(false);
+            return false;
+        }
+
+        showstatus('Uploaded');
+        workingstatus(false);
         return true;
     });
     return true;
@@ -167,8 +285,27 @@
  * Handle a change event
  */
 function onaccountchange() {
-    var title = $('userTitle').value;
-    var desc = $('userDescription').value;
+
+    // Validate user input
+    var title = $('userFullname').value;
+    if (title.length == 0) {
+        errorstatus('Name cannot be empty');
+        return false;
+    }
+    if (title.length > 40) {
+        errorstatus('Name cannot be longer than 40 characters');
+        return false;
+    }
+
+    var email = $('userEmail').value;
+
+    var description = $('userDescription').value;
+    if (description.length > 120) {
+        errorstatus('Bio cannot be longer than 120 characters');
+        return false;
+    }
+
+    /* TODO disabled for now
     var cal = map(function(i) {
         var sched = $('sched' + i).value;
         var svc = $('service' + i).value;
@@ -180,18 +317,24 @@
         return mklist("'key", mklist("'@name", kn), mklist("'@value", kv));
     }, range(1, 11));
 
-    var accountentry = mklist("'entry", mklist("'title", title != ''? title : username), mklist("'id", username),
-                        mklist("'content", mklist("'account", mklist("'description", desc), cons("'keys", keys), cons("'calendar", cal))));
-    var entryxml = car(atom.writeATOMEntry(valuesToElements(mklist(accountentry))));
-    if (savedaccountentryxml == entryxml)
+    var acctentry = mklist("'entry", mklist("'title", title != ''? title : username), mklist("'id", username),
+                        mklist("'content", mklist("'account", mklist("'email", email), mklist("'description", description), cons("'keys", keys), cons("'calendar", cal))));
+    */
+
+    var acctentry = mklist("'entry", mklist("'title", title != ''? title : username), mklist("'id", username),
+                        mklist("'content", mklist("'account", mklist("'email", email), mklist("'description", description))));
+    var entryxml = car(atom.writeATOMEntry(valuesToElements(mklist(acctentry))));
+    if (savedacctxml == entryxml)
         return false;
 
-    showStatus('Modified');
-    return save(entryxml);
+    showstatus('Modified');
+    return saveacct(entryxml);
 }
 
-$('userTitle').onchange = onaccountchange;
+$('userFullname').onchange = onaccountchange;
 $('userDescription').onchange = onaccountchange;
+
+/* TODO disabled for now
 map(function(i) {
     $('sched' + i).onchange = onaccountchange;
     $('service' + i).onchange = onaccountchange;
@@ -202,6 +345,19 @@
     $('value' + i).onchange = onaccountchange;
     return true;
 }, range(1, 11));
+*/
+
+/**
+ * Handle a key event.
+ */
+var lastkeyup = null;
+$('userFullname').onkeyup = $('userDescription').onkeyup = function() {
+    var t = new Date().getTime();
+    lastkeyup = t;
+    ui.delay(function() {
+            return t == lastkeyup? onaccountchange() : true;
+        }, 2000);
+};
 
 /**
  * Handle a form submit event.
@@ -212,9 +368,96 @@
 };
 
 /**
- * Get the user's account.
+ * Read and upload icon file.
  */
-getaccount();
+function readpic(files) {
+    if (!files || files.length == 0)
+        return false;
+    if (!files[0].type.match('image.*')) {
+        errorstatus('Please select an image');
+        return false;
+    }
+    workingstatus(true);
+    showstatus('Loading');
+
+    // Read the selected file into a 50x50 image
+    return ui.readimage(files[0],
+        function(e) {
+            errorstatus('Couldn\'t read the file');
+            workingstatus(false);
+        },
+        function(p) {
+            showstatus('Loading ' + p + '%');
+        },
+        function(url) {
+            // Update the user picture
+            $('userPicture').src = url;
+            showstatus('Loaded');
+
+            // Now upload it
+            ui.delay(function() {
+                var picentry = mklist("'entry", mklist("'title", username), mklist("'id", username), mklist("'author", username), mklist("'content", mklist("'picture", mklist("'image", url))));
+                var entryxml = car(atom.writeATOMEntry(valuesToElements(mklist(picentry))));
+                if (savedpicxml == entryxml) {
+                    onlinestatus();
+                    workingstatus(false);
+                    return false;
+                }
+                return savepic(entryxml);
+            });
+        }, 50, 50);
+}
+
+/**
+ * Upload a picture in an email.
+ */
+function emailpicture() {
+
+    // Generate and put a picture email upload token
+    workingstatus(true);
+    showstatus('Uploading');
+    var token = uuid4();
+    var picentry = mklist("'entry", mklist("'title", username), mklist("'id", username), mklist("'author", username), mklist("'content", mklist("'picture", mklist("'token", token))));
+    var entryxml = car(atom.writeATOMEntry(valuesToElements(mklist(picentry))));
+    pictures.put('', entryxml, function(e) {
+        if (e) {
+            showstatus('Local copy');
+            workingstatus(false);
+            return false;
+        }
+        workingstatus(false);
+
+        // Open the email app
+        var mailto = safeb64encode('p/' + username + '/' + token);
+        ui.navigate('mailto:' + mailto + '@' + topdomainname(window.location.hostname) + '?subject=Email to upload&body=Paste picture here', '_self');
+
+        // Refresh app icon
+        refreshingpic = true;
+        return ui.delay(refreshpic, 500);
+    }, 'remote');
+}
+
+/**
+ * Handle picture upload events.
+ */
+$('uploadPicture').onclick = function() {
+    if (ui.isMobile())
+        return emailpicture();
+    return $('uploadFile').click();
+};
+$('uploadFile').onchange = function(e) {
+    return readpic(e.target.files);
+};
+$('userPicture').ondrag = function(e) {
+    e.stopPropagation();
+    e.preventDefault();
+    e.dataTransfer.dropEffect = 'copy';
+};
+$('userPicture').ondrop = function(e) {
+    e.stopPropagation();
+    e.preventDefault();
+    return readpic(e.dataTransfer.files);
+};
 
 })();
 </script>
diff --git a/hosting/server/htdocs/app/cache-template.cmf b/hosting/server/htdocs/app/cache/cache-template.cmf
similarity index 87%
copy from hosting/server/htdocs/app/cache-template.cmf
copy to hosting/server/htdocs/app/cache/cache-template.cmf
index 5881cf8..40da327 100644
--- a/hosting/server/htdocs/app/cache-template.cmf
+++ b/hosting/server/htdocs/app/cache/cache-template.cmf
@@ -4,7 +4,7 @@
 
 # App resources
 /favicon.ico
-/public/iframe-min.html
+/login/
 /public/img.png
 /public/notauth/
 /public/notfound/
diff --git a/hosting/server/htdocs/app/index.html b/hosting/server/htdocs/app/index.html
index cddf4fb..cd03311 100644
--- a/hosting/server/htdocs/app/index.html
+++ b/hosting/server/htdocs/app/index.html
@@ -17,37 +17,51 @@
  * specific language governing permissions and limitations
  * under the License.    
 -->
-<html manifest="cache-manifest.cmf">
+<html manifest="cache/cache-manifest.cmf">
 <head>
+<!-- Firebug inspector -->
+<!--
+<script type="text/javascript" src="https://getfirebug.com/releases/lite/1.3/firebug-lite.js"></script>
+-->
+<!-- Weinre inspector -->
+<!--
+<script src="http://www.example.com:9998/target/target-script-min.js#anonymous"></script>
+-->
 <title></title>
 <meta name="viewport" content="width=device-width, user-scalable=no, initial-scale=1.0, minimum-scale=1.0, maximum-scale=1.0"/> 
+<!--
 <meta name="apple-mobile-web-app-capable" content="yes"/>
 <meta name="apple-mobile-web-app-status-bar-style" content="black"/>
-<link rel="apple-touch-icon" href="/public/touchicon.png"/>
+-->
+<link rel="apple-touch-icon-precomposed" href="/public/touchicon.png"/>
 <base href="/"/>
 <script type="text/javascript">
-(function() {
+try {
+
+(function apphead() {
 
 window.appcache = {};
 
 /**
  * Get and cache a resource.
  */
-appcache.get = function(uri) {
+appcache.get = function(uri, mode) {
     var h = uri.indexOf('#');
     var u = h == -1? uri : uri.substring(0, h);
 
     // Get resource from local storage first
     var ls = window.lstorage || localStorage;
     var item = null;
-    try { item = ls.getItem(u); } catch(e) {}
+    try { item = ls.getItem('ui.r.' + u); } catch(e) {}
     if (item != null && item != '')
         return item;
 
     // Get resource from network
     var http = new XMLHttpRequest();
-    http.open("GET", u, false);
+    http.open("GET", mode == 'remote'? (u + '?t=' + new Date().getTime() + '&r=' + Math.random()) : u, false);
     http.setRequestHeader("Accept", "*/*");
+    if (mode == 'remote')
+        http.setRequestHeader("If-Modified-Since", "Thu, 1 Jan 1970 00:00:00 GMT");
     http.send(null);
     if (http.status == 200) {
         if (http.getResponseHeader("X-Login") != null) {
@@ -59,7 +73,7 @@
             if (window.debug) debug('http error', u, 'No-Content');
             return null;
         }
-        try { ls.setItem(u, http.responseText); } catch(e) {}
+        try { ls.setItem('ui.r.' + u, http.responseText); } catch(e) {}
         return http.responseText;
     }
     if (window.debug) debug('http error', u, http.status, http.statusText);
@@ -74,46 +88,32 @@
 /**
  * Load Javascript and CSS.
  */
-(function() {
+(function appboot() {
 
 var bootjs = document.createElement('script');
 bootjs.type = 'text/javascript';
-bootjs.text = appcache.get('/all-min.js');
-document.head.appendChild(bootjs);
-document.head.appendChild(ui.declareCSS(appcache.get('/ui-min.css')));
+bootjs.text = 'try {\n' + appcache.get('/all-min.js') + '\n' + appcache.get('/config-min.js') + '\n} catch(e) { console.log(e.stack); throw e; }\n';
+var head = document.getElementsByTagName('head')[0];
+head.appendChild(bootjs);
+head.appendChild(ui.declareCSS(appcache.get('/ui-min.css')));
 
 })();
 
-/**
- * Redirect to login page if not signed in.
- */
-(function() {
-
-if (document.location.protocol == 'https:' && !hasauthcookie())
-    document.location = '/login/';
-
-})();
-
+} catch(e) {
+    if (window.debug) debug(e.stack);
+    throw e;
+}
 </script>
 </head>
 <body class="delayed">
-<div id="mainbodydiv" class="mainbodydiv">
-
-<div id="headdiv" class="hsection">
-<script type="text/javascript">
-(function() {
-
-$('headdiv').appendChild(ui.declareScript(appcache.get('/config-min.js')));
-
-})();
-</script>
-</div>
 
 <div id="content">
 </div>
 
 <script type="text/javascript">
-(function() {
+try {
+
+(function appbody() {
 
 /**
  * Get the app name
@@ -186,7 +186,7 @@
 applicationCache.addEventListener('cached', function(e) {
     //debug('appcache cached', e);
     map(function(res) {
-        appcache.get(res[0]);
+        appcache.get(res[0], 'remote');
     }, appresources);
 }, false);
 
@@ -316,7 +316,8 @@
                         var nesheet = document.createElement('style');
                         nesheet.id = 'style_' + e.id;
                         nesheet.type = 'text/css';
-                        document.head.appendChild(nesheet);
+                        var head = document.getElementsByTagName('head')[0];
+                        head.appendChild(nesheet);
                         nesheet.innerHTML = s;
                     } else {
                         esheet.innerHTML = s;
@@ -331,9 +332,9 @@
                 // Restart current animation if necessary
                 if (!isNil(aname) && ce.style.webkitAnimationName == aname) {
                     ce.style.webkitAnimationName = '';
-                    setTimeout(function() {
+                    ui.async(function restartanimation() {
                         ce.style.webkitAnimationName = aname;
-                    }, 0);
+                    });
                 }
                 return a;
             }
@@ -930,10 +931,7 @@
  */
 document.body.onorientationchange = function(e) {
     //debug('onorientationchange');
-
-    // Scroll to the top and hide the address bar
-    window.scrollTo(0, 0);
-
+    ui.onorientationchange(e);
     return true;
 };
 
@@ -980,14 +978,9 @@
 /**
  * Initialize the document.
  */
-function onload() {
+window.onload = function() {
     //debug('onload');
-
-    // Scroll to the top and hide the address bar
-    window.scrollTo(0, 0);
-
-    // Show the page
-    document.body.style.visibility = 'visible';
+    ui.onload();
 
     // Initialize the app composite
     getappcomposite(appname);
@@ -996,17 +989,16 @@
     getapppage(appname);
 
     return true;
-}
-
-onload();
+};
 
 })();
+
+} catch(e) {
+    debug(e.stack);
+    throw e;
+}
 </script>
 
-<div id="footdiv" class="fsection">
-</div>
-
-</div>
 </body>
 </html>
 
diff --git a/hosting/server/htdocs/cache-template.cmf b/hosting/server/htdocs/cache-template.cmf
deleted file mode 100644
index 8d9aa26..0000000
--- a/hosting/server/htdocs/cache-template.cmf
+++ /dev/null
@@ -1,18 +0,0 @@
-CACHE MANIFEST
-
-# Version SHA1
-
-# App resources
-/
-/favicon.ico
-/public/iframe-min.html
-/public/img.png
-/public/notauth/
-/public/notfound/
-/public/notyet/
-/public/oops/
-/public/touchicon.png
-
-NETWORK:
-*
-
diff --git a/hosting/server/htdocs/app/cache-template.cmf b/hosting/server/htdocs/cache/cache-template.cmf
similarity index 87%
rename from hosting/server/htdocs/app/cache-template.cmf
rename to hosting/server/htdocs/cache/cache-template.cmf
index 5881cf8..8347c55 100644
--- a/hosting/server/htdocs/app/cache-template.cmf
+++ b/hosting/server/htdocs/cache/cache-template.cmf
@@ -3,8 +3,9 @@
 # Version SHA1
 
 # App resources
+/
 /favicon.ico
-/public/iframe-min.html
+/login/
 /public/img.png
 /public/notauth/
 /public/notfound/
diff --git a/hosting/server/htdocs/cache/index.html b/hosting/server/htdocs/cache/index.html
new file mode 100644
index 0000000..f5c8179
--- /dev/null
+++ b/hosting/server/htdocs/cache/index.html
@@ -0,0 +1,51 @@
+<!DOCTYPE html>
+<!--
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ * 
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ * 
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.    
+-->
+<html manifest="/cache/cache-manifest.cmf">
+<head>
+<script type="text/javascript">
+applicationCache.addEventListener('checking', function(e) {
+    return window.parent.onappcachechecking(e);
+}, false);
+applicationCache.addEventListener('error', function(e) {
+    return window.parent.onappcacheerror(e);
+}, false);
+applicationCache.addEventListener('noupdate', function(e) {
+    return window.parent.onappcachenoupdate(e);
+}, false);
+applicationCache.addEventListener('downloading', function(e) {
+    return window.parent.onappcachedownloading(e);
+}, false);
+applicationCache.addEventListener('progress', function(e) {
+    return window.parent.onappcacheprogress(e);
+}, false);
+applicationCache.addEventListener('updateready', function(e) {
+    return window.parent.onappcacheupdateready(e);
+}, false);
+applicationCache.addEventListener('cached', function(e) {
+    return window.parent.onappcachecached(e);
+}, false);
+window.onload = function() {
+    window.parent.onloadappcache();
+};
+</script>
+</head>
+<body>
+</body>
+</html>
diff --git a/hosting/server/htdocs/clone/index.html b/hosting/server/htdocs/clone/index.html
index 0a2f773..c6a9658 100644
--- a/hosting/server/htdocs/clone/index.html
+++ b/hosting/server/htdocs/clone/index.html
@@ -19,29 +19,24 @@
 -->
 <div id="bodydiv" class="body">
 
-<div class="viewform">
+<div id="viewform" class="viewform">
 
 <form id="cloneAppForm">
 <table style="width: 100%;">
-<tr><td><b>New App Name:</b></td></tr>
-<tr><td><input type="text" id="appName" class="flatentry" size="15" autocapitalize="off" placeholder="Your app name"/></td></tr>
-<tr><tr><td style="padding-top: 6px;"><b>Icon:</b></td></tr>
-<tr><td><img id="appimg" style="width: 50px; height: 50px; vertical-align: top;"></td></tr>
-<tr><tr><td style="padding-top: 6px;"><b>Title:</b></td></tr>
-<tr><td><input type="text" id="appTitle" class="flatentry" size="30" placeholder="Enter the title of your app" style="width: 300px;"/></td></tr>
-<tr><tr><td style="padding-top: 6px;"><b>Description:</b></td></tr>
-<tr><td><textarea id="appDescription" class="flatentry" cols="40" rows="3" placeholder="Enter a short description of your app" style="width: 300px;"></textarea></td></tr>
-<tr><td>
-<input id="cloneAppOKButton" type="submit" class="graybutton bluebutton" style="font-weight: bold;" value="Clone" title="Clone the app"/>
+<tr><td class="label">New URL:</td></tr>
+<tr><td><span id="hostname" class="readentry"></span><input type="text" id="appName" class="flatentry" size="18" autocapitalize="off" placeholder="New app name"/></td></tr>
+<tr><td style="padding-top: 20px;">
+<input id="cloneAppOKButton" type="submit" class="bluebutton" style="font-weight: bold;" value="Clone" title="Clone this app"/>
 <input id="cloneAppCancelButton" type="button" class="graybutton" value="Cancel"/>
 </td></tr>
 </table>
 </form>
+<br/>
 
 </div>
 
 <script type="text/javascript">
-(function() {
+(function clonebody() {
 
 /**
  * Get the app name.
@@ -49,20 +44,20 @@
 var appname = ui.fragmentParams(location)['app'];
 
 /**
- * Set page titles.
+ * Setup page layout.
  */
-document.title = config.windowtitle() + ' - ' + config.clone() + ' - ' + appname;
-$('viewhead').innerHTML = '<span class="smenu">' + config.clone() + ' ' + appname + '</span>';
-$('cloneAppOKButton').value = config.clone();
-$('cloneAppOKButton').title = config.clone() + ' this app';
+(function layout() {
+    document.title = config.windowtitle() + ' - ' + config.clone() + ' - ' + appname;
+    $('viewhead').innerHTML = '<span class="smenu">' + config.clone() + ' ' + appname + '</span>';
+    if (!ui.isMobile())
+        $('viewform').className = 'viewform flatscrollbars';
+    $('hostname').innerHTML = window.location.hostname + '/';
+    $('cloneAppOKButton').value = config.clone();
+    $('cloneAppOKButton').title = config.clone() + ' this app';
+})();
 
 /**
- * Set images.
- */
-$('appimg').src = ui.b64img(appcache.get('/public/app.b64'));
-
-/**
- * Init service references.
+ * Initialize service references.
  */
 var editorComp = sca.component("Editor");
 var apps = sca.reference(editorComp, "apps");
@@ -71,50 +66,60 @@
  * The current app entry and corresponding saved XML content.
  */
 var appentry;
-var savedappentryxml = '';
+var savedappxml = '';
 
 /**
- * Get and display an app.
+ * Get and display the requested app.
  */
-function getapp(name) {
-    if (isNil(name))
+(function getapp() {
+    if (isNil(appname))
         return false;
-    showStatus('Loading');
+    workingstatus(true);
+    showstatus('Loading');
 
-    return apps.get(name, function(doc) {
+    return apps.get(appname, function(doc) {
 
         // Stop now if we didn't get the app
         if (doc == null) {
-            showError('App not available');
+            errorstatus('Couldn\'t get the app info');
+            workingstatus(false);
             return false;
         }
-        showOnlineStatus();
 
-        appentry = doc != null? car(elementsToValues(atom.readATOMEntry(mklist(doc)))) : mklist("'entry", mklist("'title", ''), mklist("'id", name));
-        $('appTitle').value = cadr(assoc("'title", cdr(appentry)));
-        var content = cadr(assoc("'content", cdr(appentry)));
-        var description = assoc("'description", content);
-        $('appDescription').value = isNil(description) || isNil(cadr(description))? '' : cadr(description);
-        savedappentryxml = car(atom.writeATOMEntry(valuesToElements(mklist(appentry))));
+        appentry = doc != null? car(elementsToValues(atom.readATOMEntry(mklist(doc)))) : mklist("'entry", mklist("'title", ''), mklist("'id", appname));
+        var content = cadr(assoc("'content", appentry));
+        savedappxml = car(atom.writeATOMEntry(valuesToElements(mklist(appentry))));
+
+        onlinestatus();
+        workingstatus(false);
         return true;
     });
-}
+})();
 
 /**
  * Save an app.
  */
-function save(name, entryxml) {
-    showStatus('Saving');
-    savedappentryxml = entryxml;
-    apps.put(name, savedappentryxml, function(e) {
+function saveapp(name, entryxml) {
+    workingstatus(true);
+    showstatus('Saving');
+
+    savedappxml = entryxml;
+    apps.put(name, savedappxml, function(e) {
         if (e) {
-            showStatus('Local copy');
+            if (e.code && e.code == 404) {
+                errorstatus('App name is taken, please pick another name');
+                workingstatus(false);
+                return false;
+            }
+            showstatus('Local copy');
+            workingstatus(false);
             return false;
         }
-        showStatus('Saved');
+        showstatus('Saved');
+        workingstatus(false);
 
-        // Open it in the page editor
-        ui.navigate('/#view=page&app=' + name, '_view');
+        // Open the app in the page editor
+        ui.navigate('/#view=info&app=' + name, '_view');
         return false;
     });
     return false;
@@ -124,19 +129,36 @@
  * Clone an app.
  */
 $('cloneAppForm').onsubmit = function() {
+
+    // Validate app name
     var name = $('appName').value;
-    if (name == '') {
-        showError('Missing app name');
+    if (name.length < 3 || name.length > 10) {
+        errorstatus('App name must be between 3 and 10 characters');
         return false;
     }
-    showStatus('Saving');
+    name = name.toLowerCase();
+    var anum = name.split('').reduce(function(p, c, i, a) { return p && ((c >= 'a' && c <= 'z') || (c >= '0' && c<= '9')); }, true);
+    if (!anum) {
+        errorstatus('App name is taken, please pick another name');
+        return false;
+    }
+    if (name.charAt(0) < 'a' || name.charAt(0) > 'z') {
+        errorstatus('App name must start with a letter');
+        return false;
+    }
+
+    // Check reserved app names
+    var reserved = mklist('account', 'app', 'cache', 'clone', 'create', 'delete', 'graph', 'home', 'login', 'new', 'page', 'proxy', 'public', 'private', 'info', 'store');
+    if (!isNil(assoc(name, map(function(r) { return mklist(r, r); }, reserved)))) {
+        errorstatus('App name is taken, please pick another name');
+        return false;
+    }
 
     // Clone the app
-    var title = $('appTitle').value;
-    var description = $('appDescription').value;
-    appentry = mklist("'entry", mklist("'title", title != ''? title : name), mklist("'id", appname), mklist("'content", mklist("'stats", mklist("'description", description))));
+    showstatus('Modified');
+    appentry = mklist("'entry", mklist("'title", name), mklist("'id", appname), mklist("'author", username));
     var entryxml = car(atom.writeATOMEntry(valuesToElements(mklist(appentry))));
-    return save(name, entryxml);
+    return saveapp(name, entryxml);
 };
 
 /**
@@ -146,11 +168,6 @@
     history.back();
 };
 
-/**
- * Get the current app.
- */
-getapp(appname);
-
 })();
 </script>
 
diff --git a/hosting/server/htdocs/config.js b/hosting/server/htdocs/config.js
index 70d3ea1..355174e 100644
--- a/hosting/server/htdocs/config.js
+++ b/hosting/server/htdocs/config.js
@@ -32,7 +32,7 @@
 };
 
 config.hometitle = function() {
-    return '<br/><span style="font-weight: bold;">Create SCA Composite Apps</span><br/><br/>';
+    return '<br/><span style="font-weight: bold;">Create SCA Apps</span><br/><br/>';
 };
 
 config.clone = function() {
diff --git a/hosting/server/htdocs/create/index.html b/hosting/server/htdocs/create/index.html
index d8d2b30..a11c095 100644
--- a/hosting/server/htdocs/create/index.html
+++ b/hosting/server/htdocs/create/index.html
@@ -19,43 +19,38 @@
 -->
 <div id="bodydiv" class="body">
 
-<div class="viewform">
+<div id="viewform" class="viewform">
 
 <form id="createAppForm">
 <table style="width: 100%;">
-<tr><td><b>App Name:</b></td></tr>
-<tr><td><input type="text" id="appName" class="flatentry" size="15" autocapitalize="off" placeholder="Your app name"/></td></tr>
-<tr><tr><td style="padding-top: 6px;"><b>App Icon:</b></td></tr>
-<tr><td><img id="appimg" style="width: 50px; height: 50px; vertical-align: top;"></td></tr>
-<tr><tr><td style="padding-top: 6px;"><b>App Title:</b></td></tr>
-<tr><td><input type="text" id="appTitle" class="flatentry" size="30" placeholder="Enter the title of your app" style="width: 300px;"/></td></tr>
-<tr><tr><td style="padding-top: 6px;"><b>Description:</b></td></tr>
-<tr><td><textarea id="appDescription" class="flatentry" cols="40" rows="3" placeholder="Enter a short description of your app" style="width: 300px;"></textarea></td></tr>
-<tr><td>
-<input id="createAppOKButton" type="submit" class="graybutton bluebutton" style="font-weight: bold;" value="Create" title="Create the app"/>
+<tr><td class="label">URL:</td></tr>
+<tr><td><span id="hostname" class="readentry"></span><input type="text" id="appName" class="flatentry" size="18" autocapitalize="off" placeholder="New app name"/></td></tr>
+<tr><td style="padding-top: 20px;">
+<input id="createAppOKButton" type="submit" class="bluebutton" style="font-weight: bold;" value="Create" title="Create the app"/>
 <input id="createAppCancelButton" type="button" class="graybutton" value="Cancel"/>
 </td></tr>
 </table>
 </form>
+<br/>
 
 </div>
 
 <script type="text/javascript">
-(function() {
+(function createbody() {
 
 /**
- * Set page titles.
+ * Setup page layout.
  */
-document.title = config.windowtitle() + ' - Create App';
-$('viewhead').innerHTML = '<span class="smenu">Create an App</span>';
+(function layout() {
+    document.title = config.windowtitle() + ' - New App';
+    $('viewhead').innerHTML = '<span class="smenu">New App</span>';
+    if (!ui.isMobile())
+        $('viewform').className = 'viewform flatscrollbars';
+    $('hostname').innerHTML = window.location.hostname + '/';
+})();
 
 /**
- * Set images.
- */
-$('appimg').src = ui.b64img(appcache.get('/public/app.b64'));
-
-/**
- * Init service references.
+ * Initialize service references.
  */
 var editorComp = sca.component("Editor");
 var apps = sca.reference(editorComp, "apps");
@@ -64,23 +59,32 @@
  * The current app entry and corresponding saved XML content.
  */
 var appentry;
-var savedappentryxml = '';
+var savedappxml = '';
 
 /**
  * Save an app.
  */
-function save(name, entryxml) {
-    showStatus('Saving');
-    savedappentryxml = entryxml;
-    apps.put(name, savedappentryxml, function(e) {
+function saveapp(name, entryxml) {
+    workingstatus(true);
+    showstatus('Saving');
+
+    savedappxml = entryxml;
+    apps.put(name, savedappxml, function(e) {
         if (e) {
-            showStatus('Local copy');
+            if (e.code && e.code == 404) {
+                errorstatus('App name is taken, please pick another name');
+                workingstatus(false);
+                return false;
+            }
+            showstatus('Local copy');
+            workingstatus(false);
             return false;
         }
-        showStatus('Saved');
+        showstatus('Saved');
+        workingstatus(false);
 
-        // Open it in the page editor
-        ui.navigate('/#view=page&app=' + name, '_view');
+        // Open the app in the page editor
+        ui.navigate('/#view=info&app=' + name, '_view');
         return false;
     });
     return false;
@@ -90,19 +94,36 @@
  * Create an app.
  */
 $('createAppForm').onsubmit = function() {
+
+    // Validate app name
     var name = $('appName').value;
-    if (name == '') {
-        showError('Missing app name');
+    if (name.length < 3 || name.length > 10) {
+        errorstatus('App name must be between 3 and 10 characters');
         return false;
     }
-    showStatus('Modified');
+    name = name.toLowerCase();
+    var anum = name.split('').reduce(function(p, c, i, a) { return p && ((c >= 'a' && c <= 'z') || (c >= '0' && c<= '9')); }, true);
+    if (!anum) {
+        errorstatus('App name can only use numbers and letters');
+        return false;
+    }
+    if (name.charAt(0) < 'a' || name.charAt(0) > 'z') {
+        errorstatus('App name must start with a letter');
+        return false;
+    }
+
+    // Check reserved app names
+    var reserved = mklist('account', 'app', 'cache', 'clone', 'create', 'delete', 'graph', 'home', 'login', 'new', 'page', 'proxy', 'public', 'private', 'info', 'store');
+    if (!isNil(assoc(name, map(function(r) { return mklist(r, r); }, reserved)))) {
+        errorstatus('App name is taken, please pick another name');
+        return false;
+    }
 
     // Clone the 'new' app template
-    var title = $('appTitle').value;
-    var description = $('appDescription').value;
-    appentry = mklist("'entry", mklist("'title", title != ''? title : name), mklist("'id", 'new'), mklist("'content", mklist("'stats", mklist("'description", description))));
+    showstatus('Modified');
+    appentry = mklist("'entry", mklist("'title", name), mklist("'id", 'new'), mklist("'author", username));
     var entryxml = car(atom.writeATOMEntry(valuesToElements(mklist(appentry))));
-    return save(name, entryxml);
+    return saveapp(name, entryxml);
 };
 
 /**
@@ -115,7 +136,7 @@
 /**
  * Show the status.
  */
-showOnlineStatus();
+onlinestatus();
 
 })();
 </script>
diff --git a/hosting/server/htdocs/delete/index.html b/hosting/server/htdocs/delete/index.html
index 5a668af..81cfa0b 100644
--- a/hosting/server/htdocs/delete/index.html
+++ b/hosting/server/htdocs/delete/index.html
@@ -19,31 +19,32 @@
 -->
 <div id="bodydiv" class="body">
 
-<div class="viewform">
+<div id="viewform" class="viewform">
 
 <form id="deleteAppForm">
 <table style="width: 100%;">
-<tr><tr><td style="padding-top: 6px;"><b>App Icon:</b></td></tr>
-<tr><td><img id="appimg" style="width: 50px; height: 50px; vertical-align: top;"></td></tr>
-<tr><tr><td style="padding-top: 6px;"><b>App Title:</b></td></tr>
-<tr><td><input type="text" id="appTitle" class="flatentry" size="30" readonly="readonly" style="width: 300px;"/></td></tr>
-<tr><tr><td style="padding-top: 6px;"><b>Author:</b></td></tr>
-<tr><td><span id="appAuthor"></span></td></tr>
-<tr><tr><td style="padding-top: 6px;"><b>Updated:</b></td></tr>
-<tr><td><span id="appUpdated"></span></td></tr>
-<tr><tr><td style="padding-top: 6px;"><b>Description:</b></td></tr>
-<tr><td><textarea id="appDescription" class="flatentry" cols="40" rows="3" readonly="readonly" style="width: 300px;"></textarea></td></tr>
+<tr><tr><td class="label">URL:</td></tr>
+<tr><td><input type="text" id="appURL" class="readentry" size="30" readonly="readonly" placeholder="App URL" style="width: 300px;"/></td></tr>
+<tr><tr><td class="label">Icon:</td></tr>
+<tr><td><img id="appIcon" style="width: 50px; height: 50px; vertical-align: top;"></td></tr>
+<tr><tr><td class="label">Author:</td></tr>
+<tr><td><img id="authorPhoto" style="width: 50px; height: 50px; vertical-align: middle;"><input type="text" id="appAuthor" class="readentry" size="30" readonly="readonly" placeholder="Author of the app" style="width: 248px;"/></td></tr>
+<tr><tr><td class="label">Updated:</td></tr>
+<tr><td><input type="text" id="appUpdated" class="readentry" size="30" readonly="readonly" placeholder="App update date" style="width: 300px;"/></td></tr>
+<tr><tr><td class="label">Description:</td></tr>
+<tr><td><textarea id="appDescription" class="readentry" cols="40" rows="3" readonly="readonly" placeholder="No description for this app" style="width: 300px;"></textarea></td></tr>
 <tr><td>
-<input id="deleteAppOKButton" type="submit" class="graybutton bluebutton" style="font-weight: bold;" value="Delete" title="Delete the app"/>
+<input id="deleteAppOKButton" type="submit" class="bluebutton" style="font-weight: bold;" value="Delete" title="Delete the app"/>
 <input id="deleteAppCancelButton" type="button" class="graybutton" value="Cancel"/>
 </td></tr>
 </table>
 </form>
+<br/>
 
 </div>
 
 <script type="text/javascript">
-(function() {
+(function deletebody() {
 
 /**
  * Get the app name.
@@ -51,18 +52,24 @@
 var appname = ui.fragmentParams(location)['app'];
 
 /**
- * Set page titles.
+ * Setup page layout.
  */
-document.title = config.windowtitle() + ' - ' + 'Delete' + ' - ' + appname;
-$('viewhead').innerHTML = '<span class="smenu">Delete ' + appname + '</span>';
+(function layout() {
+    document.title = config.windowtitle() + ' - ' + 'Delete' + ' - ' + appname;
+    $('viewhead').innerHTML = '<span class="smenu">Delete ' + appname + '</span>';
+    if (!ui.isMobile())
+        $('viewform').className = 'viewform flatscrollbars';
+    $('appURL').value = window.location.hostname + '/' + appname + '/';
+})();
 
 /**
  * Set images.
  */
-$('appimg').src = ui.b64img(appcache.get('/public/app.b64'));
+$('appIcon').src = ui.b64png(appcache.get('/public/app.b64'));
+$('authorPhoto').src = ui.b64png(appcache.get('/public/user.b64'));
 
 /**
- * Init service references.
+ * Initialize service references.
  */
 var editorComp = sca.component("Editor");
 var apps = sca.reference(editorComp, "apps");
@@ -73,49 +80,56 @@
 var appentry;
 
 /**
- * Get and display an app.
+ * Get and display the requested app.
  */
-function getapp(name) {
-    if (isNil(name))
+(function getapp() {
+    if (isNil(appname))
         return false;
-    showStatus('Loading');
+    workingstatus(true);
+    showstatus('Loading');
 
-    return apps.get(name, function(doc) {
+    return apps.get(appname, function(doc) {
 
         // Stop now if we didn't get the app
         if (doc == null) {
-            showError('App not available');
+            errorstatus('Couldn\'t get the app info');
+            workingstatus(false);
             return false;
         }
-        showOnlineStatus();
 
-        appentry = doc != null? car(elementsToValues(atom.readATOMEntry(mklist(doc)))) : mklist("'entry", mklist("'title", ''), mklist("'id", name));
-        $('appTitle').value = cadr(assoc("'title", cdr(appentry)));
-        $('appAuthor').innerHTML = cadr(assoc("'author", cdr(appentry)));
-        $('appUpdated').innerHTML = cadr(assoc("'updated", cdr(appentry)));
-        var content = cadr(assoc("'content", cdr(appentry)));
+        appentry = doc != null? car(elementsToValues(atom.readATOMEntry(mklist(doc)))) : mklist("'entry", mklist("'title", ''), mklist("'id", appname));
+        var author = cadr(assoc("'author", appentry));
+        $('appAuthor').value = author.split('@')[0];
+        $('appUpdated').value = xmldatetime(cadr(assoc("'updated", appentry))).toLocaleDateString();
+        var content = cadr(assoc("'content", appentry));
         var description = assoc("'description", content);
         $('appDescription').value = isNil(description) || isNil(cadr(description))? '' : cadr(description);
+
+        onlinestatus();
+        workingstatus(false);
         return true;
     });
-}
+})();
 
 /**
- * Delete an app.
+ * Delete the app.
  */
 $('deleteAppForm').onsubmit = function() {
-    showStatus('Deleting');
+    workingstatus(true);
+    showstatus('Deleting');
 
     // Delete the app
     apps.del(appname, function(e) {
         if (e) {
-            showStatus('Local copy');
+            showstatus('Local copy');
+            workingstatus(false);
             return false;
         }
-        showOnlineStatus();
+        onlinestatus();
+        workingstatus(false);
 
         // Return to the app store
-        ui.navigate('/#view=store', '_view');
+        ui.navigate('/#view=store&category=myapps&idx=5', '_view');
         return false;
     });
     return false;
@@ -128,11 +142,6 @@
     history.back();
 };
 
-/**
- * Get the current app.
- */
-getapp(appname);
-
 })();
 </script>
 
diff --git a/hosting/server/htdocs/favicon-16.xcf b/hosting/server/htdocs/favicon-16.xcf
new file mode 100644
index 0000000..e43dcba
--- /dev/null
+++ b/hosting/server/htdocs/favicon-16.xcf
Binary files differ
diff --git a/hosting/server/htdocs/favicon-32.xcf b/hosting/server/htdocs/favicon-32.xcf
new file mode 100644
index 0000000..c173633
--- /dev/null
+++ b/hosting/server/htdocs/favicon-32.xcf
Binary files differ
diff --git a/hosting/server/htdocs/favicon.ico b/hosting/server/htdocs/favicon.ico
index a7b502b..bd60462 100644
--- a/hosting/server/htdocs/favicon.ico
+++ b/hosting/server/htdocs/favicon.ico
Binary files differ
diff --git a/hosting/server/htdocs/graph/index.html b/hosting/server/htdocs/graph/index.html
index d360336..d01bfa1 100644
--- a/hosting/server/htdocs/graph/index.html
+++ b/hosting/server/htdocs/graph/index.html
@@ -19,13 +19,13 @@
 -->
 <div id="bodydiv" class="body">
 
-<div id="contentdiv" class="viewcontent" style="width: 2500px;">
-<div id="graphdiv" class="graphdiv" style="top: 0px; left: -2500px; width: 5000px; height: 5000px;"></div>
-<div id="playdiv" style="position: absolute; top: 0x; left: 0px; width: 2500px; height: 5000px; visibility: hidden"></div>
+<div id="viewcontent" class="viewcontent" style="width: 100%;">
+<div id="graphdiv" class="graphcontent" style="top: 0px; left: -2500px; width: 5000px; height: 5000px;"></div>
+<div id="playdiv" style="position: absolute; top: 0x; left: 0px; width: 2500px; height: 5000px; display: none"></div>
 </div>
 
 <script type="text/javascript">
-(function() {
+(function graphbody() {
 
 /**
  * Get the current app name.
@@ -49,11 +49,14 @@
  * Set header div.
  */
 $('viewhead').innerHTML = '<span id="appTitle" class="cmenu">' + appname + '</span>' +
-'<input type="button" id="deleteCompButton" title="Delete a component" class="graybutton redbutton plusminus" style="position: absolute; top: 4px; left: 5px;" disabled="true" value="-"/>' +
-'<span style="position: absolute; top: 0px; left: 45px; right: 115px; padding: 0px; background: transparent;"><input id="compValue" type="text" value="" class="flatentry" title="Component value" autocapitalize="off" placeholder="Value" style="position: absolute; left: 0px; top: 4px; width: 100%; visibility: hidden;" readonly="readonly"/></span>' +
+'<input type="button" id="deleteCompButton" title="Delete a component" class="redbutton plusminus" style="position: absolute; top: 4px; left: 5px;" disabled="true" value="-"/>' +
+'<span style="position: absolute; top: 0px; left: 45px; right: 115px; padding: 0px; background: transparent;"><input id="compValue" type="text" value="" class="flatentry" title="Component value" autocapitalize="off" placeholder="Value" style="position: absolute; left: 0px; top: 4px; width: 100%; display: none;" readonly="readonly"/></span>' +
 '<input type="button" id="playCompButton" title="View component value" class="graybutton plusminus" style="position: absolute; top: 4px; right: 75px;" disabled="true" value="&gt;"/>' +
-'<input type="button" id="copyCompButton" title="Copy a component" class="graybutton bluebutton" style="position: absolute; top: 4px; right: 40px;" disabled="true" value="C"/>' +
-'<input type="button" id="addCompButton" title="Add a component" class="graybutton bluebutton plusminus" style="position: absolute; top: 4px; right: 5px;" disabled="true" value="+"/>';
+'<input type="button" id="copyCompButton" title="Copy a component" class="bluebutton" style="position: absolute; top: 4px; right: 40px;" disabled="true" value="C"/>' +
+'<input type="button" id="addCompButton" title="Add a component" class="bluebutton plusminus" style="position: absolute; top: 4px; right: 5px;" disabled="true" value="+"/>';
+
+if (!ui.isMobile())
+    $('viewcontent').className = 'viewcontent flatscrollbars';
 
 /**
  * Track the current app composite, author, and saved XML content.
@@ -159,8 +162,8 @@
     graph.dragged = false;
     graph.selected = null;
     cvalue.readOnly = true;
-    cvalue.style.visibility = 'hidden';
-    atitle.style.visibility = 'visible';
+    cvalue.style.display = 'none';
+    atitle.style.display = 'block';
     ccopy.disabled = true;
     cdelete.disabled = true;
     cadd.disabled = !editable;
@@ -182,6 +185,54 @@
     }
 
     /**
+     * Render component move animation.
+     */
+    function onmoveanimation() {
+        //debug('onmoveanimation');
+
+        // Stop animation if we're not dragging an element anymore
+        if (graph.dragging == null)
+            return true;
+
+        // Request the next animation frame
+        ui.animation(onmoveanimation);
+
+        // Nothing to do if the selected component has not moved
+        if (graph.moveX == graph.dragX && graph.moveY == graph.dragY)
+            return true;
+
+        // Remember that the selected component has been dragged
+        graph.dragged = true;
+
+        // Cut wire to the dragged component
+        if (graph.dragging.parentNode != graphdiv) {
+            var compos = scdl.composite(graphdiv.compos);
+            setElement(compos, graph.sortcompos(graph.cutwire(graph.dragging, compos, graphdiv)));
+
+            // Bring component to the top
+            graph.bringtotop(graph.dragging, graphdiv);
+        }
+
+        // Calculate new position of the dragged component
+        var gpos = graph.relpos(graph.dragging);
+        var newX = gpos.x + (graph.moveX - graph.dragX);
+        var newY = gpos.y + (graph.moveY - graph.dragY);
+        if (newX >= graph.palcx)
+            graph.dragX = graph.moveX
+        else
+            newX = graph.palcx;
+        if (newY >= 0)
+            graph.dragY = graph.moveY;
+        else
+            newY = 0;
+
+        // Move it
+        graph.move(graph.dragging, graph.mkpath().pos(newX, newY));
+
+        return false;
+    };
+
+    /**
      * Handle a mouse down or touch start event.
      */
     function onmousedown(e) {
@@ -209,6 +260,9 @@
         graph.dragX = pos.screenX;
         graph.dragY = pos.screenY;
 
+        // Start move animation
+        ui.animation(onmoveanimation);
+
         e.preventDefault();
         return true;
     };
@@ -393,69 +447,28 @@
     /**
      * Handle a mouse or touch move event.
      */
-    function onmousemove(e) {
-        if (graph.dragging == null)
-            return true;
-
-        // Ignore duplicate  mouse move events
-        if (graph.moveX == graph.dragX && graph.moveY == graph.dragY)
-            return true;
-
-        // Remember that the component was dragged
-        graph.dragged = true;
-
-        // Cut wire to component
-        if (graph.dragging.parentNode != graphdiv) {
-            var compos = scdl.composite(graphdiv.compos);
-            setElement(compos, graph.sortcompos(graph.cutwire(graph.dragging, compos, graphdiv)));
-
-            // Bring component to the top
-            graph.bringtotop(graph.dragging, graphdiv);
-        }
-
-        // Calculate new position of dragged element
-        var gpos = graph.relpos(graph.dragging);
-        var newX = gpos.x + (graph.moveX - graph.dragX);
-        var newY = gpos.y + (graph.moveY - graph.dragY);
-        if (newX >= graph.palcx)
-            graph.dragX = graph.moveX
-        else
-            newX = graph.palcx;
-        if (newY >= 0)
-            graph.dragY = graph.moveY;
-        else
-            newY = 0;
-
-        // Move the dragged element
-        graph.move(graph.dragging, graph.mkpath().pos(newX, newY));
-
-        return false;
-    };
-
     if (!ui.isMobile()) {
         window.onmousemove = function(e) {
             //debug('onmousemove');
 
-            // Remember mouse position
+            // Record mouse position
             graph.moveX = e.screenX;
             graph.moveY = e.screenY;
-
-            return onmousemove(e);
+            if (graph.dragging == null)
+                return true;
+            return false;
         }
     } else {
         graphdiv.ontouchmove = function(e) {
             //debug('ontouchmove');
             
-            // Remember touch position
+            // Record touch position
             var pos = e.touches[0];
-            if (graph.moveX == pos.screenX && graph.moveY == pos.screenY)
-                return true;
             graph.moveX = pos.screenX;
             graph.moveY = pos.screenY;
-            if (graph.moveX == graph.dragX && graph.moveY == graph.dragY)
+            if (graph.dragging == null)
                 return true;
-
-            return onmousemove(e);
+            return false;
         }
     }
 
@@ -465,7 +478,7 @@
     function onvaluechange() {
         if (graph.selected == null)
             return false;
-        if (graphdiv.parentNode.style.visibility == 'hidden')
+        if (graphdiv.parentNode.style.display == 'none')
             return false;
 
         // Change component name and refactor references to it
@@ -496,8 +509,8 @@
             graph.setproperty(graph.selected.comp, cvalue.value);
             var hasprop = graph.hasproperty(graph.selected.comp);
             cvalue.readOnly = (hasprop? false : true) || !editable;
-            cvalue.style.visibility = hasprop? 'visible' : 'hidden';
-            atitle.style.visibility = hasprop? 'hidden' : 'visible';
+            cvalue.style.display = hasprop? 'block' : 'none';
+            atitle.style.display = hasprop? 'none' : 'block';
             cvalue.value = graph.property(graph.selected.comp);
 
             // Refresh the composite
@@ -597,10 +610,11 @@
         return false;
     };
 
-    // Create a hidden SVG element to help compute the width
-    // of component and reference titles
+    // Create a hidden element to help compute the width of
+    // component and reference titles
     graph.offtitles = document.createElement('span');
     graph.offtitles.style.visibility = 'hidden';
+    graph.offtitles.style.display = 'block';
     graph.offtitles.position = 'absolute';
     graph.offtitles.top = -500;
     graph.offtitles.width = 500;
@@ -792,8 +806,8 @@
     if (isNil(g) || !s) {
         cvalue.value = '';
         cvalue.readOnly = true;
-        cvalue.style.visibility = 'hidden';
-        atitle.style.visibility = 'visible';
+        cvalue.style.display = 'none';
+        atitle.style.display = 'block';
         ccopy.disabled = true;
         cdelete.disabled = true;
         if (isNil(g))
@@ -806,8 +820,8 @@
 
     cvalue.value = graph.hasproperty(g.comp)? graph.property(g.comp) : g.id;
     cvalue.readOnly = false || !editable;
-    cvalue.style.visibility = 'visible';
-    atitle.style.visibility = 'hidden';
+    cvalue.style.display = 'block';
+    atitle.style.display = 'none';
     ccopy.disabled = false || !editable;
     cdelete.disabled = false || !editable;
 
@@ -1817,13 +1831,15 @@
 function getapp(name, g) {
     if (isNil(name))
         return false;
-    showStatus('Loading');
+    workingstatus(true);
+    showstatus('Loading');
 
     return composites.get(name, function(doc) {
 
         // Stop now if we didn't get a composite
         if (doc == null) {
-            showError('App not available');
+            errorstatus('Couldn\'t get the app info');
+            workingstatus(false);
             return false;
         }
 
@@ -1849,7 +1865,12 @@
         author = elementValue(namedElementChild("'author", composentry));
         editable = author == username;
         cadd.disabled = !editable;
-        showStatus(editable? onlineStatus() : 'Read only');
+        if (editable)
+            onlinestatus();
+        else
+            showstatus('Read only');
+        
+        workingstatus(false);
         return true;
     });
 }
@@ -1912,17 +1933,21 @@
  * Save the current composite.
  */
 function save(savexml) {
-    showStatus('Saving');
+    workingstatus(true);
+    showstatus('Saving');
+
     savedcomposxml = savexml;
     var entry = '<?xml version="1.0" encoding="UTF-8"?>\n' + '<entry xmlns="http://www.w3.org/2005/Atom">' +
         '<title type="text">' + appname + '</title><id>' + appname + '</id><author><email>' + author + '</email></author>' +
         '<content type="application/xml">' + savedcomposxml + '</content></entry>';
     composites.put(appname, entry, function(e) {
         if (e) {
-            showStatus('Local copy');
+            showstatus('Local copy');
+            workingstatus(false);
             return false;
         }
-        showStatus('Saved');
+        showstatus('Saved');
+        workingstatus(false);
         return false;
     });
     return true;
@@ -1938,17 +1963,17 @@
     var newxml = car(writeXML(composite, false));
     if (savedcomposxml == newxml)
         return false;
-    showStatus('Modified');
+    showstatus('Modified');
 
     // Save property changes right away
     if (prop)
         return save(newxml);
 
     // Autosave other changes after 1 second
-    showStatus('Modified');
-    setTimeout(function() {
+    showstatus('Modified');
+    ui.delay(function autosave() {
         if (savedcomposxml == newxml) {
-            showStatus('Saved');
+            showstatus('Saved');
             return false;
         }
         return save(newxml);
@@ -1997,7 +2022,7 @@
     cplay.value = '<';
     gvisible = false;
     pdiv.innerHTML = '';
-    pdiv.style.visibility = 'visible';
+    pdiv.style.display = 'block';
 
     // Get the component result data
     var comp = sca.component(gcomp.id, appname);
@@ -2030,9 +2055,9 @@
         return displaydata(t, '100%');
     });
 
-    setTimeout(function() {
-        graphdiv.style.visibility = 'hidden'
-    }, 0);
+    ui.async(function hidegraphdiv() {
+        graphdiv.style.display = 'none'
+    });
     return true;
 }
 
@@ -2043,13 +2068,13 @@
     if (gvisible)
         return true;
     cplay.value = '>';
-    graphdiv.style.visibility = 'visible'
+    graphdiv.style.display = 'block'
     gvisible = true;
     graph.compselect(gcomp, true, atitle, cvalue, ccopy, cdelete);
-    setTimeout(function() {
-        pdiv.style.visibility = 'hidden';
+    ui.async(function hideplaydiv() {
+        pdiv.style.display = 'none';
         pdiv.innerHTML = '';
-    }, 0);
+    });
     return true;
 }
 
diff --git a/hosting/server/htdocs/home/home.b64 b/hosting/server/htdocs/home/home.b64
deleted file mode 100644
index 9131135..0000000
--- a/hosting/server/htdocs/home/home.b64
+++ /dev/null
@@ -1 +0,0 @@
-iVBORw0KGgoAAAANSUhEUgAAAaoAAACRCAIAAAAdJ2t7AAAgAElEQVR42u2d+1NT1/r/99/AD2f68ZfjOOOFGXFwhoLScqgow2E+IAylGUqDCAlBwFgU6yUwMkVyKnKGD7anSvVrcWwZLziVFioUiQJCzIWQkHATwrXCEVQUBP1Uz6d+n7XXTtgJm5ug3J5n3tMuHtfaa+2tefE8a629wrxGQ0NDW5HG4CNAQ0ND/KGhoaEh/tDQ0NAQf2hoaGiIPzQ0NDTEHxoaGhriDw0NDQ3xh4aGhob4Q0NDQ0P8oaGhoSH+0NDQ0BB/aGhoaMsSfy9fvhwbGxsdHX2G9tYMHi88ZHjUS+75L92R4/NchPe4iPA3MNyhsV4pbzz1q/mr4oZM1FsSPN5yS6763qX23obBwcHnz5/T5/981DrQ90NfV9b9zmP3O1MXpWBgWX09F/vvG/kjb3nyNK/5nsJg3KczJGnrUDMUPC5FnfGMpVnf3ePwL2GoZbAxr1+r6Ffv669NWtqCW4AbaTjT36Xn3+Piwl/rv6tKzEpk07sUPHBjR3l/f//w8PDTxxX3O9MWK/UmKq2v9wYdeUnv/b1IvbkJHuD1e230eQ73lPSr9y556glwcG9/y3V6j4sLfxD32dlXYs4sa838rS2zvF2JekuCxwsPGR41PHaIAXu67va0HaVk6e9Kfdyf+nQgdXhwMQoGBsODQQIBIQa8025NUOu4j7HOkGC0JDQ0JZibUTMVPC6jBR4dEBBiwJ62O73VCZQXD9RJT/QJI4aEZ/VLW3ALcCNwO4SAXXog4NxjwPnEH+S8lH1lrcrb9zMr+5SodyB41PDASRbccrqj+SCwD8jyn1HF/40tdsEgYaiQBWdr66TVtcC+RKNF1tYpa+9CvYnaOuEBQhZsvZvdeUsK7HuiT/yzSfa6efkIbgduimTB/f2QBS8W/L18+bK88RSN+5B9C0DAhv9pb063Nqfc7zy6JNhnJ2CP9cT+O2pJVU2Ctg7ZN3cCKrQG6+39nSpJf03CMmOfnYAPdIp+1ua4EjJv+BsbG6NrHZCOTftxVdXHfRYXVNCB5JpEPYcPy33/UXts5k1KGr5qbzoE+BvoPbyQRBved/6gd5Hl4Myb3O84JquqAfzJ6oz2j7G4MNd1x1aXLYnhjXPCgbT2qvvu9IjGhWk+hew3GHYLujgGXUhrCmhhjlfepzV0qGSAv4d3x5HxoibCqpHOFjRv1uod6EmdnOIPsLMo8Dc6Okoz39/apsdfaVkQw7x3ug1JN4naktYwTHxZ2izwZ84E9oEe3j+0kPgbiHVjmBO1B2bepL87TcLiT2ow2T7DdV4Mw6yP9ErLnSMOYorSGWZDqGlhmk+u8Rv8+HomlKCLmKJjtDDHi+/VGYB9oMd2chnDQxgm43L07CjzZq3eiUYMCRR/gJ1Fgb9nz55R/JW3T/9ZLVXBg3X9vgfKx1U9yDvH0BgeSE/yhwwjnw3+fjUrKf4e9S1Y9PfHMER/8fBXm/tG+Iurb7B9hitdGca7pHXuoIkpUTKMf3hLl6ylXfqGzbeGzT/+BG4wpgQ4uCVsHvBXT/E3pI3jeGEhIDtVJJkdZd6s1TvRs3oOf4CdRYm/nuTPXN+L/+kwG8vIw13fi81PIeWOZCjv/3//Db/8wuXvM6z5p+4uZT/DxVVR/q7U9568IBk85bVRH7q6xx50rmkXW8E1PM6dVvjsX0kq1n+xIGQzw9haRRWzzis/cc41YR8Ee7/32dkUwU6dVFT2qb1CfL58ik5nPn7BTq9c5Ya3OeA9+O+BOeOvqSjwI9tD+Czzk0djijHLJyEb132+dyN1HsmP/WMSJzR/VP/JZ5zvL7lF8f/H1dz4Zdpm2zUjnrI1u37lOvoo6C/w3zNzwV9jhZttzGszSsEjOq9cZfccOhfNfsijik6vXU99W30uVtJPfnTZOZtzg/f5Chu/GNfdodTrkVMsdW6+wSuvVDaJk4e/xoDPQyFe26lt57NGojq3er2/m+367uz1WefWtSFbSdc5Fc6XdbxBScXp/1ofE25ywN/EG5kD/mIKgrnukrNF4BkqCRCvow6XU2fBIy2Vr2Y83Q16Qhbrtx4Ms/rnil1OrRB/s4r+vkwLYxhxFHChuPjv5CmGicq5tHfN/xSFEI/rBzmq5K//5QtFueqYqn43ZHyb5R9frP+COuN/UrBxIhipmZPzAYGC6tiEQBIu5Xtad/js2R1QjL16uLyKOINPxF6p/+J0PukdEsnSKhEUPkyNKtDJD8cRvnyYkSTYqQNe2VZr4j6+qEvJOUEG8FnBF4Kdznz8gjXLecOTB5A2U0R/5R3KYvNxUHnHpPgbqw+Ei8i+juyyJqmv+jFsTvpUR5zMRm916z59wd+g+OWv+wSdf1gj4FP60cGgJmtSVb43aX4zmavJeN7Uxd/M8ySk0x0cqyf3GJL5SVurNDeI/PEU0d+zh4r+LiIoTBL9tUZcIsxyO1EgqmqUlLH8yiiIrK0JzTsCZa8ik0x7wQU+u7uzRVWVgYf8ye+VqnZpLXGu2pMtqq3ZmZNIal6qo/hj1ifuLKukTo+LdZKyXMbWPCgjlmDrfI2gkwseG03+u8ll/EpMQuEhuX5olSbsm/3s9TWck4n1zUjfnvfVhMve4t9gTAnkvFtCefgTvJHJYLfH0pKoM4CgMFn013eZ/BuTpQdaaySvKgM3Mcw2mW9LZWR1Nvn9nfVD1GvNTvJXuCtgsIL8y9umCHVqhfibdfJ7rQAen3tBn/L0wTXsv4b3L5Iy4eDPLBT+UU9rHv4MUHU25fsTEA6tOalLKzYriptT4r3Ha57k1XSaESt1qHD8yzDSqrTti6+vJhX3KMvb0q6UfbqGRcnZjDWMa1Ax1zAZfrdtzkgS7LScd33SivG9wv14/B9i+CwG/STU6XczHr9gp9+Sjv7ODa9DPkXya7yX3dZ0GFSt/+pK1fFfjMcF8ffHQFLVr7GPhhVjAwe6asPcWCpRft2wUvQcuRrBMBEhA0JO3dcQIfz1RuuBR93Jj/oSTvxtYs19R1ik6rP/CmR4RAH3WDpF8nu9qTCxuhr0490Ckz6t15omnPy2lK625YZSU83Oi8XRLV0Skymy6DRwwbvIFJ4ByIuN5BCgCTiRG65tZ51bA6tM0dq66LpKry2A5NxdLIkCamnNdj8Y3I7snWkQlyXamrcHwP2uTxd0itnmq7Yw7JXbJ8mOmUDb9f1DeJ1WEWeY0GVjeDdop569IHgjEiH2Fel/vKdKBF29dfbzctVejW7a5NecDj2vKq2IHqqJGtJEZMGvsGC/FxASXvbmgr1g3yFMfudh7q85Dj7QaSo5/MqLL4iCv9LDZUlQDi84zDJrzVlu6ePYYW9mTUbSxX+9z/DNFVLUj39yrHnAVRh/XzdzP17McSWM6zmcdtCdfzE5Bx3fIq7hl1N0ys+vv4cLMjtsrej1Q34S6vTMjMcv2OmZE/yOvpx4p/a4D8BHSQcFwB8InALJ7/C+H9M28vux46+qz5Yd561jNvp3Cznv5m92GORGxi0i6D6p+Vf9AK158MxGgj8TAeXfHnCMO0SdgnEfgI+SDgqAPxAJBifir7F4FYs5FoUav89D+QMBvygHPOkxjiwQ5UQ6DHg94xKSzfJrfO1CdMKfkA7+y+yPauc7laFCTjEXx7H9CoVgFH876xyuH8nGjGHsok240GVjeDc4EX+CNxIjFPcB+CjpoAD4A4FTAH/GsG0AsmtkEaMl29Xh4uuYTcG+I2yOzEbzTJ6dd7xWiL83WPo4dhhCadf3yCpHxzESIrGf9dPNSif8HfAmgRgbZ31gi7OUV8piTxanlDjWlAvj7z07iU7G0UAMLuX6D9UXpZAb9sg3s8n192x4ZeNLSjgzaacq5+hvHH8n5YwNf86dfjvj8Qt2mseP/iZf+oCEl2Kut/0g6KdqJeAPnDcbTzjhz/Q1BGXrinRJTx8DCqUfAf50B1n8/cVOuht77TGds7OWxHSeXTZ4ddVG3qhMeOyIv9yNE6K/yZc+IOGlmJPUakF3dUrAHzjbO76aAn/hGRA9+QeU1MQ0kqiQ+Esa2ZBqv5gDgcl/T6T3xRrWaQ8JuyKLCgILK6PZyTsb/tjojIv+xpEUuAcQoxR0Unru1JoCSfLrH1oniL8NdvyRalz0xy2YhAlddmr8Cd7IxHWbRNsK7/0qotSbNwB/4FTo1YL4o1wzKKBnt24bR7qvBZVejngFhW/t4YK71eLcCvH3Jiu/V/LJbBcTEALpZAENeegM4AT8QSDGTX4djLrSnFbwEwmv1qTuKZ0R/pg14pCC5rTv88k03GdXD7Oke//r+mPlbV98KSepd/C/klX1UWRVQS76vipJzrL4w0k6dbz+x+yU3O6iNu76kKcLdjrz8U9S82N2vjKuqO3wyVT3yeb++FHeo74vDMZMIGCxJtPSZPd/weGPUGlzlfXg2EDS1YN/JfM4+fE0+nOLDWzrO2C6Sqb5jtjm/pyc3Ixe2iddfQfabrIVMqOfCuHvqS6InWSMejCw70bmxsnm/vhRnsTQkKP+BQhobMg8pb/BYdEghD/CgsidtY0SU43/Hm4xQaIi83Rr0y5EmUyhOWRCzU/VSifvVn9+LrLORCfXXA5d56bhQpQRdaZwdurQnczNZZOah0jz8DwyYef6TeUkThZkjWRIa8l1cmOEoj+XCHr9/bbrj+NP8LKyKfEneCNTL3EMaSW3b2cBARVVN+/U/NPmt5FLHwogE6f4t1TGvCghs8Ah8oBuTbT1Bx8ob0oJ5SYEFWGAPPK3Lgt85dgK8fdGG1/MsRB5+eeQ1VJV7ad03YBdT4CPuisffx+ydQoKgtbYfgdtjhNd65lQczL82ScuMmLLybbq2GD7dcS+wRDvx0WR0En16Yes88O4Hf5kMIcFO3UizkV28YS/dCvY6azGL9jpRXYVhQ2RXdn5ymNT4+/50BFa5guc3NyfNVJm6+KjWG8ZcGnvJxzp/sb5ZdmRY2MKQSdcoa3I375G+dHekN+HFWP1QLp1TskvyZcLbI03rmMnGQ9Ojb+4pnsc8ngCpw1/pWttdJDWFtiztVURia7rGWb3OfBHnE+3/6W429Zzwelir7w7V9wCKMmm+SP3XDOu0poiFoW21eQLMZM42eb+FGRRl47Q9RAB/G2xXT+tQOLYSrgv3g2ylXew+OMKgjcyNf7+1xRHy3yB08aL6HyRCyUdWds9673JdvFtu/z6LFGnIO1d50FDwr6zHtx6iGMrxN+87fub8oWHL4ub00o7js+wPreFsEOpaksrdXiN5Hip43WuFPw9+GAUN6/XkbSZv4oybadchWk7nc34BWuyTtWMN7gM9H7BZx/86AidI0/7Djx9fMTuYUm3zvRY8cfAAZIUT+60TSAeeuR4hcnf9yA1/5jxBhep3shnH/w4+WaO9pg6U0zjhJWHltZo8LcIOSdUFrgCV7Nreue0+wobu6SmKVvN8rJT3MhkG1we3pXy2Qc/Or/CYZS8Gt/TJxnSRI8Yp3+jw6EV4u+t42+WmvkLJKpaEZt9+x448Xc2BnQ/3fzWO513TVzigHAPEl6QPe6bQk9rIer9i3pgeue8a+ISB4kBDSQRHo/7lpre2mshb7K9GcI9SHhBvLhveQrxZ4OaOelAxqdXZvb6cJEqNl7+QbD4/dgTn15sPv5uOl1U+PujO/ZMdljX4+mdiL8ZvRSsLfZJOx3ZiPhD/C0E/laanPB3r+PET9VKkMGYKZT8LiI54e+0peKuTgnKUf8yXfKLmh5/Wn1u6s0boNu3swSTX8Qf4m9Z4Q/CPbrxBQQFp6WPxYw/CPfoxhcQFJyXPlCzxB+Ee3TjCwgKE5Y+EH+Iv+WCP7rtGfA3cF9B2XftLpHTxpdFiD+67Rnwt9dYT9nXalaAnDe+oGaGP7rtGfA3oNtL2SevvANy3vhiU8u3XjKRu6rC5tdEFKZ7HZW5HZX7VJdxu1teaUSFCq+ju9wyFP5WvdMhCJF5MtfklMAReuxK2c58pW9Bth+R0rf0WhRbTdqS75chc0uWef18TQyewWuB49XYmgVnw8lyikZUoPBI3uV2NMXXUClB/KGmV7H5eLX+KyBgV9uRu+YTFH/gXAwnvkyDvy7Fj3cLgIBJavUVwyWKP/L+r8CJL6jplagzXL11FgjYVZVYWPM9xV8iOe9+wlsflFYl/nS/C/cihz5M7PD+B5NfFPPaGC5z8Nm2QLOqTmGPnvD0oW/FsTuo+W9WhQL7VCkOzrxr0YYUF8bZvIb0Th2tUtVIEX+oaVTewWW7fAm/9LbI9OyhgiKPL+GX3lAz0B5LC0UeX8IvvTVLDNnj74BS/HXnsns6Pb2sGnHhLsKsbSmhfflste0+3TXhR9lTYbJs5/2NFPlw7T192OhPwh4J43JU4Zuv9Dml8Cq8LH5dE8QS1qWwRGxQrqVM7Lvsf0rhk5fuk690t20sFY1cYzfNbvfuM0b/vIvwMSNfjPhDTa9fjA7sm+zIg0WoXqsD+yY98gA1w/xXo+Ozb9IjD/Shm3iBFsWf9ay3ePvqjG8jyeY+Fm0hirChsrBCpU9pCaSiUnrgFYc/S0Syvf12Fn+WCJaPLoVFkd0VYro3cDCfbJneJAsYaZYOloVVXw7v5p0W86LIlzaH4JHiL4Q9WkYjJ/BNZkeC+EPNKAac9sCrRRsDTnfgFWp2MeC0B169NoqydnkZasT5wbzkd1xRp7YTv1hpO9dPHyajBwJ6eg2yHhVLKLHcI8TGr9eandscste11ZVSg2I137VN5NM3njtHZbG4/LmCJrli9kdmk+cqFs2uZj3O/aHmbz/gUhHi723vB7QpJm/7BPxZxHnBztN8L0r8tjF0wm71zyUxQ9fYtFfk/6o5gkzYbfd9QWYS/QBbm7Z7qK6F5rG5MxPsp09fSyfysnIDTolWsZFgEA0M6YFam2SBNiLT4JFh1nEdlVZIEH8oxB/qbeEv3wl/RhHHIMbNoHFGTB+byTIiv9Pbac7rniXj6CaT+VqNvMoadsrP0+d2+mrKQfbEBDY89PQe4nWdV8StL7ewM4MhKTvJLCT7ljGzK/AV4g+F+EO9E/yxJx0QQnnYWdbCrpAcZafhhi57sX/qnes5YeWWcSk55yP2dJGlh5G9MmV+tGYnPTOVIq8mcJO9bAwnq8zrPPpsg6FpMk236cyjbUUF8YdC/KHecvJrzR0/93QTGwOGpIdxC7KMi0zmSldLknMjXuijhzTRQ/qYkcqgEHZCsFsvfUGRxzAyuQf95hByXpZFRLezbAt2E3sy9nPzuYlCHuAGKVsZl2S5RwjbXJwdgckvCvGHemvRX7Btf1+zpHCX83a8EBLKSTW8LTLJ6TtfOH71pdi+8tssNed62BeUxba90C8qgrhlE7igzH+Qt2mGrvPaJDVke4x3LQ8Ywm3PKMQf6q3hb+ZfcRnDHoc1k5oSNjB02rEsHSFOyZs2R/yhEH+ohcIfvvOL+EP8If4Qf4g/xB/iD/GH+EP8If4Qf4i/RY+/DpVsmePPKEf8oRB/qAn409dbb+8H/D3WLNsTT0caFBR/o6OjiD8U4g9lOwhLb7xXcxLwN6hettHfk9bvKP7GxsYQfyjEH4pTQr25sb68QyX5vTr+z6ZlyL4/m+Mf9Bgo/l6+fIn4QyH+UDb8mZubmpqaNRd6q/c80ScuMwIC+550lVD2DQ4OzpFaiD/EH+JvueGvs7PTbDZ3NFf3N5x5oFM8qZOPGBKe1S9lGeUjDQrIee1xH9jz588RfyjEH8oBf4CGtrY2q9Xav3xteHh47tRC/CH+EH/LEH80NwRbfuCDm5p73If4Q/wtFvzJEH/zJ7mlhb8n7uXLl2NjY6Ojo8+WuMEtwI3Mca0D8Yf4W1z6d3fa/jtq8k3nBhPCa+5SNLXOy5bglWCIP8TfAutBT+pJtZZEf/p6hNfc9V1bB+IP8Yf4Wxp61JdaXm8E/MWrtbK2TuTXXBTf3mXo/R3xh/hD/C0NDQ+mNjU1XdDp96i1iUYLEnAu7Cvp6bUvESD+EH+IvyWAP7pPrbql9YylWaEz7NMa9upA9StR+vpEvTGh3pxgbnZWvRn+CCo4Nflcb0ytN55paFS33rPabHBwcJEsLyD+UIi/qfBH96mVmy3Z2rr9d9R0IXgFCm58f7X6pFpbXm+EiBh+K9A4rqftjvVutvX2fnqUy7TqqZT11ybNWup9/VpFf8OZ/i79PG4uQfyhEH/T4O+HBktctVpaXStZqexz0gWdHiJi+K3QbfqhYwbI43RL2lu9503Y58DBvf0t1+drazHiD4X4mwp/d9qtZOmjVpOkrUvQ1snqjFKDKa6+YQWK3Li+Pl6t3aPWVre03mso77wV11sVD/q9On5QHfdYIx3Sxk3UE51suC5+ju+WjRgSnugTHqhZAnbp5+XFMsQfCvE3Ff4g56X4w6UPTm2d8CjOWJoh54WADiKyd3l4AXQE3ZEseD6OFUD8oRB/U218odueceOLEwEVWgM9uLS/JuEdH9wC3T3QKeblUCnEHwrxN9W2Z+6lN962Z4m2MqKufWpASGuuuu8+FtHoXH8mbedR4sJc1x1bXbYkht3ixiOtKbAPbC7apzXQtY6Hd8fB9KImwjr7Y5zfoNWTOvm8HCmK+EMh/qZ66Y1O9o+/9NZYupphPAqneQcupiSTYdaHmhzrz6zt/KnOi4FRRHql5X58nRtPTNExbmBz/dYOA13QGD+23hgewjAZl6Nnh783ajViSJiXA+URfyjE32xOfGkhCPMuaZ0pKfj1Z9t2rqp0ndAdy+UtYfOAvwnf2WYhIDtVJJnl15a/Sav5+jY1xB8K8Tdz/DX6wYeVNbd/fu++foPXJQ3xm0rd1m/wyKtkQ7wKKPucy/mv9THhJl79nGJeuQJqRpedW7ueOjZ4nyceierc6vVb14ZsBZcrW4dLmYnf3213KK3tnlMsnVA5qui0/WpeeaVkGFxvzNqMUknFaXY8DvibOIA54C+mIJjrLjlbBJ6hkgDxOupwOXUWPNJS+WrG092gJ/yyfuvBMKt/rtjl1Arxh/hD/C2Mnj1U9HcRQWGy6E9cmE1wk3EhQttAcBZxDkgUXXiEfIJDciUQXhWlM8zWoMuQY26BHJNXv5VfltZecGGYVXuyRbU1O3MSwe91qS6mRMmiINY3I92/xMQL2Vj/+sTQKk3YN/uh6HFRw6+8Pe8rgpnd2aKqyqCMWILI87ciLpEKbicKRFWNMSXceOz4ExzAZLDbY2lJ1BlAUJgs+uu77AsXkaUHWmskryoDNzHMNplvS2VkdbY7+LN+iHqt2Un4vytgsCIA/r9NEerUCvGH+EP8LYyuNxUmVleDfrxbYNKn9VrTpk1+xeeBGqER7V2hn29lSRQpImXCwV32IGuS5Dc8wx8oGVhlitbWRddVem2hrQiwAqomziQSf2At/bHdP8S5clgaDCAxkqvfHhABrEyP4XVnp569IDgAiRD7ivQ/3lMlgq7eOvt5uWqvRjdt8mtOh55XlVZED9VEDWkisjwZJtjvBYSEl725YC/YdwiTX8Qf4m+RxH0APko6KAD+QCQYnIi/xuJVwJQiNjSruwoBlF9JKSRyXufPrWUY36JiKLud14znmPz6vLIoJ5Lh23rGJSRbTIjmH9YojL+dddyPohP+QLdIXuVw8DD7o9r5FZQxvO4m4k9wADFCcR+Aj5IOCoA/EDgF8GcM2wYgu0YWMVqyXR0uvo7ZFOw7wubIeZ7EkWfnHa8V4g/xh/hbiDm+LgX3RletFnRXpwT8gbO94ytB/Plw6wmNvjsAHBsAQ+GNjf7c1J5/aF2XE/64+rwyG6/F2uK1rsiigsDCymhCtK0T1yVY/G2w4y9wtz364yqzVxvHX+AeZlr8CQ5AOvGbeW0rvPeriFJv3gD8gVOhVwvij3LNoICe3bpttOq+FlR6OeIVFL51txHR3WpxboX4Q/wh/hZ4iUNiaMhR/wIENDZkntLf4LBosOHPdB0+2WsPnRbVNhJq5JGJNmaHEtLGCBpP0RlAO/749XllSVku1F39+bnIOhOdpHM5dD1mKvwxLhHKiDpTeB6Z+3Pn5v64yjElZFZx9aELUSaugus3lbIp8Sc4gKmXOIa0ktu3s4CAiqqbd2r+afPbyKUPBZCJU/xbKmNelPiRJyEP6NZEW3/wgfKmlFBuQlARBsgjvylkga8cWyH+EH+IvwXGX1zTvYkv+YPTRgSTb8SGcVhoCwjRTpSSrc6q0+yaQw1Zqy0DHu1g2cSv79A24ny6iy0WWrU7V9xCW/lPir8tXGXXtAJJu3NlUd4Re6659tCFGHab4Vob/uzjYUFJByYwgKnx97+muIkHGYDTRqXofJELJR1Z2z3rvcl28W27/PosUacg7V3nQUPCvrMe3HqIYyvEH+IP8bfAG1ykeiOfffCjExQkja3S2WwT4dd3aNvSGl1nimlsn24TtZLNr7ukJqg8xR5DerXZ7OCbbgBOc3wP70r57IMfnV/hMEpeje/pkwxpokeM07/R4dAK8Yf4Q/wt7PZmEgMaSCLMi/sWTOxmmg1zf1VjXrY3Q7gHCS+IF/e9ayH+EH+Iv5WCP6m22CftdGQj4g/xh/hD/L1l/J22VNzVKUE56l8Ek9+VIyf8afW5qTdvgG7fzhJMfhF/iD/U0l76oBtfQFCYsPSxcvEH4R7d+AKCwoSlD8Qf4g+1NPFHtz0D/vYa6yn7Ws0KkPPGl5WHP7rtGfA3oNtL2SevvANy2vjyqjI0S+aWkR70AvGH+EMtrW3PP94tAAImqdVXDJco/sj7vxPf+lhhStQZrt46CwTsqkosrPme4i+R/a47pznBkWte7C4XryHEH+IPtbReeqPI40v4pbcVpj2WFoo8voRferNEGX7YqSkRz4Rfr4zSV4g/xB/ib5Go1+rAvkmPPFiB+a9Gx2ffZEcevKgIlG1fLZYHjFDA1YRliVZz255FXoYabpFk8Jq/7SCsVafyIxB/iCESqGQAAAU2SURBVD/E35I58GrFxoDTHng1co283MZ4+pDkVx8mpojzXCv25F5FMesBkf70BECZzJ2+IX1qtqdDI/4Qf4i/BTvtGTUZ/oo4/EH0Z80lZ61uEvnRU14K2dfaxMpwAzkIiwlJIcsj3ex7b/QgLMQf4g/xh/hbJvgzp68lZxpkc7lti5L8GKIIo37Hs7C8hxB/iD/EH+Jv2eCPnve3aVcAu74hLZWtIvhLDzco2OhPsXPEIukrCsrP9lddi3iF+EP8If4Qf8sGf69rgjZxix7uR0Wr2OJqjcZ+2vOqLKVvMjsnOKuDXhB/iD/EH+JvMeLvRQn51g5muw9d+R0qsq/wQobrqirjjvNr+dbLnviG7PIbxJVfxB/iD/G3pPH3Si9WKdmvlvN0mMt7oY8e0kw4x9QSM8ODsBB/iD/EH+JvsePPkMIdnLpJFoTv/CL+UIi/lYS/XO/kXW5HU/y6jXjkAeIPhfhbYXN/eOIL4g+F+Fsp+OtQyRYMf0Y54g/xh/hD/C0Q/vT11tv7AX+PNQtw4ulIg4Lib3R0FPGH+EP8If7e7UFYeuO9mpOAv0H1AkR/T1q/o/gbGxtD/CH+EH+Iv3eqhHpzY315h0rye3X8n03vlH1/Nsc/6DFQ/L18+RLxh/hD/CH+3i3+zM1NTU3Nmgu91Xue6BPfGQGBfU+6Sij7BgcHXy9fQ/wh/hB/ixd/nZ2dZrO5o7m6v+HMA53iSZ18xJDwrP7tyCgfaVBAzmuP+8CeP3+O+EP8If4QfwuAPwBQW1ub1WrtXwgbHh5+vawN8Yf4Q/wtavzRDBTsXYIPulvecR/iD/G3WPAnQ/wJSW5p4e+8e/ny5djY2Ojo6LO3ZnBx6GIZr3Ug/hB/i0v/7k7bf0dNvuncYELk8aVoal32G48Rf6gVjb8HPakn1VoS/enrEXl8fdfWgfhD/KGWM/4e9aWW1xsBf/FqraytE6lHFd/eZej9HfGH+EMtZ/wND6Y2NTVd0On3qLWJRgsSkLKvpKfXvhCB+EP8oZYt/ujutuqW1jOWZkV9g9zUmNDQlGBuXmmSW1oUza2Q89rjvmW/8w7xh1rp+FvY3W2L2Zb9zjvEHwrxtzC72xazrZCdd4g/1Pzgr73p0NLEX/o73t22mG2l7bxD/KHmA38WZXtzOuDv4f1DSwt/TwdO4gQ/GuIPNRf8ZbY2fgv4e9CzxPA3NFCA+END/KHeXKUtmY3mW+1NKb3th/8zumTY95/R1Af9DYg/NMQf6s0Fj52cDWe51N125HF/6pIgILBv6OFN3N2GhvhDzRV/3Nlw7eq+nov/7sl63H/s6UDq8OAiVPrw4EnIee1xH+5uQ0P8oeaEv6W7ew53t6Eh/lBzxd+S2z2Hu9vQlgn+fjV/hfhbKFVYs5bQ7jnc3Ya2rPAH/6DLG08B/n5ry0QYLYA6vsYFBDS0hcEf/DJX37sE+CtrRfwtgOp6ChF/aGgLgz9IZNp7G0rMyhJz5u37SMB3rY7fzYg/NLSFwR/Y4OCgsaMcCFjWqkQCvks191Xi7jk0tIXE3/Pnz+HjBzEgZME3m0/9du/Eb21kJQT1NlRhzars/AZyXnvch7vn0NAWDH9gw8PDeFQR7p5DQ1uJ+KMxIJ7ahrvn0NBWIv6orfBT23D3HBraysUfGhoaGuIPDQ0NDfGHhoaGhvhDQ0NDQ/yhoaGhIf7Q0NDQEH9oaGhoiD80NDQ0xB8aGhoa4g8NDQ0N8YeGhoaG+ENDQ0ND/KGhoaEh/tDQ0NAQf2hoaGiIPzQ0tJVs/x8Xr6boMQqPwwAAAABJRU5ErkJggg==
\ No newline at end of file
diff --git a/hosting/server/htdocs/home/home.png b/hosting/server/htdocs/home/home.png
deleted file mode 100644
index 8f5a0b0..0000000
--- a/hosting/server/htdocs/home/home.png
+++ /dev/null
Binary files differ
diff --git a/hosting/server/htdocs/home/index.html b/hosting/server/htdocs/home/index.html
index 130c05f..d7a098e 100644
--- a/hosting/server/htdocs/home/index.html
+++ b/hosting/server/htdocs/home/index.html
@@ -19,7 +19,7 @@
 -->
 <div id="bodydiv" class="body">
 
-<div class="viewcontent" style="margin-left: auto; margin-right: auto; text-align: center;">
+<div id="viewcontent" class="viewcontent" style="margin-left: auto; margin-right: auto; text-align: center;">
 
 <br/>
 <div id="hometitle" style="font-size: 28px;"></div>
@@ -29,22 +29,27 @@
 <div id="homeanimation" style="width: 320px; height: 280px; padding: 0px; margin: 0px auto;"></div>
 -->
 
-<input type="button" class="graybutton bluebutton" style="font-size: 21px; padding: 10px; height: 50px;" id="getstarted" title="Get Started" value="Get Started"/>
+<input type="button" class="bluebutton" style="font-size: 21px; padding: 10px; height: 50px;" id="getstarted" title="Get Started" value="Get Started"/>
 
 <br/><br/>
-<div class="note">Requires Safari 5+, Chrome 11+, Firefox 4+, IE 9+</div>
+<div class="note">Requires Safari 5+, Chrome 11+, Firefox 4+ or IE 9+</div>
+<br/>
 
 </div>
 
 <script type="text/javascript">
-(function() {
+(function homebody() {
 
 /**
- * Set page titles.
+ * Setup page layout.
  */
-document.title = config.windowtitle();
-$('viewhead').innerHTML = '<span class="bcmenu">' + config.pagetitle() + '</span>';
-$('hometitle').innerHTML = config.hometitle();
+(function layout() {
+    document.title = config.windowtitle();
+    $('viewhead').innerHTML = '<span class="bcmenu">' + config.pagetitle() + '</span>';
+    $('hometitle').innerHTML = config.hometitle();
+    if (!ui.isMobile())
+        $('viewcontent').className = 'viewcontent flatscrollbars';
+})();
 
 $('getstarted').onclick = function() {
     return ui.navigate('/#view=store', '_view');
@@ -53,22 +58,24 @@
 /**
  * Display animation.
  */
-var anim = $('homeanimation');
-if (!isNil(anim)) {
-    anim.style.background = 'url(\'' + ui.b64img(appcache.get('/home/home.b64')) + '\')';
-    var bgpos = 0;
-    setInterval(function() {
-        bgpos = bgpos -280;
-        if (bgpos == -2800)
-            bgpos = 0;
-        anim.style.backgroundPosition = '0px ' + ui.pixpos(bgpos);
-    }, 2000);
-}
+(function homeanimation() {
+    var anim = $('homeanimation');
+    if (!isNil(anim)) {
+        anim.style.background = 'url(\'' + ui.b64png(appcache.get('/home/home.b64')) + '\')';
+        var bgpos = 0;
+        setInterval(function homeanimation() {
+            bgpos = bgpos -280;
+            if (bgpos == -2800)
+                bgpos = 0;
+            anim.style.backgroundPosition = '0px ' + ui.pixpos(bgpos);
+        }, 2000);
+    }
+})();
 
 /**
  * Show the status.
  */
-showOnlineStatus();
+onlinestatus();
 
 })();
 </script>
diff --git a/hosting/server/htdocs/index.html b/hosting/server/htdocs/index.html
index e3e0461..6ea6f80 100644
--- a/hosting/server/htdocs/index.html
+++ b/hosting/server/htdocs/index.html
@@ -17,50 +17,69 @@
  * specific language governing permissions and limitations
  * under the License.    
 -->
-<html manifest="/cache-manifest.cmf">
+<html>
 <head>
+<!-- Firebug inspector -->
+<!--
+<script type="text/javascript" src="https://getfirebug.com/releases/lite/1.3/firebug-lite.js"></script>
+-->
+<!-- Weinre inspector -->
+<!--
+<script src="http://www.example.com:9998/target/target-script-min.js#anonymous"></script>
+-->
 <title></title>
 <meta name="viewport" content="width=device-width, user-scalable=no, initial-scale=1.0, minimum-scale=1.0, maximum-scale=1.0"/> 
+<!--
 <meta name="apple-mobile-web-app-capable" content="yes"/>
 <meta name="apple-mobile-web-app-status-bar-style" content="black"/>
-<link rel="apple-touch-icon" href="/public/touchicon.png"/>
+-->
+<link rel="apple-touch-icon-precomposed" href="/public/touchicon.png"/>
 <base href="/"/>
 <script type="text/javascript">
-(function() {
+try {
+
+(function roothead() {
 
 window.appcache = {};
 
 /**
  * Get and cache a resource.
  */
-appcache.get = function(uri) {
+appcache.get = function(uri, mode) {
     var h = uri.indexOf('#');
     var u = h == -1? uri : uri.substring(0, h);
 
     // Get resource from local storage first
     var ls = window.lstorage || localStorage;
-    var item = null;
-    try { item = ls.getItem(u); } catch(e) {}
-    if (item != null && item != '')
-        return item;
+    if (mode != 'remote') {
+        var item = null;
+        try { item = ls.getItem('ui.r.' + u); } catch(e) {}
+        if (item != null && item != '')
+            return item;
+        if (mode == 'local')
+            return null;
+    }
 
     // Get resource from network
     //if (window.debug) debug('appcache.get', u);
     var http = new XMLHttpRequest();
-    http.open("GET", u, false);
+    http.open("GET", mode == 'remote'? (u + '?t=' + new Date().getTime() + '&r=' + Math.random()) : u, false);
     http.setRequestHeader("Accept", "*/*");
     http.send(null);
     if (http.status == 200) {
-        if (http.getResponseHeader("X-Login") != null) {
+        var xl = http.getResponseHeader("X-Login");
+        if (xl != null && xl != '') {
             if (window.debug) debug('http error', u, 'X-Login');
             // Redirect to login page if not signed in
             document.location = '/login/';
             return null;
-        } else if (http.responseText == '' || http.getResponseHeader("Content-Type") == null) {
+        }
+        var ct = http.getResponseHeader("Content-Type");
+        if (http.responseText == '' || ct == null || ct == '') {
             if (window.debug) debug('http error', u, 'No-Content');
             return null;
         }
-        try { ls.setItem(u, http.responseText); } catch(e) {}
+        try { ls.setItem('ui.r.' + u, http.responseText); } catch(e) {}
         return http.responseText;
     }
     if (window.debug) debug('http error', u, http.status, http.statusText);
@@ -83,82 +102,68 @@
 /**
  * Load Javascript and CSS.
  */
-(function() {
+(function rootboot() {
 
-var bootjs = document.createElement('script');
-bootjs.type = 'text/javascript';
-bootjs.text = appcache.get('/all-min.js');
-document.head.appendChild(bootjs);
-document.head.appendChild(ui.declareCSS(appcache.get('/ui-min.css')));
+window.eval.call(window, 'try {\n' + appcache.get('/all-min.js') + '\n' + appcache.get('/config-min.js') + '\n} catch(e) { console.log(e.stack); throw e; }\n');
+ui.includeCSS(appcache.get('/ui-min.css'));
 
 // Disable cache for testing
 //lstorage.enabled = false;
 
 })();
 
-/**
- * Redirect to login page if not signed in.
- */
-(function() {
-
-if (document.location.protocol == 'https:' && !hasauthcookie())
-    document.location = '/login/';
-
-})();
+} catch(e) {
+    if (window.debug) debug(e.stack);
+    throw e;
+}
 </script>
 </head>
 <body class="delayed">
-<div id="mainbodydiv" class="mainbody">
 
-<div id="headdiv" class="hsection">
-<script type="text/javascript">
-(function() {
-
-$('headdiv').appendChild(ui.declareScript(appcache.get('/config-min.js')));
-
-})();
-</script>
+<div id="menucontainer" class="tbarmenu">
+<div id="menu"></div>
 </div>
 
-<div id="menubackground" class="tbarbackground fixed"></div>
-<div id="menu" class="tbarmenu fixed"></div>
+<div id="viewheadcontainer" class="viewhead">
+<div id="viewhead"></div>
+</div>
 
-<div id="viewheadbackground" class="viewheadbackground fixed"></div>
-<div id="viewhead" class="viewhead fixed"></div>
+<div id="working" class="working" style="display: none;"></div>
 
 <div id="viewcontainer"></div>
 
-<div id="viewfootbackground" class="viewfootbackground fixed"></div>
-<div id="viewfoot" class="viewfoot fixed"></div>
-<div id="status" class="status fixed" style="visibility: hidden;"></div>
+<div id="viewfootcontainer" class="viewfoot">
+<div id="viewfoot"></div>
+<div id="status"></div>
+</div>
+
+<div id="installer" class="installer"></div>
 
 <script type="text/javascript">
-(function() {
+try {
+
+(function rootbody() {
 
 /**
- * Init service references.
+ * Setup page layout.
+ */
+(function layout() {
+
+    document.title = config.windowtitle();
+    $('viewcontainer').className = ui.isMobile()? 'viewcontainer3dm' : 'viewcontainer3d';
+    $('status').className = ui.isMobile()? 'status3dm' : 'status3d';
+
+})();
+
+/**
+ * Initialize service references.
  */
 var editorComp = sca.component("Editor");
-var user= sca.defun(sca.reference(editorComp, "user"));
+var user = sca.defun(sca.reference(editorComp, "user"));
 var accounts = sca.reference(editorComp, "accounts");
 
 /**
- * Set page title.
- */
-document.title = config.windowtitle();
-
-/**
- * Init div variables.
- */
-var bdiv = $('mainbodydiv');
-var mdiv = $('menu'); 
-var hdiv = $('viewhead'); 
-var vcontainer = $('viewcontainer');
-vcontainer.className = ui.isMobile()? 'viewcontainer3dm' : 'viewcontainer3d';
-var fdiv = $('viewfoot'); 
-
-/**
- * The current user name and account entry.
+ * The current user name.
  */
 window.username = 'anonymous';
 
@@ -169,134 +174,95 @@
 var storeidx = 0;
 
 /**
- * Pre-fetch app resources.
+ * Populate cache with app resources.
  */
 var appresources = [
     ['/all-min.js'],
     ['/ui-min.css'],
     ['/account/', 9],
-    ['/clone/', 3],
-    ['/create/', 2],
-    ['/delete/', 3],
+    ['/clone/', 4],
+    ['/create/', 3],
+    ['/delete/', 4],
     ['/graph/', 5],
     ['/config-min.js'],
     ['/home/', 0],
-    ['/home/home.b64'],
     ['/page/', 4],
     ['/public/app.b64'],
     ['/public/config-min.js'],
-    ['/public/grid72.b64'],
-    ['/public/iframe-min.html'],
     ['/public/img.b64'],
+    ['/public/rate.b64'],
+    ['/public/ratings.b64'],
     ['/public/user.b64'],
-    ['/stats/', 2],
+    ['/rate/', 4],
+    ['/search/', 2],
+    ['/info/', 3],
     ['/store/', 1]
 ];
 
 /**
- * Show a status message.
+ * Init status message area.
  */
-window.showStatus = function(s, c) {
-    //debug('status', s);
-    var sdiv = $('status');
-    if (isNil(sdiv))
-        return s;
-    sdiv.innerHTML = '<span class="' + (c? c : 'okstatus') + '">' + s + '</span>';
-    sdiv.className = 'status fixed';
-    sdiv.style.visibility = 'visible';
+(function initstatus() {
+    if (isNil($('status')))
+        return;
+    $('status').style.display = 'none';
     
     function divtransitionend(e) {
-        e.target.style.visibility = 'hidden';
-        e.target.className = 'status fixed';
+        e.target.style.display = 'none';
+        e.target.className = ui.isMobile()? 'status3dm' : 'status3d';
+        e.target.error = false;
     }
-    if (!sdiv.addedTransitionEnd) {
-        sdiv.addEventListener('webkitTransitionEnd', divtransitionend, false);
-        sdiv.addEventListener('transitionend', divtransitionend, false);
-        sdiv.addedTransitionEnd = true;
-    }
-    sdiv.className = 'statusout3 fixed';
+    $('status').addEventListener('webkitTransitionEnd', divtransitionend, false);
+    $('status').addEventListener('transitionend', divtransitionend, false);
+})();
+
+/**
+ * Show a status message.
+ */
+window.showstatus = function(s, c) {
+    //debug('show status', s);
+    if (isNil($('status')) || $('status').error)
+        return s;
+    $('status').innerHTML = '<span class="' + (c? c : 'okstatus') + '">' + s + '</span>';
+    $('status').className = ui.isMobile()? 'status3dm' : 'status3d';
+    $('status').style.display = 'block';
+    $('status').error = c == 'errorstatus';
+    if ($('status').delay)
+        ui.cancelDelay($('status').delay);
+    $('status').delay = ui.delay(function hidestatus() {
+        $('status').className = ui.isMobile()? 'statusout3dm' : 'statusout3d';
+        $('status').error = false;
+    }, 3000);
     return s;
-}
+};
 
 /**
  * Show an error message.
  */
-window.showError = function(s) {
+window.errorstatus = function(s) {
     //debug('error', s);
-    return showStatus(s, 'errorstatus');
-}
+    return showstatus(s, 'errorstatus');
+};
+
+/**
+ * Show working status.
+ */
+window.workingstatus = function(w, c) {
+    //debug('show working', w);
+    if (isNil($('working')))
+        return w;
+    if (!ui.isMobile())
+        $('working').style.top = ui.pixpos(Math.round(window.clientHeight / 2));
+    $('working').style.display = w? 'block' : 'none';
+    return w;
+};
 
 /**
  * Show the online/offline status.
  */
-window.showOnlineStatus = function() {
-    return navigator.onLine? showStatus('Online') : showError('Offline');
-}
-
-/**
- * Handle application cache events.
- */
-applicationCache.addEventListener('checking', function(e) {
-    //debug('appcache checking', e);
-    showStatus('Checking');
-}, false);
-applicationCache.addEventListener('error', function(e) {
-    //debug('appcache error', e);
-    showOnlineStatus();
-}, false);
-applicationCache.addEventListener('noupdate', function(e) {
-    //debug('appcache noupdate', e);
-    showOnlineStatus();
-}, false);
-applicationCache.addEventListener('downloading', function(e) {
-    //debug('appcache downloading', e);
-    showStatus('Updating');
-}, false);
-applicationCache.addEventListener('progress', function(e) {
-    //debug('appcache progress', e);
-    showStatus('Updating');
-}, false);
-applicationCache.addEventListener('updateready', function(e) {
-    //debug('appcache updateready', e);
-    try {
-        applicationCache.swapCache();
-    } catch(e) {}
-    showOnlineStatus();
-    //debug('appcache swapped', e);
-
-    // Update offline resources in local storage and reload the page
-    map(function(res) {
-        showStatus('Updating');
-        appcache.remove(res[0]);
-        appcache.get(res[0]);
-    }, append(appresources, config.appresources()));
-    window.location.reload();
-}, false);
-applicationCache.addEventListener('cached', function(e) {
-    //debug('appcache cached', e);
-    showOnlineStatus();
-
-    // Install offline resources in local storage
-    map(function(res) {
-        showStatus('Installing');
-        appcache.remove(res[0]);
-        appcache.get(res[0]);
-    }, append(appresources, config.appresources()));
-}, false);
-
-/**
- * Handle network offline/online events.
- */
-window.addEventListener('offline', function(e) {
-    //debug('going offline');
-    showStatus('Offline');
-}, false);
-window.addEventListener('online', function(e) {
-    //debug('going online');
-    showStatus('Online');
-}, false);
-
-//debug(navigator.onLine? 'online' : 'offline');
+window.onlinestatus = function() {
+    return navigator.onLine? (ui.isMobile()? showstatus('Online') : showstatus('Online')) : errorstatus('Offline');
+};
 
 /**
  * Handle view transitions.
@@ -351,32 +317,36 @@
  * Return the last visited location.
  */
 function lastvisited() {
-    if (username != lstorage.getItem('ui.lastvisit.user'))
+    if (username != lstorage.getItem('ui.v.user'))
         return null;
-    return lstorage.getItem('ui.lastvisit.url');
+    return lstorage.getItem('ui.v.url');
 }
 
 /**
  * Build and show the menu bar.
  */
-function showmenu(mdiv, view, appname) {
-    mdiv.innerHTML = ui.menubar(
+function showmenu(view, appname) {
+     $('menu').innerHTML = ui.menubar(
         append(mklist(ui.menu('menuhome', 'Home', '/', '_view', view == 'home'),
-                    ui.menu('menustore', 'Store', '/#view=store&category=' + storecat + '&idx=' + storeidx, '_view', view === 'store')),
+                    ui.menu('menustore', 'Store', '/#view=store&category=' + storecat + '&idx=' + storeidx, '_view', view == 'store'),
+                    ui.menu('menusearch', 'Search', '/#view=search', '_view', view == 'search')),
                 (isNil(appname) || appname == 'undefined')?
                     mklist() :
                     mklist(
-                        ui.menu('menustats', 'Stats', '/#view=stats&app=' + appname, '_view', view == 'stats'),
-                        ui.menu('menupage', 'Page', '/#view=page&app=' + appname, '_view', view == 'page'),
+                        ui.menu('menuinfo', 'Info', '/#view=info&app=' + appname, '_view', view == 'info'),
+                        ui.menu('menupage', 'Edit', '/#view=page&app=' + appname, '_view', view == 'page')
+                        /* TODO disabled for now
+                        ,
                         ui.menu('menulogic', config.logic(), '/#view=graph&app=' + appname, '_view', view == 'graph'),
-                        ui.menu('menurun', '<span class="greentext" style="font-weight: bold">Run!</span>', '/' + appname + '/', '_blank', false))),
+                        ui.menu('menurun', '<span class="greentext" style="font-weight: bold">Run!</span>', '/' + appname + '/', '_blank', false)
+                        */
+                        )),
         (isNil(appname) || appname == 'undefined')? mklist(
-            hasauthcookie()? ui.menufunc('menusignout', 'Sign out', 'return logout();', false) : ui.menu('menusignin', 'Sign in', '/login/', '_self', false),
+            ui.menufunc('menusignout', 'Sign out', 'return logout();', false),
             ui.menu('menuaccount', 'Account', '/#view=account', '_view', view == 'account')) :
             mklist());
 
-    if (fdiv.innerHTML == '') 
-        fdiv.innerHTML = config.viewfoot();
+    $('viewfoot').innerHTML = config.viewfoot();
 }
 
 /**
@@ -390,7 +360,7 @@
         return false;
 
     var accountentry = car(elementsToValues(atom.readATOMEntry(mklist(doc))));
-    username = cadr(assoc("'id", cdr(accountentry)));
+    username = cadr(assoc("'id", accountentry));
     return true;
 }
 
@@ -401,8 +371,8 @@
     //debug('showview', url);
 
     // Save last visited location
-    lstorage.setItem('ui.lastvisit.user', username);
-    lstorage.setItem('ui.lastvisit.url', url);
+    lstorage.setItem('ui.v.user', username);
+    lstorage.setItem('ui.v.url', url);
 
     // Determine the view to show
     var params = ui.fragmentParams(url);
@@ -426,10 +396,11 @@
 
     // Show the menu bar
     var appname = params['app'];
-    showmenu(mdiv, view, appname);
+    showmenu(view, appname);
 
-    // Scroll to the top and hide the address bar
-    window.scrollTo(0, 0);
+    // Make sure that the document is visible
+    if (document.body.style.display != 'block')
+        document.body.style.display = 'block';
 
     // Start to unload the front view and create a new view
     if (ui.isMobile()) {
@@ -444,21 +415,17 @@
         var vdiv = mkviewdiv(vtransition + 'viewloading3dm');
         var vdoc = appcache.get(uri);
         vdiv.innerHTML = vdoc;
-        vcontainer.appendChild(vdiv);
+        $('viewcontainer').appendChild(vdiv);
         map(ui.evalScript, ui.innerScripts(vdiv));
 
-        // Make sure the top document is visible
-        if (document.body.style.visibility != 'visible')
-            document.body.style.visibility = 'visible';
-
-        setTimeout(function() {
+        ui.async(function mtransitionview() {
             // Transition the old view out
             if (!isNil(ovdiv))
                 ovdiv.className = vtransition + 'viewunloaded3dm';
 
             // Transition the new view in
             vdiv.className = 'viewloaded3dm';
-        }, 100);
+        });
 
     } else {
         // Prepare current view for transition out
@@ -470,21 +437,17 @@
         var vdiv = mkviewdiv('viewloading3d');
         var vdoc = appcache.get(uri);
         vdiv.innerHTML = vdoc;
-        vcontainer.appendChild(vdiv);
+        $('viewcontainer').appendChild(vdiv);
         map(ui.evalScript, ui.innerScripts(vdiv));
 
-        // Make sure the top document is visible
-        if (document.body.style.visibility != 'visible')
-            document.body.style.visibility = 'visible';
-
-        setTimeout(function() {
+        ui.async(function transitionview() {
             // Transition the new view in
             vdiv.className = 'viewloaded3d';
 
             // Transition the old view out
             if (!isNil(ovdiv))
                 ovdiv.parentNode.removeChild(ovdiv);
-        }, 100);
+        });
     }
 
     // Track the current visible view
@@ -501,12 +464,14 @@
 
     // Add url to the history if necessary
     if (url != ui.pathandparams(location)) {
-        history.pushState(null, null, url);
-        //debug('pushstate', history.length);
+        if (history.pushState) {
+            history.pushState(null, null, url);
+            //debug('pushstate', history.length);
+        }
 
         // Update the location hash if necessary
         var f = ui.fragment(url);
-        if (f != '' && f != location.hash) {
+        if (f != location.hash) {
             location.hash = f;
             //debug('hash', f);
         }
@@ -520,6 +485,10 @@
 window.onnavigate = function(url) {
     //debug('onnavigate', url);
 
+    // Cleanup installer
+    if ($('installer').innerHTML != '')
+        $('installer').innerHTML = '';
+
     // Update the browser window location
     updatelocation(url);
 
@@ -541,12 +510,11 @@
  */
 window.logout = function() {
     // Clear session cookie and user-specific local storage entries
-    clearauthcookie();
     lstorage.removeItem('/r/Editor/accounts');
     lstorage.removeItem('/r/Editor/dashboards');
-    document.location = '/login/';
+    document.location = '/logout/dologout/';
     return false;
-}
+};
 
 /**
  * Handle history.
@@ -555,10 +523,13 @@
     //debug('onpopstate', history.length);
     var furl = ui.fragment(location);
     var url = location.pathname + (furl == ''? '' : '#' + furl);
-
-    // Show the current view
     if (url == viewurl)
         return true;
+
+    // Cleanup element lookups memoized in current document
+    ui.unmemo$();
+
+    // Show the current view
     return showview(url);
 
 }, false);
@@ -567,10 +538,13 @@
     //debug('onhashchange');
     var furl = ui.fragment(location);
     var url = location.pathname + (furl == ''? '' : '#' + furl);
-
-    // Show the current view
     if (url == viewurl)
         return true;
+
+    // Cleanup element lookups memoized in current document
+    ui.unmemo$();
+
+    // Show the current view
     return showview(url);
 
 }, false);
@@ -580,18 +554,128 @@
  */
 document.body.onorientationchange = function(e) {
     //debug('onorientationchange');
-    ui.onorientationchange(e);
-
-    // Resize menu and view header
-    mdiv.style.width = ui.pixpos(document.documentElement.clientWidth);
-    hdiv.style.width = ui.pixpos(document.documentElement.clientWidth);
-    return true;
+    return ui.onorientationchange(e);
 };
 
 /**
+ * Install the application cache.
+ */
+(function installappcache() {
+    if (ui.isMobile()) {
+        // On mobile devices, trigger usage of an application cache manifest
+        window.onappcachechecking = function(e) {
+            //debug('appcache checking', e);
+            workingstatus(true);
+            showstatus('Checking');
+        };
+        window.onappcacheerror = function(e) {
+            //debug('appcache error', e);
+            onlinestatus();
+            workingstatus(false);
+        };
+        window.onappcachenoupdate = function(e) {
+            //debug('appcache noupdate', e);
+            onlinestatus();
+            workingstatus(false);
+        };
+        window.onappcachedownloading = function(e) {
+            //debug('appcache downloading', e);
+            workingstatus(true);
+            showstatus('Updating');
+        };
+        window.onappcacheprogress = function(e) {
+            //debug('appcache progress', e);
+            workingstatus(true);
+            showstatus('Updating');
+        };
+        window.onappcacheupdateready = function(e) {
+            //debug('appcache updateready', e);
+            try {
+                applicationCache.swapCache();
+            } catch(e) {}
+            onlinestatus();
+            workingstatus(false);
+            //debug('appcache swapped', e);
+
+            // Update offline resources in local storage and reload the page
+            map(function(res) {
+                showstatus('Updating');
+                appcache.remove(res[0]);
+                appcache.get(res[0], 'remote');
+            }, append(appresources, config.appresources()));
+            window.location.reload();
+        };
+        window.onappcachecached = function(e) {
+            //debug('appcache cached', e);
+            onlinestatus();
+            workingstatus(false);
+
+            // Install offline resources in local storage
+            map(function(res) {
+                showstatus('Installing');
+                appcache.remove(res[0]);
+                appcache.get(res[0], 'remote');
+            }, append(appresources, config.appresources()));
+        };
+
+        window.onloadappcache = function() {
+            //debug('appcache iframe loaded');
+        };
+
+        ui.delay(function() {
+            $('installer').innerHTML = '<iframe src="/cache/" class="installer"></iframe>';
+        });
+
+    } else {
+        // On non-mobile devices, check for cache-manifest changes ourselves.
+        workingstatus(true);
+        showstatus('Checking');
+        var lcmf = appcache.get('/cache/cache-manifest.cmf', 'local');
+        var rcmf = appcache.get('/cache/cache-manifest.cmf', 'remote');
+        if (lcmf == rcmf) {
+            onlinestatus();
+            workingstatus(false);
+            return true;
+        }
+
+        //debug('cache-manifest changed, reloading');
+        ui.delay(function() {
+            workingstatus(true);
+            showstatus('Updating');
+            ui.delay(function() {
+                workingstatus(true);
+                showstatus('Updating');
+                map(function(res) {
+                    appcache.remove(res[0]);
+                    appcache.get(res[0], 'remote');
+                }, append(appresources, config.appresources()));
+                if (!isNil(lcmf)) {
+                    //debug('reloading');
+                    window.location.reload();
+                }
+                onlinestatus();
+                workingstatus(false);
+            });
+        });
+    }
+})();
+
+/**
+ * Handle network offline/online events.
+ */
+window.addEventListener('offline', function(e) {
+    //debug('going offline');
+    showstatus('Offline');
+}, false);
+window.addEventListener('online', function(e) {
+    //debug('going online');
+    showstatus('Online');
+}, false);
+
+/**
  * Initialize the document.
  */
-function onload() {
+window.onload = function() {
     //debug('onload', history.length);
     ui.onload();
 
@@ -625,16 +709,15 @@
     if (url == viewurl)
         return true;
     return showview(url);
-}
-
-onload();
+};
 
 })();
+
+} catch(e) {
+    debug(e.stack);
+    throw e;
+}
 </script>
 
-<div id="footdiv" class="fsection">
-</div>
-
-</div>
 </body>
 </html>
diff --git a/hosting/server/htdocs/info/index.html b/hosting/server/htdocs/info/index.html
new file mode 100644
index 0000000..0d72062
--- /dev/null
+++ b/hosting/server/htdocs/info/index.html
@@ -0,0 +1,494 @@
+<!DOCTYPE html>
+<!--
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ * 
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ * 
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.    
+-->
+<div id="bodydiv" class="body">
+
+<div id="viewform" class="viewform">
+
+<form id="appForm">
+<table style="width: 100%;">
+<tr><td class="label">URL:</td></tr>
+<tr><td><input type="text" id="appURL" class="readentry" size="30" readonly="readonly" placeholder="App URL" style="width: 300px;"/></td></tr>
+<tr><td class="label">Icon:</td></tr>
+<tr><td><img id="appIcon" style="width: 50px; height: 50px; vertical-align: top;"/><input id="uploadIcon" type="button" class="lightbutton" value="Upload" style="display:none;"/><input id="uploadFile" type="file" accept="image/*" style="display: none;"/><span id="refreshingIcon" class="refreshing" style="display:none;"/></td></tr>
+<tr><td class="label">Author:</td></tr>
+<tr><td><img id="authorPicture" style="width: 50px; height: 50px; vertical-align: middle;"/><input type="text" id="appAuthor" class="readentry" size="30" readonly="readonly" placeholder="Author of the app" style="width: 248px;"/></td></tr>
+<tr><td class="label">Rating:</td></tr>
+<tr><td><span id="appRating" class="ratings">&nbsp;</span><input id="rateApp" type="button" class="lightbutton" value="Rate this app"/></td></tr>
+<tr><td><input type="text" id="appRatings" class="readentry" size="20" readonly="readonly" placeholder="Number of ratings" style="font-size: 12px;"/></td></tr>
+<tr><td class="label">Updated:</td></tr>
+<tr><td><input type="text" id="appUpdated" class="readentry" size="30" readonly="readonly" placeholder="App update date" style="width: 300px;"/></td></tr>
+<tr><td class="label">Description:</td></tr>
+<tr><td><textarea id="appDescription" class="readentry" cols="40" rows="3" readonly="readonly" placeholder="Short description of the app" style="width: 300px;"></textarea></td></tr>
+</table>
+</form>
+<br/>
+
+</div>
+
+<script type="text/javascript">
+(function infobody() {
+
+/**
+ * Get the app name.
+ */
+var appname = ui.fragmentParams(location)['app'];
+
+/**
+ * Setup page layout.
+ */
+(function layout() {
+    document.title = config.windowtitle() + ' - Info - ' + appname;
+    $('viewhead').innerHTML = '<span id="appname" class="cmenu">' + appname + '</span>' +
+        '<input type="button" class="redbutton plusminus" style="position: absolute; top: 4px; left: 5px;" id="deleteApp" value="-" title="Delete this app" disabled="true"/>' +
+        '<span style="position: absolute; top: 0px; right: 5px;">' +
+        '<input type="button" class="greenbutton" id="runApp" value="Run" title="Run this app"/>' +
+        '<input type="button" class="bluebutton" id="cloneApp" value="'+ config.clone() +'" title="' + config.clone() + ' this app"/>' +
+        '</span>';
+    if (!ui.isMobile())
+        $('viewform').className = 'viewform flatscrollbars';
+    $('appURL').value = window.location.hostname + '/' + appname + '/';
+
+    $('viewform').appendChild(ui.declareCSS(
+        '.ratings { ' +
+        'background: url(\'' + ui.b64png(appcache.get('/public/ratings.b64')) + '\'); ' +
+        'vertical-align: middle; width: 50px; height: 14px; display: inline-block; ' +
+        ' }'));
+})();
+
+/**
+ * Set images.
+ */
+(function drawImages() {
+    $('appIcon').src = ui.b64png(appcache.get('/public/app.b64'));
+    $('authorPicture').src = ui.b64png(appcache.get('/public/user.b64'));
+})();
+
+/**
+ * Initialize service references.
+ */
+var editorComp = sca.component("Editor");
+var apps = sca.reference(editorComp, "apps");
+var icons = sca.reference(editorComp, "icons");
+var pictures = sca.reference(editorComp, "pictures");
+var ratings = sca.reference(editorComp, "ratings");
+
+/**
+ * The current app entry, author and saved XML content.
+ */
+var savedappxml = '';
+var author;
+var savediconxml;
+
+/**
+ * Get and display the requested app.
+ */
+(function getapp() {
+    if (isNil(appname))
+        return false;
+    workingstatus(true);
+    showstatus('Loading');
+
+    return apps.get(appname, function(doc) {
+
+        // Stop now if we didn't get the app
+        if (doc == null) {
+            errorstatus('Couldn\'t get the app info');
+            workingstatus(false);
+            return false;
+        }
+
+        var appentry = car(elementsToValues(atom.readATOMEntry(mklist(doc))));
+        author = cadr(assoc("'author", appentry));
+        $('appAuthor').value = author.split('@')[0];
+        var updated = assoc("'updated", appentry);
+        $('appUpdated').value = isNil(updated)? '' : xmldatetime(cadr(updated)).toLocaleDateString();
+        var content = cadr(assoc("'content", appentry));
+        var description = assoc("'description", content);
+        $('appDescription').value = isNil(description) || isNil(cadr(description))? '' : cadr(description);
+        //var ratingy = -20 * (4 - Math.floor(Math.random() * 4));
+        //$('appRating').style.backgroundPosition = '0px ' + ratingy + 'px';
+        //$('appRatings').value = '';
+        savedappxml = car(atom.writeATOMEntry(valuesToElements(mklist(appentry))));
+
+        // Enable author to edit and delete the app
+        if (username == author) {
+            $('appDescription').readOnly = false;
+            $('appDescription').className = 'flatentry';
+            $('uploadIcon').style.display = 'inline';
+            $('deleteApp').disabled = false;
+            $('deleteApp').onclick = function() {
+                return ui.navigate('/#view=delete&app=' + appname, '_view');
+            }
+            onlinestatus();
+        } else {
+            showstatus('Read only');
+        }
+        workingstatus(false);
+        return true;
+    });
+})();
+
+/**
+ * Get and display the author's picture.
+ */
+(function getpic(author) {
+    workingstatus(true);
+    showstatus('Loading');
+
+    return pictures.get(author, function(doc) {
+
+        // Stop now if we didn't get a picture
+        if (doc == null) {
+            errorstatus('Author picture not available');
+            workingstatus(false);
+            return false;
+        }
+
+        var picentry = car(elementsToValues(atom.readATOMEntry(mklist(doc))));
+        var content = assoc("'content", picentry);
+        var picture = assoc("'picture", content);
+        var img = assoc("'image", picture);
+        if (!isNil(img))
+            $('authorPicture').src = cadr(img);
+
+        onlinestatus();
+        workingstatus(false);
+        return true;
+    });
+    return true;
+})();
+
+/**
+ * Get and display the app icon.
+ */
+(function geticon() {
+    if (isNil(appname))
+        return false;
+    workingstatus(true);
+    showstatus('Loading');
+
+    return icons.get(appname, function(doc) {
+        // Stop now if we didn't get an icon
+        if (doc == null) {
+            errorstatus('Icon not available');
+            workingstatus(false);
+            return false;
+        }
+
+        var iconentry = car(elementsToValues(atom.readATOMEntry(mklist(doc))));
+        savediconxml = car(atom.writeATOMEntry(valuesToElements(mklist(iconentry))));
+        var content = assoc("'content", iconentry);
+        var icon = assoc("'icon", content);
+        var img = assoc("'image", icon);
+        if (!isNil(img))
+            $('appIcon').src = cadr(img);
+
+        onlinestatus();
+        workingstatus(false);
+        return true;
+    });
+    return true;
+})();
+
+/**
+ * Refresh icon.
+ */
+var refreshingicon = false;
+function refreshicon() {
+    if (isNil(appname))
+        return false;
+    if (!refreshingicon)
+        return false;
+    $('refreshingIcon').style.display = 'inline-block';
+    return icons.get(appname, function(doc) {
+        if (doc == null) {
+            errorstatus('Icon not available');
+            $('refreshingIcon').style.display = 'none';
+            refreshingicon = false;
+            return false;
+        }
+
+        var iconentry = car(elementsToValues(atom.readATOMEntry(mklist(doc))));
+        var content = assoc("'content", iconentry);
+        var icon = assoc("'icon", content);
+        var token = assoc("'token", icon);
+
+        // Update icon
+        if (isNil(token)) {
+            var entryxml = car(atom.writeATOMEntry(valuesToElements(mklist(iconentry))));
+            savediconxml = entryxml;
+            var img = assoc("'image", icon);
+            if (!isNil(img))
+                $('appIcon').src = cadr(img);
+            $('refreshingIcon').style.display = 'none';
+            refreshingicon = false;
+            return true;
+        }
+
+        // Refresh in 2 secs
+        return ui.delay(refreshicon, 2000);
+    }, 'remote');
+    return true;
+}
+
+/**
+ * Get and display the app ratings.
+ */
+(function getratings() {
+    if (isNil(appname))
+        return false;
+    workingstatus(true);
+    showstatus('Loading');
+
+    return ratings.get(appname, function(doc) {
+        // Stop now if we didn't get an icon
+        if (doc == null) {
+            errorstatus('Ratings not available');
+            workingstatus(false);
+            return false;
+        }
+
+        var ratingsentry = car(elementsToValues(atom.readATOMEntry(mklist(doc))));
+        var aratings = assoc("'ratings", assoc("'content", ratingsentry));
+        var ar = assoc("'rating", aratings);
+        var ar1 = assoc("'rating1", aratings);
+        var ar2 = assoc("'rating2", aratings);
+        var ar3 = assoc("'rating3", aratings);
+        var ar4 = assoc("'rating4", aratings);
+        var rating = isNil(ar)? 0 : Number(cadr(ar));
+        var reviews = (isNil(ar1)? 0 : Number(cadr(ar1))) + (isNil(ar2)? 0 : Number(cadr(ar2))) + (isNil(ar3)? 0 : Number(cadr(ar3))) + (isNil(ar4)? 0 : Number(cadr(ar4)));
+
+        var ratingy = -20 * (4 - Math.floor(rating));
+        $('appRating').style.backgroundPosition = '0px ' + ratingy + 'px';
+        $('appRatings').value = reviews + (reviews > 1? ' ratings' : ' rating');
+
+        onlinestatus();
+        workingstatus(false);
+        return true;
+    });
+    return true;
+})();
+
+/**
+ * Save the current app.
+ */
+function saveapp(entryxml) {
+    workingstatus(true);
+    showstatus('Saving');
+    savedappxml = entryxml;
+    apps.put(appname, savedappxml, function(e) {
+        if (e) {
+            showstatus('Local copy');
+            workingstatus(false);
+            return false;
+        }
+
+        showstatus('Saved');
+        workingstatus(false);
+        return false;
+    });
+    return true;
+}
+
+/**
+ * Save the app icon.
+ */
+function saveicon(entryxml) {
+    workingstatus(true);
+    showstatus('Uploading');
+    savedappxml = entryxml;
+    icons.put(appname, savedappxml, function(e) {
+        if (e) {
+            showstatus('Local copy');
+            workingstatus(false);
+            return false;
+        }
+
+        showstatus('Uploaded');
+        workingstatus(false);
+        return true;
+    });
+    return true;
+}
+
+/**
+ * Handle a change event
+ */
+function onappchange() {
+    if (username != author)
+        return false;
+
+    // Validate user input
+    var description = $('appDescription').value;
+    if (description.length > 120) {
+        errorstatus('Description cannot be longer than 120 characters');
+        return false;
+    }
+
+    // Save the changes
+    var appentry = mklist("'entry", mklist("'title", appname), mklist("'id", appname), mklist("'content", mklist("'info", mklist("'description", description))));
+    var entryxml = car(atom.writeATOMEntry(valuesToElements(mklist(appentry))));
+    if (savedappxml == entryxml)
+        return false;
+    showstatus('Modified');
+    return saveapp(entryxml);
+}
+
+$('appDescription').onchange = onappchange;
+
+/**
+ * Handle a key event.
+ */
+var lastkeyup = null;
+$('appDescription').onkeyup = function() {
+    var t = new Date().getTime();
+    lastkeyup = t;
+    ui.delay(function() {
+            return t == lastkeyup? onappchange() : true;
+        }, 2000);
+};
+
+/**
+ * Handle a form submit event.
+ */
+$('appForm').onsubmit = function() {
+    onappchange();
+    return false;
+};
+
+/**
+ * Handle Clone button event.
+ */
+$('cloneApp').onclick = function() {
+    return ui.navigate('/#view=clone&app=' + appname, '_view');
+};
+
+/**
+ * Handle Run button event.
+ */
+$('runApp').onclick = function() {
+    return ui.navigate('/' + appname + '/', '_blank');
+};
+
+/**
+ * Read and upload icon file.
+ */
+function uploadicon(files) {
+    if (username != author)
+        return false;
+    if (!files || files.length == 0)
+        return false;
+    if (!files[0].type.match('image.*')) {
+        errorstatus('Please select an image');
+        return false;
+    }
+    workingstatus(true);
+    showstatus('Loading');
+
+    // Read the selected file into a 50x50 image
+    return ui.readimage(files[0],
+        function(e) {
+            errorstatus('Couldn\'t read the file');
+            workingstatus(false);
+        },
+        function(p) {
+            showstatus('Loading ' + p + '%');
+        },
+        function(url) {
+            // Update the app icon
+            $('appIcon').src = url;
+            showstatus('Loaded');
+
+            // Now upload it
+            ui.delay(function() {
+                var iconentry = mklist("'entry", mklist("'title", appname), mklist("'id", appname), mklist("'author", username), mklist("'content", mklist("'icon", mklist("'image", url))));
+                var entryxml = car(atom.writeATOMEntry(valuesToElements(mklist(iconentry))));
+                if (savediconxml == entryxml) {
+                    onlinestatus();
+                    workingstatus(false);
+                    return false;
+                }
+                return saveicon(entryxml);
+            });
+        }, 50, 50);
+}
+
+/**
+ * Upload an icon in an email.
+ */
+function emailicon() {
+
+    // Generate and put an icon email upload token
+    workingstatus(true);
+    showstatus('Uploading');
+    var token = uuid4();
+    var iconentry = mklist("'entry", mklist("'title", appname), mklist("'id", appname), mklist("'author", username), mklist("'content", mklist("'icon", mklist("'token", token))));
+    var entryxml = car(atom.writeATOMEntry(valuesToElements(mklist(iconentry))));
+    icons.put(appname, entryxml, function(e) {
+        if (e) {
+            showstatus('Local copy');
+            workingstatus(false);
+            return false;
+        }
+        workingstatus(false);
+
+        // Open the email app
+        var mailto = safeb64encode('i/' + appname + '/' + token);
+        ui.navigate('mailto:' + mailto + '@' + topdomainname(window.location.hostname) + '?subject=Email to upload&body=Paste icon here', '_self');
+
+        // Refresh app icon
+        refreshingicon = true;
+        return ui.delay(refreshicon, 500);
+    }, 'remote');
+}
+
+/**
+ * Handle icon upload events.
+ */
+$('uploadIcon').onclick = function() {
+    if (ui.isMobile())
+        return emailicon();
+    return $('uploadFile').click();
+};
+$('uploadFile').onchange = function(e) {
+    return uploadicon(e.target.files);
+};
+$('appIcon').ondrag = function(e) {
+    e.stopPropagation();
+    e.preventDefault();
+    e.dataTransfer.dropEffect = 'copy';
+};
+$('appIcon').ondrop = function(e) {
+    e.stopPropagation();
+    e.preventDefault();
+    return uploadicon(e.dataTransfer.files);
+};
+
+/**
+ * Handle rate button event.
+ */
+$('rateApp').onclick = function() {
+    return ui.navigate('/#view=rate&app=' + appname, '_view');
+};
+
+})();
+</script>
+
+</div>
diff --git a/hosting/server/htdocs/login/index.html b/hosting/server/htdocs/login/index.html
index bf09339..efc3fea 100644
--- a/hosting/server/htdocs/login/index.html
+++ b/hosting/server/htdocs/login/index.html
@@ -19,87 +19,113 @@
 -->
 <html>
 <head>
+<!-- Firebug inspector -->
+<!--
+<script type="text/javascript" src="https://getfirebug.com/releases/lite/1.3/firebug-lite.js"></script>
+-->
+<!-- Weinre inspector -->
+<!--
+<script src="http://www.example.com:9998/target/target-script-min.js#anonymous"></script>
+-->
 <title>Sign in</title>
 <meta name="viewport" content="width=device-width, user-scalable=no, initial-scale=1.0, minimum-scale=1.0, maximum-scale=1.0"/> 
+<!--
 <meta name="apple-mobile-web-app-capable" content="yes"/>
 <meta name="apple-mobile-web-app-status-bar-style" content="black"/>
+-->
+<link rel="apple-touch-icon-precomposed" href="/public/touchicon.png"/>
 <base href="/login/"/>
 <script type="text/javascript">
-(function() {
+try {
+
+(function loginhead() {
 
 window.appcache = {};
 
 /**
  * Get and cache a resource.
  */
-appcache.get = function(uri) {
+appcache.get = function(uri, mode) {
     var h = uri.indexOf('#');
     var u = h == -1? uri : uri.substring(0, h);
 
     // Get resource from local storage first
     var ls = window.lstorage || localStorage;
-    var item = null;
-    try { item = ls.getItem(u); } catch(e) {}
-    if (item != null && item != '')
-        return item;
+    if (mode != 'remote') {
+        var item = null;
+        try { item = ls.getItem('ui.r.' + u); } catch(e) {}
+        if (item != null && item != '')
+            return item;
+        if (mode == 'local')
+            return null;
+    }
 
     // Get resource from network
+    //if (window.debug) debug('appcache.get', u);
     var http = new XMLHttpRequest();
-    http.open("GET", u, false);
+    http.open("GET", mode == 'remote'? (u + '?t=' + new Date().getTime() + '&r=' + Math.random()) : u, false);
     http.setRequestHeader("Accept", "*/*");
     http.send(null);
     if (http.status == 200) {
-        if (http.getResponseHeader("X-Login") != null) {
+        var xl = http.getResponseHeader("X-Login");
+        if (xl != null && xl != '') {
             if (window.debug) debug('http error', u, 'X-Login');
             return null;
-        } else if (http.responseText == '' || http.getResponseHeader("Content-Type") == null) {
+        }
+        var ct = http.getResponseHeader("Content-Type");
+        if (http.responseText == '' || ct == null || ct == '') {
             if (window.debug) debug('http error', u, 'No-Content');
             return null;
         }
-        try { ls.setItem(u, http.responseText); } catch(e) {}
+        try { ls.setItem('ui.r.' + u, http.responseText); } catch(e) {}
         return http.responseText;
     }
     if (window.debug) debug('http error', u, http.status, http.statusText);
     return null;
 };
 
+appcache.remove = function(uri) {
+    var h = uri.indexOf('#');
+    var u = h == -1? uri : uri.substring(0, h);
+    var ls = window.lstorage || localStorage;
+    try { ls.removeItem(u); } catch(e) {}
+    return true;
+};
+
 })();
 
 /**
  * Load Javascript and CSS.
  */
-(function() {
+(function loginboot() {
 
-var bootjs = document.createElement('script');
-bootjs.type = 'text/javascript';
-bootjs.text = appcache.get('/all-min.js');
-document.head.appendChild(bootjs);
-document.head.appendChild(ui.declareCSS(appcache.get('/ui-min.css')));
+window.eval.call(window, 'try {\n' + appcache.get('/all-min.js') + '\n' + appcache.get('/public/config-min.js') + '\n} catch(e) { console.log(e.stack); throw e; }\n');
+ui.includeCSS(appcache.get('/ui-min.css'));
+
+// Disable cache for testing
+//lstorage.enabled = false;
 
 })();
 
+} catch(e) {
+    if (window.debug) debug(e.stack);
+    throw e;
+}
 </script>
 </head>
-<body class="delayed"">
-<div id="mainbodydiv" class="bodydiv">
+<body class="delayed">
 
-<div id="headdiv" class="hsection">
-<script type="text/javascript">
-(function() {
-
-$('headdiv').appendChild(ui.declareScript(appcache.get('/public/config-min.js')));
-
-})();
-</script>
+<div id="menucontainer" class="tbarmenu">
+<div id="menu"></div>
 </div>
 
-<div id="menubackground" class="tbarbackground fixed"></div>
-<div id="menu" class="tbarmenu fixed"></div>
-
-<div id="viewheadbackground" class="viewheadbackground fixed"></div>
-<div id="viewhead" class="viewhead fixed">
+<div id="viewheadcontainer" class="viewhead">
+<div id="viewhead">
 <span class="cmenu">Sign in</span>
 </div>
+</div>
+
+<div id="working" class="working" style="display: none;"></div>
 
 <div id="viewcontainer">
 <div id="view">
@@ -109,9 +135,9 @@
 <form id="formSignin" name="formSignin" method="POST" action="/login/dologin" style="width: 100%;">
 <table style="width: 100%;">
 <tr><td><span id="loginprompt" style="font-size: 16px;"></span></tr></td>
-<tr><td><input type="text" class="flatentry" name="httpd_username" value="" placeholder="User id"/></td></tr>
+<tr><td><input type="text" class="flatentry" name="httpd_username" value="" placeholder="Username or email"/></td></tr>
 <tr><td><input type="password" class="flatentry" name="httpd_password" value="" placeholder="Password"/></td></tr>
-<tr><td><input type="submit" class="graybutton bluebutton" style="font-size: 16px; line-height: 16px; padding: 6px; height: 32px" value="Sign in"/></td></tr>
+<tr><td><input type="submit" class="bluebutton" style="font-size: 16px; line-height: 16px; padding: 6px; height: 32px" value="Sign in"/></td></tr>
 </table>
 <input type="hidden" name="httpd_location" value="/"/>
 </form>
@@ -120,7 +146,7 @@
 <form name="facebookOAuth2Form" style="width: 100%;">
 <table style="width: 100%;">
 <tr><td><span style="font-size: 16px;">Sign in with your <span style="font-weight: bold;">Facebook</span> account</span></td></tr>
-<tr><td><input type="button" id="facebookOAuth2Signin" value="Sign in" class="graybutton bluebutton" style="font-size: 16px; line-height: 16px; padding: 6px; height: 32px"/></td></tr>
+<tr><td><input type="button" id="facebookOAuth2Signin" value="Sign in" class="bluebutton" style="font-size: 16px; line-height: 16px; padding: 6px; height: 32px"/></td></tr>
 </table>
 </form>
 <br/>
@@ -128,7 +154,7 @@
 <form name="googleOAuth2Form" style="width: 100%;">
 <table style="width: 100%;">
 <tr><td><span style="font-size: 16px;">Sign in with your <span style="font-weight: bold;" >Google</span> account</span></td></tr>
-<tr><td><input type="button" id="googleOAuth2Signin" value="Sign in" class="graybutton bluebutton" style="font-size: 16px; line-height: 16px; padding: 6px; height: 32px"/></td></tr>
+<tr><td><input type="button" id="googleOAuth2Signin" value="Sign in" class="bluebutton" style="font-size: 16px; line-height: 16px; padding: 6px; height: 32px"/></td></tr>
 </table>
 </form>
 <br/>
@@ -147,78 +173,110 @@
 </div>
 </div>
 
-<div id="viewfootbackground" class="viewfootbackground fixed"></div>
-<div id="viewfoot" class="viewfoot fixed"></div>
-<div id="status" class="status fixed" style="visibility: hidden;"></div>
+<div id="viewfootcontainer" class="viewfoot">
+<div id="viewfoot"></div>
+<div id="status"></div>
+</div>
+
+<div id="installer" class="installer"></div>
 
 <script type="text/javascript">
-(function() {
+try {
+
+(function loginbody() {
 
 /**
- * Init div variables.
+ * Setup page layout.
  */
-var mbdiv = $('menubackground'); 
-var mdiv = $('menu'); 
-var hdiv = $('viewhead'); 
-var hbdiv = $('viewheadbackground'); 
-$('viewcontainer').className = ui.isMobile()? 'viewcontainer3d' : 'viewcontainer3dm';
-$('view').className = ui.isMobile()? 'viewloaded3d' : 'viewloaded3dm';
-$('loginprompt').innerHTML = config.loginprompt();
-var fdiv = $('viewfoot'); 
+(function layout() {
+    $('viewcontainer').className = ui.isMobile()? 'viewcontainer3dm' : 'viewcontainer3d';
+    $('view').className = ui.isMobile()? 'viewloaded3dm' : 'viewloaded3d';
+    $('loginprompt').innerHTML = config.loginprompt();
+    document.title = config.windowtitle() + ' - Sign in';
+    $('viewhead').innerHTML = '<span class="bcmenu">' + config.pagetitle() + '</span>' +
+        '<span class="rmenu"><input type="button" id="signUp" class="redbutton" style="font-size: 16px; line-height: 16px; padding: 6px; height: 32px" title="' + config.signuptitle() + '" value="Sign up"/></span>';
+    if (!ui.isMobile())
+        $('viewcontent').className = 'viewcontent flatscrollbars';
+    $('status').className = ui.isMobile()? 'status3dm' : 'status3d';
+})();
 
 /**
- * Set page titles.
+ * Setup menu bar.
  */
-document.title = config.windowtitle() + ' - Sign in';
-$('viewhead').innerHTML = '<span class="bcmenu">' + config.pagetitle() + '</span>';
+(function showmenu() {
+    $('menu').innerHTML = ui.menubar(mklist(ui.menu('menuhome', 'Home', '/', '_self', false)), mklist());
+    $('viewfoot').innerHTML = config.viewfoot();
+})();
 
 /**
- * Build and show the menu bar.
+ * Initialize status message area.
  */
-function showmenu(mdiv) {
-    mdiv.innerHTML = ui.menubar(mklist(ui.menu('menuhome', 'Home', '/', '_self', false)), mklist());
-    fdiv.innerHTML = config.viewfoot();
-}
-
-showmenu(mdiv);
+(function initstatus() {
+    if (isNil($('status')))
+        return;
+    $('status').style.display = 'none';
+    
+    function divtransitionend(e) {
+        e.target.style.display = 'none';
+        e.target.className = ui.isMobile()? 'status3dm' : 'status3d';
+        e.target.error = false;
+    }
+    $('status').addEventListener('webkitTransitionEnd', divtransitionend, false);
+    $('status').addEventListener('transitionend', divtransitionend, false);
+})();
 
 /**
  * Show a status message.
  */
-window.showStatus = function(s, c) {
-    //debug('status', s);
-    var sdiv = $('status');
-    if (isNil(sdiv))
+window.showstatus = function(s, c) {
+    //debug('show status', s);
+    if (isNil($('status')) || $('status').error)
         return s;
-    sdiv.innerHTML = '<span class="' + (c? c : 'okstatus') + '">' + s + '</span>';
-    sdiv.className = 'status fixed';
-    sdiv.style.visibility = 'visible';
-    
-    function divtransitionend(e) {
-        e.target.style.visibility = 'hidden';
-        e.target.className = 'status fixed';
-    }
-    if (!sdiv.addedTransitionEnd) {
-        sdiv.addEventListener('webkitTransitionEnd', divtransitionend, false);
-        sdiv.addEventListener('transitionend', divtransitionend, false);
-        sdiv.addedTransitionEnd = true;
-    }
-    sdiv.className = 'statusout3 fixed';
+    $('status').innerHTML = '<span class="' + (c? c : 'okstatus') + '">' + s + '</span>';
+    $('status').className = ui.isMobile()? 'status3dm' : 'status3d';
+    $('status').style.display = 'block';
+    $('status').error = c == 'errorstatus';
+    if ($('status').delay)
+        ui.cancelDelay($('status').delay);
+    $('status').delay = ui.delay(function hidestatus() {
+        $('status').className = ui.isMobile()? 'statusout3dm' : 'statusout3d';
+        $('status').error = false;
+    }, 3000);
     return s;
-}
+};
 
 /**
  * Show an error message.
  */
-window.showError = function(s) {
+window.errorstatus = function(s) {
     //debug('error', s);
-    return showStatus(s, 'errorstatus');
-}
+    return showstatus(s, 'errorstatus');
+};
 
 /**
- * Parse the query parameeters.
+ * Show working status.
  */
-function queryParams() {
+window.workingstatus = function(w, c) {
+    //debug('show working', w);
+    if (isNil($('working')))
+        return w;
+    if (!ui.isMobile())
+        $('working').style.top = ui.pixpos(Math.round(window.clientHeight / 2));
+    $('working').style.display = w? 'block' : 'none';
+    return w;
+};
+
+/**
+ * Show the online/offline status.
+ */
+window.onlinestatus = function() {
+    return navigator.onLine? (ui.isMobile()? showstatus('Online') : showstatus('Online')) : errorstatus('Offline');
+};
+
+/**
+ * Parse the query parameters.
+ */
+function qparams() {
     var qp = new Array();
     var qs = window.location.search.substring(1).split('&');
     for (var i = 0; i < qs.length; i++) {
@@ -232,20 +290,17 @@
 /**
  * Show login status.
  */
-function showLoginStatus() {
-    var a = queryParams()['openauth_attempt'];
-    debug('a', a);
+function loginstatus() {
+    var a = qparams()['openauth_attempt'];
     if (typeof(a) != 'undefined' && a == '1')
-        showError('Incorrect email or password, please try again');
+        errorstatus('Incorrect email or password, please try again');
 }
 
-showLoginStatus();
-
 /**
  * Return the referrer URL.
  */
-function openauthReferrer() {
-    var r = queryParams()['openauth_referrer'];
+function openauthreferrer() {
+    var r = qparams()['openauth_referrer'];
     if (typeof(r) == 'undefined' || domainname(r) != domainname(window.location.hostname))
         return '/';
     var q = r.indexOf('?');
@@ -257,9 +312,8 @@
 /**
  * Signin with OAuth 2.0.
  */
-function submitOAuth2Signin(w) {
+function submitoauth2signin(w) {
     parms = w();
-    clearauthcookie();
     lstorage.removeItem('/r/Editor/accounts');
     lstorage.removeItem('/r/Editor/dashboards');
     document.oauth2Signin.oauth2_authorize.value = parms[0];
@@ -268,70 +322,195 @@
     document.oauth2Signin.oauth2_info.value = parms[3];
     document.oauth2Signin.oauth2_scope.value = parms[4];
     document.oauth2Signin.oauth2_display.value = parms[5];
-    document.oauth2Signin.openauth_referrer.value = openauthReferrer();
+    document.oauth2Signin.openauth_referrer.value = openauthreferrer();
     document.oauth2Signin.action = '/oauth2/authorize/';
     document.oauth2Signin.submit();
 }
 
-function withFacebook() {
+function withfacebook() {
     var parms = ['https://graph.facebook.com/oauth/authorize', 'https://graph.facebook.com/oauth/access_token', 'facebook.com', 'https://graph.facebook.com/me', 'email', ui.isMobile()? 'touch' : 'page'];
     return parms;
 }
 
-function withGoogle() {
+function withgoogle() {
     var parms = ['https://accounts.google.com/o/oauth2/auth', 'https://accounts.google.com/o/oauth2/token', 'google.com', 'https://www.googleapis.com/oauth2/v1/userinfo', 'https://www.googleapis.com/auth/userinfo.email https://www.googleapis.com/auth/userinfo.profile', ''];
     return parms;
 }
 
 $('facebookOAuth2Signin').onclick = function() {
-    return submitOAuth2Signin(withFacebook);
+    return submitoauth2signin(withfacebook);
 };
 
 $('googleOAuth2Signin').onclick = function() {
-    return submitOAuth2Signin(withGoogle);
+    return submitoauth2signin(withgoogle);
 };
 
 /**
- * Signin with a userid and password.
+ * Signin with a username and password.
  */
-function submitFormSignin() {
-    clearauthcookie();
+$('formSignin').onsubmit = function submitformsignin() {
     document.formSignin.httpd_location.value = '/';
     document.formSignin.submit();
-}
+};
 
-$('formSignin').onsubmit = submitFormSignin;
+/**
+ * Signup.
+ */
+$('signUp').onclick = function submitsignup() {
+    ui.navigate('/public/notyet/', '_self');
+};
 
 /**
  * Handle orientation change.
  */
 document.body.onorientationchange = function(e) {
     //debug('onorientationchange');
-    ui.onorientationchange(e);
-
-    // Resize menu and view header
-    mdiv.style.width = ui.pixpos(document.documentElement.clientWidth);
-    hdiv.style.width = ui.pixpos(document.documentElement.clientWidth);
-    return true;
+    return ui.onorientationchange(e);
 };
 
 /**
+ * Populate cache with app resources.
+ */
+var appresources = [
+    ['/all-min.js'],
+    ['/ui-min.css'],
+    ['/public/config-min.js']
+];
+
+/**
+ * Install the application cache.
+ */
+(function installappcache() {
+    if (ui.isMobile()) {
+        // On mobile devices, trigger usage of an application cache manifest
+        window.onappcachechecking = function(e) {
+            //debug('appcache checking', e);
+            workingstatus(true);
+            showstatus('Checking');
+        };
+        window.onappcacheerror = function(e) {
+            //debug('appcache error', e);
+            onlinestatus();
+            workingstatus(false);
+        };
+        window.onappcachenoupdate = function(e) {
+            //debug('appcache noupdate', e);
+            onlinestatus();
+            workingstatus(false);
+        };
+        window.onappcachedownloading = function(e) {
+            //debug('appcache downloading', e);
+            workingstatus(true);
+            showstatus('Updating');
+        };
+        window.onappcacheprogress = function(e) {
+            //debug('appcache progress', e);
+            workingstatus(true);
+            showstatus('Updating');
+        };
+        window.onappcacheupdateready = function(e) {
+            //debug('appcache updateready', e);
+            try {
+                applicationCache.swapCache();
+            } catch(e) {}
+            onlinestatus();
+            workingstatus(false);
+            //debug('appcache swapped', e);
+
+            // Update offline resources in local storage and reload the page
+            map(function(res) {
+                showstatus('Updating');
+                appcache.remove(res[0]);
+                appcache.get(res[0], 'remote');
+            }, append(appresources, config.appresources()));
+            window.location.reload();
+        };
+        window.onappcachecached = function(e) {
+            //debug('appcache cached', e);
+            onlinestatus();
+            workingstatus(false);
+
+            // Install offline resources in local storage
+            map(function(res) {
+                showstatus('Updating');
+                appcache.remove(res[0]);
+                appcache.get(res[0], 'remote');
+            }, append(appresources, config.appresources()));
+        };
+
+        window.onloadappcache = function() {
+            //debug('appcache iframe loaded');
+        };
+
+        ui.delay(function() {
+            $('installer').innerHTML = '<iframe src="/public/cache/" class="installer"></iframe>';
+        });
+
+    } else {
+        // On non-mobile devices, check for cache-manifest changes ourselves.
+        workingstatus(true);
+        showstatus('Checking');
+        var lcmf = appcache.get('/public/cache/cache-manifest.cmf', 'local');
+        var rcmf = appcache.get('/public/cache/cache-manifest.cmf', 'remote');
+        if (lcmf == rcmf) {
+            onlinestatus();
+            workingstatus(false);
+            return true;
+        }
+
+        //debug('cache-manifest changed, reloading');
+        ui.delay(function() {
+            workingstatus(true);
+            showstatus('Updating');
+            ui.delay(function() {
+                workingstatus(true);
+                showstatus('Updating');
+                map(function(res) {
+                    appcache.remove(res[0]);
+                    appcache.get(res[0], 'remote');
+                }, append(appresources, config.appresources()));
+                if (!isNil(lcmf)) {
+                    //debug('reloading');
+                    window.location.reload();
+                }
+                onlinestatus();
+                workingstatus(false);
+            });
+        });
+    }
+})();
+
+/**
+ * Handle network offline/online events.
+ */
+window.addEventListener('offline', function(e) {
+    //debug('going offline');
+    showstatus('Offline');
+}, false);
+window.addEventListener('online', function(e) {
+    //debug('going online');
+    showstatus('Online');
+}, false);
+
+/**
  * Initialize the document.
  */
-function onload() {
+window.onload = function() {
     //debug('onload');
     ui.onload();
 
-    // Show the page
-    document.body.style.visibility = 'visible';
+    // Show the login status
+    loginstatus();
     return true;
-}
-
-onload();
+};
 
 })();
+
+} catch(e) {
+    debug(e.stack);
+    throw e;
+}
 </script>
 
-</div>
 </body>
 </html>
diff --git a/hosting/server/htdocs/page/index.html b/hosting/server/htdocs/page/index.html
index 6a6e042..c6e1108 100644
--- a/hosting/server/htdocs/page/index.html
+++ b/hosting/server/htdocs/page/index.html
@@ -19,59 +19,69 @@
 -->
 <div id="bodydiv" class="body">
 
-<div id="contentdiv" class="viewcontent" style="width: 2500px;">
-<div id="pagediv" class="pagediv" style="top: 0px; left: -2500px; width: 5000px; height: 5000px;">
+<div id="viewcontent" class="viewcontent">
+<div id="pagecontainer">
+<div id="pagediv" class="pagediv">
+</div>
 
 <!--
-<div class="guide" style="position: absolute; left: 2500px; top: 0px; width: 320px; height: 460px;"></div>
-<div class="guide" style="position: absolute; left: 2500px; top: 0px; width: 480px; height: 300px;"></div>
-<div class="guide" style="position: absolute; left: 2500px; top: 0px; width: 768px; height: 911px;"></div>
-<div class="guide" style="position: absolute; left: 2500px; top: 0px; width: 1024px; height: 655px;"></div>
+<div class="guide" style="position: absolute; left: 0px; top: 0px; width: 320px; height: 460px;"></div>
+<div class="guide" style="position: absolute; left: 0px; top: 0px; width: 480px; height: 300px;"></div>
+<div class="guide" style="position: absolute; left: 0px; top: 0px; width: 768px; height: 911px;"></div>
+<div class="guide" style="position: absolute; left: 0px; top: 0px; width: 1024px; height: 655px;"></div>
 -->
 
-<span class="h1" id="palette:h1" style="position: absolute; left: 0px; top: 0px;"><h1>Header Level 1</h1></span>
-<span class="h2" id="palette:h2" style="position: absolute; left: 0px; top: 30px;"><h2>Header Level 2</h2></span>
-<span class="section" id="palette:section" style="position: absolute; left: 0px; top: 60px; width: 220px;"><span class="Section">Section</span></span>
-<span class="text" id="palette:text" style="position: absolute; left: 0px; top: 90px;"><span>Some text</span></span>
-<span class="link" id="palette:link" style="position: absolute; left: 80px; top: 90px;"><a href="/"><span>Link</span></a></span>
-<span class="button" id="palette:graybutton" style="position: absolute; left: 0px; top: 120px;"><input type="button" value="Button" class="graybutton"/></span>
-<span class="button" id="palette:bluebutton" style="position: absolute; left: 80px; top: 120px;"><input type="button" value="Button" class="graybutton bluebutton"/></span>
-<span class="button" id="palette:redbutton" style="position: absolute; left: 160px; top: 120px;"><input type="button" value="Button" class="graybutton redbutton"/></span>
-<span class="entry" id="palette:entry" style="position: absolute; left: 0px; top: 160px;"><input type="text" value="Entry Field" class="flatentry" size="20" autocapitalize="off"/></span>
-<span class="password" id="palette:password" style="position: absolute; left: 0px; top: 190px;"><input type="password" value="Password" class="flatentry" size="20"/></span>
-<span class="checkbox" id="palette:checkbox" style="position: absolute; left: 0px; top: 220px;"><input type="checkbox" value="Checkbox" class="flatcheckbox"/><span>Checkbox</span></span>
+</div>
+
+<div id="playdiv" class="playdiv" style="display: none;"></div>
+
+</div>
+
+<div id="palettecontainer">
+<div id="paletteview" style="display: none;">
+
+<div id="palettecontent" class="palettecontent">
+<table class="palettetable">
+<tr><td class="palettetd"><span class="hd1" id="palette:h1"><span>Header 1</span></span></td></tr>
+<tr><td class="palettetd"><span class="hd2" id="palette:h2"><span>Header 2</span></span></td></tr>
+<tr><td class="palettetd"><span class="section" id="palette:section"><span>Section</span></span></td></tr>
+<tr><td class="palettetd"><span class="text" id="palette:text"><span>Some text</span></span></td></tr>
+<tr><td class="palettetd"><span class="link" id="palette:link"><a href="/"><span>Link</span></a></span></td></tr>
+<tr><td class="palettetd"><span class="button" id="palette:graybutton"><input type="button" value="Button" class="graybutton"/></span></td></tr>
+<tr><td class="palettetd"><span class="button" id="palette:redbutton"><input type="button" value="Button" class="redbutton"/></span></td></tr>
+<tr><td class="palettetd"><span class="button" id="palette:greenbutton"><input type="button" value="Button" class="greenbutton"/></span></td></tr>
+<tr><td class="palettetd"><span class="button" id="palette:bluebutton"><input type="button" value="Button" class="bluebutton"/></span></td></tr>
+<tr><td class="palettetd"><span class="entry" id="palette:entry"><input type="text" value="Entry Field" class="flatentry" size="10" autocapitalize="off" readonly="true" style="cursor: default;"/></span></td></tr>
+<tr><td class="palettetd"><span class="password" id="palette:password"><input type="password" value="Password" class="flatentry" size="10" readonly="true" style="cursor: default;"/></span></td></tr>
+<tr><td class="palettetd"><span class="checkbox" id="palette:checkbox"><input type="checkbox" value="Checkbox" class="flatcheckbox"/><span>Checkbox</span></span></td></tr>
 <!--
-<span class="select" id="palette:select" style="position: absolute; left: 80px; top: 220px;"><select><option value="select">Selection</option></select></span>
+<tr><td class="palettetd"><span class="select" id="palette:select"><select disabled="true"><option value="select">Selection</option></select></span></td></tr>
 -->
-<span class="list" id="palette:list" style="position: absolute; left: 0px; top: 250px; width: 220px;">
-<table class="datatable" style="width: 220px;">
-<tr><td class="datatd">List</td></tr>
-<tr><td class="datatd">List</td></tr>
-<tr><td class="datatd">List</td></tr>
+<tr><td class="palettetd"><span class="list" id="palette:list">
+<table class="datatable">
+<tr><td class="datatd"><span>List</span></td></tr>
+<tr><td class="datatd"><span>List</span></td></tr>
+<tr><td class="datatd"><span>List</span></td></tr>
 </table>
-</span>
-<span class="table" id="palette:table" style="position: absolute; left: 0px; top: 320px; width: 220px;">
-<table class="datatable" style="width: 220px;">
-<tr><td class="datatdl">Table</td><td class="datatdr">Table</td></tr>
-<tr><td class="datatdl">Table</td><td class="datatdr">Table</td></tr>
-<tr><td class="datatdl">Table</td><td class="datatdr">Table</td></tr>
+</span></td></tr>
+<tr><td class="palettetd"><span class="table" id="palette:table">
+<table class="datatable">
+<tr><td class="datatdl"><span>Table</span></td><td class="datatdr"><span>Table</span></td></tr>
+<tr><td class="datatdl"><span>Table</span></td><td class="datatdr"><span>Table</span></td></tr>
+<tr><td class="datatdl"><span>Table</span></td><td class="datatdr"><span>Table</span></td></tr>
 </table>
-</span>
-<!--
-<span class="iframe fakeframe" id="palette:iframe" style="position: absolute; left: 0px; top: 380px; width: 200px;"><a href="/public/iframe-min.html"><span class="fakeframe"><span>Frame ...</span></span></a></span>
--->
-<span class="img" id="palette:img" style="position: absolute; left: 0px; top: 410px;"><img id="imgimg"/></span>
-</div>
-
-<div id="playdiv" style="visibility: hidden; position: absolute; top: 0px; left: 0px; width: 2500px; height: 5000px;">
+</span></td></tr>
+<tr><td class="palettetd"><span class="img" id="palette:img"><img id="imgimg"/></span></td></tr>
+</table>
 </div>
 
 </div>
+</div>
 
-<div id="buffer" style="visibility: hidden; width: 0px; height: 0px"></div>
+<div id="xhtmlbuffer" style="display: none;"></div>
 
 <script type="text/javascript">
-(function() {
+(function pagebody() {
 
 /**
  * Get the current app name.
@@ -79,402 +89,247 @@
 var appname = ui.fragmentParams(location)['app'];
 
 /**
- * Return the link to an app.
+ * Setup page layout.
  */
-function applink(appname) {
-    var protocol = location.protocol;
-    var host = location.hostname;
-    var port = ':' + location.port;
-    if (port == ':80' || port == ':443' || port == ':')
-        port = '';
-    var link = protocol + '//' + host + port + '/' + appname + '/';
-    return link;
-}
+(function layout() {
+    document.title = config.windowtitle() + ' - Page - ' + appname;
 
-/**
- * Set page titles.
- */
-document.title = config.windowtitle() + ' - Page - ' + appname;
+    $('viewhead').innerHTML = '<span id="appTitle" class="cmenu">' + appname + '</span>' +
+    '<input type="button" id="deleteWidgetButton" title="Delete a Widget" class="redbutton plusminus" style="position: absolute; top: 4px; left: 5px;" disabled="true" value="-"/>' +
+    '<span style="position: absolute; top: 0px; left: 45px; right: 115px; padding: 0px; background: transparent;"><input id="widgetValue" type="text" value="" class="flatentry" title="Widget value" autocapitalize="off" placeholder="Value" style="position: absolute; left: 0px; top: 4px; width: 100%; display: none;" readonly="readonly"/></span>' +
+    '<input type="button" id="playPageButton" title="View page" class="greenbutton plusminus" style="position: absolute; top: 4px; right: 75px;" value="&gt;"/>' +
+    '<input type="button" id="copyWidgetButton" title="Copy a Widget" class="bluebutton" style="position: absolute; top: 4px; right: 40px; font-size: 16px;" disabled="true" value="C"/>' +
+    '<input type="button" id="addWidgetButton" title="Add a Widget" class="bluebutton plusminus" style="position: absolute; top: 4px; right: 5px;" disabled="true" value="+"/>';
 
-/**
- * Set header div.
- */
-$('viewhead').innerHTML = '<span id="appTitle" class="cmenu">' + appname + '</span>' +
-'<input type="button" id="deleteWidgetButton" title="Delete a Widget" class="graybutton redbutton plusminus" style="position: absolute; top: 4px; left: 5px;" disabled="true" value="-"/>' +
-'<span style="position: absolute; top: 0px; left: 45px; right: 115px; padding: 0px; background: transparent;"><input id="widgetValue" type="text" value="" class="flatentry" title="Widget value" autocapitalize="off" placeholder="Value" style="position: absolute; left: 0px; top: 4px; width: 100%; visibility: hidden;" readonly="readonly"/></span>' +
-'<input type="button" id="playPageButton" title="View page" class="graybutton plusminus" style="position: absolute; top: 4px; right: 75px;" value="&gt;"/>' +
-'<input type="button" id="copyWidgetButton" title="Copy a Widget" class="graybutton bluebutton" style="position: absolute; top: 4px; right: 40px; font-size: 16px;" disabled="true" value="C"/>' +
-'<input type="button" id="addWidgetButton" title="Add a Widget" class="graybutton bluebutton plusminus" style="position: absolute; top: 4px; right: 5px;" disabled="true" value="+"/>';
+    if (ui.isMobile()) {
+        $('palettecontainer').className = 'palettecontainer3dm';
+        $('paletteview').className = 'paletteloaded3dm';
+    } else {
+        $('viewcontent').className = 'viewcontent flatscrollbars';
+        $('palettecontainer').className = 'palettecontainer3d';
+        $('paletteview').className = 'paletteloaded3d';
+        $('palettecontent').className = 'palettecontent flatscrollbars';
+    }
+
+    $('imgimg').src = ui.b64png(appcache.get('/public/img.b64'));
+})();
 
 /**
  * Track the current page, author, and saved XHTML content.
  */
 var author = '';
 var editable = false;
-var savedpagexhtml = '';
+var savedxhtml = '';
 
 /**
- * Page editor area, widget value field, add, delete and play page buttons.
- */
-var cdiv = $('contentdiv');
-var pagediv = $('pagediv');
-var evisible = true;
-var pdiv = $('playdiv');
-var wadd = $('addWidgetButton');
-var wdelete = $('deleteWidgetButton');
-var wcopy = $('copyWidgetButton');
-var wvalue = $('widgetValue');
-var atitle = $('appTitle');
-var pplay = $('playPageButton');
-
-/**
- * Set images.
- */
-$('imgimg').src = ui.b64img(appcache.get('/public/img.b64'));
-
-/**
- * Init component references.
+ * Initialize component references.
  */
 var editorComp = sca.component('Editor');
 var pages = sca.reference(editorComp, 'pages');
 
 /**
- * Page editing functions.
+ * Return the transform property of a widget.
  */
-var page = {};
+var msiefixupbounds = ui.isMSIE();
+function widgettransform(e) {
+    if (!isNil(e.xtranslate))
+        return [e.xtranslate, e.ytranslate];
+    var t = e.style.getPropertyValue('-webkit-transform') || e.style.getPropertyValue('-moz-transform') ||
+            e.style.getPropertyValue('-ms-transform') || e.style.getPropertyValue('-o-transform') ||
+            e.style.getPropertyValue('transform');
+    if (t) {
+        var xy = t.split('(')[1].split(')')[0].split(',');
+        return [ui.numpos(xy[0]), ui.numpos(xy[1])];
+    }
+    if (e.id.substring(0, 8) == 'palette:') {
+        // On Internet Explorer get the view bounding rect as the palette
+        // doesn't return a correct bounding rect
+        var pbr = msiefixupbounds? $('viewcontent').getBoundingClientRect() : $('palettecontent').getBoundingClientRect();
+        var br = e.getBoundingClientRect();
+        return [br.left - pbr.left, br.top - pbr.top];
+    }
+    return [0, 0];
+}
 
 /**
- * Default positions and sizes.
+ * Return the x position of a widget.
  */
-page.palcx = 2500;
+function widgetxpos(e) {
+    var t = widgettransform(e)[0];
+    return ui.numpos(e.style.left) + (isNil(t)? 0 : t);
+}
 
 /**
- * Init a page editor.
+ * Return the y position of a widget.
  */
-page.mkedit = function(pagediv, atitle, wvalue, wadd, wcopy, wdelete, onchange, onselect) {
+function widgetypos(e) {
+    var t = widgettransform(e)[1];
+    return ui.numpos(e.style.top) + (isNil(t)? 0 : t);
+}
 
-    // Track element dragging and selection
-    page.dragging = null;
-    page.selected = null;
-    wvalue.readOnly = true;
-    wvalue.style.visibility = 'hidden';
-    atitle.style.visibility = 'visible';
-    page.mousemoved = false;
-    wcopy.disabled = true;
-    wdelete.disabled = true;
-    wadd.disabled = !editable;
+/**
+ * Return the class of a widget.
+ */
+function widgetclass(e) {
+    return e.className.split(' ')[0];
+}
 
-    // Trigger widget select and page change events
-    page.onpagechange = onchange;
-    page.onselectwidget = onselect;
+/**
+ * Initialize a widget.
+ */
+function fixupwidget(e) {
+
+    // Add draggable class
+    var wc = e.className;
+    e.className = ui.isMobile()? (wc + ' draggable3dm') : (wc + ' draggable3d');
+
+    // Convert widget position to a CSS transform
+    var x = ui.numpos(e.style.left);
+    var y = ui.numpos(e.style.top);
+    var t = 'translate(' + x + 'px,' + y + 'px)';
+    e.style.setProperty('-webkit-transform', t, null);
+    e.style.setProperty('-moz-transform', t, null);
+    e.style.setProperty('-o-transform', t, null);
+    e.style.setProperty('-ms-transform', t, null);
+    e.style.setProperty('transform', t, null);
+    e.xtranslate = x;
+    e.ytranslate = y;
+    e.style.left = ui.pixpos(0);
+    e.style.top = ui.pixpos(0);
+
+    if (wc == 'entry' || wc == 'password') {
+        var i = car(childElements(e));
+        i.readOnly = true;
+        i.style.cursor = 'default';
+        return e;
+    }
+    if (wc == 'link') {
+        var l = car(childElements(e));
+        l.onclick = function(e) { return false; };
+        return e;
+    }
+    return e;
+}
+
+/**
+ * Cleanup a widget before saving it.
+ */
+function cleanupwidget(e) {
+    //debug('cleanupwidget', e);
+
+    // Adjust widget class
+    var wc = widgetclass(e);
+    e.className = wc;
+
+    // Convert CSS transform to an absolute position
+    e.style.left = ui.pixpos(widgetxpos(e));
+    e.style.top = ui.pixpos(widgetypos(e));
+    e.style.removeProperty('-webkit-transform');
+    e.style.removeProperty('-moz-transform');
+    e.style.removeProperty('-o-transform');
+    e.style.removeProperty('-ms-transform');
+    e.style.removeProperty('transform');
+    e.xtranslate = null;
+    e.ytranslate = null;
+
+    // Clear outline
+    e.style.removeProperty('outline');
+
+    if (wc == 'entry' || wc == 'password') {
+        var i = car(childElements(e));
+        i.readOnly = false;
+        i.style.cursor = null;
+        return e;
+    }
+    if (wc == 'link') {
+        var l = car(childElements(e));
+        l.onclick = null;
+        return e;
+    }
+    return e;
+}
+
+/**
+ * Clone a widget.
+ */
+function clonewidget(e) {
 
     /**
-     * Handle a mouse down event.
+     * Clone an element's HTML.
      */
-    function onmousedown(e) {
-        // On mouse controlled devices, run component selection logic right away
-        var selected = page.selected;
-        if (typeof e.touches == 'undefined') {
-            //debug('onmousedown-click');
-            onclick(e);
-        }
+    function mkclone(e) {
+        var ne = document.createElement('span');
 
-        // Find a draggable element
-        var dragging = page.draggable(e.target, pagediv);
-        if (dragging == null || dragging != page.selected)
-            return true;
-        page.dragging = dragging;
+        // Skip the palette: prefix
+        ne.id = 'page:' + e.id.substr(8);
 
-        // Remember mouse position
-        var pos = typeof e.touches != "undefined"? e.touches[0] : e;
-        page.mousemoved = false;
-        page.dragX = pos.screenX;
-        page.dragY = pos.screenY;
-        page.moveX = pos.screenX;
-        page.moveY = pos.screenY;
+        // Copy the class and HTML content
+        ne.className = widgetclass(e);
+        ne.innerHTML = e.innerHTML;
 
-        // Prevent default behavior on first click on a widget
-        if (e.preventDefault)
-            e.preventDefault();
-        else
-            e.returnValue = false;
+        // Fixup the widget
+        fixupwidget(ne);
 
+        return ne;
+    }
+
+    /**
+     * Clone an element's position.
+     */
+    function posclone(ne, e) {
+        ne.style.position = 'absolute';
+        movewidget(ne, widgetxpos(e), widgetypos(e));
+        return ne;
+    }
+
+    return posclone(mkclone(e), e);
+}
+
+/**
+ * Select a widget.
+ */
+function selectwidget(n, s) {
+    //debug('selectwidget', n, s);
+    if (isNil(n) || !s) {
+        // Clear the widget value field
+        $('widgetValue').value = '';
+        $('widgetValue').readOnly = true;
+        $('widgetValue').style.display = 'none';
+
+        // Show the app title
+        $('appTitle').style.display = 'block';
+
+        // Update the copy and delete buttons
+        $('copyWidgetButton').disabled = true;
+        $('deleteWidgetButton').disabled = true;
+
+        // Clear the widget outline
+        if (!isNil(n))
+            n.style.removeProperty('outline');
         return true;
     }
 
-    if (!ui.isMobile()) {
-        pagediv.onmousedown = function(e) {
-            //debug('onmousedown');
-            return onmousedown(e);
-        };
-    } else {
-        pagediv.ontouchstart = function(e) {
-            //debug('ontouchstart');
-            return onmousedown(e);
-        };
-    }
+    // Outline the widget
+    n.style.outline = '2px solid #598edd';
 
-    /**
-     * Handle a mouse up event.
-     */
-    function onmouseup(e) {
-        if (page.dragging == null)
-            return true;
+    // Update the widget value field
+    $('widgetValue').value = widgettext(n);
+    $('widgetValue').readOnly = false || !editable;
+    $('widgetValue').style.display = 'block';
 
-        // Snap to grid
-        var newX = page.gridsnap(ui.numpos(page.dragging.style.left));
-        var newY = page.gridsnap(ui.numpos(page.dragging.style.top));
-        page.dragging.style.left = ui.pixpos(newX);
-        page.dragging.style.top = ui.pixpos(newY);
+    // Hide the app title
+    $('appTitle').style.display = 'none';
 
-        // Fixup widget style
-        page.initwidget(page.dragging);
-
-        // Forget dragged element
-        page.dragging = null;
-
-        // Trigger page change event
-        page.onpagechange(false);
-
-        // Simulate onclick event
-        onclick(e);
-
-        return true;
-    }
-
-    if (!ui.isMobile()) {
-        pagediv.onmouseup = function(e) {
-            //debug('onmouseup');
-            return onmouseup(e);
-        };
-    } else {
-        pagediv.ontouchend = function(e) {
-            //debug('ontouchend');
-            return onmouseup(e);
-        }
-    }
-
-    /**
-     * Handle a mouse move event.
-     */
-    function onmousemove(e) {
-
-        // Track mouse moves
-        page.mousemoved = true;
-
-        if (page.dragging == null)
-            return true;
-
-        // Ignore duplicate mouse move events
-        if (page.moveX == page.dragX && page.moveY == page.dragY)
-            return true;
-
-        // Compute position of dragged element
-        var curX = ui.numpos(page.dragging.style.left);
-        var curY = ui.numpos(page.dragging.style.top);
-        var newX = curX + (page.moveX - page.dragX);
-        var newY = curY + (page.moveY - page.dragY);
-        if (newX >= page.palcx)
-            page.dragX = page.moveX;
-        else
-            newX = page.palcx;
-        if (newY >= 0)
-            page.dragY = page.moveY;
-        else
-            newY = 0;
-
-        // Move the dragged element
-        page.dragging.style.left = ui.pixpos(newX);
-        page.dragging.style.top = ui.pixpos(newY);
-        page.constrainwidget(page.dragging);
-
-        return true;
-    }
-
-    if (!ui.isMobile()) {
-        window.onmousemove = function(e) {
-
-            // Remember mouse position
-            page.moveX = e.screenX;
-            page.moveY = e.screenY;
-
-            return onmousemove(e);
-        };
-    } else {
-        pagediv.ontouchmove = function(e) {
-
-            // Remember touch position
-            var pos = e.touches[0];
-            if (page.moveX == pos.screenX && page.moveY == pos.screenY)
-                return true;
-            page.moveX = pos.screenX;
-            page.moveY = pos.screenY;
-            if (page.moveX == page.dragX && page.moveY == page.dragY)
-                return true;
-
-            onmousemove(e);
-        };
-    }
-
-    /**
-     * Handle a mouse click event.
-     */
-    function onclick(e) {
-
-        // Find selected element
-        var selected = page.draggable(e.target, pagediv);
-        if (selected == null) {
-            if (page.selected != null) {
-
-                // Reset current selection
-                page.selectwidget(page.selected, false, atitle, wvalue, wcopy, wdelete);
-                page.selected = null;
-
-                // Trigger widget select event
-                page.onselectwidget(null);
-            }
-
-            // Dismiss the palette
-            if (ui.numpos(pagediv.style.left) != (page.palcx * -1))
-                pagediv.style.left = ui.pixpos(page.palcx * -1);
-
-            return true;
-        }
-
-        // Deselect the previously selected element
-        page.selectwidget(page.selected, false, atitle, wvalue, wcopy, wdelete);
-
-        // Clone element dragged from palette
-        if (selected.id.substring(0, 8) == 'palette:') {
-            page.selected = page.clone(selected);
-
-            // Move into the editing area and hide the palette
-            page.selected.style.left = ui.pixpos(ui.numpos(page.selected.style.left) + page.palcx);
-            page.initwidget(page.selected);
-            pagediv.style.left = ui.pixpos(page.palcx * -1);
-            page.constrainwidget(page.selected);
-        
-            // Bring it to the top
-            page.bringtotop(page.selected);
-
-            // Trigger page change event
-            page.onpagechange(true);
-
-            // Select the element
-            page.selectwidget(page.selected, true, atitle, wvalue, wcopy, wdelete);
-
-            // Trigger widget select event
-            page.onselectwidget(page.selected);
-
-            return true;
-
-        }
-
-        // Bring selected element to the top
-        page.selected = selected;
-        page.bringtotop(page.selected);
-
-        // Select the element
-        page.selectwidget(page.selected, true, atitle, wvalue, wcopy, wdelete);
-
-        // Trigger widget select event
-        page.onselectwidget(page.selected);
-
-        return true;
-    }
-
-    if (!ui.isMobile()) {
-        pagediv.onclick = function(e) {
-            //debug('onclick');
-            return onclick(e);
-        };
-    } else {
-        pagediv.onclick = function(e) {
-            //debug('onclick');
-            return onclick(e);
-        };
-    }
-
-    /**
-     * Handle field on change events.
-     */
-    wvalue.onchange = wvalue.onblur = function() {
-        if (page.selected == null)
-            return false;
-        page.settext(page.selected, wvalue.value);
-
-        // Trigger page change event
-        page.onpagechange(true);
-        return false;
-    };
-
-    // Handle add widget event.
-    wadd.onclick = function() {
-
-        // Show the palette
-        pagediv.style.left = ui.pixpos(0);
-        return false;
-    };
-
-    // Handle delete event.
-    wdelete.onclick = function() {
-        if (page.selected == null)
-            return false;
-
-        // Reset current selection
-        page.selectwidget(page.selected, false, atitle, wvalue, wcopy, wdelete);
-
-        // Remove selected widget
-        page.selected.parentNode.removeChild(page.selected);
-        page.selected = null;
-
-        // Trigger widget select event
-        page.onselectwidget(null);
-
-        // Trigger page change event
-        page.onpagechange(true);
-        return false;
-    };
-
-    // Handle copy event.
-    wcopy.onclick = function() {
-        if (page.selected == null)
-            return false;
-        if (page.selected.id.substring(0, 8) == 'palette:')
-            return false;
-
-        // Reset current selection
-        page.selectwidget(page.selected, false, atitle, wvalue, wcopy, wdelete);
-
-        // Clone selected widget
-        page.selected = page.clone(page.selected);
-
-        // Move 10 pixels down right
-        page.selected.style.left = ui.pixpos(ui.numpos(page.selected.style.left) + 10);
-        page.selected.style.top = ui.pixpos(ui.numpos(page.selected.style.top) + 10);
-        page.constrainwidget(page.selected);
-    
-        // Bring it to the top
-        page.bringtotop(page.selected);
-
-        // Select the element
-        page.selectwidget(page.selected, true, atitle, wvalue, wcopy, wdelete);
-
-        // Trigger widget select event
-        page.onselectwidget(page.selected);
-
-        // Trigger page change event
-        page.onpagechange(true);
-        return false;
-    };
-
-    return pagediv;
-};
+    // Update the copy and delete buttons
+    $('copyWidgetButton').disabled = false || !editable;
+    $('deleteWidgetButton').disabled = false || !editable;
+    return true;
+}
 
 /**
  * Return the text of a widget.
  */
-page.text = function(e) {
+function widgettext(e) {
     function formula(e) {
         var f = e.id;
         if (f.substring(0, 5) != 'page:')
@@ -483,43 +338,36 @@
     }
 
     function constant(e, f) {
-        if (e.className == 'h1' || e.className == 'h2' || e.className == 'text' || e.className == 'section') {
+        var wc = widgetclass(e);
+        if (wc == 'hd1' || wc == 'hd2' || wc == 'text' || wc == 'section') {
             var t = car(childElements(e)).innerHTML;
             return t == f? '' : t;
         }
-        if (e.className == 'button' || e.className == 'checkbox') {
+        if (wc == 'button' || wc == 'checkbox') {
             var t = car(childElements(e)).value;
             return t == f? '' : t;
         }
-        if (e.className == 'entry' || e.className == 'password') {
+        if (wc == 'entry' || wc == 'password') {
             var t = car(childElements(e)).defaultValue;
             return t == f? '' : t;
         }
-        /*
-        if (e.className == 'select') {
+        if (wc == 'select') {
             var t = car(childElements(car(childElements(e)))).value;
             return t == f? '' : t;
         }
-        */
-        if (e.className == 'link') {
+        if (wc == 'link') {
             var lhr = car(childElements(e)).href;
             var hr = lhr.substring(0, 5) == 'link:'? lhr.substring(5) : '';
             var t = car(childElements(car(childElements(e)))).innerHTML;
             return t == f? hr : (t == hr? hr : (t == ''? hr : hr + ',' + t));
         }
-        if (e.className == 'img') {
+        if (wc == 'img') {
             var src = car(childElements(e)).src;
             return src == location.href? '' : src;
         }
-        /*
-        if (e.className == 'iframe') {
-            var hr = car(childElements(e)).href;
-            return hr == location.href? '' : hr;
-        }
-        */
-        if (e.className == 'list')
+        if (wc == 'list')
             return '';
-        if (e.className == 'table')
+        if (wc == 'table')
             return '';
         return '';
     }
@@ -527,41 +375,36 @@
     var f = formula(e);
     var c = constant(e, f);
     return f == ''? c : (c == ''? f : f + ',' + c);
-};
+}
 
 /**
  * Return true if a widget has editable text.
  */
-page.hastext = function(e) {
-    if (e.className == 'h1' || e.className == 'h2' || e.className == 'text' || e.className == 'section')
+function widgethastext(e) {
+    var wc = widgetclass(e);
+    if (wc == 'hd1' || wc == 'hd2' || wc == 'text' || wc == 'section')
         return true;
-    if (e.className == 'button' || e.className == 'checkbox')
+    if (wc == 'button' || wc == 'checkbox')
         return true;
-    if (e.className == 'entry' || e.className == 'password')
+    if (wc == 'entry' || wc == 'password')
         return true;
-    /*
-    if (e.className == 'select')
+    if (wc == 'select')
         return false;
-    */
-    if (e.className == 'link')
+    if (wc == 'link')
         return true;
-    if (e.className == 'img')
+    if (wc == 'img')
         return true;
-    /*
-    if (e.className == 'iframe')
-        return true;
-    */
-    if (e.className == 'list')
+    if (wc == 'list')
         return false;
-    if (e.className == 'table')
+    if (wc == 'table')
         return false;
     return false;
-};
+}
 
 /**
  * Set the text of a widget.
  */
-page.settext = function(e, t) {
+function setwidgettext(e, t) {
     function formula(t) {
         if (t.length > 1 && t.substring(0, 1) == '=')
             return car(t.split(','));
@@ -575,268 +418,690 @@
     var f = formula(t);
     var c = constant(t);
 
-    e.id = f != ''? f.substring(1) : ('page:' + e.className);
+    var wc = widgetclass(e);
+    e.id = f != ''? f.substring(1) : ('page:' + wc);
 
-    if (e.className == 'h1' || e.className == 'h2' || e.className == 'text' || e.className == 'section') {
+    if (wc == 'hd1' || wc == 'hd2' || wc == 'text' || wc == 'section') {
         car(childElements(e)).innerHTML = isNil(c)? f : car(c);
         return t;
     }
-    if (e.className == 'button') {
+    if (wc == 'button') {
         car(childElements(e)).value = isNil(c)? f : car(c);
         return t;
     }
-    if (e.className == 'entry' || e.className == 'password') {
+    if (wc == 'entry' || wc == 'password') {
         car(childElements(e)).defaultValue = isNil(c)? f : car(c);
         return t;
     }
-    if (e.className == 'checkbox') {
+    if (wc == 'checkbox') {
         car(childElements(e)).value = isNil(c)? f : car(c);
         map(function(n) { if (n.nodeName == "SPAN") n.innerHTML = isNil(c)? f : car(c); return n; }, nodeList(e.childNodes));
         return t;
     }
-    /*
-    if (e.className == 'select') {
+    if (wc == 'select') {
         var ce = car(childElements(car(childElements(e))));
         ce.value = isNil(c)? f : car(c);
         ce.innerHTML = isNil(c)? f : car(c);
         return t;
     }
-    */
-    if (e.className == 'list') {
+    if (wc == 'list') {
         e.innerHTML = '<table class="datatable" style="width: 100%;;"><tr><td class="datatd">' + (isNil(c)? f : car(c)) + '</td></tr><tr><td class="datatd">...</td></tr></table>';
         return t;
     }
-    if (e.className == 'table') {
+    if (wc == 'table') {
         e.innerHTML = '<table class="datatable" style="width: 100%;"><tr><td class="datatdl">' + (isNil(c)? f : car(c)) + '</td><td class="datatdr">...</td></tr><tr><td class="datatdl">...</td><td class="datatdr">...</td></tr></table>';
         return t;
     }
-    if (e.className == 'link') {
+    if (wc == 'link') {
         var ce = car(childElements(e));
         ce.href = isNil(c)? 'link:/index.html' : ('link:' + car(c));
         car(childElements(ce)).innerHTML = isNil(c)? (f != ''? f : '/index.html') : isNil(cdr(c))? (f != ''? f : car(c)) : cadr(c);
         return t;
     }
-    if (e.className == 'img') {
+    if (wc == 'img') {
         car(childElements(e)).src = isNil(c)? '/public/img.png' : car(c);
         return t;
     }
-    /*
-    if (e.className == 'iframe') {
-        car(childElements(e)).href = isNil(c)? '/public/iframe-min.html' : car(c);
-        return t;
-    }
-    */
     return '';
-};
-
-/**
- * Initialize a widget.
- */
-page.initwidget = function(e) {
-
-    // Add a Webkit transform to leverage hardware acceleration
-    e.style.setProperty('-webkit-transform', 'translate(0px, 0px)', null);
-
-    /*
-    if (e.className == 'iframe') {
-        var f = car(childElements(e));
-        //e.innerHTML = '<iframe src="' + f.href + '" frameborder="no" scrolling="no"></iframe>';
-        return e;
-    }
-    */
-
-    if (e.className == 'section') {
-        e.style.width = '100%';
-        return e;
-    }
-    if (e.className == 'text' || e.className == 'h1' || e.className == 'h2') {
-        return e;
-    }
-    if (e.className == 'button') {
-        return e;
-    }
-    if (e.className == 'checkbox') {
-        return e;
-    }
-    if (e.className == 'list' || e.className == 'table') {
-        e.style.width = '100%';
-        var t = car(childElements(e));
-        t.style.width = '100%';
-        return e;
-    }
-    if (e.className == 'img') {
-        var i = car(childElements(e));
-        if (i.src != '' && i.src.substring(0, 5) == 'data:')
-            i.src = '/public/img.png';
-        return e;
-    }
-    if (e.className == 'entry' || e.className == 'password') {
-        var i = car(childElements(e));
-        i.readOnly = true;
-        i.style.cursor = 'default';
-        return e;
-    }
-    if (e.className == 'link') {
-        var l = car(childElements(e));
-        l.onclick = function(e) { return false; };
-        return e;
-    }
-    return e;
-}
-
-/**
- * Enforce widget position and style constraints.
- */
-page.constrainwidget = function(e) {
-    if (e.className == 'section' || e.className == 'list' || e.className == 'table') {
-        e.style.left = ui.pixpos(page.palcx);
-        return e;
-    }
-    return e;
-};
-
-/**
- * Cleanup of a widget before saving it.
- */
-page.cleanupwidget = function(e) {
-    //debug('cleanupwidget', e);
-
-    // Clear outline
-    e.style.outline = null;
-
-    // Clear the Webkit transform
-    e.style.removeProperty('-webkit-transform');
-
-    if (e.className == 'entry' || e.className == 'password') {
-        var i = car(childElements(e));
-        i.readOnly = false;
-        i.style.cursor = null;
-        return e;
-    }
-    if (e.className == 'link') {
-        var l = car(childElements(e));
-        l.onclick = null;
-        return e;
-    }
-    return e;
-}
-
-/**
- * Find a draggable element in a hierarchy of elements.
- */
-page.draggable = function(n, e) {
-    if (n == e)
-        return null;
-    if (!isNil(n.id) && n.id != '')
-        return n;
-    return page.draggable(n.parentNode, e);
 }
 
 /**
  * Align a pos along a 9pixel grid.
  */
-page.gridsnap = function(x) {
-    return Math.round(x / 9) * 9;
+function snaptogrid(x) {
+    return Math.round(x / 10) * 10;
 }
 
 /**
- * Bring an element and its parent to the top.
+ * Bring a node to the top.
  */
-page.bringtotop = function(n) {
+function bringtotop(n) {
     n.parentNode.appendChild(n);
 }
 
 /**
- * Select a widget.
+ * Move a widget.
  */
-page.selectwidget = function(n, s, atitle, wvalue, wcopy, wdelete) {
-    //debug('selectwidget', n, s);
-    if (isNil(n) || !s) {
-        // Clear the widget value field
-        wvalue.value = '';
-        wvalue.readOnly = true;
-        wvalue.style.visibility = 'hidden';
-        atitle.style.visibility = 'visible';
-        wcopy.disabled = true;
-        wdelete.disabled = true;
+var iefixuptransform = ui.isMSIE();
+var fffixupoutline = ui.isFirefox() && (ui.firefoxVersion() > 4);
+function movewidget(e, x, y) {
+    var t = 'translate(' + x + 'px,' + y + 'px)';
+    e.style.setProperty('-webkit-transform', t, null);
+    e.style.setProperty('-moz-transform', t, null);
+    e.style.setProperty('-o-transform', t, null);
+    // On Internet Explorer set the property directly as setProperty
+    // doesn't seem to apply
+    if (iefixuptransform)
+        e.style.msTransform = t;
+    e.style.setProperty('transform', t, null);
+    e.xtranslate = x;
+    e.ytranslate = y;
+    return e;
+}
 
-        // Clear the widget outline
-        if (!isNil(n))
-            n.style.outline = null;
+/**
+ * Return a widget bounding rect.
+ */
+var fffixupbounds = ui.isFirefox() && (ui.firefoxVersion() < 12);
+function widgetbounds(e) {
+    var br = e.getBoundingClientRect();
+    if (!fffixupbounds)
+        return br;
+
+    // On Firefox < 12, apply CSS transform translation to bounding rect manually
+    //debug('fixup br', e, br.left, br.top, br.right, br.bottom, t[0], t[1]);
+    function fixuptransform(e) {
+        var t = widgettransform(e);
+        if (!isNil(e.xtranslate))
+            return [e.xtranslate, e.ytranslate];
+        var t = e.style.getPropertyValue('-webkit-transform') || e.style.getPropertyValue('-moz-transform') ||
+                e.style.getPropertyValue('-ms-transform') || e.style.getPropertyValue('-o-transform') ||
+                e.style.getPropertyValue('transform');
+        if (t) {
+            var xy = t.split('(')[1].split(')')[0].split(',');
+            return [ui.numpos(xy[0]), ui.numpos(xy[1])];
+        }
+        return [0, 0];
+    }
+
+    var t = fixuptransform(e);
+    var fbr = new Object();
+    fbr.left = br.left + t[0];
+    fbr.top = br.top + t[1];
+    fbr.right = fbr.left + e.offsetWidth;
+    fbr.bottom = fbr.top + e.offsetHeight;
+    return fbr;
+}
+
+/**
+ * Find a draggable element in a list.
+ */
+function draggable(x, y, l) {
+    //debug('draggable?', x, y, l);
+    if (isNil(l))
+        return null;
+    var n = car(l);
+    if (isNil(n.id) || n.id == '') {
+        var d = draggable(x, y, reverse(nodeList(n.childNodes)));
+        if (!isNil(d))
+            return d;
+        return draggable(x, y, cdr(l));
+    }
+    var br = widgetbounds(n);
+    //debug('element br', n, br.left, br.top, br.right, br.bottom);
+    if (x >= br.left && x <= br.right && y >= br.top && y <= br.bottom)
+        return n;
+    return draggable(x, y, cdr(l));
+}
+
+/**
+ * Play page in a frame.
+ */
+function showplaying() {
+    $('playPageButton').value = '<';
+    $('playdiv').style.display = 'block';
+    $('playdiv').visible = true;
+    $('playdiv').innerHTML = '';
+    $('playdiv').innerHTML = '<iframe id="playappframe" style="position: relative; border: 0px;" scrolling="no" frameborder="0" src="/' + appname + '"></iframe>';
+    if ($('pagediv').visible) {
+        $('pagediv').style.display = 'none'
+        $('pagediv').visible = false;
+    }
+    hidepalette();
+    return true;
+}
+
+/**
+ * Show the page editor.
+ */
+function showeditor() {
+    $('playPageButton').value = '>';
+    $('pagediv').style.display = 'block'
+    $('pagediv').visible = true;
+    if ($('playdiv').visible) {
+        $('playdiv').style.display = 'none';
+        $('playdiv').innerHTML = '';
+        $('playdiv').visible = false;
+    }
+    hidepalette();
+    return true;
+}
+
+/**
+ * Palette animation.
+ */
+function palettetransitionend(e) {
+    if ($('paletteview').className == 'paletteunloaded3dm')
+        $('paletteview').style.display = 'none';
+}
+
+$('paletteview').addEventListener('webkitTransitionEnd', palettetransitionend, false);
+$('paletteview').addEventListener('transitionend', palettetransitionend, false);
+
+/**
+ * Show the palette.
+ */
+function showpalette() {
+    if (ui.isMobile()) {
+        $('paletteview').className = 'paletteloading3dm';
+        $('paletteview').style.display = 'block';
+        $('paletteview').visible = true;
+        ui.async(function transitionview() {
+            $('paletteview').className = 'paletteloaded3dm';
+        });
+    } else {
+        $('paletteview').className = 'paletteloaded3d';
+        $('paletteview').style.display = 'block';
+        $('paletteview').visible = true;
+    }
+    return true;
+}
+
+/**
+ * Hide the palette.
+ */
+function hidepalette() {
+    if (ui.isMobile()) {
+        $('paletteview').className = 'paletteunloading3dm';
+        $('paletteview').visible = false;
+        ui.async(function transitionview() {
+            $('paletteview').className = 'paletteunloaded3dm';
+        });
+    } else {
+        $('paletteview').className = 'paletteunloaded3d';
+        $('paletteview').style.display = 'none';
+        $('paletteview').visible = false;
+    }
+    return true;
+}
+
+/**
+ * Create page editor.
+ */
+function mkeditor() {
+
+    // Initialize header elements
+    $('widgetValue').readOnly = true;
+    $('widgetValue').style.display = 'none';
+    $('appTitle').style.display = 'block';
+    $('copyWidgetButton').disabled = true;
+    $('deleteWidgetButton').disabled = true;
+    $('addWidgetButton').disabled = !editable;
+
+    // Track widget dragging and selection
+    var dragging = null;
+    var selected = null;
+    var moved = false;
+    var mdown = false;
+    var moveX = 0;
+    var moveY = 0;
+    var dragX = 0;
+    var dragY = 0;
+
+    /**
+    * Handle a page change event
+    */
+    function onpagechange(prop) {
+        if (!editable)
+            return false;
+
+        var newxml = pagexhtml();
+        if (savedxhtml == newxml)
+            return false;
+        showstatus('Modified');
+
+        // Save property changes right away
+        if (prop)
+            return save(newxml);
+
+        // Autosave other changes after 1 second
+        ui.delay(function autosave() {
+            if (savedxhtml == newxml) {
+                showstatus('Saved');
+                return false;
+            }
+            return save(newxml);
+        }, 1000);
+        return true;
+    }
+
+    /**
+    * Handle a widget select event.
+    */
+    function onselectwidget(w) {
+        if (w == selected)
+            return true;
+        selected = w;
+        return true;
+    }
+
+    /**
+     * Render widget move animation.
+     */
+    function onmoveanimation() {
+        //debug('onmoveanimation');
+
+        // Stop animation if we're not dragging an element anymore
+        if (dragging == null)
+            return true;
+
+        // Request the next animation frame
+        ui.animation(onmoveanimation);
+
+        // Nothing to do if the selected widget has not moved
+        if (moveX == dragX && moveY == dragY)
+            return true;
+
+        // Compute position of dragged element
+        var curX = widgetxpos(dragging);
+        var curY = widgetypos(dragging);
+        var newX = curX + (moveX - dragX);
+        var newY = curY + (moveY - dragY);
+
+        var okx = true;
+        if (newX + dragging.clientWidth > 1024) {
+            newX = 1024 - dragging.clientWidth;
+            okx = false;
+        }
+        if (newX < 0) {
+            newX = 0;
+            okx = false;
+        }
+        if (okx)
+            dragX = moveX;
+        var oky = true;
+        if (newY + dragging.clientHeight > 1024) {
+            newY = 1024 - dragging.clientHeight;
+            oky= false;
+        }
+        if (newY < 0) {
+            newY = 0;
+            oky = false;
+        }
+        if (oky)
+            dragY = moveY;
+
+        // On Firefox > 4, remove outline before moving widget as it's not
+        // correctly painted
+        if (fffixupoutline)
+            dragging.style.removeProperty('outline');
+
+        // Move the dragged element
+        movewidget(dragging, newX, newY);
 
         return true;
     }
 
-    // Outline the widget
-    n.style.outline = '2px solid #598edd';
+    /**
+     * Handle a mouse down event.
+     */
+    function onmousedown(e) {
+        // On mouse controlled devices, run component selection logic right away
+        if (!ui.isMobile()) {
+            //debug('onmousedown-click');
+            onclick(e);
+        }
 
-    // Update the widget value field
-    wvalue.value = page.text(n);
-    wvalue.readOnly = false || !editable;
-    wvalue.style.visibility = 'visible';
-    atitle.style.visibility = 'hidden';
-    wcopy.disabled = false || !editable;
-    wdelete.disabled = false || !editable;
+        // Find a draggable widget
+        var d = draggable(moveX, moveY, reverse(nodeList($('pagediv').childNodes)));
+        //debug('dragging', d, 'selected', selected);
+        if (d == null || d != selected)
+            return true;
+        dragging = d;
+
+        // Remember mouse position
+        dragX = moveX;
+        dragY = moveY;
+
+        // Start move animation
+        ui.animation(onmoveanimation);
+
+        e.preventDefault();
+        return true;
+    }
+
+    if (!ui.isMobile()) {
+        $('pagediv').onmousedown = function(e) {
+            //debug('onmousedown', e.target);
+            mdown = true;
+            moveX = e.clientX;
+            moveY = e.clientY;
+            moved = false;
+            return onmousedown(e);
+        };
+        $('palettecontent').onmousedown = function(e) {
+            //debug('onmousedown', e.target);
+            mdown = true;
+            moveX = e.clientX;
+            moveY = e.clientY;
+            moved = false;
+            return onmousedown(e);
+        };
+    } else {
+        $('pagediv').ontouchstart = function(e) {
+            //debug('ontouchstart');
+            mdown = true;
+            moveX = e.touches[0].clientX;
+            moveY = e.touches[0].clientY;
+            moved = false;
+            return onmousedown(e);
+        };
+        $('pagediv').addEventListener('touchstart', function(e) {
+            //debug('ontouchstart');
+            mdown = true;
+            moveX = e.touches[0].clientX;
+            moveY = e.touches[0].clientY;
+            moved = false;
+            return onmousedown(e);
+        }, false);
+        $('palettecontent').ontouchstart = function(e) {
+            //debug('ontouchstart');
+            mdown = true;
+            moveX = e.touches[0].clientX;
+            moveY = e.touches[0].clientY;
+            moved = false;
+            return onmousedown(e);
+        };
+    }
+
+    /**
+     * Handle a mouse up event.
+     */
+    function onmouseup(e) {
+        // Simulate onclick event
+        if (ui.isMobile() && !moved) {
+            //debug('ontouchend-click');
+            return onclick(e);
+        }
+
+        if (dragging == null)
+            return true;
+
+        // Snap dragged widget to grid
+        var newX = snaptogrid(widgetxpos(dragging));
+        var newY = snaptogrid(widgetypos(dragging));
+        movewidget(dragging, newX, newY);
+
+        // Forget dragged element
+        dragging = null;
+
+        // Trigger page change event
+        onpagechange(false);
+
+        // On Firefox > 4, re-apply the outline after the widget has been repositioned
+        if (fffixupoutline && !isNil(selected)) {
+            ui.delay(function() {
+                if (!isNil(selected))
+                    selected.style.outline = '2px solid #598edd';
+            }, 32);
+        }
+        return true;
+    }
+
+    if (!ui.isMobile()) {
+        window.onmouseup = function(e) {
+            //debug('onmouseup');
+            if (!mdown)
+                return true;
+            return onmouseup(e);
+        };
+    } else {
+        window.ontouchend = function(e) {
+            //debug('ontouchend');
+            if (!mdown)
+                return true;
+            return onmouseup(e);
+        }
+    }
+
+    if (!ui.isMobile()) {
+        window.onmousemove = function(e) {
+            //debug('onmousemove');
+
+            // Record mouse position
+            if (e.clientX != moveX) {
+                moved = true;
+                moveX = e.clientX;
+            }
+            if (e.clientY != moveY) {
+                moved = true;
+                moveY = e.clientY;
+            }
+            if (dragging == null)
+                return true;
+            return false;
+        };
+    } else {
+        window.ontouchmove = function(e) {
+            //debug('ontouchmove');
+
+            // Record touch position
+            var t = e.touches[0];
+            if (t.clientX != moveX) {
+                moved = true;
+                moveX = t.clientX;
+            }
+            if (t.clientY != moveY) {
+                moved = true;
+                moveY = t.clientY;
+            }
+            if (dragging == null)
+                return true;
+            return false;
+        };
+    }
+
+    /**
+     * Handle a mouse click event.
+     */
+    function onclick(e) {
+
+        // Find selected element
+        var palvis = $('paletteview').visible? true : false;
+        var s = draggable(moveX, moveY, reverse(nodeList((palvis? $('palettecontent') : $('pagediv')).childNodes)));
+        //debug('selected', s);
+        if (s == null) {
+            if (selected != null) {
+
+                // Reset current selection
+                selectwidget(selected, false);
+                selected = null;
+
+                // Trigger widget select event
+                onselectwidget(null);
+            }
+
+            // Dismiss the palette
+            if (palvis && isNil(draggable(moveX, moveY, mklist($('palettecontent')))))
+                hidepalette();
+
+            return true;
+        }
+
+        // Deselect the previously selected element
+        selectwidget(selected, false);
+
+        // Clone widget dragged from palette
+        if (s.id.substring(0, 8) == 'palette:') {
+            selected = clonewidget(s);
+
+            // Add it to the page
+            $('pagediv').appendChild(selected);
+            movewidget(selected, widgetxpos(selected) + $('viewcontent').scrollLeft, widgetypos(selected) + $('viewcontent').scrollTop);
+
+            // Hide the palette
+            hidepalette();
+
+            // Trigger page change event
+            onpagechange(true);
+
+            // Select the element
+            selectwidget(selected, true);
+
+            // Trigger widget select event
+            onselectwidget(selected);
+
+            return true;
+        }
+
+        // Bring selected widget to the top
+        selected = s;
+        bringtotop(selected);
+
+        // Select the widget
+        selectwidget(selected, true);
+
+        // Trigger widget select event
+        onselectwidget(selected);
+
+        return true;
+    }
+
+    /*
+    if (!ui.isMobile()) {
+        $('pagediv').onclick = function(e) {
+            //debug('onclick');
+            moveX = e.clientX;
+            moveY = e.clientY;
+            return onclick(e);
+        };
+    } else {
+        window.onclick = function(e) {
+            //debug('onclick');
+            moveX = e.touches[0].clientX;
+            moveY = e.touches[0].clientY;
+            return onclick(e);
+        };
+    }
+    */
+
+    /**
+     * Handle field on change events.
+     */
+    $('widgetValue').onchange = $('widgetValue').onblur = function() {
+        if (selected == null)
+            return false;
+        setwidgettext(selected, $('widgetValue').value);
+
+        // Trigger page change event
+        onpagechange(true);
+        return false;
+    };
+
+    // Handle add widget event.
+    $('addWidgetButton').onclick = function() {
+
+        // Show / hide the palette
+        if ($('paletteview').visible)
+            return hidepalette();
+        return showpalette();
+    };
+
+    // Handle delete event.
+    $('deleteWidgetButton').onclick = function() {
+        if (selected == null)
+            return false;
+
+        // Reset current selection
+        selectwidget(selected, false);
+
+        // Remove selected widget
+        selected.parentNode.removeChild(selected);
+        selected = null;
+
+        // Trigger widget select event
+        onselectwidget(null);
+
+        // Trigger page change event
+        onpagechange(true);
+        return false;
+    };
+
+    // Handle copy event.
+    $('copyWidgetButton').onclick = function() {
+        if (selected == null)
+            return false;
+        if (selected.id.substring(0, 8) == 'palette:')
+            return false;
+
+        // Reset current selection
+        selectwidget(selected, false);
+
+        // Clone selected widget
+        selected = clonewidget(selected);
+
+        // Add it to the page
+        $('pagediv').appendChild(selected);
+
+        // Move 10 pixels down right
+        movewidget(selected, widgetxpos(selected) + 10, widgetypos(selected) + 10);
+    
+        // Bring it to the top
+        bringtotop(selected);
+
+        // Select the element
+        selectwidget(selected, true);
+
+        // Trigger widget select event
+        onselectwidget(selected);
+
+        // Trigger page change event
+        onpagechange(true);
+        return false;
+    };
+
+    /**
+    * Handle play page button event.
+    */
+    $('playPageButton').onclick = function() {
+
+        // Show / hide the page play frame
+        if ($('playdiv').visible)
+            return showeditor();
+        return showplaying();
+    }
+
+    // Show the editor
+    showeditor();
 
     return true;
 };
 
 /**
- * Clone a palette element.
+ * Get and display the requested app page.
  */
-page.clone = function(e) {
-
-    /**
-     * Clone an element's HTML.
-     */
-    function mkclone(e) {
-        var ne = document.createElement('span');
-
-        // Skip the palette: prefix
-        ne.id = 'page:' + e.id.substr(8);
-
-        // Copy the class and HTML content
-        ne.className = e.className;
-        ne.innerHTML = e.innerHTML;
-
-        // Fixup the widget style
-        page.initwidget(ne);
-
-        return ne;
-    }
-
-    /**
-     * Clone an element's position.
-     */
-    function posclone(ne, e) {
-        ne.style.position = 'absolute';
-        ne.style.left = ui.pixpos(ui.numpos(e.style.left));
-        ne.style.top = ui.pixpos(ui.numpos(e.style.top));
-        e.parentNode.appendChild(ne);
-        return ne;
-    }
-
-    return posclone(mkclone(e), e);
-};
-
-/**
- * Track the current widget.
- */
-var widget = null;
-
-/**
- * Get and display an app page.
- */
-function getpage(name, pagediv) {
-    if (isNil(name))
+(function getpage() {
+    if (isNil(appname))
         return false;
-    showStatus('Loading');
+    workingstatus(true);
+    showstatus('Loading');
 
-    return pages.get(name, function(doc) {
+    return pages.get(appname, function(doc) {
 
         // Stop now if we didn't get a page
         if (doc == null) {
-            showError('App not available');
+            errorstatus('Couldn\'t get the app info');
+            workingstatus(false);
             return false;
         }
 
@@ -844,67 +1109,56 @@
         var pageentry = car(atom.readATOMEntry(mklist(doc)));
         var content = namedElementChild("'content", pageentry);
         var el = isNil(content)? mklist() : elementChildren(content);
-        var buffer = $('buffer');
         if (isNil(el))
-            buffer.innerHTML = '<div id="page"></div>';
+            $('xhtmlbuffer').innerHTML = '<div id="page"></div>';
         else
-            buffer.innerHTML = writeStrings(writeXML(el, false));
+            $('xhtmlbuffer').innerHTML = writeStrings(writeXML(el, false));
 
         // Remove any existing page nodes from the editor div
         var fnodes = filter(function(e) {
-            if (isNil(e.id) || e.id == '' || e.id.substr(0, 8) == 'palette:')
-                return false;
-            var x = ui.numpos(e.style.left) - 2500;
-            if (x < 0 || ui.numpos(e.style.top) < 0)
+            if (isNil(e.id) || e.id == '')
                 return false;
             return true;
-        }, nodeList(pagediv.childNodes));
+        }, nodeList($('pagediv').childNodes));
 
         map(function(e) {
-            pagediv.removeChild(e);
+            $('pagediv').removeChild(e);
         }, fnodes);
 
-        // Append new page nodes to editor
+        // Fixup widgets and append them to the editor
         map(function(e) {
-                pagediv.appendChild(e);
-                if (!isNil(e.style))
-                    e.style.left = ui.pixpos(ui.numpos(e.style.left) + 2500);
-                page.initwidget(e);
+                $('pagediv').appendChild(e);
+                fixupwidget(e);
                 return e;
-            }, nodeList(buffer.childNodes[0].childNodes));
+            }, nodeList($('xhtmlbuffer').childNodes[0].childNodes));
 
-        savedpagexhtml = pagexhtml(pagediv);
+        savedxhtml = pagexhtml($('pagediv'));
 
         // Enable author to edit the page
         author = elementValue(namedElementChild("'author", pageentry));
         editable = author == username;
-        wadd.disabled = !editable;
-        showStatus(editable? onlineStatus() : 'Read only');
+        $('addWidgetButton').disabled = !editable;
+        if (editable)
+            onlinestatus();
+        else
+            showstatus('Read only');
 
+        workingstatus(false);
         return true;
     });
-}
-
-/**
- * Handle add widget button click event.
- */
-wadd.onclick = function(e) {
-    // Show the widget palette
-    pagediv.style.left = ui.pixpos(0);
-};
+})();
 
 /**
  * Return the current page XHTML content.
  */
-function pagexhtml(pagediv) {
+function pagexhtml() {
 
     // Copy page DOM to hidden buffer
-    var buffer = $('buffer');
-    buffer.innerHTML = '<div id="page"></div>'
-    var div = buffer.childNodes[0];
+    $('xhtmlbuffer').innerHTML = '<div id="page"></div>'
+    var div = $('xhtmlbuffer').childNodes[0];
 
     // Capture the nodes inside the page div
-    div.innerHTML = pagediv.innerHTML;
+    div.innerHTML = $('pagediv').innerHTML;
     var nodes = nodeList(div.childNodes);
     map(function(e) {
         div.removeChild(e);
@@ -915,30 +1169,25 @@
     // part of the page, as well as nodes positioned out the
     // editing area
     var fnodes = filter(function(e) {
-        if (isNil(e.id) || e.id == '' || e.id.substr(0, 8) == 'palette:')
-            return false;
-        var x = ui.numpos(e.style.left) - 2500;
-        if (x < 0 || ui.numpos(e.style.top) < 0)
+        if (isNil(e.id) || e.id == '')
             return false;
         return true;
     }, nodes);
 
     // Reposition and cleanup nodes
     map(function(e) {
-        var x = ui.numpos(e.style.left) - 2500;
-        e.style.left = ui.pixpos(x);
-        page.cleanupwidget(e);
+        cleanupwidget(e);
         return e;
     }, fnodes);
 
     // Sort them by position
     var snodes = fnodes.sort(function(a, b) {
-        var ay = ui.numpos(a.style.top);
-        var by = ui.numpos(b.style.top);
+        var ay = widgetypos(a);
+        var by = widgetypos(b);
         if (ay < by) return -1;
         if (ay > by) return 1;
-        var ax = ui.numpos(a.style.left);
-        var bx = ui.numpos(b.style.left);
+        var ax = widgetxpos(a);
+        var bx = widgetxpos(b);
         if (ax < bx) return -1;
         if (ax > bx) return 1;
         return 0;
@@ -960,10 +1209,11 @@
  * Save the current page.
  */
 function save(newxml) {
-    showStatus('Saving');
+    workingstatus(true);
+    showstatus('Saving');
 
     // Get the current page XHTML content
-    savedpagexhtml = newxml;
+    savedxhtml = newxml;
 
     // Update the page ATOM entry
     var entry = '<?xml version="1.0" encoding="UTF-8"?>\n' + '<entry xmlns="http://www.w3.org/2005/Atom">' +
@@ -972,108 +1222,21 @@
 
     pages.put(appname, entry, function(e) {
         if (e) {
-            showStatus('Local copy');
+            showstatus('Local copy');
+            workingstatus(false);
             return false;
         }
-        showStatus('Saved');
+        showstatus('Saved');
+        workingstatus(false);
         return false;
     });
     return true;
 };
 
 /**
- * Handle a page change event
- */
-function onpagechange(prop) {
-    if (!editable)
-        return false;
-
-    var newxml = pagexhtml(pagediv);
-    if (savedpagexhtml == newxml)
-        return false;
-    showStatus('Modified');
-
-    // Save property changes right away
-    if (prop)
-        return save(newxml);
-
-    // Autosave other changes after 1 second
-    setTimeout(function() {
-        if (savedpagexhtml == newxml) {
-            showStatus('Saved');
-            return false;
-        }
-        return save(newxml);
-    }, 1000);
-    return true;
-}
-
-/**
- * Handle a widget select event.
- */
-function onselectwidget(w) {
-    if (w == widget)
-        return true;
-    widget = w;
-    return true;
-}
-
-/**
- * Play page in a frame.
- */
-function playpage() {
-    if (!evisible)
-        return true;
-    page.selectwidget(widget, false, atitle, wvalue, wcopy, wdelete);
-    page.selected = null;
-    pplay.value = '<';
-    evisible = false;
-    pdiv.style.visibility = 'visible';
-    pdiv.innerHTML = '';
-    pdiv.innerHTML = '<iframe id="playappframe" style="position: relative; height: 5000px; width: 2500px; border: 0px;" scrolling="no" frameborder="0" src="/' +
-                        appname + '"></iframe>';
-    setTimeout(function() {
-        pagediv.style.visibility = 'hidden'
-    }, 0);
-    return true;
-}
-
-/**
-    * Show the page editor.
- */
-function showedit() {
-    if (evisible)
-        return true;
-    pplay.value = '>';
-    pagediv.style.visibility = 'visible'
-    evisible = true;
-    page.selectwidget(widget, true, atitle, wvalue, wcopy, wdelete);
-    page.selected = widget;
-    setTimeout(function() {
-        pdiv.style.visibility = 'hidden';
-        pdiv.innerHTML = '';
-    }, 0);
-    return true;
-}
-
-/**
- * Handle play page button event.
- */
-pplay.onclick = function() {
-    if (!evisible)
-        return showedit();
-    return playpage();
-}
-
-/**
  * Initialize the page editor.
  */
-page.mkedit(pagediv, atitle, wvalue, wadd, wcopy, wdelete, onpagechange, onselectwidget);
-
-/**
- * Get and display the current app page.
- */
-getpage(appname, pagediv);
+mkeditor();
 
 })();
 </script>
diff --git a/hosting/server/htdocs/proxy/public/cache/cache-template.cmf b/hosting/server/htdocs/proxy/public/cache/cache-template.cmf
new file mode 100644
index 0000000..232b0f9
--- /dev/null
+++ b/hosting/server/htdocs/proxy/public/cache/cache-template.cmf
@@ -0,0 +1,11 @@
+CACHE MANIFEST
+
+# Version SHA1
+
+# App resources
+/favicon.ico
+/proxy/public/oops/
+
+NETWORK:
+*
+
diff --git a/hosting/server/htdocs/proxy/public/cache/index.html b/hosting/server/htdocs/proxy/public/cache/index.html
new file mode 100644
index 0000000..4006001
--- /dev/null
+++ b/hosting/server/htdocs/proxy/public/cache/index.html
@@ -0,0 +1,51 @@
+<!DOCTYPE html>
+<!--
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ * 
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ * 
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.    
+-->
+<html manifest="/proxy/public/cache/cache-manifest.cmf">
+<head>
+<script type="text/javascript">
+applicationCache.addEventListener('checking', function(e) {
+    return window.parent.onappcachechecking(e);
+}, false);
+applicationCache.addEventListener('error', function(e) {
+    return window.parent.onappcacheerror(e);
+}, false);
+applicationCache.addEventListener('noupdate', function(e) {
+    return window.parent.onappcachenoupdate(e);
+}, false);
+applicationCache.addEventListener('downloading', function(e) {
+    return window.parent.onappcachedownloading(e);
+}, false);
+applicationCache.addEventListener('progress', function(e) {
+    return window.parent.onappcacheprogress(e);
+}, false);
+applicationCache.addEventListener('updateready', function(e) {
+    return window.parent.onappcacheupdateready(e);
+}, false);
+applicationCache.addEventListener('cached', function(e) {
+    return window.parent.onappcachecached(e);
+}, false);
+window.onload = function() {
+    window.parent.onloadappcache();
+};
+</script>
+</head>
+<body>
+</body>
+</html>
diff --git a/hosting/server/htdocs/proxy/public/oops/index.html b/hosting/server/htdocs/proxy/public/oops/index.html
index b1d118d..5ea9d76 100644
--- a/hosting/server/htdocs/proxy/public/oops/index.html
+++ b/hosting/server/htdocs/proxy/public/oops/index.html
@@ -19,33 +19,50 @@
 -->
 <html>
 <head>
+<!-- Firebug inspector -->
+<!--
+<script type="text/javascript" src="https://getfirebug.com/releases/lite/1.3/firebug-lite.js"></script>
+-->
+<!-- Weinre inspector -->
+<!--
+<script src="http://www.example.com:9998/target/target-script-min.js#anonymous"></script>
+-->
 <title>Oops</title>
 <meta name="viewport" content="width=device-width, user-scalable=no, initial-scale=1.0, minimum-scale=1.0, maximum-scale=1.0"/> 
+<!--
 <meta name="apple-mobile-web-app-capable" content="yes"/>
 <meta name="apple-mobile-web-app-status-bar-style" content="black"/>
+-->
+<link rel="apple-touch-icon-precomposed" href="/proxy/public/touchicon.png"/>
 <base href="/proxy/public/oops/"/>
 <script type="text/javascript">
-(function() {
+try {
+
+(function oopshead() {
 
 window.appcache = {};
 
 /**
  * Get and cache a resource.
  */
-appcache.get = function(uri) {
+appcache.get = function(uri, mode) {
     var h = uri.indexOf('#');
     var u = h == -1? uri : uri.substring(0, h);
 
     // Get resource from local storage first
     var ls = window.lstorage || localStorage;
-    var item = null;
-    try { item = ls.getItem(u); } catch(e) {}
-    if (item != null && item != '')
-        return item;
+    if (mode != 'remote') {
+        var item = null;
+        try { item = ls.getItem('ui.r.' + u); } catch(e) {}
+        if (item != null && item != '')
+            return item;
+        if (mode == 'local')
+            return null;
+    }
 
     // Get resource from network
     var http = new XMLHttpRequest();
-    http.open("GET", u, false);
+    http.open("GET", mode == 'remote'? (u + '?t=' + new Date().getTime() + '&r=' + Math.random()) : u, false);
     http.setRequestHeader("Accept", "*/*");
     http.send(null);
     if (http.status == 200) {
@@ -56,7 +73,7 @@
             if (window.debug) debug('http error', u, 'No-Content');
             return null;
         }
-        try { ls.setItem(u, http.responseText); } catch(e) {}
+        try { ls.setItem('ui.r.' + u, http.responseText); } catch(e) {}
         return http.responseText;
     }
     if (window.debug) debug('http error', u, http.status, http.statusText);
@@ -68,36 +85,32 @@
 /**
  * Load Javascript and CSS.
  */
-(function() {
+(function oopsboot() {
 
 var bootjs = document.createElement('script');
 bootjs.type = 'text/javascript';
-bootjs.text = appcache.get('/proxy/all-min.js');
-document.head.appendChild(bootjs);
-document.head.appendChild(ui.declareCSS(appcache.get('/proxy/ui-min.css')));
+bootjs.text = 'try {\n' + appcache.get('/proxy/all-min.js') + '\n' + appcache.get('/proxy/public/config-min.js') + '\n} catch(e) { console.log(e.stack); throw e; }\n';
+var head = document.getElementsByTagName('head')[0];
+head.appendChild(bootjs);
+head.appendChild(ui.declareCSS(appcache.get('/proxy/ui-min.css')));
 
 })();
 
+} catch(e) {
+    if (window.debug) debug(e.stack);
+    throw e;
+}
 </script>
 </head>
 <body class="delayed">
-<div id="mainbodydiv" class="mainbodydiv">
 
-<div id="headdiv" class="hsection">
-<script type="text/javascript">
-(function() {
-
-$('headdiv').appendChild(ui.declareScript(appcache.get('/proxy/public/config-min.js')));
-
-})();
-</script>
+<div id="menucontainer" class="tbarmenu">
+<div id="menu"></div>
 </div>
 
-<div id="menubackground" class="tbarbackground fixed"></div>
-<div id="menu" class="tbarmenu fixed"></div>
-
-<div id="viewheadbackground" class="viewheadbackground fixed"></div>
-<div id="viewhead" class="viewhead fixed"></div>
+<div id="viewheadcontainer" class="viewhead">
+<div id="viewhead"></div>
+</div>
 
 <div id="viewcontainer">
 <div id="view">
@@ -110,84 +123,250 @@
 </div>
 </div>
 
-<div id="viewfootbackground" class="viewfootbackground fixed"></div>
-<div id="viewfoot" class="viewfoot fixed"></div>
+<div id="viewfootcontainer" class="viewfoot">
+<div id="viewfoot"></div>
+<div id="status"></div>
+</div>
+
+<div id="installer" class="installer"></div>
 
 <script type="text/javascript">
-(function() {
+try {
+
+(function oopsbody() {
 
 /**
- * Init div variables.
+ * Setup page layout.
  */
-var mdiv = $('menu'); 
-var hdiv = $('viewhead'); 
-$('viewcontainer').className = ui.isMobile()? 'viewcontainer3d' : 'viewcontainer3dm';
-$('view').className = ui.isMobile()? 'viewloaded3d' : 'viewloaded3dm';
-var fdiv = $('viewfoot'); 
+(function layout() {
+    $('viewcontainer').className = ui.isMobile()? 'viewcontainer3dm' : 'viewcontainer3d';
+    $('view').className = ui.isMobile()? 'viewloaded3dm' : 'viewloaded3d';
+    document.title = config.windowtitle() + ' - Oops';
+    $('viewhead').innerHTML = '<span class="bcmenu">' + config.pagetitle() + '</span>';
+    if (!ui.isMobile())
+        $('viewcontent').className = 'viewcontent flatscrollbars';
+    $('status').className = ui.isMobile()? 'status3dm' : 'status3d';
+})();
 
 /**
- * Set page title.
+ * Setup menu bar.
  */
-document.title = config.windowtitle() + ' - Oops';
-$('viewhead').innerHTML = '<span class="bcmenu">' + config.pagetitle() + '</span>';
+(function showmenu() {
+    $('menu').innerHTML = ui.menubar(
+        mklist(ui.menu('menuhome', 'Home', '/', '_self', false)), mklist());
+    $('viewfoot').innerHTML = config.viewfoot();
+})();
 
 /**
- * Build and show the menu bar.
+ * Initialize status message area.
  */
-function showmenu(mdiv) {
-    mdiv.innerHTML = ui.menubar(
-        mklist(ui.menu('menuhome', 'Home', '/', '_self', false)),
-            mklist(hasauthcookie()? ui.menufunc('menusignout', 'Sign out', 'return logout();', false) : ui.menu('menusignin', 'Sign in', '/login/', '_self', false)));
-    fdiv.innerHTML = config.viewfoot();
-}
-
-showmenu(mdiv);
+(function initstatus() {
+    if (isNil($('status')))
+        return;
+    $('status').style.display = 'none';
+    
+    function divtransitionend(e) {
+        e.target.style.display = 'none';
+        e.target.className = ui.isMobile()? 'status3dm' : 'status3d';
+        e.target.error = false;
+    }
+    $('status').addEventListener('webkitTransitionEnd', divtransitionend, false);
+    $('status').addEventListener('transitionend', divtransitionend, false);
+})();
 
 /**
- * Log the current user out.
+ * Show a status message.
  */
-window.logout = function() {
-    // Clear session cookie and user-specific local storage entries
-    clearauthcookie();
-    lstorage.removeItem('/r/Editor/accounts');
-    lstorage.removeItem('/r/Editor/dashboards');
-    document.location = '/login/';
-    return false;
-}
+window.showstatus = function(s, c) {
+    //debug('show status', s);
+    if (isNil($('status')) || $('status').error)
+        return s;
+    $('status').innerHTML = '<span class="' + (c? c : 'okstatus') + '">' + s + '</span>';
+    $('status').className = ui.isMobile()? 'status3dm' : 'status3d';
+    $('status').style.display = 'block';
+    $('status').error = c == 'errorstatus';
+    if ($('status').delay)
+        ui.cancelDelay($('status').delay);
+    $('status').delay = ui.delay(function hidestatus() {
+        $('status').className = ui.isMobile()? 'statusout3dm' : 'statusout3d';
+        $('status').error = false;
+    }, 3000);
+    return s;
+};
+
+/**
+ * Show an error message.
+ */
+window.errorstatus = function(s) {
+    //debug('error', s);
+    return showstatus(s, 'errorstatus');
+};
+
+/**
+ * Show working status.
+ */
+window.workingstatus = function(w, c) {
+    //debug('show working', w);
+    if (isNil($('working')))
+        return w;
+    if (!ui.isMobile())
+        $('working').style.top = ui.pixpos(Math.round(window.clientHeight / 2));
+    $('working').style.display = w? 'block' : 'none';
+    return w;
+};
+
+/**
+ * Show the online/offline status.
+ */
+window.onlinestatus = function() {
+    return navigator.onLine? (ui.isMobile()? showstatus('Online') : showstatus('Online')) : errorstatus('Offline');
+
+};
 
 /**
  * Handle orientation change.
  */
 document.body.onorientationchange = function(e) {
     //debug('onorientationchange');
-    ui.onorientationchange(e);
-
-    // Resize menu and view header
-    mdiv.style.width = ui.pixpos(document.documentElement.clientWidth);
-    hdiv.style.width = ui.pixpos(document.documentElement.clientWidth);
-    return true;
+    return ui.onorientationchange(e);
 };
 
 /**
+ * Populate cache with app resources.
+ */
+var appresources = [
+    ['/proxy/all-min.js'],
+    ['/proxy/ui-min.css'],
+    ['/proxy/public/config-min.js']
+];
+
+/**
+ * Install the application cache.
+ */
+(function installappcache() {
+    if (ui.isMobile()) {
+        // On mobile devices, trigger usage of an application cache manifest
+        window.onappcachechecking = function(e) {
+            //debug('appcache checking', e);
+            workingstatus(true);
+            showstatus('Checking');
+        };
+        window.onappcacheerror = function(e) {
+            //debug('appcache error', e);
+            onlinestatus();
+            workingstatus(false);
+        };
+        window.onappcachenoupdate = function(e) {
+            //debug('appcache noupdate', e);
+            onlinestatus();
+            workingstatus(false);
+        };
+        window.onappcachedownloading = function(e) {
+            //debug('appcache downloading', e);
+            workingstatus(true);
+            showstatus('Updating');
+        };
+        window.onappcacheprogress = function(e) {
+            //debug('appcache progress', e);
+            workingstatus(true);
+            showstatus('Updating');
+        };
+        window.onappcacheupdateready = function(e) {
+            //debug('appcache updateready', e);
+            try {
+                applicationCache.swapCache();
+            } catch(e) {}
+            onlinestatus();
+            workingstatus(false);
+            //debug('appcache swapped', e);
+
+            // Update offline resources in local storage and reload the page
+            map(function(res) {
+                showstatus('Updating');
+                appcache.remove(res[0]);
+                appcache.get(res[0], 'remote');
+            }, append(appresources, config.appresources()));
+            window.location.reload();
+        };
+        window.onappcachecached = function(e) {
+            //debug('appcache cached', e);
+            onlinestatus();
+            workingstatus(false);
+
+            // Install offline resources in local storage
+            map(function(res) {
+                showstatus('Updating');
+                appcache.remove(res[0]);
+                appcache.get(res[0], 'remote');
+            }, append(appresources, config.appresources()));
+        };
+
+        window.onloadappcache = function() {
+            //debug('appcache iframe loaded');
+        };
+
+        ui.delay(function() {
+            $('installer').innerHTML = '<iframe src="/proxy/public/cache/" class="installer"></iframe>';
+        });
+
+    } else {
+        // On non-mobile devices, check for cache-manifest changes ourselves.
+        workingstatus(true);
+        showstatus('Checking');
+        var lcmf = appcache.get('/proxy/public/cache/cache-manifest.cmf', 'local');
+        var rcmf = appcache.get('/proxy/public/cache/cache-manifest.cmf', 'remote');
+        if (lcmf == rcmf) {
+            onlinestatus();
+            workingstatus(false);
+            return true;
+        }
+
+        //debug('cache-manifest changed, reloading');
+        ui.delay(function() {
+            showstatus('Updating');
+            ui.delay(function() {
+                map(function(res) {
+                    appcache.remove(res[0]);
+                    appcache.get(res[0], 'remote');
+                }, append(appresources, config.appresources()));
+                if (!isNil(lcmf)) {
+                    //debug('reloading');
+                    window.location.reload();
+                }
+                onlinestatus();
+                workingstatus(false);
+            });
+        });
+    }
+})();
+
+/**
+ * Handle network offline/online events.
+ */
+window.addEventListener('offline', function(e) {
+    //debug('going offline');
+    showstatus('Offline');
+}, false);
+window.addEventListener('online', function(e) {
+    //debug('going online');
+    showstatus('Online');
+}, false);
+
+/**
  * Initialize the document.
  */
-function onload() {
+window.onload = function() {
     //debug('onload');
-    ui.onload();
-
-    // Show the page
-    document.body.style.visibility = 'visible';
-    return true;
-}
-
-onload();
+    return ui.onload();
+};
 
 })();
+
+} catch(e) {
+    debug(e.stack);
+    throw e;
+}
 </script>
 
-<div id="footdiv" class="fsection">
-</div>
-
-</div>
 </body>
 </html>
diff --git a/hosting/server/htdocs/public/app.b64 b/hosting/server/htdocs/public/app.b64
index 7ed235a..4690a5c 100644
--- a/hosting/server/htdocs/public/app.b64
+++ b/hosting/server/htdocs/public/app.b64
@@ -1 +1 @@
-iVBORw0KGgoAAAANSUhEUgAAADIAAAAyAgMAAABjUWAiAAAABGdBTUEAALGPC/xhBQAAAAxQTFRFyN+N+dR1/PCI////6HjE5gAAADJJREFUKM9j+I8EPjBQifeBAQSY6coLBYN6inhaq0Bg6SDn/f//akB466ExTS6P2ukMAKumzarJO/66AAAAAElFTkSuQmCC
\ No newline at end of file
+iVBORw0KGgoAAAANSUhEUgAAADIAAAAyCAYAAAAeP4ixAAAABmJLR0QA/wD/AP+gvaeTAAAACXBIWXMAAAsTAAALEwEAmpwYAAAAfklEQVRo3u3YoQ2EQBBA0VlauEJwODQUcw1QAQ1QDGgcjkKo4U4Q0JhNCLyvVs3kZd1E6F6l49H02y/Hgqn7pIiIeogs8+fvbiie8iMgICAgICAgICAgICAgICDSlc67Vu6709pWWeaX4+KuBQICAgICAgICAgICAgICovf1B5ehDldEK+NOAAAAAElFTkSuQmCC
diff --git a/hosting/server/htdocs/public/app.png b/hosting/server/htdocs/public/app.png
index 1f73274..7293fe7 100644
--- a/hosting/server/htdocs/public/app.png
+++ b/hosting/server/htdocs/public/app.png
Binary files differ
diff --git a/hosting/server/htdocs/public/app.xcf b/hosting/server/htdocs/public/app.xcf
index 741b7ff..b144d46 100644
--- a/hosting/server/htdocs/public/app.xcf
+++ b/hosting/server/htdocs/public/app.xcf
Binary files differ
diff --git a/hosting/server/htdocs/app/cache-template.cmf b/hosting/server/htdocs/public/cache/cache-template.cmf
similarity index 87%
copy from hosting/server/htdocs/app/cache-template.cmf
copy to hosting/server/htdocs/public/cache/cache-template.cmf
index 5881cf8..40da327 100644
--- a/hosting/server/htdocs/app/cache-template.cmf
+++ b/hosting/server/htdocs/public/cache/cache-template.cmf
@@ -4,7 +4,7 @@
 
 # App resources
 /favicon.ico
-/public/iframe-min.html
+/login/
 /public/img.png
 /public/notauth/
 /public/notfound/
diff --git a/hosting/server/htdocs/public/cache/index.html b/hosting/server/htdocs/public/cache/index.html
new file mode 100644
index 0000000..c810cc1
--- /dev/null
+++ b/hosting/server/htdocs/public/cache/index.html
@@ -0,0 +1,51 @@
+<!DOCTYPE html>
+<!--
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ * 
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ * 
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.    
+-->
+<html manifest="/public/cache/cache-manifest.cmf">
+<head>
+<script type="text/javascript">
+applicationCache.addEventListener('checking', function(e) {
+    return window.parent.onappcachechecking(e);
+}, false);
+applicationCache.addEventListener('error', function(e) {
+    return window.parent.onappcacheerror(e);
+}, false);
+applicationCache.addEventListener('noupdate', function(e) {
+    return window.parent.onappcachenoupdate(e);
+}, false);
+applicationCache.addEventListener('downloading', function(e) {
+    return window.parent.onappcachedownloading(e);
+}, false);
+applicationCache.addEventListener('progress', function(e) {
+    return window.parent.onappcacheprogress(e);
+}, false);
+applicationCache.addEventListener('updateready', function(e) {
+    return window.parent.onappcacheupdateready(e);
+}, false);
+applicationCache.addEventListener('cached', function(e) {
+    return window.parent.onappcachecached(e);
+}, false);
+window.onload = function() {
+    window.parent.onloadappcache();
+};
+</script>
+</head>
+<body>
+</body>
+</html>
diff --git a/hosting/server/htdocs/public/config.js b/hosting/server/htdocs/public/config.js
index 54818f4..be23c7c 100644
--- a/hosting/server/htdocs/public/config.js
+++ b/hosting/server/htdocs/public/config.js
@@ -35,6 +35,10 @@
     return '<span>Sign in with your userid and password</span>';
 };
 
+config.signuptitle = function() {
+    return 'Sign up for an account';
+};
+
 config.viewfoot = function() {
     return ui.menubar(mklist(ui.menu('menuabout', 'About', '/', '_view', 'note')), mklist());
 };
diff --git a/hosting/server/htdocs/public/delete.b64 b/hosting/server/htdocs/public/delete.b64
deleted file mode 100644
index c8137d7..0000000
--- a/hosting/server/htdocs/public/delete.b64
+++ /dev/null
@@ -1 +0,0 @@
-iVBORw0KGgoAAAANSUhEUgAAABkAAAAZCAYAAADE6YVjAAAAAXNSR0IArs4c6QAAAAZiS0dEANAAPwBBXloXjQAAAAlwSFlzAAALEwAACxMBAJqcGAAAAAd0SU1FB9sEFhQaKzNh4PgAAAMKSURBVEjHxZZPTBNBFMa/maVbWjcUi0YiIHIoNBADTUgsqCWgUUFjwkk5CXLUBKIc9KIXjx64oMSDoiggGC8koImCGDWYkADRIiQQgikWCq0WoXW33R0PpYjSLeWP8btN3sv85s17894QrKNeIBng8gFmJSDZgGIAqJeBjQCkH5AHioGZaHsQNUMP+ByKYB0ByVjvIAxsUkHcrRJI9pggXYBWB1pLQUqxQSlg3X4o9WWAqArpAhL04JoIYMQmxQCPD3JlGbCwBtIFaPXgWrcC+AtUEY6Ihg060NrtACyf3KgDrQ2v6e8kbzwH0URBSnvA56xAKIJ1kRzNbS2ZNhYssjodVj41VbPaxqemaqxOh9XGgkXmtpbMyKDQvqQXSKbg2iKGzfPE0v8uV7BYDIuDg95B66FhJkmM8DyxfHifK+TlGRaHhryDBwuHmSSxyBUnn6Ohh6aSQElin86U26XZWVGwWAxZD5tMAGBufmAS8vIMkssl2s+Uj6gBQuLySS/oTQpyONr9GmxHhAMvnltovJZ+73vjTiyyJSmipHw8WTrkfd33Y52385arAr1EAF00R3HqixRwu38mnT61O35/uh4AJq7Ujc0/affEUGsCDfWi9TXX3uEOeDwBABCnp/3OO42uGPuAgQLUG4urueVRlsZo1ACANiVFZ7rTkBFjMXtpqJtGV9q1q3uNJ47vlpd88kTt5VEWCLLk6gtpeyrP74qheY5wlaB6AhSqOSUUFOzIun8vh8RxZKKmZvRrw20X0WjkxCKbceexo0Z3Z+d8wDUXVIeQdgrIA6rFl5DAmVsfZ1MtT+faO5zOxrtzADB1/Ybj28tX85wgxOU8e5pN9XqqHos8QIuBGQY2GTEPD5tM8en79P7x8aWxqurx1bbPZytGRYfDrzOZBHPzA5PanCkGZki4d3GQG7DNksFdLIFkpwBQAsmugHVvJ0AB6w5PypW79EOpZ4BnOwAM8Pih1P/R6gGgDBB9kCu3Clo1GcU1kGXQgg9yxWavTgHrXp6IC///t/Iv/l2/AGa0Qa2X0eC0AAAAAElFTkSuQmCC
\ No newline at end of file
diff --git a/hosting/server/htdocs/public/delete.png b/hosting/server/htdocs/public/delete.png
deleted file mode 100644
index fb56bae..0000000
--- a/hosting/server/htdocs/public/delete.png
+++ /dev/null
Binary files differ
diff --git a/hosting/server/htdocs/public/delete.xcf b/hosting/server/htdocs/public/delete.xcf
deleted file mode 100644
index 7691f50..0000000
--- a/hosting/server/htdocs/public/delete.xcf
+++ /dev/null
Binary files differ
diff --git a/hosting/server/htdocs/public/grid72.b64 b/hosting/server/htdocs/public/grid72.b64
deleted file mode 100644
index 34be13e..0000000
--- a/hosting/server/htdocs/public/grid72.b64
+++ /dev/null
@@ -1 +0,0 @@
-iVBORw0KGgoAAAANSUhEUgAAAEgAAABIAgMAAAAog1vUAAAABGdBTUEAALGPC/xhBQAAAAlQTFRFwuD84/T+////fj9v9QAAACxJREFUOMtjWLUqa9WsVctWrYQxVjAMCqFQdBDCMOrUUaeOOnXUqYPPqZgAABmg/C7pJC7lAAAAAElFTkSuQmCC
\ No newline at end of file
diff --git a/hosting/server/htdocs/public/grid72.png b/hosting/server/htdocs/public/grid72.png
deleted file mode 100644
index cf60081..0000000
--- a/hosting/server/htdocs/public/grid72.png
+++ /dev/null
Binary files differ
diff --git a/hosting/server/htdocs/public/iframe.html b/hosting/server/htdocs/public/iframe.html
deleted file mode 100644
index e2b862d..0000000
--- a/hosting/server/htdocs/public/iframe.html
+++ /dev/null
@@ -1,28 +0,0 @@
-<!DOCTYPE html>
-<!--
- * Licensed to the Apache Software Foundation (ASF) under one
- * or more contributor license agreements.  See the NOTICE file
- * distributed with this work for additional information
- * regarding copyright ownership.  The ASF licenses this file
- * to you under the Apache License, Version 2.0 (the
- * "License"); you may not use this file except in compliance
- * with the License.  You may obtain a copy of the License at
- * 
- *   http://www.apache.org/licenses/LICENSE-2.0
- * 
- * Unless required by applicable law or agreed to in writing,
- * software distributed under the License is distributed on an
- * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
- * KIND, either express or implied.  See the License for the
- * specific language governing permissions and limitations
- * under the License.    
--->
-<html>
-<head>
-</head>
-<body style="margin:3px; padding: 0px; background-color: #dcdcdc;">
-
-<div>frame ...</div>
-
-</body>
-</html>
diff --git a/hosting/server/htdocs/public/img.b64 b/hosting/server/htdocs/public/img.b64
index 97dae68..1025ce0 100644
--- a/hosting/server/htdocs/public/img.b64
+++ b/hosting/server/htdocs/public/img.b64
@@ -1 +1 @@
-iVBORw0KGgoAAAANSUhEUgAAACAAAAAgCAMAAABEpIrGAAAABGdBTUEAALGPC/xhBQAAAIRQTFRFwdt/w9yEw9+MxN2GxN6NxN+Oxd2Mxd6Nxt6Lx96Lx96Nx9+NyN6MyN+MyN+N8u2I8+2I+NBq+NFr+NFt+NFu+NJz+NN0+dR1+dR3+dZw+dh4+9Fy+9Nz++5++++B+++F/NNz/PCH/PCI/PGW/PKc/fKd/vzp/vzq/v7+/v/z/9Jx////nQZfHwAAAIxJREFUOMtj0CYAGKiiQANdUAPdBAZmFMCIYQUzHwrgpKECblYwYEJ2LYoCHi0FMBCEAmF0E3hkxFGABJICXnYWFhY2aVE4EENTwCWgCARKCCCFoUAJFQw9BYycnBz8eBSA04cqPhNAQIX+CiSFhIRE8CiQ10ROMNgUqKNnHGU5FCCrhqZAg7Z5Ey8AALiBh6brcmloAAAAAElFTkSuQmCC
\ No newline at end of file
+iVBORw0KGgoAAAANSUhEUgAAACAAAAAgCAYAAABzenr0AAAABmJLR0QA/wD/AP+gvaeTAAAACXBIWXMAAAsTAAALEwEAmpwYAAAAX0lEQVRYw2NgGGDAyMDAwODe/vY/OZp3VgozMjAwMNhOYSBL/+EcBkamgQ6BUQeMOmDUAaMOGHXAqANGAcX1OQMDA8NFDzOy9OvvODXaHhh1wKgDRh0w6oBRB4y2BxgA0K4ON379R2QAAAAASUVORK5CYII=
diff --git a/hosting/server/htdocs/public/img.png b/hosting/server/htdocs/public/img.png
index 2363b25..e05e74f 100644
--- a/hosting/server/htdocs/public/img.png
+++ b/hosting/server/htdocs/public/img.png
Binary files differ
diff --git a/hosting/server/htdocs/public/img.xcf b/hosting/server/htdocs/public/img.xcf
index ffcc124..c173633 100644
--- a/hosting/server/htdocs/public/img.xcf
+++ b/hosting/server/htdocs/public/img.xcf
Binary files differ
diff --git a/hosting/server/htdocs/public/notauth/index.html b/hosting/server/htdocs/public/notauth/index.html
index 8985239..f453e04 100644
--- a/hosting/server/htdocs/public/notauth/index.html
+++ b/hosting/server/htdocs/public/notauth/index.html
@@ -19,33 +19,50 @@
 -->
 <html>
 <head>
+<!-- Firebug inspector -->
+<!--
+<script type="text/javascript" src="https://getfirebug.com/releases/lite/1.3/firebug-lite.js"></script>
+-->
+<!-- Weinre inspector -->
+<!--
+<script src="http://www.example.com:9998/target/target-script-min.js#anonymous"></script>
+-->
 <title>Sorry</title>
 <meta name="viewport" content="width=device-width, user-scalable=no, initial-scale=1.0, minimum-scale=1.0, maximum-scale=1.0"/> 
+<!--
 <meta name="apple-mobile-web-app-capable" content="yes"/>
 <meta name="apple-mobile-web-app-status-bar-style" content="black"/>
+-->
+<link rel="apple-touch-icon-precomposed" href="/public/touchicon.png"/>
 <base href="/public/notauth/"/>
 <script type="text/javascript">
-(function() {
+try {
+
+(function notauthhead() {
 
 window.appcache = {};
 
 /**
  * Get and cache a resource.
  */
-appcache.get = function(uri) {
+appcache.get = function(uri, mode) {
     var h = uri.indexOf('#');
     var u = h == -1? uri : uri.substring(0, h);
 
     // Get resource from local storage first
     var ls = window.lstorage || localStorage;
-    var item = null;
-    try { item = ls.getItem(u); } catch(e) {}
-    if (item != null && item != '')
-        return item;
+    if (mode != 'remote') {
+        var item = null;
+        try { item = ls.getItem('ui.r.' + u); } catch(e) {}
+        if (item != null && item != '')
+            return item;
+        if (mode == 'local')
+            return null;
+    }
 
     // Get resource from network
     var http = new XMLHttpRequest();
-    http.open("GET", u, false);
+    http.open("GET", mode == 'remote'? (u + '?t=' + new Date().getTime() + '&r=' + Math.random()) : u, false);
     http.setRequestHeader("Accept", "*/*");
     http.send(null);
     if (http.status == 200) {
@@ -56,7 +73,7 @@
             if (window.debug) debug('http error', u, 'No-Content');
             return null;
         }
-        try { ls.setItem(u, http.responseText); } catch(e) {}
+        try { ls.setItem('ui.r.' + u, http.responseText); } catch(e) {}
         return http.responseText;
     }
     if (window.debug) debug('http error', u, http.status, http.statusText);
@@ -68,128 +85,287 @@
 /**
  * Load Javascript and CSS.
  */
-(function() {
+(function notauthboot() {
 
 var bootjs = document.createElement('script');
 bootjs.type = 'text/javascript';
-bootjs.text = appcache.get('/all-min.js');
-document.head.appendChild(bootjs);
-document.head.appendChild(ui.declareCSS(appcache.get('/ui-min.css')));
+bootjs.text = 'try {\n' + appcache.get('/all-min.js') + '\n' + appcache.get('/public/config-min.js') + '\n} catch(e) { console.log(e.stack); throw e; }\n';
+var head = document.getElementsByTagName('head')[0];
+head.appendChild(bootjs);
+head.appendChild(ui.declareCSS(appcache.get('/ui-min.css')));
 
 })();
 
+} catch(e) {
+    if (window.debug) debug(e.stack);
+    throw e;
+}
 </script>
 </head>
 <body class="delayed">
-<div id="mainbodydiv" class="mainbodydiv">
 
-<div id="headdiv" class="hsection">
-<script type="text/javascript">
-(function() {
-
-$('headdiv').appendChild(ui.declareScript(appcache.get('/public/config-min.js')));
-
-})();
-</script>
+<div id="menucontainer" class="tbarmenu">
+<div id="menu"></div>
 </div>
 
-<div id="menubackground" class="tbarbackground fixed"></div>
-<div id="menu" class="tbarmenu fixed"></div>
-
-<div id="viewheadbackground" class="viewheadbackground fixed"></div>
-<div id="viewhead" class="viewhead fixed"></div>
+<div id="viewheadcontainer" class="viewhead">
+<div id="viewhead"></div>
+</div>
 
 <div id="viewcontainer">
 <div id="view">
 <div id="viewcontent" class="viewcontent" style="margin-left: auto; margin-right: auto; text-align: center;">
 
 <br/>
-<div class="hd2">Sorry, you're not authorized to view this page.</div>
+<div class="hd2">Sorry, you're not authorized<br/>to view this page.</div>
 
 </div>
 </div>
 </div>
 
-<div id="viewfootbackground" class="viewfootbackground fixed"></div>
-<div id="viewfoot" class="viewfoot fixed"></div>
+<div id="viewfootcontainer" class="viewfoot">
+<div id="viewfoot"></div>
+<div id="status"></div>
+</div>
+
+<div id="installer" class="installer"></div>
 
 <script type="text/javascript">
-(function() {
+try {
+
+(function notauthbody() {
 
 /**
- * Init div variables.
+ * Setup page layout.
  */
-var mdiv = $('menu'); 
-var hdiv = $('viewhead'); 
-$('viewhead').innerHTML = '<span class="bcmenu">' + config.pagetitle() + '</span>';
-$('viewcontainer').className = ui.isMobile()? 'viewcontainer3d' : 'viewcontainer3dm';
-$('view').className = ui.isMobile()? 'viewloaded3d' : 'viewloaded3dm';
-var fdiv = $('viewfoot'); 
+(function layout() {
+    $('viewcontainer').className = ui.isMobile()? 'viewcontainer3dm' : 'viewcontainer3d';
+    $('view').className = ui.isMobile()? 'viewloaded3dm' : 'viewloaded3d';
+    document.title = config.windowtitle() + ' - Sorry';
+    $('viewhead').innerHTML = '<span class="bcmenu">' + config.pagetitle() + '</span>';
+    if (!ui.isMobile())
+        $('viewcontent').className = 'viewcontent flatscrollbars';
+    $('status').className = ui.isMobile()? 'status3dm' : 'status3d';
+})();
 
 /**
- * Set page title.
+ * Setup menu bar.
  */
-document.title = config.windowtitle() + ' - Sorry';
-$('viewhead').innerHTML = '<span class="bcmenu">' + config.pagetitle() + '</span>';
+(function showmenu() {
+    $('menu').innerHTML = ui.menubar(
+        mklist(ui.menu('menuhome', 'Home', '/', '_self', false)), mklist());
+    $('viewfoot').innerHTML = config.viewfoot();
+})();
 
 /**
- * Build and show the menu bar.
+ * Initialize status message area.
  */
-function showmenu(mdiv) {
-    mdiv.innerHTML = ui.menubar(
-        mklist(ui.menu('menuhome', 'Home', '/', '_self', false)),
-            mklist(hasauthcookie()? ui.menufunc('menusignout', 'Sign out', 'return logout();', false) : ui.menu('menusignin', 'Sign in', '/login/', '_self', false)));
-    fdiv.innerHTML = config.viewfoot();
-}
-
-showmenu(mdiv);
+(function initstatus() {
+    if (isNil($('status')))
+        return;
+    $('status').style.display = 'none';
+    
+    function divtransitionend(e) {
+        e.target.style.display = 'none';
+        e.target.className = ui.isMobile()? 'status3dm' : 'status3d';
+        e.target.error = false;
+    }
+    $('status').addEventListener('webkitTransitionEnd', divtransitionend, false);
+    $('status').addEventListener('transitionend', divtransitionend, false);
+})();
 
 /**
- * Log the current user out.
+ * Show a status message.
  */
-window.logout = function() {
-    // Clear session cookie and user-specific local storage entries
-    clearauthcookie();
-    lstorage.removeItem('/r/Editor/accounts');
-    lstorage.removeItem('/r/Editor/dashboards');
-    document.location = '/login/';
-    return false;
-}
+window.showstatus = function(s, c) {
+    //debug('show status', s);
+    if (isNil($('status')) || $('status').error)
+        return s;
+    $('status').innerHTML = '<span class="' + (c? c : 'okstatus') + '">' + s + '</span>';
+    $('status').className = ui.isMobile()? 'status3dm' : 'status3d';
+    $('status').style.display = 'block';
+    $('status').error = c == 'errorstatus';
+    if ($('status').delay)
+        ui.cancelDelay($('status').delay);
+    $('status').delay = ui.delay(function hidestatus() {
+        $('status').className = ui.isMobile()? 'statusout3dm' : 'statusout3d';
+        $('status').error = false;
+    }, 3000);
+    return s;
+};
+
+/**
+ * Show an error message.
+ */
+window.errorstatus = function(s) {
+    //debug('error', s);
+    return showstatus(s, 'errorstatus');
+};
+
+/**
+ * Show working status.
+ */
+window.workingstatus = function(w, c) {
+    //debug('show working', w);
+    if (isNil($('working')))
+        return w;
+    if (!ui.isMobile())
+        $('working').style.top = ui.pixpos(Math.round(window.clientHeight / 2));
+    $('working').style.display = w? 'block' : 'none';
+    return w;
+};
+
+/**
+ * Show the online/offline status.
+ */
+window.onlinestatus = function() {
+    return navigator.onLine? (ui.isMobile()? showstatus('Online') : showstatus('Online')) : errorstatus('Offline');
+};
 
 /**
  * Handle orientation change.
  */
 document.body.onorientationchange = function(e) {
     //debug('onorientationchange');
-    ui.onorientationchange(e);
-
-    // Resize menu and view header
-    mdiv.style.width = ui.pixpos(document.documentElement.clientWidth);
-    hdiv.style.width = ui.pixpos(document.documentElement.clientWidth);
-
-    return true;
+    return ui.onorientationchange(e);
 };
 
 /**
+ * Populate cache with app resources.
+ */
+var appresources = [
+    ['/all-min.js'],
+    ['/ui-min.css'],
+    ['/public/config-min.js']
+];
+
+/**
+ * Install the application cache.
+ */
+(function installappcache() {
+    if (ui.isMobile()) {
+        // On mobile devices, trigger usage of an application cache manifest
+        window.onappcachechecking = function(e) {
+            //debug('appcache checking', e);
+            workingstatus(true);
+            showstatus('Checking');
+        };
+        window.onappcacheerror = function(e) {
+            //debug('appcache error', e);
+            onlinestatus();
+            workingstatus(false);
+        };
+        window.onappcachenoupdate = function(e) {
+            //debug('appcache noupdate', e);
+            onlinestatus();
+            workingstatus(false);
+        };
+        window.onappcachedownloading = function(e) {
+            //debug('appcache downloading', e);
+            workingstatus(true);
+            showstatus('Updating');
+        };
+        window.onappcacheprogress = function(e) {
+            //debug('appcache progress', e);
+            workingstatus(true);
+            showstatus('Updating');
+        };
+        window.onappcacheupdateready = function(e) {
+            //debug('appcache updateready', e);
+            try {
+                applicationCache.swapCache();
+            } catch(e) {}
+            onlinestatus();
+            workingstatus(false);
+            //debug('appcache swapped', e);
+
+            // Update offline resources in local storage and reload the page
+            map(function(res) {
+                showstatus('Updating');
+                appcache.remove(res[0]);
+                appcache.get(res[0], 'remote');
+            }, append(appresources, config.appresources()));
+            window.location.reload();
+        };
+        window.onappcachecached = function(e) {
+            //debug('appcache cached', e);
+            onlinestatus();
+            workingstatus(false);
+
+            // Install offline resources in local storage
+            map(function(res) {
+                showstatus('Updating');
+                appcache.remove(res[0]);
+                appcache.get(res[0], 'remote');
+            }, append(appresources, config.appresources()));
+        };
+
+        window.onloadappcache = function() {
+            //debug('appcache iframe loaded');
+        };
+
+        ui.delay(function() {
+            $('installer').innerHTML = '<iframe src="/public/cache/" class="installer"></iframe>';
+        });
+
+    } else {
+        // On non-mobile devices, check for cache-manifest changes ourselves.
+        workingstatus(true);
+        showstatus('Checking');
+        var lcmf = appcache.get('/public/cache/cache-manifest.cmf', 'local');
+        var rcmf = appcache.get('/public/cache/cache-manifest.cmf', 'remote');
+        if (lcmf == rcmf) {
+            onlinestatus();
+            workingstatus(false);
+            return true;
+        }
+
+        //debug('cache-manifest changed, reloading');
+        ui.delay(function() {
+            showstatus('Updating');
+            ui.delay(function() {
+                map(function(res) {
+                    appcache.remove(res[0]);
+                    appcache.get(res[0], 'remote');
+                }, append(appresources, config.appresources()));
+                if (!isNil(lcmf)) {
+                    //debug('reloading');
+                    window.location.reload();
+                }
+                onlinestatus();
+                workingstatus(false);
+            });
+        });
+    }
+})();
+
+/**
+ * Handle network offline/online events.
+ */
+window.addEventListener('offline', function(e) {
+    //debug('going offline');
+    showstatus('Offline');
+}, false);
+window.addEventListener('online', function(e) {
+    //debug('going online');
+    showstatus('Online');
+}, false);
+
+/**
  * Initialize the document.
  */
-function onload() {
+window.onload = function() {
     //debug('onload');
-    ui.onload();
-
-    // Show the page
-    document.body.style.visibility = 'visible';
-    return true;
-}
-
-onload();
+    return ui.onload();
+};
 
 })();
+
+} catch(e) {
+    debug(e.stack);
+    throw e;
+}
 </script>
 
-<div id="footdiv" class="fsection">
-</div>
-
-</div>
 </body>
 </html>
diff --git a/hosting/server/htdocs/public/notfound/index.html b/hosting/server/htdocs/public/notfound/index.html
index 8f0d486..c847514 100644
--- a/hosting/server/htdocs/public/notfound/index.html
+++ b/hosting/server/htdocs/public/notfound/index.html
@@ -19,33 +19,50 @@
 -->
 <html>
 <head>
+<!-- Firebug inspector -->
+<!--
+<script type="text/javascript" src="https://getfirebug.com/releases/lite/1.3/firebug-lite.js"></script>
+-->
+<!-- Weinre inspector -->
+<!--
+<script src="http://www.example.com:9998/target/target-script-min.js#anonymous"></script>
+-->
 <title>Page not found</title>
 <meta name="viewport" content="width=device-width, user-scalable=no, initial-scale=1.0, minimum-scale=1.0, maximum-scale=1.0"/> 
+<!--
 <meta name="apple-mobile-web-app-capable" content="yes"/>
 <meta name="apple-mobile-web-app-status-bar-style" content="black"/>
+-->
+<link rel="apple-touch-icon-precomposed" href="/public/touchicon.png"/>
 <base href="/public/notfound/"/>
 <script type="text/javascript">
-(function() {
+try {
+
+(function notfoundhead() {
 
 window.appcache = {};
 
 /**
  * Get and cache a resource.
  */
-appcache.get = function(uri) {
+appcache.get = function(uri, mode) {
     var h = uri.indexOf('#');
     var u = h == -1? uri : uri.substring(0, h);
 
     // Get resource from local storage first
     var ls = window.lstorage || localStorage;
-    var item = null;
-    try { item = ls.getItem(u); } catch(e) {}
-    if (item != null && item != '')
-        return item;
+    if (mode != 'remote') {
+        var item = null;
+        try { item = ls.getItem('ui.r.' + u); } catch(e) {}
+        if (item != null && item != '')
+            return item;
+        if (mode == 'local')
+            return null;
+    }
 
     // Get resource from network
     var http = new XMLHttpRequest();
-    http.open("GET", u, false);
+    http.open("GET", mode == 'remote'? (u + '?t=' + new Date().getTime() + '&r=' + Math.random()) : u, false);
     http.setRequestHeader("Accept", "*/*");
     http.send(null);
     if (http.status == 200) {
@@ -56,7 +73,7 @@
             if (window.debug) debug('http error', u, 'No-Content');
             return null;
         }
-        try { ls.setItem(u, http.responseText); } catch(e) {}
+        try { ls.setItem('ui.r.' + u, http.responseText); } catch(e) {}
         return http.responseText;
     }
     if (window.debug) debug('http error', u, http.status, http.statusText);
@@ -68,36 +85,32 @@
 /**
  * Load Javascript and CSS.
  */
-(function() {
+(function notfoundboot() {
 
 var bootjs = document.createElement('script');
 bootjs.type = 'text/javascript';
-bootjs.text = appcache.get('/all-min.js');
-document.head.appendChild(bootjs);
-document.head.appendChild(ui.declareCSS(appcache.get('/ui-min.css')));
+bootjs.text = 'try {\n' + appcache.get('/all-min.js') + '\n' + appcache.get('/public/config-min.js') + '\n} catch(e) { console.log(e.stack); throw e; }\n';
+var head = document.getElementsByTagName('head')[0];
+head.appendChild(bootjs);
+head.appendChild(ui.declareCSS(appcache.get('/ui-min.css')));
 
 })();
 
+} catch(e) {
+    if (window.debug) debug(e.stack);
+    throw e;
+}
 </script>
 </head>
 <body class="delayed">
-<div id="mainbodydiv" class="mainbodydiv">
 
-<div id="headdiv" class="hsection">
-<script type="text/javascript">
-(function() {
-
-$('headdiv').appendChild(ui.declareScript(appcache.get('/public/config-min.js')));
-
-})();
-</script>
+<div id="menucontainer" class="tbarmenu">
+<div id="menu"></div>
 </div>
 
-<div id="menubackground" class="tbarbackground fixed"></div>
-<div id="menu" class="tbarmenu fixed"></div>
-
-<div id="viewheadbackground" class="viewheadbackground fixed"></div>
-<div id="viewhead" class="viewhead fixed"></div>
+<div id="viewheadcontainer" class="viewhead">
+<div id="viewhead"></div>
+</div>
 
 <div id="viewcontainer">
 <div id="view">
@@ -105,90 +118,255 @@
 
 <br/>
 <div class="hd2">Sorry, that page was not found.</div>
-<div>You may have clicked an expired link or mistyped the address.</div>
+<div>You may have clicked an expired link<br/>or mistyped the address.</div>
 
 </div>
 </div>
 </div>
 
-<div id="viewfootbackground" class="viewfootbackground fixed"></div>
-<div id="viewfoot" class="viewfoot fixed"></div>
+<div id="viewfootcontainer" class="viewfoot">
+<div id="viewfoot"></div>
+<div id="status"></div>
+</div>
+
+<div id="installer" class="installer"></div>
 
 <script type="text/javascript">
-(function() {
+try {
+
+(function notfoundbody() {
 
 /**
- * Init div variables.
+ * Setup page layout.
  */
-var mdiv = $('menu'); 
-var hdiv = $('viewhead'); 
-$('viewcontainer').className = ui.isMobile()? 'viewcontainer3d' : 'viewcontainer3dm';
-$('view').className = ui.isMobile()? 'viewloaded3d' : 'viewloaded3dm';
-var fdiv = $('viewfoot'); 
+(function layout() {
+    $('viewcontainer').className = ui.isMobile()? 'viewcontainer3dm' : 'viewcontainer3d';
+    $('view').className = ui.isMobile()? 'viewloaded3dm' : 'viewloaded3d';
+    document.title = config.windowtitle() + ' - Page not found';
+    $('viewhead').innerHTML = '<span class="bcmenu">' + config.pagetitle() + '</span>';
+    if (!ui.isMobile())
+        $('viewcontent').className = 'viewcontent flatscrollbars';
+    $('status').className = ui.isMobile()? 'status3dm' : 'status3d';
+})();
 
 /**
- * Set page title.
+ * Setup menu bar.
  */
-document.title = config.windowtitle() + ' - Page not found';
-$('viewhead').innerHTML = '<span class="bcmenu">' + config.pagetitle() + '</span>';
+(function showmenu() {
+    $('menu').innerHTML = ui.menubar(
+        mklist(ui.menu('menuhome', 'Home', '/', '_self', false)), mklist());
+    $('viewfoot').innerHTML = config.viewfoot();
+})();
 
 /**
- * Build and show the menu bar.
+ * Initialize status message area.
  */
-function showmenu(mdiv) {
-    mdiv.innerHTML = ui.menubar(
-        mklist(ui.menu('menuhome', 'Home', '/', '_self', false)),
-            mklist(hasauthcookie()? ui.menufunc('menusignout', 'Sign out', 'return logout();', false) : ui.menu('menusignin', 'Sign in', '/login/', '_self', false)));
-    fdiv.innerHTML = config.viewfoot();
-}
-
-showmenu(mdiv);
+(function initstatus() {
+    if (isNil($('status')))
+        return;
+    $('status').style.display = 'none';
+    
+    function divtransitionend(e) {
+        e.target.style.display = 'none';
+        e.target.className = ui.isMobile()? 'status3dm' : 'status3d';
+        e.target.error = false;
+    }
+    $('status').addEventListener('webkitTransitionEnd', divtransitionend, false);
+    $('status').addEventListener('transitionend', divtransitionend, false);
+})();
 
 /**
- * Log the current user out.
+ * Show a status message.
  */
-window.logout = function() {
-    // Clear session cookie and user-specific local storage entries
-    clearauthcookie();
-    lstorage.removeItem('/r/Editor/accounts');
-    lstorage.removeItem('/r/Editor/dashboards');
-    document.location = '/login/';
-    return false;
-}
+window.showstatus = function(s, c) {
+    //debug('show status', s);
+    if (isNil($('status')) || $('status').error)
+        return s;
+    $('status').innerHTML = '<span class="' + (c? c : 'okstatus') + '">' + s + '</span>';
+    $('status').className = ui.isMobile()? 'status3dm' : 'status3d';
+    $('status').style.display = 'block';
+    $('status').error = c == 'errorstatus';
+    if ($('status').delay)
+        ui.cancelDelay($('status').delay);
+    $('status').delay = ui.delay(function hidestatus() {
+        $('status').className = ui.isMobile()? 'statusout3dm' : 'statusout3d';
+        $('status').error = false;
+    }, 3000);
+    return s;
+};
+
+/**
+ * Show an error message.
+ */
+window.errorstatus = function(s) {
+    //debug('error', s);
+    return showstatus(s, 'errorstatus');
+};
+
+/**
+ * Show working status.
+ */
+window.workingstatus = function(w, c) {
+    //debug('show working', w);
+    if (isNil($('working')))
+        return w;
+    if (!ui.isMobile())
+        $('working').style.top = ui.pixpos(Math.round(window.clientHeight / 2));
+    $('working').style.display = w? 'block' : 'none';
+    return w;
+};
+
+/**
+ * Show the online/offline status.
+ */
+window.onlinestatus = function() {
+    return navigator.onLine? (ui.isMobile()? showstatus('Online') : showstatus('Online')) : errorstatus('Offline');
+};
 
 /**
  * Handle orientation change.
  */
 document.body.onorientationchange = function(e) {
     //debug('onorientationchange');
-    ui.onorientationchange(e);
-
-    // Resize menu and view header
-    mdiv.style.width = ui.pixpos(document.documentElement.clientWidth);
-    hdiv.style.width = ui.pixpos(document.documentElement.clientWidth);
-    return true;
+    return ui.onorientationchange(e);
 };
 
 /**
+ * Populate cache with app resources.
+ */
+var appresources = [
+    ['/all-min.js'],
+    ['/ui-min.css'],
+    ['/public/config-min.js']
+];
+
+/**
+ * Install the application cache.
+ */
+(function installappcache() {
+    if (ui.isMobile()) {
+        // On mobile devices, trigger usage of an application cache manifest
+        window.onappcachechecking = function(e) {
+            //debug('appcache checking', e);
+            workingstatus(true);
+            showstatus('Checking');
+        };
+        window.onappcacheerror = function(e) {
+            //debug('appcache error', e);
+            onlinestatus();
+            workingstatus(false);
+        };
+        window.onappcachenoupdate = function(e) {
+            //debug('appcache noupdate', e);
+            onlinestatus();
+            workingstatus(false);
+        };
+        window.onappcachedownloading = function(e) {
+            //debug('appcache downloading', e);
+            workingstatus(true);
+            showstatus('Updating');
+        };
+        window.onappcacheprogress = function(e) {
+            //debug('appcache progress', e);
+            workingstatus(true);
+            showstatus('Updating');
+        };
+        window.onappcacheupdateready = function(e) {
+            //debug('appcache updateready', e);
+            try {
+                applicationCache.swapCache();
+            } catch(e) {}
+            onlinestatus();
+            workingstatus(false);
+            //debug('appcache swapped', e);
+
+            // Update offline resources in local storage and reload the page
+            map(function(res) {
+                showstatus('Updating');
+                appcache.remove(res[0]);
+                appcache.get(res[0], 'remote');
+            }, append(appresources, config.appresources()));
+            window.location.reload();
+        };
+        window.onappcachecached = function(e) {
+            //debug('appcache cached', e);
+            onlinestatus();
+            workingstatus(false);
+
+            // Install offline resources in local storage
+            map(function(res) {
+                showstatus('Updating');
+                appcache.remove(res[0]);
+                appcache.get(res[0], 'remote');
+            }, append(appresources, config.appresources()));
+        };
+
+        window.onloadappcache = function() {
+            //debug('appcache iframe loaded');
+        };
+
+        ui.delay(function() {
+            $('installer').innerHTML = '<iframe src="/public/cache/" class="installer"></iframe>';
+        });
+
+    } else {
+        // On non-mobile devices, check for cache-manifest changes ourselves.
+        workingstatus(true);
+        showstatus('Checking');
+        var lcmf = appcache.get('/public/cache/cache-manifest.cmf', 'local');
+        var rcmf = appcache.get('/public/cache/cache-manifest.cmf', 'remote');
+        if (lcmf == rcmf) {
+            onlinestatus();
+            workingstatus(false);
+            return true;
+        }
+
+        //debug('cache-manifest changed, reloading');
+        ui.delay(function() {
+            showstatus('Updating');
+            ui.delay(function() {
+                map(function(res) {
+                    appcache.remove(res[0]);
+                    appcache.get(res[0], 'remote');
+                }, append(appresources, config.appresources()));
+                if (!isNil(lcmf)) {
+                    //debug('reloading');
+                    window.location.reload();
+                }
+                onlinestatus();
+                workingstatus(false);
+            });
+        });
+    }
+})();
+
+/**
+ * Handle network offline/online events.
+ */
+window.addEventListener('offline', function(e) {
+    //debug('going offline');
+    showstatus('Offline');
+}, false);
+window.addEventListener('online', function(e) {
+    //debug('going online');
+    showstatus('Online');
+}, false);
+
+/**
  * Initialize the document.
  */
-function onload() {
+window.onload = function() {
     //debug('onload');
-    ui.onload();
-
-    // Show the page
-    document.body.style.visibility = 'visible';
-    return true;
-}
-
-onload();
+    return ui.onload();
+};
 
 })();
+
+} catch(e) {
+    debug(e.stack);
+    throw e;
+}
 </script>
 
-<div id="footdiv" class="fsection">
-</div>
-
-</div>
 </body>
 </html>
diff --git a/hosting/server/htdocs/public/notyet/index.html b/hosting/server/htdocs/public/notyet/index.html
index e43a992..4bcb372 100644
--- a/hosting/server/htdocs/public/notyet/index.html
+++ b/hosting/server/htdocs/public/notyet/index.html
@@ -19,33 +19,50 @@
 -->
 <html>
 <head>
+<!-- Firebug inspector -->
+<!--
+<script type="text/javascript" src="https://getfirebug.com/releases/lite/1.3/firebug-lite.js"></script>
+-->
+<!-- Weinre inspector -->
+<!--
+<script src="http://www.example.com:9998/target/target-script-min.js#anonymous"></script>
+-->
 <title>Page not found</title>
 <meta name="viewport" content="width=device-width, user-scalable=no, initial-scale=1.0, minimum-scale=1.0, maximum-scale=1.0"/> 
+<!--
 <meta name="apple-mobile-web-app-capable" content="yes"/>
 <meta name="apple-mobile-web-app-status-bar-style" content="black"/>
+-->
+<link rel="apple-touch-icon-precomposed" href="/public/touchicon.png"/>
 <base href="/public/notyet/"/>
 <script type="text/javascript">
-(function() {
+try {
+
+(function notyethead() {
 
 window.appcache = {};
 
 /**
  * Get and cache a resource.
  */
-appcache.get = function(uri) {
+appcache.get = function(uri, mode) {
     var h = uri.indexOf('#');
     var u = h == -1? uri : uri.substring(0, h);
 
     // Get resource from local storage first
     var ls = window.lstorage || localStorage;
-    var item = null;
-    try { item = ls.getItem(u); } catch(e) {}
-    if (item != null && item != '')
-        return item;
+    if (mode != 'remote') {
+        var item = null;
+        try { item = ls.getItem('ui.r.' + u); } catch(e) {}
+        if (item != null && item != '')
+            return item;
+        if (mode == 'local')
+            return null;
+    }
 
     // Get resource from network
     var http = new XMLHttpRequest();
-    http.open("GET", u, false);
+    http.open("GET", mode == 'remote'? (u + '?t=' + new Date().getTime() + '&r=' + Math.random()) : u, false);
     http.setRequestHeader("Accept", "*/*");
     http.send(null);
     if (http.status == 200) {
@@ -56,7 +73,7 @@
             if (window.debug) debug('http error', u, 'No-Content');
             return null;
         }
-        try { ls.setItem(u, http.responseText); } catch(e) {}
+        try { ls.setItem('ui.r.' + u, http.responseText); } catch(e) {}
         return http.responseText;
     }
     if (window.debug) debug('http error', u, http.status, http.statusText);
@@ -68,127 +85,288 @@
 /**
  * Load Javascript and CSS.
  */
-(function() {
+(function notyetboot() {
 
 var bootjs = document.createElement('script');
 bootjs.type = 'text/javascript';
-bootjs.text = appcache.get('/all-min.js');
-document.head.appendChild(bootjs);
-document.head.appendChild(ui.declareCSS(appcache.get('/ui-min.css')));
+bootjs.text = 'try {\n' + appcache.get('/all-min.js') + '\n' + appcache.get('/public/config-min.js') + '\n} catch(e) { console.log(e.stack); throw e; }\n';
+var head = document.getElementsByTagName('head')[0];
+head.appendChild(bootjs);
+head.appendChild(ui.declareCSS(appcache.get('/ui-min.css')));
 
 })();
 
+} catch(e) {
+    if (window.debug) debug(e.stack);
+    throw e;
+}
 </script>
 </head>
 <body class="delayed">
-<div id="mainbodydiv" class="mainbodydiv">
 
-<div id="headdiv" class="hsection">
-<script type="text/javascript">
-(function() {
-
-$('headdiv').appendChild(ui.declareScript(appcache.get('/public/config-min.js')));
-
-})();
-</script>
+<div id="menucontainer" class="tbarmenu">
+<div id="menu"></div>
 </div>
 
-<div id="menubackground" class="tbarbackground fixed"></div>
-<div id="menu" class="tbarmenu fixed"></div>
-
-<div id="viewheadbackground" class="viewheadbackground fixed"></div>
-<div id="viewhead" class="viewhead fixed"></div>
+<div id="viewheadcontainer" class="viewhead">
+<div id="viewhead"></div>
+</div>
 
 <div id="viewcontainer">
 <div id="view">
 <div id="viewcontent" class="viewcontent" style="margin-left: auto; margin-right: auto; text-align: center;">
 
 <br/>
-<div class="hd2">Sorry, that page is still under construction.</div>
+<div class="hd2">Sorry, this page is still<br/>under construction.</div>
 <div>Please check back later.</div>
 
 </div>
 </div>
 </div>
 
-<div id="viewfootbackground" class="viewfootbackground fixed"></div>
-<div id="viewfoot" class="viewfoot fixed"></div>
+<div id="viewfootcontainer" class="viewfoot">
+<div id="viewfoot"></div>
+<div id="status"></div>
+</div>
+
+<div id="installer" class="installer"></div>
 
 <script type="text/javascript">
-(function() {
+try {
+
+(function notyetbody() {
 
 /**
- * Init div variables.
+ * Setup page layout.
  */
-var mdiv = $('menu'); 
-var hdiv = $('viewhead'); 
-$('viewcontainer').className = ui.isMobile()? 'viewcontainer3d' : 'viewcontainer3dm';
-$('view').className = ui.isMobile()? 'viewloaded3d' : 'viewloaded3dm';
-var fdiv = $('viewfoot'); 
+(function layout() {
+    $('viewcontainer').className = ui.isMobile()? 'viewcontainer3dm' : 'viewcontainer3d';
+    $('view').className = ui.isMobile()? 'viewloaded3dm' : 'viewloaded3d';
+    document.title = config.windowtitle() + ' - Page not found';
+    $('viewhead').innerHTML = '<span class="bcmenu">' + config.pagetitle() + '</span>';
+    if (!ui.isMobile())
+        $('viewcontent').className = 'viewcontent flatscrollbars';
+    $('status').className = ui.isMobile()? 'status3dm' : 'status3d';
+})();
 
 /**
- * Set page title.
+ * Setup menu bar.
  */
-document.title = config.windowtitle() + ' - Page not found';
-$('viewhead').innerHTML = '<span class="bcmenu">' + config.pagetitle() + '</span>';
+(function showmenu() {
+    $('menu').innerHTML = ui.menubar(
+        mklist(ui.menu('menuhome', 'Home', '/', '_self', false)), mklist());
+    $('viewfoot').innerHTML = config.viewfoot();
+})();
 
 /**
- * Build and show the menu bar.
+ * Initialize status message area.
  */
-function showmenu(mdiv) {
-    mdiv.innerHTML = ui.menubar(
-        mklist(ui.menu('menuhome', 'Home', '/', '_self', false)),
-            mklist(hasauthcookie()? ui.menufunc('menusignout', 'Sign out', 'return logout();', false) : ui.menu('menusignin', 'Sign in', '/login/', '_self', false)));
-    fdiv.innerHTML = config.viewfoot();
-}
-
-showmenu(mdiv);
+(function initstatus() {
+    if (isNil($('status')))
+        return;
+    $('status').style.display = 'none';
+    
+    function divtransitionend(e) {
+        e.target.style.display = 'none';
+        e.target.className = ui.isMobile()? 'status3dm' : 'status3d';
+        e.target.error = false;
+    }
+    $('status').addEventListener('webkitTransitionEnd', divtransitionend, false);
+    $('status').addEventListener('transitionend', divtransitionend, false);
+})();
 
 /**
- * Log the current user out.
+ * Show a status message.
  */
-window.logout = function() {
-    // Clear session cookie and user-specific local storage entries
-    clearauthcookie();
-    lstorage.removeItem('/r/Editor/accounts');
-    lstorage.removeItem('/r/Editor/dashboards');
-    document.location = '/login/';
-    return false;
-}
+window.showstatus = function(s, c) {
+    //debug('show status', s);
+    if (isNil($('status')) || $('status').error)
+        return s;
+    $('status').innerHTML = '<span class="' + (c? c : 'okstatus') + '">' + s + '</span>';
+    $('status').className = ui.isMobile()? 'status3dm' : 'status3d';
+    $('status').style.display = 'block';
+    $('status').error = c == 'errorstatus';
+    if ($('status').delay)
+        ui.cancelDelay($('status').delay);
+    $('status').delay = ui.delay(function hidestatus() {
+        $('status').className = ui.isMobile()? 'statusout3dm' : 'statusout3d';
+        $('status').error = false;
+    }, 3000);
+    return s;
+};
+
+/**
+ * Show an error message.
+ */
+window.errorstatus = function(s) {
+    //debug('error', s);
+    return showstatus(s, 'errorstatus');
+};
+
+/**
+ * Show working status.
+ */
+window.workingstatus = function(w, c) {
+    //debug('show working', w);
+    if (isNil($('working')))
+        return w;
+    if (!ui.isMobile())
+        $('working').style.top = ui.pixpos(Math.round(window.clientHeight / 2));
+    $('working').style.display = w? 'block' : 'none';
+    return w;
+};
+
+/**
+ * Show the online/offline status.
+ */
+window.onlinestatus = function() {
+    return navigator.onLine? (ui.isMobile()? showstatus('Online') : showstatus('Online')) : errorstatus('Offline');
+};
 
 /**
  * Handle orientation change.
  */
 document.body.onorientationchange = function(e) {
     //debug('onorientationchange');
-    ui.onorientationchange(e);
-
-    // Resize menu and view header
-    mdiv.style.width = ui.pixpos(document.documentElement.clientWidth);
-    hdiv.style.width = ui.pixpos(document.documentElement.clientWidth);
-    return true;
+    return ui.onorientationchange(e);
 };
 
 /**
+ * Populate cache with app resources.
+ */
+var appresources = [
+    ['/all-min.js'],
+    ['/ui-min.css'],
+    ['/public/config-min.js']
+];
+
+/**
+ * Install the application cache.
+ */
+(function installappcache() {
+    if (ui.isMobile()) {
+        // On mobile devices, trigger usage of an application cache manifest
+        window.onappcachechecking = function(e) {
+            //debug('appcache checking', e);
+            workingstatus(true);
+            showstatus('Checking');
+        };
+        window.onappcacheerror = function(e) {
+            //debug('appcache error', e);
+            onlinestatus();
+            workingstatus(false);
+        };
+        window.onappcachenoupdate = function(e) {
+            //debug('appcache noupdate', e);
+            onlinestatus();
+            workingstatus(false);
+        };
+        window.onappcachedownloading = function(e) {
+            //debug('appcache downloading', e);
+            workingstatus(true);
+            showstatus('Updating');
+        };
+        window.onappcacheprogress = function(e) {
+            //debug('appcache progress', e);
+            workingstatus(true);
+            showstatus('Updating');
+        };
+        window.onappcacheupdateready = function(e) {
+            //debug('appcache updateready', e);
+            try {
+                applicationCache.swapCache();
+            } catch(e) {}
+            onlinestatus();
+            workingstatus(false);
+            //debug('appcache swapped', e);
+
+            // Update offline resources in local storage and reload the page
+            map(function(res) {
+                showstatus('Updating');
+                appcache.remove(res[0]);
+                appcache.get(res[0], 'remote');
+            }, append(appresources, config.appresources()));
+            window.location.reload();
+        };
+        window.onappcachecached = function(e) {
+            //debug('appcache cached', e);
+            onlinestatus();
+            workingstatus(false);
+
+            // Install offline resources in local storage
+            map(function(res) {
+                showstatus('Updating');
+                appcache.remove(res[0]);
+                appcache.get(res[0], 'remote');
+            }, append(appresources, config.appresources()));
+        };
+
+        window.onloadappcache = function() {
+            //debug('appcache iframe loaded');
+        };
+
+        ui.delay(function() {
+            $('installer').innerHTML = '<iframe src="/public/cache/" class="installer"></iframe>';
+        });
+
+    } else {
+        // On non-mobile devices, check for cache-manifest changes ourselves.
+        workingstatus(true);
+        showstatus('Checking');
+        var lcmf = appcache.get('/public/cache/cache-manifest.cmf', 'local');
+        var rcmf = appcache.get('/public/cache/cache-manifest.cmf', 'remote');
+        if (lcmf == rcmf) {
+            onlinestatus();
+            workingstatus(false);
+            return true;
+        }
+
+        //debug('cache-manifest changed, reloading');
+        ui.delay(function() {
+            showstatus('Updating');
+            ui.delay(function() {
+                map(function(res) {
+                    appcache.remove(res[0]);
+                    appcache.get(res[0], 'remote');
+                }, append(appresources, config.appresources()));
+                if (!isNil(lcmf)) {
+                    //debug('reloading');
+                    window.location.reload();
+                }
+                onlinestatus();
+                workingstatus(false);
+            });
+        });
+    }
+})();
+
+/**
+ * Handle network offline/online events.
+ */
+window.addEventListener('offline', function(e) {
+    //debug('going offline');
+    showstatus('Offline');
+}, false);
+window.addEventListener('online', function(e) {
+    //debug('going online');
+    showstatus('Online');
+}, false);
+
+/**
  * Initialize the document.
  */
-function onload() {
+window.onload = function() {
     //debug('onload');
-    ui.onload();
-
-    // Show the page
-    document.body.style.visibility = 'visible';
-    return true;
-}
-
-onload();
+    return ui.onload();
+};
 
 })();
+
+} catch(e) {
+    debug(e.stack);
+    throw e;
+}
 </script>
 
-<div id="footdiv" class="fsection">
-</div>
-
-</div>
 </body>
 </html>
diff --git a/hosting/server/htdocs/public/oops/index.html b/hosting/server/htdocs/public/oops/index.html
index cc97c53..68554ef 100644
--- a/hosting/server/htdocs/public/oops/index.html
+++ b/hosting/server/htdocs/public/oops/index.html
@@ -19,33 +19,50 @@
 -->
 <html>
 <head>
+<!-- Firebug inspector -->
+<!--
+<script type="text/javascript" src="https://getfirebug.com/releases/lite/1.3/firebug-lite.js"></script>
+-->
+<!-- Weinre inspector -->
+<!--
+<script src="http://www.example.com:9998/target/target-script-min.js#anonymous"></script>
+-->
 <title>Oops</title>
 <meta name="viewport" content="width=device-width, user-scalable=no, initial-scale=1.0, minimum-scale=1.0, maximum-scale=1.0"/> 
+<!--
 <meta name="apple-mobile-web-app-capable" content="yes"/>
 <meta name="apple-mobile-web-app-status-bar-style" content="black"/>
+-->
+<link rel="apple-touch-icon-precomposed" href="/public/touchicon.png"/>
 <base href="/public/oops/"/>
 <script type="text/javascript">
-(function() {
+try {
+
+(function oopshead() {
 
 window.appcache = {};
 
 /**
  * Get and cache a resource.
  */
-appcache.get = function(uri) {
+appcache.get = function(uri, mode) {
     var h = uri.indexOf('#');
     var u = h == -1? uri : uri.substring(0, h);
 
     // Get resource from local storage first
     var ls = window.lstorage || localStorage;
-    var item = null;
-    try { item = ls.getItem(u); } catch(e) {}
-    if (item != null && item != '')
-        return item;
+    if (mode != 'remote') {
+        var item = null;
+        try { item = ls.getItem('ui.r.' + u); } catch(e) {}
+        if (item != null && item != '')
+            return item;
+        if (mode == 'local')
+            return null;
+    }
 
     // Get resource from network
     var http = new XMLHttpRequest();
-    http.open("GET", u, false);
+    http.open("GET", mode == 'remote'? (u + '?t=' + new Date().getTime() + '&r=' + Math.random()) : u, false);
     http.setRequestHeader("Accept", "*/*");
     http.send(null);
     if (http.status == 200) {
@@ -56,7 +73,7 @@
             if (window.debug) debug('http error', u, 'No-Content');
             return null;
         }
-        try { ls.setItem(u, http.responseText); } catch(e) {}
+        try { ls.setItem('ui.r.' + u, http.responseText); } catch(e) {}
         return http.responseText;
     }
     if (window.debug) debug('http error', u, http.status, http.statusText);
@@ -68,36 +85,32 @@
 /**
  * Load Javascript and CSS.
  */
-(function() {
+(function oopsboot() {
 
 var bootjs = document.createElement('script');
 bootjs.type = 'text/javascript';
-bootjs.text = appcache.get('/all-min.js');
-document.head.appendChild(bootjs);
-document.head.appendChild(ui.declareCSS(appcache.get('/ui-min.css')));
+bootjs.text = 'try {\n' + appcache.get('/all-min.js') + '\n' + appcache.get('/public/config-min.js') + '\n} catch(e) { console.log(e.stack); throw e; }\n';
+var head = document.getElementsByTagName('head')[0];
+head.appendChild(bootjs);
+head.appendChild(ui.declareCSS(appcache.get('/ui-min.css')));
 
 })();
 
+} catch(e) {
+    if (window.debug) debug(e.stack);
+    throw e;
+}
 </script>
 </head>
 <body class="delayed">
-<div id="mainbodydiv" class="mainbodydiv">
 
-<div id="headdiv" class="hsection">
-<script type="text/javascript">
-(function() {
-
-$('headdiv').appendChild(ui.declareScript(appcache.get('/public/config-min.js')));
-
-})();
-</script>
+<div id="menucontainer" class="tbarmenu">
+<div id="menu"></div>
 </div>
 
-<div id="menubackground" class="tbarbackground fixed"></div>
-<div id="menu" class="tbarmenu fixed"></div>
-
-<div id="viewheadbackground" class="viewheadbackground fixed"></div>
-<div id="viewhead" class="viewhead fixed"></div>
+<div id="viewheadcontainer" class="viewhead">
+<div id="viewhead"></div>
+</div>
 
 <div id="viewcontainer">
 <div id="view">
@@ -110,84 +123,249 @@
 </div>
 </div>
 
-<div id="viewfootbackground" class="viewfootbackground fixed"></div>
-<div id="viewfoot" class="viewfoot fixed"></div>
+<div id="viewfootcontainer" class="viewfoot">
+<div id="viewfoot"></div>
+<div id="status"></div>
+</div>
+
+<div id="installer" class="installer"></div>
 
 <script type="text/javascript">
-(function() {
+try {
+
+(function oopsbody() {
 
 /**
- * Init div variables.
+ * Setup page layout.
  */
-var mdiv = $('menu'); 
-var hdiv = $('viewhead'); 
-$('viewcontainer').className = ui.isMobile()? 'viewcontainer3d' : 'viewcontainer3dm';
-$('view').className = ui.isMobile()? 'viewloaded3d' : 'viewloaded3dm';
-var fdiv = $('viewfoot'); 
+(function layout() {
+    $('viewcontainer').className = ui.isMobile()? 'viewcontainer3dm' : 'viewcontainer3d';
+    $('view').className = ui.isMobile()? 'viewloaded3dm' : 'viewloaded3d';
+    document.title = config.windowtitle() + ' - Oops';
+    $('viewhead').innerHTML = '<span class="bcmenu">' + config.pagetitle() + '</span>';
+    if (!ui.isMobile())
+        $('viewcontent').className = 'viewcontent flatscrollbars';
+    $('status').className = ui.isMobile()? 'status3dm' : 'status3d';
+})();
 
 /**
- * Set page title.
+ * Setup menu bar.
  */
-document.title = config.windowtitle() + ' - Oops';
-$('viewhead').innerHTML = '<span class="bcmenu">' + config.pagetitle() + '</span>';
+(function showmenu() {
+    $('menu').innerHTML = ui.menubar(
+        mklist(ui.menu('menuhome', 'Home', '/', '_self', false)), mklist());
+    $('viewfoot').innerHTML = config.viewfoot();
+})();
 
 /**
- * Build and show the menu bar.
+ * Initialize status message area.
  */
-function showmenu(mdiv) {
-    mdiv.innerHTML = ui.menubar(
-        mklist(ui.menu('menuhome', 'Home', '/', '_self', false)),
-            mklist(hasauthcookie()? ui.menufunc('menusignout', 'Sign out', 'return logout();', false) : ui.menu('menusignin', 'Sign in', '/login/', '_self', false)));
-    fdiv.innerHTML = config.viewfoot();
-}
-
-showmenu(mdiv);
+(function initstatus() {
+    if (isNil($('status')))
+        return;
+    $('status').style.display = 'none';
+    
+    function divtransitionend(e) {
+        e.target.style.display = 'none';
+        e.target.className = ui.isMobile()? 'status3dm' : 'status3d';
+        e.target.error = false;
+    }
+    $('status').addEventListener('webkitTransitionEnd', divtransitionend, false);
+    $('status').addEventListener('transitionend', divtransitionend, false);
+})();
 
 /**
- * Log the current user out.
+ * Show a status message.
  */
-window.logout = function() {
-    // Clear session cookie and user-specific local storage entries
-    clearauthcookie();
-    lstorage.removeItem('/r/Editor/accounts');
-    lstorage.removeItem('/r/Editor/dashboards');
-    document.location = '/login/';
-    return false;
-}
+window.showstatus = function(s, c) {
+    //debug('show status', s);
+    if (isNil($('status')) || $('status').error)
+        return s;
+    $('status').innerHTML = '<span class="' + (c? c : 'okstatus') + '">' + s + '</span>';
+    $('status').className = ui.isMobile()? 'status3dm' : 'status3d';
+    $('status').style.display = 'block';
+    $('status').error = c == 'errorstatus';
+    if ($('status').delay)
+        ui.cancelDelay($('status').delay);
+    $('status').delay = ui.delay(function hidestatus() {
+        $('status').className = ui.isMobile()? 'statusout3dm' : 'statusout3d';
+        $('status').error = false;
+    }, 3000);
+    return s;
+};
+
+/**
+ * Show an error message.
+ */
+window.errorstatus = function(s) {
+    //debug('error', s);
+    return showstatus(s, 'errorstatus');
+};
+
+/**
+ * Show working status.
+ */
+window.workingstatus = function(w, c) {
+    //debug('show working', w);
+    if (isNil($('working')))
+        return w;
+    if (!ui.isMobile())
+        $('working').style.top = ui.pixpos(Math.round(window.clientHeight / 2));
+    $('working').style.display = w? 'block' : 'none';
+    return w;
+};
+
+/**
+ * Show the online/offline status.
+ */
+window.onlinestatus = function() {
+    return navigator.onLine? (ui.isMobile()? showstatus('Online') : showstatus()) : errorstatus('Offline');
+};
 
 /**
  * Handle orientation change.
  */
 document.body.onorientationchange = function(e) {
     //debug('onorientationchange');
-    ui.onorientationchange(e);
-
-    // Resize menu and view header
-    mdiv.style.width = ui.pixpos(document.documentElement.clientWidth);
-    hdiv.style.width = ui.pixpos(document.documentElement.clientWidth);
-    return true;
+    return ui.onorientationchange(e);
 };
 
 /**
+ * Populate cache with app resources.
+ */
+var appresources = [
+    ['/all-min.js'],
+    ['/ui-min.css'],
+    ['/public/config-min.js']
+];
+
+/**
+ * Install the application cache.
+ */
+(function installappcache() {
+    if (ui.isMobile()) {
+        // On mobile devices, trigger usage of an application cache manifest
+        window.onappcachechecking = function(e) {
+            //debug('appcache checking', e);
+            workingstatus(true);
+            showstatus('Checking');
+        };
+        window.onappcacheerror = function(e) {
+            //debug('appcache error', e);
+            onlinestatus();
+            workingstatus(false);
+        };
+        window.onappcachenoupdate = function(e) {
+            //debug('appcache noupdate', e);
+            onlinestatus();
+            workingstatus(false);
+        };
+        window.onappcachedownloading = function(e) {
+            //debug('appcache downloading', e);
+            workingstatus(true);
+            showstatus('Updating');
+        };
+        window.onappcacheprogress = function(e) {
+            //debug('appcache progress', e);
+            workingstatus(true);
+            showstatus('Updating');
+        };
+        window.onappcacheupdateready = function(e) {
+            //debug('appcache updateready', e);
+            try {
+                applicationCache.swapCache();
+            } catch(e) {}
+            onlinestatus();
+            workingstatus(false);
+            //debug('appcache swapped', e);
+
+            // Update offline resources in local storage and reload the page
+            map(function(res) {
+                showstatus('Updating');
+                appcache.remove(res[0]);
+                appcache.get(res[0], 'remote');
+            }, append(appresources, config.appresources()));
+            window.location.reload();
+        };
+        window.onappcachecached = function(e) {
+            //debug('appcache cached', e);
+            onlinestatus();
+            workingstatus(false);
+
+            // Install offline resources in local storage
+            map(function(res) {
+                showstatus('Updating');
+                appcache.remove(res[0]);
+                appcache.get(res[0], 'remote');
+            }, append(appresources, config.appresources()));
+        };
+
+        window.onloadappcache = function() {
+            //debug('appcache iframe loaded');
+        };
+
+        ui.delay(function() {
+            $('installer').innerHTML = '<iframe src="/public/cache/" class="installer"></iframe>';
+        });
+
+    } else {
+        // On non-mobile devices, check for cache-manifest changes ourselves.
+        workingstatus(true);
+        showstatus('Checking');
+        var lcmf = appcache.get('/public/cache/cache-manifest.cmf', 'local');
+        var rcmf = appcache.get('/public/cache/cache-manifest.cmf', 'remote');
+        if (lcmf == rcmf) {
+            onlinestatus();
+            workingstatus(false);
+            return true;
+        }
+
+        //debug('cache-manifest changed, reloading');
+        ui.delay(function() {
+            showstatus('Updating');
+            ui.delay(function() {
+                map(function(res) {
+                    appcache.remove(res[0]);
+                    appcache.get(res[0], 'remote');
+                }, append(appresources, config.appresources()));
+                if (!isNil(lcmf)) {
+                    //debug('reloading');
+                    window.location.reload();
+                }
+                onlinestatus();
+                workingstatus(false);
+            });
+        });
+    }
+})();
+
+/**
+ * Handle network offline/online events.
+ */
+window.addEventListener('offline', function(e) {
+    //debug('going offline');
+    showstatus('Offline');
+}, false);
+window.addEventListener('online', function(e) {
+    //debug('going online');
+    showstatus('Online');
+}, false);
+
+/**
  * Initialize the document.
  */
-function onload() {
+window.onload = function() {
     //debug('onload');
-    ui.onload();
-
-    // Show the page
-    document.body.style.visibility = 'visible';
-    return true;
-}
-
-onload();
+    return ui.onload();
+};
 
 })();
+
+} catch(e) {
+    debug(e.stack);
+    throw e;
+}
 </script>
 
-<div id="footdiv" class="fsection">
-</div>
-
-</div>
 </body>
 </html>
diff --git a/hosting/server/htdocs/public/rate.png b/hosting/server/htdocs/public/rate.png
new file mode 100644
index 0000000..27c744c
--- /dev/null
+++ b/hosting/server/htdocs/public/rate.png
Binary files differ
diff --git a/hosting/server/htdocs/public/rate.xcf b/hosting/server/htdocs/public/rate.xcf
new file mode 100644
index 0000000..eb807f6
--- /dev/null
+++ b/hosting/server/htdocs/public/rate.xcf
Binary files differ
diff --git a/hosting/server/htdocs/public/ratings.png b/hosting/server/htdocs/public/ratings.png
new file mode 100644
index 0000000..9c10be5
--- /dev/null
+++ b/hosting/server/htdocs/public/ratings.png
Binary files differ
diff --git a/hosting/server/htdocs/public/ratings.xcf b/hosting/server/htdocs/public/ratings.xcf
new file mode 100644
index 0000000..cad8d31
--- /dev/null
+++ b/hosting/server/htdocs/public/ratings.xcf
Binary files differ
diff --git a/hosting/server/htdocs/public/search.png b/hosting/server/htdocs/public/search.png
new file mode 100644
index 0000000..d5178fe
--- /dev/null
+++ b/hosting/server/htdocs/public/search.png
Binary files differ
diff --git a/hosting/server/htdocs/public/search.xcf b/hosting/server/htdocs/public/search.xcf
new file mode 100644
index 0000000..30d03df
--- /dev/null
+++ b/hosting/server/htdocs/public/search.xcf
Binary files differ
diff --git a/hosting/server/htdocs/public/touchicon-50.xcf b/hosting/server/htdocs/public/touchicon-50.xcf
new file mode 100644
index 0000000..8dcc8e4
--- /dev/null
+++ b/hosting/server/htdocs/public/touchicon-50.xcf
Binary files differ
diff --git a/hosting/server/htdocs/public/touchicon-53.xcf b/hosting/server/htdocs/public/touchicon-53.xcf
new file mode 100644
index 0000000..b2dcd6f
--- /dev/null
+++ b/hosting/server/htdocs/public/touchicon-53.xcf
Binary files differ
diff --git a/hosting/server/htdocs/public/touchicon-57.xcf b/hosting/server/htdocs/public/touchicon-57.xcf
new file mode 100644
index 0000000..5ab2849
--- /dev/null
+++ b/hosting/server/htdocs/public/touchicon-57.xcf
Binary files differ
diff --git a/hosting/server/htdocs/public/touchicon.b64 b/hosting/server/htdocs/public/touchicon.b64
deleted file mode 100644
index 2239f6a..0000000
--- a/hosting/server/htdocs/public/touchicon.b64
+++ /dev/null
@@ -1 +0,0 @@
-iVBORw0KGgoAAAANSUhEUgAAADkAAAA5CAIAAAADehTSAAAAAXNSR0IArs4c6QAAAAlwSFlzAAALEwAACxMBAJqcGAAAAAd0SU1FB9sDGxMkCJXGmL8AAAHwSURBVGje7ZpNbhNBEIXf625sCzA/QUhkg8SGiGxZcpDcgRux5hLkEjkE7BAS9gIyk+muxyZIsSeOG09bsXHX0p4pfVNdP8/loSTsiTnsj1XWyrrMutVSG+ic/ftNqe1mIMtSjsPUu9EQJ6H/UdvNLr59cgwFWaM1p8dnLx6dFGYF6RhIXzLVGIChB3VX8Fg0DWrPqqyHxTq4MUnKKEEBoNvIN4uxiqTkofUXpgZKsqtMx3Djpb45lNWAxxbfXf6wtdH9+vkKBLLGrFLz4M1HTk+K5gAIgBCVcaTI1gOK/acazqqbw2PdYzE7tdyh9AFJTL0zNDIJMInAZpKPzBzmIZuUnjoa9QQkOBHAyWbigYDaTslybg/59f7Q4+003pqwhqcbqjLH9H2OXw0Ksl6XsWB/a39lhf1rz8vOnKoHKmtlrayVtbLuuc6SFK1Z2hEZkBwAv1us4zA9PT7rDX3v9dPiOeBxT/uY0A+qd6Pbl2Sax/kXDN9LlcrXO3Rk9k/QWluVtbIe2O5toBGwFum3bLH/pEso7RarrPNHH/D8JbBIpsjJqx2Lq3Xu2Xv61yvXJzf6/b3nK2Htyu8WB9P/XltF/wfVllgFxet9azGL+bjMD5IUYbPSMktwT8hRSdalkizcufKcs77vUlkr61bsD5lbwtgOKPT2AAAAAElFTkSuQmCC
\ No newline at end of file
diff --git a/hosting/server/htdocs/public/touchicon.png b/hosting/server/htdocs/public/touchicon.png
index f22c33d..1975eb1 100644
--- a/hosting/server/htdocs/public/touchicon.png
+++ b/hosting/server/htdocs/public/touchicon.png
Binary files differ
diff --git a/hosting/server/htdocs/public/touchicon.xcf b/hosting/server/htdocs/public/touchicon.xcf
deleted file mode 100644
index fc713b4..0000000
--- a/hosting/server/htdocs/public/touchicon.xcf
+++ /dev/null
Binary files differ
diff --git a/hosting/server/htdocs/public/user.b64 b/hosting/server/htdocs/public/user.b64
index 7ed235a..70e650a 100644
--- a/hosting/server/htdocs/public/user.b64
+++ b/hosting/server/htdocs/public/user.b64
@@ -1 +1 @@
-iVBORw0KGgoAAAANSUhEUgAAADIAAAAyAgMAAABjUWAiAAAABGdBTUEAALGPC/xhBQAAAAxQTFRFyN+N+dR1/PCI////6HjE5gAAADJJREFUKM9j+I8EPjBQifeBAQSY6coLBYN6inhaq0Bg6SDn/f//akB466ExTS6P2ukMAKumzarJO/66AAAAAElFTkSuQmCC
\ No newline at end of file
+iVBORw0KGgoAAAANSUhEUgAAADIAAAAyCAYAAAAeP4ixAAAABmJLR0QA/wD/AP+gvaeTAAAACXBIWXMAAAsTAAALEwEAmpwYAAADG0lEQVRo3u3a326iQBQG8G8EhAIRMFXHJrXEJo0XNfER+jz7PrvP04fojb0gJq2BaCOmBUuRMntls82a/hkYdDeeW+OnPweGMycC/0kRALi+vmb/MuLq6orU/pcVOUAOEEEliwhVVRWUUjiOA13XIcsysizDarVCGIYIggAvLy/7C5EkCf1+H91uF4SQd68pigLLsmBZFs7OzuD7PjzPw+vr635BDMPA5eUlNE37fM8nBCcnJ2g2m7i5uUEcx/txj5imidFo9CXEn6VpGkajEUzT3D1EVVUMh0PIMt/iyrKM4XAIVVV3CxkMBqjX64Uy6vU6BoPB7iCUUti2Xco9Zts2KKXVQwghcF231C3Udd2/djvhkE6nU/i63na/dTqdaiFFLgMRuTXem9OyLCGQRqPBtXlwQRzHEXdAIoQrv8b7q4ksnnwuiGEYQiE8+VyQsnerMvK5IIqiCIXw5HNBajWx5zGefK5vxJjYoQtPPhckyzKhEJ58LkiapkIhPMdgLshqtRIKeX5+rgby9PQkFMKTzwVZLpdCITz5XJAoipAkiRBEkiSIoqi6Nn42mwmB8OZyQ6bTKfI8LxWR5zmm02m1kDRN4ft+qRDf97m39kK9xmQyKe2ZkqYpJpPJbqYoWZZhPB4XblkYYxiPx4U6hsLd32KxgOd5hTI8z8NisSiUUcrs9/7+HgDQ7/e/Nc5hjMHzvLf37xyywcRxjIuLiy/NgJMkwe3tLcIwLOXzC0EURYFhGDAMA5qmQdM0rNdrSJIEWZa3rk6e50jTFFEUodlsQtd1xHGMOI6xXq+rgWiaBsdxYNs2Go3Gt6fvm0PTBr1tlR4fH7FcLhGG4be6h08hpmmi1Wrh+PgYuq4L7bE2wHa7/dZlPzw8YD6ff9q2bIXIsgxKKSilwicmH5Wu6+j1euj1eojjGEEQIAiCrdv0O8jR0RFOT0/RbrchSRL2qQzDwPn5OVzXxWw2w93d3btzi7wBuK6LVqvFPQ2vqiRJQrfbBaUU8/n8rRsgjLGfjLEf+w746FlECPlFmOiRSEV1+OfDAXKAfFy/AU/NFLNNzbKxAAAAAElFTkSuQmCC
diff --git a/hosting/server/htdocs/public/user.png b/hosting/server/htdocs/public/user.png
index 1f73274..e99b308 100644
--- a/hosting/server/htdocs/public/user.png
+++ b/hosting/server/htdocs/public/user.png
Binary files differ
diff --git a/hosting/server/htdocs/public/user.xcf b/hosting/server/htdocs/public/user.xcf
new file mode 100644
index 0000000..2f304c4
--- /dev/null
+++ b/hosting/server/htdocs/public/user.xcf
Binary files differ
diff --git a/hosting/server/htdocs/rate/index.html b/hosting/server/htdocs/rate/index.html
new file mode 100644
index 0000000..22f11f6
--- /dev/null
+++ b/hosting/server/htdocs/rate/index.html
@@ -0,0 +1,190 @@
+<!DOCTYPE html>
+<!--
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ * 
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ * 
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.    
+-->
+<div id="bodydiv" class="body">
+
+<div id="viewform" class="viewform">
+
+<form id="rateAppForm">
+<table style="width: 100%;">
+<tr><td class="label">Rating:</td></tr>
+<tr><td class="lightlabel" id="taptorate"></td></tr>
+<tr><td class="label">
+<span style="display: inline-block; width: 2px;"></span>
+<span id="rateApp1" class="graystar">&nbsp;</span><span style="display: inline-block; width: 20px;"></span>
+<span id="rateApp2" class="graystar">&nbsp;</span><span style="display: inline-block; width: 20px;"></span>
+<span id="rateApp3" class="graystar">&nbsp;</span><span style="display: inline-block; width: 20px;"></span>
+<span id="rateApp4" class="graystar">&nbsp;</span>
+</td></tr>
+<tr><td class="lightlabel" id="ratedescription">&nbsp;</span></td></tr>
+<tr><td style="padding-top: 20px;">
+<input id="rateAppDoneButton" type="button" class="graybutton" value="Done"/>
+</td></tr>
+</table>
+</form>
+<br/>
+
+</div>
+
+<script type="text/javascript">
+(function ratebody() {
+
+/**
+ * Get the app name.
+ */
+var appname = ui.fragmentParams(location)['app'];
+
+/**
+ * Setup page layout.
+ */
+(function layout() {
+    document.title = config.windowtitle() + ' - ' + 'Rate' + ' - ' + appname;
+    $('viewhead').innerHTML = '<span class="smenu">' + 'Rate' + ' ' + appname + '</span>';
+    if (!ui.isMobile())
+        $('viewform').className = 'viewform flatscrollbars';
+
+    $('viewform').appendChild(ui.declareCSS(
+        '.redstar { ' +
+        'background: url(\'' + ui.b64png(appcache.get('/public/rate.b64')) + '\'); background-repeat: no-repeat; ' +
+        'vertical-align: middle; width: 40px; height: 40px; display: inline-block; background-position: 0px 1px;' +
+        ' } ' +
+        '.graystar { ' +
+        'background: url(\'' + ui.b64png(appcache.get('/public/rate.b64')) + '\'); background-repeat: no-repeat; ' +
+        'vertical-align: middle; width: 40px; height: 40px; display: inline-block; background-position: -50px 1px;' +
+        ' }'));
+
+    $('taptorate').innerHTML = ui.isMobile()? 'Tap a star to select a rating' : ' Click a star to select a rating';
+})();
+
+/**
+ * Initialize service references.
+ */
+var editorComp = sca.component("Editor");
+var reviews = sca.reference(editorComp, "reviews");
+
+/**
+ * Initialize the rate buttons.
+ */
+var rateAppButtons = [
+    [$('rateApp1'), 1, function() { return onclickrating(1); }, 'Don\'t like it'],
+    [$('rateApp2'), 2, function() { return onclickrating(2); }, 'It\'s ok'],
+    [$('rateApp3'), 3, function() { return onclickrating(3); }, 'It\'s good'],
+    [$('rateApp4'), 4, function() { return onclickrating(4); }, 'It\'s great']
+];
+(function initRateAppButtons() {
+    map(function(b) {
+        b[0].onclick = b[2];
+    }, rateAppButtons);
+})();
+
+/**
+ * Select a rating.
+ */
+var selectedrating = 0;
+function selectrating(r) {
+    selectedrating = r;
+    map(function(b) {
+        b[0].className = b[1] <= r? 'redstar' : 'graystar';
+    }, rateAppButtons);
+    $('ratedescription').innerHTML = rateAppButtons[r - 1][3];
+    return true;
+}
+
+/**
+ * The current app entry and corresponding saved XML content.
+ */
+var appentry;
+var savedxml = '';
+
+/**
+ * Get and display the requested app rating.
+ */
+(function getrating() {
+    if (isNil(appname))
+        return false;
+    workingstatus(true);
+    showstatus('Loading');
+
+    return reviews.get(appname, function(doc) {
+
+        // Stop now if we didn't get the rating
+        if (doc == null) {
+            onlinestatus();
+            workingstatus(false);
+            return false;
+        }
+
+        appentry = doc != null? car(elementsToValues(atom.readATOMEntry(mklist(doc)))) : mklist("'entry", mklist("'title", ''), mklist("'id", appname));
+        savedxml = car(atom.writeATOMEntry(valuesToElements(mklist(appentry))));
+        var content = cadr(assoc("'content", appentry));
+        if (!isNil(content))
+            selectrating(parseInt(cadr(content)));
+
+        onlinestatus();
+        workingstatus(false);
+        return true;
+    });
+})();
+
+/**
+ * Save an app rating.
+ */
+function save(name, entryxml) {
+    workingstatus(true);
+    showstatus('Saving');
+
+    savedxml = entryxml;
+    reviews.put(name, savedxml, function(e) {
+        if (e) {
+            showstatus('Local copy');
+            workingstatus(false);
+            return false;
+        }
+        showstatus('Saved');
+        workingstatus(false);
+        return false;
+    });
+    return false;
+}
+
+/**
+ * Handle rating click event.
+ */
+function onclickrating(r) {
+    // Select the rating
+    selectrating(r);
+
+    // Save
+    showstatus('Saving');
+    appentry = mklist("'entry", mklist("'title", appname), mklist("'id", appname), mklist("'content", mklist("'rating", selectedrating.toString())));
+    var entryxml = car(atom.writeATOMEntry(valuesToElements(mklist(appentry))));
+    return save(appname, entryxml);
+}
+
+/**
+ * Navigate back.
+ */
+$('rateAppDoneButton').onclick = function() {
+    history.back();
+};
+
+})();
+</script>
+
+</div>
diff --git a/hosting/server/htdocs/search/index.html b/hosting/server/htdocs/search/index.html
new file mode 100644
index 0000000..47d5a75
--- /dev/null
+++ b/hosting/server/htdocs/search/index.html
@@ -0,0 +1,196 @@
+<!DOCTYPE html>
+<!--
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ * 
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ * 
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.    
+-->
+<div id="bodydiv" class="body">
+
+<div id="viewcontent" class="viewcontent">
+
+<div id="apps"></div>
+<br/>
+
+</div>
+
+<script type="text/javascript">
+(function searchbody() {
+
+/**
+ * Setup page layout.
+ */
+(function layout() {
+    document.title = config.windowtitle() + ' - Apps';
+    if (!ui.isMobile())
+        $('viewcontent').className = 'viewcontent flatscrollbars';
+
+    $('viewhead').innerHTML = '<form id="searchForm">' +
+    '<span style="position: absolute; top: 0px; left: 5px; right: 70px; padding: 0px; background: transparent;"><input type="text" id="searchQuery" value="" class="flatentry" title="Search" autocapitalize="off" placeholder="Search for apps" style="position: absolute; left: 0px; top: 4px; width: 100%;"></span>' +
+    '<input type="submit" id="searchButton" title="Search" class="bluebutton search" style="position: absolute; top: 4px; right: 5px; width: 60px; background-position: center center; background-repeat: no-repeat; background-image: url(\'' + ui.b64png(appcache.get('/public/search.b64')) + '\');" value=" "/>' +
+    '</form>';
+
+    $('viewcontent').appendChild(ui.declareCSS(
+        '.ratings { ' +
+        'background: url(\'' + ui.b64png(appcache.get('/public/ratings.b64')) + '\'); ' +
+        'margin-top: 6px; width: 50px; height: 14px; display: inline-block; ' +
+        ' }'));
+})();
+
+/**
+ * Initialize service references.
+ */
+var editorComp = sca.component("Editor");
+var search = sca.reference(editorComp, "search");
+var icons = sca.reference(editorComp, "icons");
+
+/**
+ * Edit an app.
+ */
+function editapp(appname) {
+    return ui.navigate('/#view=page&app=' + appname, '_view');
+}
+
+/**
+ * View an app.
+ */
+function viewapp(appname) {
+    return ui.navigate('/#view=info&app=' + appname, '_view');
+}
+
+/**
+ * Get and display an app icon.
+ */
+function geticon(appname) {
+    if (isNil(appname))
+        return false;
+
+    return icons.get(appname, function(doc) {
+        // Stop now if we didn't get an icon
+        if (doc == null)
+            return false;
+
+        var iconentry = car(elementsToValues(atom.readATOMEntry(mklist(doc))));
+        var content = assoc("'content", iconentry);
+        var icon = assoc("'icon", content);
+        var img = assoc("'image", icon);
+        if (!isNil(img)) {
+            var appimg = $('search_app_img_' + appname);
+            if (!isNil(appimg))
+                appimg.src = cadr(img);
+        }
+        return true;
+    });
+    return true;
+}
+
+/**
+ * Get and display list of apps.
+ */
+function getapps(query) {
+    workingstatus(true);
+    showstatus('Searching');
+
+    function display(doc) {
+
+        // Stop now if we didn't get the apps
+        if (doc == null) {
+            errorstatus('Not available');
+            workingstatus(false);
+            return false;
+        }
+
+        var feed = car(elementsToValues(atom.readATOMFeed(mklist(doc))));
+        var aentries = assoc("'entry", feed);
+        var entries = isNil(aentries)? mklist() : isList(car(cadr(aentries)))? cadr(aentries) : mklist(cdr(aentries));
+
+        var defappimg = ui.b64png(appcache.get('/public/app.b64'));
+
+        var apps = '<div>';
+        var icons = mklist();
+
+        (function displayentries(entries) {
+            if (isNil(entries))
+                return apps;
+            var entry = car(entries);
+            var title = cadr(assoc("'title", entry))
+            var name = cadr(assoc("'id", entry));
+            var author = cadr(assoc("'author", entry));
+            var updated = xmldatetime(cadr(assoc("'updated", entry))).toLocaleDateString();
+
+            var aratings = assoc("'info", assoc("'content", entry));
+            var ar = assoc("'rating", aratings);
+            var ar1 = assoc("'rating1", aratings);
+            var ar2 = assoc("'rating2", aratings);
+            var ar3 = assoc("'rating3", aratings);
+            var ar4 = assoc("'rating4", aratings);
+            var rating = isNil(ar)? 0 : Number(cadr(ar));
+            var reviews = (isNil(ar1)? 0 : Number(cadr(ar1))) + (isNil(ar2)? 0 : Number(cadr(ar2))) + (isNil(ar3)? 0 : Number(cadr(ar3))) + (isNil(ar4)? 0 : Number(cadr(ar4)));
+
+            apps += '<div class="box">'
+            apps += '<div class="appicon">'
+            apps += ui.href('appicon_' + name, '/#view=info&app=' + name, '_view', '<img id="search_app_img_' + name + '" src="' + defappimg + '" width="50" height="50"></img>');
+            //apps += '<br/><input type="button" class="lightbutton" value="Run" onclick="ui.navigate(\'/' + name + '/\', \'_blank\');"/>';
+            apps += '</div>'
+            apps += '<div class="appdetails">';
+            apps += '<span class="apptitle">' + ui.href('search_app_title_' + name, '/#view=info&app=' + name, '_view', name) + '</span>';
+            apps += '<br/><span>' + 'by&nbsp;' + author.split('@')[0] + '</span>';
+            var ratingy = -20 * (4 - Math.floor(rating));
+            apps += '<br/><span class="ratings" style="background-position: 0px ' + ratingy + 'px;">&nbsp;</span>';
+            apps += '<br/><span style="font-size: 10px;">' + reviews + (reviews > 1? ' ratings' : ' rating') + '</span>';
+            /*apps += '<br/><span>' + updated + '</span>';*/
+            apps += '</div>';
+            apps += '</div>';
+
+            icons = cons(name, icons);
+
+            return displayentries(cdr(entries));
+        })(entries);
+
+        apps += '</div>';
+        $('apps').innerHTML = apps;
+
+        ui.unmemo$('search_app_');
+
+        (function displayicons(icons) {
+            if (isNil(icons))
+                return true;
+            geticon(car(icons));
+            return displayicons(cdr(icons));
+        })(reverse(icons));
+
+        onlinestatus();
+        workingstatus(false);
+    }
+
+    return search.get('?q=' + query, display);
+}
+
+/**
+ * Handle search form submit.
+ */
+$('searchForm').onsubmit = function() {
+    if ($('searchQuery').value.trim() == '')
+        return false;
+    if (ui.isMobile())
+        $('searchQuery').blur();
+    getapps($('searchQuery').value.trim());
+    return false;
+};
+
+})();
+</script>
+
+</div>
diff --git a/hosting/server/htdocs/stats/index.html b/hosting/server/htdocs/stats/index.html
deleted file mode 100644
index 7c3d9a6..0000000
--- a/hosting/server/htdocs/stats/index.html
+++ /dev/null
@@ -1,179 +0,0 @@
-<!DOCTYPE html>
-<!--
- * Licensed to the Apache Software Foundation (ASF) under one
- * or more contributor license agreements.  See the NOTICE file
- * distributed with this work for additional information
- * regarding copyright ownership.  The ASF licenses this file
- * to you under the Apache License, Version 2.0 (the
- * "License"); you may not use this file except in compliance
- * with the License.  You may obtain a copy of the License at
- * 
- *   http://www.apache.org/licenses/LICENSE-2.0
- * 
- * Unless required by applicable law or agreed to in writing,
- * software distributed under the License is distributed on an
- * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
- * KIND, either express or implied.  See the License for the
- * specific language governing permissions and limitations
- * under the License.    
--->
-<div id="bodydiv" class="body">
-
-<div class="viewform">
-
-<form id="appForm">
-<table style="width: 100%;">
-<tr><tr><td><b>Icon:</b></td></tr>
-<tr><td><img id="appimg" style="width: 50px; height: 50px; vertical-align: top;"></td></tr>
-<tr><tr><td style="padding-top: 6px;"><b>Title:</b></td></tr>
-<tr><td><input type="text" class="flatentry" id="appTitle" size="30" readonly="readonly" placeholder="Enter the title of your app" style="width: 300px;"/></td></tr>
-<tr><tr><td style="padding-top: 6px;"><b>Author:</b></td></tr>
-<tr><td><span id="appAuthor"></span></td></tr>
-<tr><tr><td style="padding-top: 6px;"><b>Updated:</b></td></tr>
-<tr><td><span id="appUpdated"></span></td></tr>
-<tr><tr><td style="padding-top: 6px;"><b>Description:</b></td></tr>
-<tr><td><textarea id="appDescription" class="flatentry" cols="40" rows="3" readonly="readonly" placeholder="Enter a short description of your app" style="width: 300px;"></textarea></td></tr>
-</table>
-</form>
-
-</div>
-
-<script type="text/javascript">
-(function() {
-
-/**
- * Get the app name.
- */
-var appname = ui.fragmentParams(location)['app'];
-
-/**
- * Set page titles.
- */
-document.title = config.windowtitle() + ' - Stats - ' + appname;
-$('viewhead').innerHTML = '<span id="appname" class="cmenu">' + appname + '</span>' +
-'<input type="button" class="graybutton redbutton plusminus" style="position: absolute; top: 4px; left: 5px;" id="deleteApp" value="-" title="Delete the app" disabled="true"/>' +
-'<input type="button" class="graybutton bluebutton" style="position: absolute; top: 4px; right: 5px;" id="cloneApp" value="'+ config.clone() +'" title="' + config.clone() + ' this app"/>';
-
-/**
- * Set images.
- */
-$('appimg').src = ui.b64img(appcache.get('/public/app.b64'));
-
-/**
- * Init service references.
- */
-var editorComp = sca.component("Editor");
-var apps = sca.reference(editorComp, "apps");
-
-/**
- * The current app entry, author and saved XML content.
- */
-var savedappentryxml = '';
-var author;
-var appentry;
-
-/**
- * Get and display an app.
- */
-function getapp(name) {
-    if (isNil(name))
-        return false;
-    showStatus('Loading');
-
-    return apps.get(name, function(doc) {
-
-        // Stop now if we didn't get the app
-        if (doc == null) {
-            showError('App not available');
-            return false;
-        }
-
-        appentry = car(elementsToValues(atom.readATOMEntry(mklist(doc))));
-        $('appTitle').value = cadr(assoc("'title", cdr(appentry)));
-        author = cadr(assoc("'author", cdr(appentry)));
-        $('appAuthor').innerHTML = author;
-        $('appUpdated').innerHTML = cadr(assoc("'updated", cdr(appentry)));
-        var content = cadr(assoc("'content", cdr(appentry)));
-        var description = assoc("'description", content);
-        $('appDescription').value = isNil(description) || isNil(cadr(description))? '' : cadr(description);
-        savedappentryxml = car(atom.writeATOMEntry(valuesToElements(mklist(appentry))));
-
-        // Enable author to edit and delete the app
-        if (username == author) {
-            $('appTitle').readOnly = false;
-            $('appDescription').readOnly = false;
-            $('deleteApp').disabled = false;
-            $('deleteApp').onclick = function() {
-                return ui.navigate('/#view=delete&app=' + appname, '_view');
-            }
-            showOnlineStatus();
-        } else {
-            $('appTitle').placeholder = '';
-            $('appDescription').placeholder = '';
-            showStatus('Read only');
-        }
-        return true;
-    });
-}
-
-/**
- * Save the current app.
- */
-function save(entryxml) {
-    showStatus('Saving');
-    savedappentryxml = entryxml;
-    apps.put(appname, savedappentryxml, function(e) {
-        if (e) {
-            showStatus('Local copy');
-            return false;
-        }
-
-        showStatus('Saved');
-        return false;
-    });
-    return true;
-}
-
-/**
- * Handle a change event
- */
-function onappchange() {
-    if (username != author)
-        return false;
-    var title = $('appTitle').value;
-    var description = $('appDescription').value;
-    appentry = mklist("'entry", mklist("'title", title != ''? title : appname), mklist("'id", appname), mklist("'content", mklist("'stats", mklist("'description", description))));
-    var entryxml = car(atom.writeATOMEntry(valuesToElements(mklist(appentry))));
-    if (savedappentryxml == entryxml)
-        return false;
-    showStatus('Modified');
-    return save(entryxml);
-}
-
-$('appTitle').onchange = onappchange;
-$('appDescription').onchange = onappchange;
-
-/**
- * Handle a form submit event.
- */
-$('appForm').onsubmit = function() {
-    onappchange();
-    return false;
-};
-
-/**
- * Handle Clone button event.
- */
-$('cloneApp').onclick = function() {
-    return ui.navigate('/#view=clone&app=' + appname, '_view');
-}
-
-/**
- * Get the current app.
- */
-getapp(appname);
-
-})();
-</script>
-
-</div>
diff --git a/hosting/server/htdocs/store/index.html b/hosting/server/htdocs/store/index.html
index 1264007..15e7aee 100644
--- a/hosting/server/htdocs/store/index.html
+++ b/hosting/server/htdocs/store/index.html
@@ -19,24 +19,39 @@
 -->
 <div id="bodydiv" class="body">
 
-<div id="apps" class="viewcontent"></div>
+<div id="viewcontent" class="viewcontent">
+
+<div id="apps"></div>
+<br/>
+
+</div>
 
 <script type="text/javascript">
-(function() {
+(function storebody() {
 
 /**
- * Set page titles.
+ * Setup page layout.
  */
-document.title = config.windowtitle() + ' - Store';
+(function layout() {
+    document.title = config.windowtitle() + ' - Apps';
+    if (!ui.isMobile())
+        $('viewcontent').className = 'viewcontent flatscrollbars';
+
+    $('viewcontent').appendChild(ui.declareCSS(
+        '.ratings { ' +
+        'background: url(\'' + ui.b64png(appcache.get('/public/ratings.b64')) + '\'); ' +
+        'margin-top: 6px; width: 50px; height: 14px; display: inline-block; ' +
+        ' }'));
+})();
 
 /**
  * The store categories
  */
 var categories = [
-    //['Featured', 'featured', 1],
+    ['Featured', 'featured', 1],
     ['Top', 'top', 2],
-    ['New', 'new', 3],
-    ['Search', 'all', 4],
+    //['New', 'new', 3],
+    //['Search', 'all', 4],
     ['My Apps', 'myapps', 5]
 ];
 
@@ -45,22 +60,22 @@
  */
 function findcategory(name) {
     if (isNil(name))
-        return findcategory('top');
+        return findcategory('featured');
     var f = filter(function(c) { return cadr(c) == name }, categories);
     if (isNil(f))
-        return findcategory('top');
+        return findcategory('featured');
     return car(f);
 }
 
 /**
- * Get the current store category.
+ * Get the requested store category.
  */
 var catname = cadr(findcategory(ui.fragmentParams(location)['category']));
 
 /**
  * Build the store menu bar
  */
-function catmenu() {
+$('viewhead').innerHTML = (function catmenu() {
     function catmenuitem(name, cat, idx) {
         var c = cat == catname? 'smenu' : 'amenu';
         return '<span>' + ui.href('storecat_' + cat, '/#view=store&category=' + cat + '&idx=' + idx, '_view', '<span class="' + c + '">' + name + '</span>') + '</span>';
@@ -68,34 +83,31 @@
 
     var m = '';
     map(function(c) { m += catmenuitem(car(c), cadr(c), caddr(c)); }, categories);
-    m += '<span class="rmenu"><input type="button" class="graybutton bluebutton" id="createApp" title="Create a new app" Value="Create"/></span>';
+    m += '<span class="rmenu"><input type="button" class="bluebutton" id="createApp" title="Create a new app" Value="Create"/></span>';
     return m;
-}
+})();
+
 
 /**
- * Build the store menu bar.
- */
-$('viewhead').innerHTML = catmenu();
-
-/**
- * Init service references.
+ * Initialize service references.
  */
 var editorComp = sca.component("Editor");
 var store = sca.reference(editorComp, "store");
 var dashboards = sca.reference(editorComp, "dashboards");
+var icons = sca.reference(editorComp, "icons");
 
 /**
  * Edit an app.
  */
-function editApp(appname) {
+function editapp(appname) {
     return ui.navigate('/#view=page&app=' + appname, '_view');
 }
 
 /**
  * View an app.
  */
-function viewApp(appname) {
-    return ui.navigate('/#view=stats&app=' + appname, '_view');
+function viewapp(appname) {
+    return ui.navigate('/#view=info&app=' + appname, '_view');
 }
 
 /**
@@ -103,66 +115,118 @@
  */
 $('createApp').onclick = function() {
     return ui.navigate('/#view=create', '_view');
+};
+
+/**
+ * Get and display an app icon.
+ */
+function geticon(appname) {
+    if (isNil(appname))
+        return false;
+
+    return icons.get(appname, function(doc) {
+        // Stop now if we didn't get an icon
+        if (doc == null)
+            return false;
+
+        var iconentry = car(elementsToValues(atom.readATOMEntry(mklist(doc))));
+        var content = assoc("'content", iconentry);
+        var icon = assoc("'icon", content);
+        var img = assoc("'image", icon);
+        if (!isNil(img)) {
+            var appimg = $('store_app_img_' + appname);
+            if (!isNil(appimg))
+                appimg.src = cadr(img);
+        }
+        return true;
+    });
+    return true;
 }
 
 /**
  * Get and display list of apps.
  */
-function getapps(catname) {
-    //debug('catname', catname);
-    showStatus('Loading');
+(function getapps() {
+    workingstatus(true);
+    showstatus('Loading');
 
     function display(doc) {
 
         // Stop now if we didn't get the apps
         if (doc == null) {
-            showError('App not available');
+            errorstatus('Couldn\'t get the list of apps');
+            workingstatus(false);
             return false;
         }
-        showOnlineStatus();
 
-        var apps = '<div>';
         var feed = car(elementsToValues(atom.readATOMFeed(mklist(doc))));
-        var aentries = assoc("'entry", cdr(feed));
+        var aentries = assoc("'entry", feed);
         var entries = isNil(aentries)? mklist() : isList(car(cadr(aentries)))? cadr(aentries) : mklist(cdr(aentries));
 
-        var appimg = ui.b64img(appcache.get('/public/app.b64'));
+        var defappimg = ui.b64png(appcache.get('/public/app.b64'));
 
-        function displayentries(entries) {
+        var apps = '<div>';
+        var icons = mklist();
+
+        (function displayentries(entries) {
             if (isNil(entries))
                 return apps;
             var entry = car(entries);
             var title = cadr(assoc("'title", entry))
             var name = cadr(assoc("'id", entry));
             var author = cadr(assoc("'author", entry));
-            var updated = cadr(assoc("'updated", entry));
+            var updated = xmldatetime(cadr(assoc("'updated", entry))).toLocaleDateString();
+
+            var aratings = assoc("'info", assoc("'content", entry));
+            var ar = assoc("'rating", aratings);
+            var ar1 = assoc("'rating1", aratings);
+            var ar2 = assoc("'rating2", aratings);
+            var ar3 = assoc("'rating3", aratings);
+            var ar4 = assoc("'rating4", aratings);
+            var rating = isNil(ar)? 0 : Number(cadr(ar));
+            var reviews = (isNil(ar1)? 0 : Number(cadr(ar1))) + (isNil(ar2)? 0 : Number(cadr(ar2))) + (isNil(ar3)? 0 : Number(cadr(ar3))) + (isNil(ar4)? 0 : Number(cadr(ar4)));
 
             apps += '<div class="box">'
-            apps += '<span class="appicon">' + ui.href('appicon_' + name, '/#view=stats&app=' + name, '_view', '<img src="' + appimg + '" width="50" height="50"></img>') + '</span>';
-            apps += '<span>'
-            apps += '<span class="apptitle">' + ui.href('apptitle_' + name, '/#view=stats&app=' + name, '_view', name) + '</span>';
+            apps += '<div class="appicon">'
+            apps += ui.href('appicon_' + name, '/#view=info&app=' + name, '_view', '<img id="store_app_img_' + name + '" src="' + defappimg + '" width="50" height="50"></img>');
+            //apps += '<br/><input type="button" class="lightbutton" value="Run" onclick="ui.navigate(\'/' + name + '/\', \'_blank\');"/>';
+            apps += '</div>'
+            apps += '<div class="appdetails">';
+            apps += '<span class="apptitle">' + ui.href('store_app_title_' + name, '/#view=info&app=' + name, '_view', name) + '</span>';
             if (catname != 'myapps')
                 apps += '<br/><span>' + 'by&nbsp;' + author.split('@')[0] + '</span>';
-            apps += '</span>';
+            var ratingy = -20 * (4 - Math.floor(rating));
+            apps += '<br/><span class="ratings" style="background-position: 0px ' + ratingy + 'px;">&nbsp;</span>';
+            apps += '<br/><span style="font-size: 10px;">' + reviews + (reviews > 1? ' ratings' : ' rating') + '</span>';
+            /*apps += '<br/><span>' + updated + '</span>';*/
             apps += '</div>';
-            return displayentries(cdr(entries));
-        }
+            apps += '</div>';
 
-        displayentries(entries);
+            icons = cons(name, icons);
+
+            return displayentries(cdr(entries));
+        })(entries);
 
         apps += '</div>';
         $('apps').innerHTML = apps;
+
+        ui.unmemo$('store_app_');
+
+        (function displayicons(icons) {
+            if (isNil(icons))
+                return true;
+            geticon(car(icons));
+            return displayicons(cdr(icons));
+        })(reverse(icons));
+
+        onlinestatus();
+        workingstatus(false);
     }
 
     if (catname == 'myapps')
         return dashboards.get('', display);
     return store.get(catname, display);
-}
-
-/**
- * Get and display the list of apps.
- */
-getapps(catname);
+})();
 
 })();
 </script>
diff --git a/hosting/server/icons.py b/hosting/server/icons.py
new file mode 100644
index 0000000..7ee9ae9
--- /dev/null
+++ b/hosting/server/icons.py
@@ -0,0 +1,175 @@
+#  Licensed to the Apache Software Foundation (ASF) under one
+#  or more contributor license agreements.  See the NOTICE file
+#  distributed with this work for additional information
+#  regarding copyright ownership.  The ASF licenses this file
+#  to you under the Apache License, Version 2.0 (the
+#  "License"); you may not use this file except in compliance
+#  with the License.  You may obtain a copy of the License at
+#  
+#    http://www.apache.org/licenses/LICENSE-2.0
+#    
+#  Unless required by applicable law or agreed to in writing,
+#  software distributed under the License is distributed on an
+#  "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+#  KIND, either express or implied.  See the License for the
+#  specific language governing permissions and limitations
+#  under the License.
+
+# App icons collection implementation
+from StringIO import StringIO
+try:
+    from PIL import Image
+except:
+    Image = None
+from base64 import b64encode, b64decode
+from urllib import urlopen
+from util import *
+from atomutil import *
+from sys import debug
+
+# Convert an id to an icon id
+def iconid(id):
+    return ("apps", car(id), "app.icon")
+
+# Convert image to a 50x50 PNG image
+def to50x50png(url):
+    debug('icons.py::to50x50png::url', url)
+    if Image is None:
+        return url
+    img = Image.open(StringIO(b64decode(url.split(',')[1])) if url.startswith('data:') else StringIO(urlopen(url).read()))
+    t = img.resize((50, 50))
+    obuf = StringIO()
+    t.save(obuf, 'PNG')
+    return 'data:image/png;base64,' + b64encode(obuf.getvalue()).replace('\n', '')
+
+# Put an icon
+def put(id, icon, user, cache, apps):
+    debug('icons.py::put::id', id)
+    debug('icons.py::put::icon', icon)
+
+    # Get the requested app
+    app = apps.get(id)
+    if isNil(app):
+        debug('icons.py::put', 'app not found', id)
+        return False
+
+    # Check app author
+    if author(app) != user.get(()) and user.get(()) != 'admin':
+        debug('icons.py::put', 'different author', author(app))
+        return False
+
+    # Get image and token from input icon
+    def image(c):
+        img = assoc("'image", c)
+        return None if isNil(img) else to50x50png(cadr(img))
+    def token(c):
+        tok = assoc("'token", c)
+        return None if isNil(tok) else cadr(tok)
+    img = image(content(icon))
+    tok = token(content(icon))
+
+    # Update the icon
+    # Put with an upload token
+    if not isNil(tok):
+        debug('icons.py::put::token', tok)
+
+        # Token alone, store token with existing image, if any
+        if isNil(img):
+            eicon = cache.get(iconid(id))
+            eimg = None if isNil(eicon) else image(content(eicon))
+            if isNil(eimg):
+                iconentry = mkentry(title(app), car(id), author(app), now(), ("'icon", ("'token", tok)))
+                debug('icons.py::put::iconentry', iconentry)
+                return cache.put(iconid(id), iconentry)
+
+            debug('icons.py::put::eimg', eimg)
+            iconentry = mkentry(title(app), car(id), author(app), now(), ("'icon", ("'image", eimg), ("'token", tok)))
+            debug('icons.py::put::iconentry', iconentry)
+            return cache.put(iconid(id), iconentry)
+
+        # Token plus image, put image if token is valid, removing the token
+        debug('icons.py::put::img', img)
+        eicon = cache.get(iconid(id))
+        etok = None if isNil(eicon) else token(content(eicon))
+        debug('icons.py::put::etok', etok)
+        if isNil(etok) or tok != etok:
+            debug('icons.py::put', 'invalid token', tok)
+            return False
+
+        iconentry = mkentry(title(app), car(id), author(app), now(), ("'icon", ("'image", img)))
+        debug('icons.py::put::iconentry', iconentry)
+        return cache.put(iconid(id), iconentry)
+
+    # Update icon image
+    if not isNil(img):
+        debug('icons.py::put::img', img)
+        iconentry = mkentry(title(app), car(id), author(app), now(), ("'icon", ("'image", img)))
+        debug('icons.py::put::iconentry', iconentry)
+        return cache.put(iconid(id), iconentry)
+
+    # Put default empty icon
+    iconentry = mkentry(title(app), car(id), author(app), now(), ())
+    debug('icons.py::put::iconentry', iconentry)
+    return cache.put(iconid(id), iconentry)
+
+# Get an icon
+def get(id, user, cache, apps):
+    debug('icons.py::get::id', id)
+    if isNil(id):
+        return (("'feed", ("'title", "Icons"), ("'id", "icons")),)
+
+    # Get the requested app
+    app = apps.get(id)
+    if isNil(app):
+        debug('icons.py::get', 'app not found', id)
+
+        # Return a default new icon
+        return mkentry(car(id), car(id), user.get(()), now(), ())
+
+    # Get the requested icon
+    icon = cache.get(iconid(id))
+    if isNil(icon):
+        debug('icons.py::get', 'icon not found', id)
+
+        # Return a default new icon
+        return mkentry(title(app), car(id), author(app), now(), ())
+
+    # Get image, token, and updated date from icon
+    def image(c):
+        img = assoc("'image", c)
+        return None if isNil(img) else cadr(img)
+    def token(c):
+        tok = assoc("'token", c)
+        return None if isNil(tok) else cadr(tok)
+    img = image(content(icon))
+    tok = token(content(icon))
+
+    # Return the icon
+    iconc = (() if isNil(img) else (("'image", img),)) + (() if isNil(tok) or (user.get(()) != author(app) and user.get(()) != 'admin') else (("'token", tok),))
+    if isNil(iconc):
+        iconentry = mkentry(title(app), car(id), author(app), updated(icon), ())
+        debug('icons.py::get::iconentry', iconentry)
+        return iconentry
+
+    iconentry = mkentry(title(app), car(id), author(app), updated(icon), ("'icon",) + iconc)
+    debug('icons.py::get::iconentry', iconentry)
+    return iconentry
+
+# Delete an icon
+def delete(id, user, cache, apps):
+    debug('icons.py::delete::id', id)
+
+    # Get the requested app
+    app = apps.get(id)
+    if isNil(app):
+        debug('icons.py::delete', 'app not found', id)
+        return False
+
+    # Check app author
+    if author(app) != user.get(()):
+        debug('icons.py::delete', 'different author', author(app))
+        return False
+
+    # Delete the icon
+    return cache.delete(iconid(id))
+
diff --git a/hosting/server/imapd-start b/hosting/server/imapd-start
new file mode 100755
index 0000000..5e02b6a
--- /dev/null
+++ b/hosting/server/imapd-start
@@ -0,0 +1,71 @@
+#!/bin/sh
+
+#  Licensed to the Apache Software Foundation (ASF) under one
+#  or more contributor license agreements.  See the NOTICE file
+#  distributed with this work for additional information
+#  regarding copyright ownership.  The ASF licenses this file
+#  to you under the Apache License, Version 2.0 (the
+#  "License"); you may not use this file except in compliance
+#  with the License.  You may obtain a copy of the License at
+#  
+#    http://www.apache.org/licenses/LICENSE-2.0
+#    
+#  Unless required by applicable law or agreed to in writing,
+#  software distributed under the License is distributed on an
+#  "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+#  KIND, either express or implied.  See the License for the
+#  specific language governing permissions and limitations
+#  under the License.
+
+# Load media received from an IMAP inbox
+here=`echo "import os; print os.path.realpath('$0')" | python`; here=`dirname $here`
+mkdir -p $1
+root=`echo "import os; print os.path.realpath('$1')" | python`
+imaphost=$2
+imapport=$3
+imapuser=$4
+imappass=$5
+admin=$6
+if [ "$admin" = "" ]; then
+    admin=admin
+fi
+apass=$7
+if [ "$apass" = "" ]; then
+    apass=admin
+fi
+log=$8
+
+python_prefix=`cat $here/../../modules/python/python.prefix`
+
+# Get HTTP server conf
+conf=`cat $root/conf/httpd.conf | grep "# Generated by: httpd-conf"`
+sslconf=`cat $root/conf/httpd.conf | grep "# Generated by: httpd-ssl-conf"`
+if [ "$sslconf" = "" ]; then
+    scheme="http"
+    addr=`echo $conf | awk '{ print $7 }'`
+    host=`$here/../../modules/http/httpd-addr ip $addr`
+    if [ "$host" = "" ]; then
+        host="localhost"
+    fi
+    port=`$here/../../modules/http/httpd-addr port $addr`
+else
+    scheme="https"
+    ssladdr=`echo $sslconf | awk '{ print $6 }'`
+    host=`$here/../../modules/http/httpd-addr ip $ssladdr`
+    if [ "$host" = "" ]; then
+        host="localhost"
+    fi
+    port=`$here/../../modules/http/httpd-addr port $ssladdr`
+fi
+
+# Configure logging
+if [ "$log" = "" ]; then
+    mkdir -p $root/logs
+    log="cat >>$root/logs/imapd"
+fi
+mkdir -p $root/imapd
+echo $log > $root/imapd/logger
+
+# Run imapd script
+nohup /bin/sh -c "($python_prefix/bin/python $here/imapd.py imaps://$imaphost:$imapport/ $imapuser $imappass $scheme://$host:$port/r/Editor/ $admin $apass $root 2>&1 | sh $root/imapd/logger)" 1>/dev/null 2>/dev/null & 
+
diff --git a/hosting/server/imapd-stop b/hosting/server/imapd-stop
new file mode 100755
index 0000000..ef4b50f
--- /dev/null
+++ b/hosting/server/imapd-stop
@@ -0,0 +1,56 @@
+#!/bin/sh
+
+#  Licensed to the Apache Software Foundation (ASF) under one
+#  or more contributor license agreements.  See the NOTICE file
+#  distributed with this work for additional information
+#  regarding copyright ownership.  The ASF licenses this file
+#  to you under the Apache License, Version 2.0 (the
+#  "License"); you may not use this file except in compliance
+#  with the License.  You may obtain a copy of the License at
+#  
+#    http://www.apache.org/licenses/LICENSE-2.0
+#    
+#  Unless required by applicable law or agreed to in writing,
+#  software distributed under the License is distributed on an
+#  "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+#  KIND, either express or implied.  See the License for the
+#  specific language governing permissions and limitations
+#  under the License.
+
+# Stop imapd
+here=`echo "import os; print os.path.realpath('$0')" | python`; here=`dirname $here`
+root=`echo "import os; print os.path.realpath('$1')" | python`
+imaphost=$2
+imapport=$3
+
+python_prefix=`cat $here/../../modules/python/python.prefix`
+
+# Get HTTP server conf
+conf=`cat $root/conf/httpd.conf | grep "# Generated by: httpd-conf"`
+sslconf=`cat $root/conf/httpd.conf | grep "# Generated by: httpd-ssl-conf"`
+if [ "$sslconf" = "" ]; then
+    scheme="http"
+    addr=`echo $conf | awk '{ print $7 }'`
+    host=`$here/../../modules/http/httpd-addr ip $addr`
+    if [ "$host" = "" ]; then
+        host="localhost"
+    fi
+    port=`$here/../../modules/http/httpd-addr port $addr`
+else
+    scheme="https"
+    ssladdr=`echo $sslconf | awk '{ print $6 }'`
+    host=`$here/../../modules/http/httpd-addr ip $ssladdr`
+    if [ "$host" = "" ]; then
+        host="localhost"
+    fi
+    port=`$here/../../modules/http/httpd-addr port $ssladdr`
+fi
+
+# Kill imapd processes
+imapc="$python_prefix/bin/python $here/imapd.py imaps://$imaphost:$imapport/"
+editc="$scheme://$host:$port/r/Editor/"
+k=`ps -ef | grep -v grep | grep "${imapc} " | grep " $editc" | awk '{ print $2 }'`
+if [ "$k" != "" ]; then
+    kill $k
+fi
+
diff --git a/hosting/server/imapd.py b/hosting/server/imapd.py
new file mode 100644
index 0000000..a60e4e1
--- /dev/null
+++ b/hosting/server/imapd.py
@@ -0,0 +1,233 @@
+#  Licensed to the Apache Software Foundation (ASF) under one
+#  or more contributor license agreements.  See the NOTICE file
+#  distributed with this work for additional information
+#  regarding copyright ownership.  The ASF licenses this file
+#  to you under the Apache License, Version 2.0 (the
+#  "License"); you may not use this file except in compliance
+#  with the License.  You may obtain a copy of the License at
+#  
+#    http://www.apache.org/licenses/LICENSE-2.0
+#    
+#  Unless required by applicable law or agreed to in writing,
+#  software distributed under the License is distributed on an
+#  "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+#  KIND, either express or implied.  See the License for the
+#  specific language governing permissions and limitations
+#  under the License.
+
+# Load media received from an IMAP inbox
+from imaplib2 import IMAP4_SSL
+from threading import Thread, Event
+from email import message_from_string
+import re
+from StringIO import StringIO
+from PIL import Image
+from base64 import b64encode, b64decode
+from httplib import HTTPConnection, HTTPSConnection
+from urlparse import urlparse
+from util import *
+from sys import stderr, argv, exit
+from traceback import print_exc
+
+# Debug print
+def debug(*args):
+    if True:
+        print >> stderr, args
+
+# Fetch an email and return the image it contains
+def fetchmail(id, imap):
+
+    # Extract address from the To header
+    htyp, hdata = imap.fetch(id, '(BODY.PEEK[HEADER])')
+    header = message_from_string(hdata[0][1])
+    if header['To'] is None:
+        return (None, 'Couldn\'t retrieve email address')
+    b64aparts = re.findall('[\w=-_]+@[\w.]+', header['To'])
+    if len(b64aparts) == 0:
+        return (None, 'Couldn\'t parse email address')
+    b64address = (b64aparts[len(b64aparts) - 1]).split('@')[0]
+    try:
+        address = b64decode((b64address.replace('-', '+').replace('_', '/') + '===')[0: len(b64address) + (len(b64address) % 4)])
+    except:
+        return (None, 'Couldn\'t decode email address')
+
+    # Check if the address targets a picture (p/) or an icon (i/)
+    if address[0:2] != 'p/' and address[0:2] != 'i/':
+        return (None, 'Email address must start with p/ or i/')
+    debug('putimages.py::readimage::address', address)
+
+    # Extract image mime body part
+    btyp, bdata = imap.fetch(id, '(BODY.PEEK[TEXT])')
+    msg = message_from_string(hdata[0][1] + bdata[0][1])
+    debug('putimages.py::readimage::msg', msg)
+    parts = map(lambda p: p.get_payload(decode=True), filter(lambda p: p.get_content_type().startswith('image/'), msg.walk()))
+    if len(parts) == 0:
+        return (None, 'Email doesn\'t contain an image')
+
+    # Convert image to a 50x50 PNG image
+    img = Image.open(StringIO(parts[0]))
+    t = img.resize((50, 50))
+    obuf = StringIO()
+    t.save(obuf, 'PNG')
+    dataurl = 'data:image/png;base64,' + b64encode(obuf.getvalue()).replace('\n', '')
+
+    # Return address, image url pair
+    return (address, dataurl)
+
+def putimage(address, dataurl, httpurl, httpuser, httppass):
+
+    # Put image into the image database
+    id = address.split('/')[1]
+    token = address.split('/')[2]
+    entry = '<?xml version="1.0" encoding="UTF-8"?>\n' + \
+        '<entry xmlns="http://www.w3.org/2005/Atom">\n' + \
+        '<title type="text">' + id + '</title>\n' + \
+        '<id>' + id + '</id>\n' + \
+        '<content type="application/xml">\n' + \
+        ('<picture>\n' if address[0:2] == 'p/' else '<icon>\n') + \
+        '<image>' + dataurl + '</image>\n' + \
+        '<token>' + token + '</token>\n' + \
+        ('</picture>\n' if address[0:2] == 'p/' else '</icon>\n') + \
+        '</content>\n' + \
+        '</entry>'
+
+    url = urlparse(httpurl)
+    conn = HTTPSConnection(url.hostname, 443 if url.port is None else url.port) if url.scheme == 'https' else \
+            HTTPConnection(url.hostname, 80 if url.port is None else url.port)
+    #conn.set_debuglevel(9)
+    path = url.path + ('pictures/' if address[0:2] == 'p/' else 'icons/') + id
+    puturl = url.scheme + '//' + url.netloc + path
+    debug('imapd.py::putimage::url', puturl)
+    auth = b64encode("%s:%s" % (httpuser, httppass)).replace('\n', '')
+    headers = { 'Authorization' : 'Basic ' + auth, 'X-Forwarded-Server' : url.hostname, 'Content-type': 'application/atom+xml', 'Accept': '*/*' }
+    conn.request('PUT', path, entry, headers)
+    response = conn.getresponse()
+    if response.status != 200:
+        debug('imapd.py::putimage::error', response.status, response.reason)
+        return (None, 'Put error: ' + repr(response.status) + ' : ' + response.reason)
+    conn.close()
+
+    return (puturl, entry)
+
+# Read and process an email
+def processmail(id, imap, httpurl, httpuser, httppass):
+    if id == '':
+        return None
+
+    # Read email and any image in it
+    address, dataurl = fetchmail(id, imap)
+    if address is None:
+        # Mark email as seen if it doesn't contain an image
+        debug('imapd.py::processmail::seen', id)
+        imap.store(id, '+FLAGS', '\SEEN')
+        return None
+
+    # Put image into the database
+    put = putimage(address, dataurl, httpurl, httpuser, httppass)
+    if put[0] is None:
+        return None
+
+    # Mark email as seen if processed successfully
+    debug('imapd.py::processmail::seen', id)
+    imap.store(id, '+FLAGS', '\SEEN')
+    return put[0]
+    
+# IMAP idle thread
+def idle(imap, httpurl, httpuser, httppass, stop, stopped):
+    try:
+        sync = Event()
+        while True:
+            # Stop the thread
+            if stop.isSet():
+                debug('imapd.py::idle::stopped')
+                stopped.set()
+                return
+
+            # Wait for changes
+            def callback(args):
+                debug('imapd.py::idle::callback')
+                if not stop.isSet():
+                    sync.set()
+                    stop.set()
+
+            debug('imapd.py::idle::waiting')
+            imap.idle(callback = callback)
+            stop.wait()
+
+            # Handle email change event
+            if sync.isSet():
+                stop.clear()
+                sync.clear()
+                debug('imapd.py::idle::sync')
+
+                # List unseen emails
+                typ, data = imap.search(None, 'UNSEEN')
+                debug('imapd.py::idle::search', typ, data)
+
+                # Process unseen email
+                map(lambda id: processmail(id, imap, httpurl, httpuser, httppass), data[0].split(' '))
+
+    except Exception as e:
+        debug('imapd.py::idle::except', e)
+        print_exc()
+        stopped.set()
+        return
+
+# Main processing loop
+def main(imapurl, imapuser, imappass, httpurl, httpuser, httppass):
+    try:
+        # Connect and login
+        url = urlparse(imapurl)
+        imap = IMAP4_SSL(url.hostname, 993 if url.port is None else url.port)
+        imap.login(imapuser, imappass)
+        imap.select('Inbox')
+        debug('imapd.py::main::connected')
+
+        try:
+            # Start imap idle thread
+            stop = Event()
+            stopped = Event()
+            idling = Thread(target=idle, args=(imap, httpurl, httpuser, httppass, stop, stopped))
+            idling.start()
+
+            # List unseen emails
+            typ, data = imap.search(None, 'UNSEEN')
+            debug('imapd.py::main::search', typ, data)
+
+            # Process unseen emails
+            map(lambda id: processmail(id, imap, httpurl, httpuser, httppass), data[0].split(' '))
+
+            # Wait 60 seconds
+            debug('imapd.py::main::waiting')
+            try:
+                stopped.wait()
+            except KeyboardInterrupt:
+                pass
+
+            # Stop the thread
+            debug('imapd.py::main::stopping')
+            stop.set()
+            idling.join()
+
+            # Close and logout
+            debug('imapd.py::main::disconnecting')
+            imap.close()
+            imap.logout()
+            return 0
+
+        except Exception as e:
+            debug('imapd.py::except', e)
+            print_exc()
+            # Close and logout
+            imap.close()
+            imap.logout()
+            return 1
+
+    except Exception as e:
+        debug('imapd.py::except', e)
+        print_exc()
+        return 1
+
+if __name__ == '__main__':
+    exit(main(argv[1], argv[2], argv[3], argv[4], argv[5], argv[6]))
+
diff --git a/hosting/server/load-authn b/hosting/server/load-authn
new file mode 100755
index 0000000..fab6dc1
--- /dev/null
+++ b/hosting/server/load-authn
@@ -0,0 +1,58 @@
+#!/bin/sh
+
+#  Licensed to the Apache Software Foundation (ASF) under one
+#  or more contributor license agreements.  See the NOTICE file
+#  distributed with this work for additional information
+#  regarding copyright ownership.  The ASF licenses this file
+#  to you under the Apache License, Version 2.0 (the
+#  "License"); you may not use this file except in compliance
+#  with the License.  You may obtain a copy of the License at
+#  
+#    http://www.apache.org/licenses/LICENSE-2.0
+#    
+#  Unless required by applicable law or agreed to in writing,
+#  software distributed under the License is distributed on an
+#  "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+#  KIND, either express or implied.  See the License for the
+#  specific language governing permissions and limitations
+#  under the License.
+
+here=`echo "import os; print os.path.realpath('$0')" | python`; here=`dirname $here`
+cd $here
+var=$HOME/var
+httpd_prefix=`cat $here/../../modules/http/httpd.prefix`
+
+user=$1
+pass=$2
+
+tmp=$3
+if [ "$tmp" = "" ]; then
+    tmp="$here/tmp"
+fi
+host=$4
+if [ "$host" = "" ]; then
+    host="localhost"
+fi
+
+# Get password hash
+rm -f $tmp/sqldb/load-authn.passwd
+touch $tmp/sqldb/load-authn.passwd
+$httpd_prefix/bin/htpasswd -b $tmp/sqldb/load-authn.passwd "$user" "$pass" 2>/dev/null
+hash=`cat $tmp/sqldb/load-authn.passwd | awk -F ":" '{ print $2 }'`
+rm -f $tmp/sqldb/load-authn.passwd
+
+# Compute user id
+slash=`echo $user | grep "/"`
+if [ "$slash" = "" ]; then
+    id="\"$user\""
+else
+    id=`echo $user | awk -F "/" '{ printf "\"%s\" \"%s\"", $2, $3 }'`
+fi
+
+# Load into database
+cat >$tmp/sqldb/load-authn.sql <<EOF
+insert into data values('("authn" $id "user.authn")', '((entry (title "$user") (id "$user") (content (hash "$hash"))))');
+EOF
+
+$here/../../components/sqldb/pgsql <$tmp/sqldb/load-authn.sql
+
diff --git a/hosting/server/load-tables b/hosting/server/load-tables
new file mode 100755
index 0000000..a070ae1
--- /dev/null
+++ b/hosting/server/load-tables
@@ -0,0 +1,73 @@
+#!/bin/sh
+
+#  Licensed to the Apache Software Foundation (ASF) under one
+#  or more contributor license agreements.  See the NOTICE file
+#  distributed with this work for additional information
+#  regarding copyright ownership.  The ASF licenses this file
+#  to you under the Apache License, Version 2.0 (the
+#  "License"); you may not use this file except in compliance
+#  with the License.  You may obtain a copy of the License at
+#  
+#    http://www.apache.org/licenses/LICENSE-2.0
+#    
+#  Unless required by applicable law or agreed to in writing,
+#  software distributed under the License is distributed on an
+#  "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+#  KIND, either express or implied.  See the License for the
+#  specific language governing permissions and limitations
+#  under the License.
+
+here=`echo "import os; print os.path.realpath('$0')" | python`; here=`dirname $here`
+
+tmp=$1
+if [ "$tmp" = "" ]; then
+    tmp="$here/tmp"
+fi
+host=$2
+if [ "$host" = "" ]; then
+    host="localhost"
+fi
+
+# Populate tables
+rm -f $tmp/sqldb/load-tables.sql
+for key in `ls $here/data/accounts`; do
+    val=`cat $here/data/accounts/$key/user.account | sed "s/'/''/g"`
+    cat >>$tmp/sqldb/load-tables.sql <<EOF
+insert into data values('("accounts" "$key" "user.account")', '$val');
+EOF
+done
+
+for key in `ls $here/data/store`; do
+    val=`cat $here/data/store/$key/store.apps | sed "s/'/''/g"`
+    cat >>$tmp/sqldb/load-tables.sql <<EOF
+insert into data values('("store" "$key" "store.apps")', '$val');
+EOF
+done
+
+for key in `ls $here/data/dashboards`; do
+    val=`cat $here/data/dashboards/$key/user.apps | sed "s/'/''/g"`
+    cat >>$tmp/sqldb/load-tables.sql <<EOF
+insert into data values('("dashboards" "$key" "user.apps")', '$val');
+EOF
+done
+
+for key in `ls $here/data/apps`; do
+    sval=`cat $here/data/apps/$key/app.info | sed "s/'/''/g"`
+    cval=`cat $here/data/apps/$key/app.composite | $here/../../modules/scheme/xml-element | $here/../../modules/scheme/element-value | sed "s/'/''/g"`
+    hval=`cat $here/data/apps/$key/htdocs/app.html | $here/../../modules/scheme/xml-element | $here/../../modules/scheme/element-value | sed "s/'/''/g"`
+    cat >>$tmp/sqldb/load-tables.sql <<EOF
+insert into data values('("apps" "$key" "app.info")', '$sval');
+insert into data values('("apps" "$key" "app.composite")', '$cval');
+insert into data values('("apps" "$key" "htdocs" "app.html")', '$hval');
+EOF
+done
+
+for key in `ls $here/data/palettes`; do
+    val=`cat $here/data//palettes/$key/palette.composite | $here/../../modules/scheme/xml-element | $here/../../modules/scheme/element-value | sed "s/'/''/g"`
+    cat >>$tmp/sqldb/load-tables.sql <<EOF
+insert into data values('("palettes" "$key" "palette.composite")', '$val');
+EOF
+done
+
+$here/../../components/sqldb/pgsql $host 6432 <$tmp/sqldb/load-tables.sql
+
diff --git a/hosting/server/pages.py b/hosting/server/pages.py
index a4f6d05..d8c774c 100644
--- a/hosting/server/pages.py
+++ b/hosting/server/pages.py
@@ -16,8 +16,8 @@
 #  under the License.
 
 # App pages collection implementation
-from time import strftime
 from util import *
+from atomutil import *
 from sys import debug
 
 # Convert an id to a page id
@@ -30,19 +30,18 @@
     debug('pages.py::put::page', page)
 
     # Get the requested app
-    app = apps.get(id);
-    if isNil(app) or app is None:
+    app = apps.get(id)
+    if isNil(app):
         debug('pages.py::put', 'app not found', id)
         return False
 
     # Check app author
-    author = cadr(assoc("'author", car(app)))
-    if author != user.get(()):
-        debug('pages.py::put', 'different author', author)
+    if author(app) != user.get(()):
+        debug('pages.py::put', 'different author', author(app))
         return False
 
     # Update the page in the page db
-    pageentry = (("'entry", assoc("'title", car(app)), ("'id", car(id)), ("'author", user.get(())), ("'updated", strftime('%b %d, %Y')), assoc("'content", car(page))),)
+    pageentry = mkentry(title(app), car(id), user.get(()), now(), content(page))
     debug('pages.py::put::pageentry', pageentry)
     return cache.put(pageid(id), pageentry)
 
@@ -54,24 +53,24 @@
 
     # Get the requested app
     app = apps.get(id)
-    if isNil(app) or app is None:
+    if isNil(app):
         debug('pages.py::get', 'app not found', id)
 
         # Return a default new page
-        return (("'entry", ("'title", car(id)), ("'id", car(id)), ("'author", user.get(())), ("'updated", strftime('%b %d, %Y'))),)
+        return mkentry(car(id), car(id), user.get(()), now(), ())
 
     # Get the requested page
     page = cache.get(pageid(id))
-    if isNil(page) or page is None:
+    if isNil(page):
         debug('pages.py::get', 'page not found', id)
 
         # Return a default new page
-        return (("'entry", ("'title", car(id)), ("'id", car(id)), assoc("'author", car(app)), assoc("'updated", car(app))),)
+        return mkentry(title(app), car(id), author(app), now(), ())
 
     # Return the page
-    def updated(u):
-        return assoc("'updated", car(app)) if isNil(u) or u is None else u
-    pageentry = (("'entry", assoc("'title", car(app)), ("'id", car(id)), assoc("'author", car(app)), updated(assoc("'updated", car(page))), assoc("'content", car(page))),)
+    debug('pages.py::get::page', page)
+    debug('pages.py::get::page::content', content(page))
+    pageentry = mkentry(title(app), car(id), author(app), updated(page), content(page))
     debug('pages.py::get::pageentry', pageentry)
     return pageentry
 
@@ -80,15 +79,14 @@
     debug('pages.py::delete::id', id)
 
     # Get the requested app
-    app = apps.get(id);
-    if isNil(app) or app is None:
+    app = apps.get(id)
+    if isNil(app):
         debug('pages.py::delete', 'app not found', id)
         return False
 
     # Check app author
-    author = cadr(assoc("'author", car(app)))
-    if author != user.get(()):
-        debug('pages.py::delete', 'different author', author)
+    if author(app) != user.get(()):
+        debug('pages.py::delete', 'different author', author(app))
         return False
 
     # Delete the page
diff --git a/hosting/server/palettes.py b/hosting/server/palettes.py
index 321db3c..38e0128 100644
--- a/hosting/server/palettes.py
+++ b/hosting/server/palettes.py
@@ -32,5 +32,5 @@
 def get(id, cache):
     if isNil(id):
         return (("'feed", ("'title", "Palettes"), ("'id", "palettes")),)
-    return (("'entry", ("'title", car(id)), ("'id", car(id)), ("'content", car(cache.get(paletteid(id))))),)
+    return mkentry(car(id), car(id), None, now(), car(cache.get(paletteid(id))))
 
diff --git a/hosting/server/pgsql b/hosting/server/pgsql
new file mode 100755
index 0000000..69ab04a
--- /dev/null
+++ b/hosting/server/pgsql
@@ -0,0 +1,32 @@
+#!/bin/sh
+
+#  Licensed to the Apache Software Foundation (ASF) under one
+#  or more contributor license agreements.  See the NOTICE file
+#  distributed with this work for additional information
+#  regarding copyright ownership.  The ASF licenses this file
+#  to you under the Apache License, Version 2.0 (the
+#  "License"); you may not use this file except in compliance
+#  with the License.  You may obtain a copy of the License at
+#  
+#    http://www.apache.org/licenses/LICENSE-2.0
+#    
+#  Unless required by applicable law or agreed to in writing,
+#  software distributed under the License is distributed on an
+#  "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+#  KIND, either express or implied.  See the License for the
+#  specific language governing permissions and limitations
+#  under the License.
+
+here=`echo "import os; print os.path.realpath('$0')" | python`; here=`dirname $here`
+
+q="$2"
+if [ "$q" = "" ]; then
+    host="localhost"
+    q="$1"
+else
+    host="$1"
+fi
+
+# Start pgsql command
+$here/../../components/sqldb/pgsql $host 6432 "$q"
+
diff --git a/hosting/server/pictures.py b/hosting/server/pictures.py
new file mode 100644
index 0000000..fab7df4
--- /dev/null
+++ b/hosting/server/pictures.py
@@ -0,0 +1,141 @@
+#  Licensed to the Apache Software Foundation (ASF) under one
+#  or more contributor license agreements.  See the NOTICE file
+#  distributed with this work for additional information
+#  regarding copyright ownership.  The ASF licenses this file
+#  to you under the Apache License, Version 2.0 (the
+#  "License"); you may not use this file except in compliance
+#  with the License.  You may obtain a copy of the License at
+#  
+#    http://www.apache.org/licenses/LICENSE-2.0
+#    
+#  Unless required by applicable law or agreed to in writing,
+#  software distributed under the License is distributed on an
+#  "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+#  KIND, either express or implied.  See the License for the
+#  specific language governing permissions and limitations
+#  under the License.
+
+# Pictures collection implementation
+from StringIO import StringIO
+try:
+    from PIL import Image
+except:
+    Image = None
+from base64 import b64encode, b64decode
+from urllib import urlopen
+from util import *
+from atomutil import *
+from sys import debug
+
+# Convert a particular user id to a picture id
+def pictureid(id):
+    return ('accounts', id, 'user.picture')
+
+# Convert image to a 50x50 PNG image
+def to50x50png(url):
+    debug('pictures.py::to50x50png::url', url)
+    if Image is None:
+        return url
+    img = Image.open(StringIO(b64decode(url.split(',')[1])) if url.startswith('data:') else StringIO(urlopen(url).read()))
+    t = img.resize((50, 50))
+    obuf = StringIO()
+    t.save(obuf, 'PNG')
+    return 'data:image/png;base64,' + b64encode(obuf.getvalue()).replace('\n', '')
+
+# Update the user's picture
+def put(id, picture, user, cache):
+    debug('pictures.py::put::id', id)
+    debug('pictures.py::put::picture', picture)
+
+    picid = user.get(()) if isNil(id) else car(id)
+
+    # Only the admin can update other user's pictures
+    if picid != user.get(()) and user.get(()) != 'admin':
+        debug('pictures.py::put', 'not owner or admin', user.get(()))
+        return False
+
+    # Get image and token from input picture
+    def image(c):
+        img = assoc("'image", c)
+        return None if isNil(img) else to50x50png(cadr(img))
+    def token(c):
+        tok = assoc("'token", c)
+        return None if isNil(tok) else cadr(tok)
+    img = image(content(picture))
+    tok = token(content(picture))
+
+    # Update the picture
+    # Put with an upload token
+    if not isNil(tok):
+        debug('pictures.py::put::token', tok)
+
+        # Token alone, store token with existing image, if any
+        if isNil(img):
+            epicture = cache.get(pictureid(picid))
+            eimg = None if isNil(epicture) else image(content(epicture))
+            if isNil(eimg):
+                picentry = mkentry(title(picture), picid, picid, now(), ("'picture", ("'token", tok)))
+                debug('pictures.py::put::picentry', picentry)
+                return cache.put(pictureid(picid), picentry)
+
+            debug('pictures.py::put::eimg', eimg)
+            picentry = mkentry(title(picture), picid, picid, now(), ("'picture", ("'image", eimg), ("'token", tok)))
+            debug('pictures.py::put::picentry', picentry)
+            return cache.put(pictureid(picid), picentry)
+
+        # Token plus image, put image if token is valid, removing the token
+        debug('pictures.py::put::img', img)
+        epicture = cache.get(pictureid(picid))
+        etok = None if isNil(epicture) else token(content(epicture))
+        debug('pictures.py::put::etok', etok)
+        if isNil(etok) or tok != etok:
+            debug('pictures.py::put', 'invalid token', tok)
+            return False
+
+        picentry = mkentry(title(picture), picid, picid, now(), ("'picture", ("'image", img)))
+        debug('pictures.py::put::picentry', picentry)
+        return cache.put(pictureid(picid), picentry)
+
+    # Update picture image
+    if not isNil(img):
+        debug('pictures.py::put::img', img)
+        picentry = mkentry(title(picture), picid, picid, now(), ("'picture", ("'image", img)))
+        debug('pictures.py::put::picentry', picentry)
+        return cache.put(pictureid(picid), picentry)
+
+    # Put default empty picture
+    picentry = mkentry(title(picture), picid, picid, now(), ())
+    debug('pictures.py::put::picentry', picentry)
+    return cache.put(pictureid(picid), picentry)
+
+# Get a user's picture
+def get(id, user, cache):
+    debug('pictures.py::get::id', id)
+
+    # Get the requested picture
+    picid = user.get(()) if isNil(id) else car(id)
+    picture = cache.get(pictureid(picid))
+    if isNil(picture):
+        return mkentry(picid, picid, picid, now(), ())
+
+    # Get image and token from picture
+    def image(c):
+        img = assoc("'image", c)
+        return None if isNil(img) else cadr(img)
+    def token(c):
+        tok = assoc("'token", c)
+        return None if isNil(tok) else cadr(tok)
+    img = image(content(picture))
+    tok = token(content(picture))
+
+    # Return the picture
+    picc = (() if isNil(img) else (("'image", img),)) + (() if isNil(tok) or (user.get(()) != author(picture) and user.get(()) != 'admin') else (("'token", tok),))
+    if isNil(picc):
+        picentry = mkentry(title(picture), picid, author(picture), updated(picture), ())
+        debug('pictures.py::get::picentry', picentry)
+        return picentry
+
+    picentry = mkentry(title(picture), picid, author(picture), updated(picture), ("'picture",) + picc)
+    debug('pictures.py::get::picentry', picentry)
+    return picentry
+
diff --git a/hosting/server/proxy-start b/hosting/server/proxy-start
index f06de9f..6ec5576 100755
--- a/hosting/server/proxy-start
+++ b/hosting/server/proxy-start
@@ -35,6 +35,17 @@
 ../../components/cache/memcached-start tmp 11211
 ../../components/cache/memcached-start tmp 11212
 
+# Configure database
+../../components/sqldb/pgsql-conf tmp
+
+# Start database
+../../components/sqldb/pgsql-start tmp
+
+# Load database tables
+./drop-tables 1>/dev/null 2>/dev/null
+./create-tables >/dev/null
+./load-tables >/dev/null
+
 # Configure server
 ../../modules/http/httpd-conf tmp www.example.com 9090 htdocs
 ../../modules/http/httpd-event-conf tmp
diff --git a/hosting/server/ratings.py b/hosting/server/ratings.py
new file mode 100644
index 0000000..5638d86
--- /dev/null
+++ b/hosting/server/ratings.py
@@ -0,0 +1,158 @@
+#  Licensed to the Apache Software Foundation (ASF) under one
+#  or more contributor license agreements.  See the NOTICE file
+#  distributed with this work for additional information
+#  regarding copyright ownership.  The ASF licenses this file
+#  to you under the Apache License, Version 2.0 (the
+#  "License"); you may not use this file except in compliance
+#  with the License.  You may obtain a copy of the License at
+#  
+#    http://www.apache.org/licenses/LICENSE-2.0
+#    
+#  Unless required by applicable law or agreed to in writing,
+#  software distributed under the License is distributed on an
+#  "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+#  KIND, either express or implied.  See the License for the
+#  specific language governing permissions and limitations
+#  under the License.
+
+# App ratings collection implementation
+from util import *
+from atomutil import *
+from sys import debug
+
+# Convert an app id to an app ratings id
+def ratingsid(id):
+    return ("ratings", car(id), "app.ratings")
+
+# Put an app ratings into the ratings db
+def put(id, ratings, user, cache, db, apps):
+    debug('ratings.py::put::id', id)
+    debug('ratings.py::put::ratings', ratings)
+
+    # Get the requested app
+    app = apps.get(id)
+    if isNil(app):
+        debug('ratings.py::put', 'app not found', id)
+        return False
+
+    # Check user
+    if user.get(()) != 'admin':
+        debug('ratings.py::put', 'not admin', user.get(()))
+        return False
+
+    # Update app ratings in the ratings db
+    ratingsentry = mkentry(title(app), car(id), author(app), now(), content(ratings))
+    debug('ratings.py::put::ratingsentry', ratingsentry)
+    return cache.put(ratingsid(id), ratingsentry)
+
+# Patch an app ratings in the ratings db
+def patch(id, ratings, user, cache, db, apps):
+    debug('ratings.py::patch::id', id)
+    debug('ratings.py::patch::ratings', ratings)
+
+    # Get the requested app
+    app = apps.get(id)
+    if isNil(app):
+        debug('ratings.py::patch', 'app not found', id)
+        return False
+
+    # Get old and new ratings
+    orating = cadr(assoc("'old", content(ratings)))
+    nrating = cadr(assoc("'new", content(ratings)))
+
+    # Configure patch script
+    script = """
+        (define author "{0}")
+        (define updated "{1}")
+        (define orating {2})
+        (define nrating {3})
+
+        (define (patch id e)
+            (define (rating x e)
+                (define a (tree-select-assoc (list x) e))
+                (if (null? a) (list x "0") (car a))
+            )
+
+            (define cratings (list (rating 'rating1 e) (rating 'rating2 e) (rating 'rating3 e) (rating 'rating4 e)))
+
+            (define (calcrating v i)
+                (define nv (+ v (if (= i orating) (- 1) (if (= i nrating) 1 0))))
+                (if (< nv 0) 0 nv)
+            )
+
+            (define (calcratings r i)
+                (if (null? r)
+                    r
+                    (cons (list (car (car r)) (calcrating (cadr (car r)) i)) (calcratings (cdr r) (+ 1 i)))
+                )
+            )
+
+            (define nratings (calcratings cratings 1))
+
+            (define neg (+ 1 (+ (* (cadr (assoc 'rating1 nratings)) 2) (cadr (assoc 'rating2 nratings)))))
+            (define pos (+ 1 (+ (* (cadr (assoc 'rating4 nratings)) 2) (cadr (assoc 'rating3 nratings)))))
+            (define arating (* 4 (/ (- (/ (+ pos 1.9208) (+ pos neg)) (/ (* 1.96 (sqrt (+ (/ (* pos neg) (+ pos neg)) 0.9604))) (+ pos neg))) (+ 1 (/ 3.8416 (+ pos neg))))))
+
+            (list (list 'entry (list 'title (cadr id)) (list 'id (cadr id)) (list 'author author) (list 'updated updated) (list 'content (cons 'ratings (cons (list 'rating arating) nratings)))))
+        )
+    """.format(author(app), now(), orating, nrating)
+
+    # Update app ratings in the ratings db
+    ratingsentry = mkentry(title(app), car(id), author(app), now(), ("'patch", script))
+    debug('ratings.py::put::ratingsentry', ratingsentry)
+    return cache.patch(ratingsid(id), ratingsentry)
+
+# Get app ratings from the ratings db
+def get(id, user, cache, db, apps):
+    debug('ratings.py::get::id', id)
+
+    # Return the top ratings
+    if isNil(id):
+        topentries = db.get((("'regex", '("ratings" .* "app.ratings")'), ("'rank", "(regexp_matches(value, '(.*\(rating )([^\)]+)(\).*)'))[2]::float"), ("'limit", 25)))
+        flatentries = tuple(map(lambda v: car(v), () if isNil(topentries) else topentries))
+        def rating(e):
+            return cadr(assoc("'rating", assoc("'ratings", assoc("'content", e))))
+        sortedentries = tuple(sorted(flatentries, key = rating, reverse = True))
+        topratings = ((("'feed", ("'title", "Ratings"), ("'id", 'ratings')) + sortedentries),)
+        debug('ratings.py::get::topratings', topratings)
+        return topratings
+
+    # Get the requested app
+    app = apps.get(id)
+    if isNil(app):
+        debug('ratings.py::get', 'app not found', id)
+
+        # Return default ratings
+        return mkentry(car(id), car(id), user.get(()), now(), ())
+
+    # Get the requested ratings
+    ratings = cache.get(ratingsid(id))
+    if isNil(ratings):
+        debug('ratings.py::get', 'ratings not found', id)
+
+        # Return default ratings
+        return mkentry(title(app), car(id), author(app), now(), ())
+
+    # Return the ratings
+    ratingsentry = mkentry(title(app), car(id), author(app), updated(ratings), content(ratings))
+    debug('ratings.py::get::ratings', ratingsentry)
+    return ratingsentry
+
+# Delete an app ratings from the ratings db
+def delete(id, user, cache, db, apps):
+    debug('ratings.py::delete::id', id)
+
+    # Get the requested app
+    app = apps.get(id)
+    if isNil(app):
+        debug('ratings.py::delete', 'app not found', id)
+        return False
+
+    # Check user
+    if user.get(()) != 'admin':
+        debug('ratings.py::delete', 'not admin', user.get(()))
+        return False
+
+    # Delete the composite
+    return cache.delete(ratingsid(id))
+
diff --git a/hosting/server/reviews.py b/hosting/server/reviews.py
new file mode 100644
index 0000000..3c175d8
--- /dev/null
+++ b/hosting/server/reviews.py
@@ -0,0 +1,156 @@
+#  Licensed to the Apache Software Foundation (ASF) under one
+#  or more contributor license agreements.  See the NOTICE file
+#  distributed with this work for additional information
+#  regarding copyright ownership.  The ASF licenses this file
+#  to you under the Apache License, Version 2.0 (the
+#  "License"); you may not use this file except in compliance
+#  with the License.  You may obtain a copy of the License at
+#  
+#    http://www.apache.org/licenses/LICENSE-2.0
+#    
+#  Unless required by applicable law or agreed to in writing,
+#  software distributed under the License is distributed on an
+#  "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+#  KIND, either express or implied.  See the License for the
+#  specific language governing permissions and limitations
+#  under the License.
+
+# App reviews collection implementation
+from util import *
+from atomutil import *
+from sys import debug
+
+# Convert a particular user id to a reviews id
+def reviewsid(user):
+    return ("reviews", user.get(()), "user.reviews")
+
+# Get user reviews from the cache
+def getreviews(id, cache):
+    debug('reviews.py::getreviews::id', id)
+    val = cache.get(id)
+    if isNil(val):
+        return ()
+    reviews = cdddr(car(val))
+    if not isNil(reviews) and isList(car(cadr(car(reviews)))):
+        # Expand list of entries
+        ereviews = tuple(map(lambda e: cons("'entry", e), cadr(car(reviews))))
+        debug('reviews.py::getreviews::ereviews', ereviews)
+        return ereviews
+
+    debug('reviews.py::getreviews::reviews', reviews)
+    return reviews
+
+# Get a review from a user's reviews
+def getreview(id, reviews):
+    if isNil(reviews):
+        return None
+    if car(id) == entryid(reviews):
+        return (car(reviews),)
+    return getreview(id, cdr(reviews))
+
+# Get reviews from the user's reviews
+def get(id, user, cache, apps, ratings):
+    debug('reviews.py::get::id', id)
+    if isNil(id):
+        reviews = ((("'feed", ("'title", "Your Reviews"), ("'id", user.get(()))) + getreviews(reviewsid(user), cache)),)
+        debug('reviews.py::get::reviews', reviews)
+        return reviews
+
+    # Get the requested app
+    app = apps.get(id)
+    if isNil(app):
+        debug('reviews.py::get', 'app not found', id)
+        return False
+
+    # Get the review
+    review = getreview(id, getreviews(reviewsid(user), cache))
+    if isNil(review):
+        debug('reviews.py::get', 'review not found', id)
+
+        # Return a default empty review
+        return mkentry(car(id), car(id), user.get(()), now(), ())
+        
+    debug('reviews.py::get::review', review)
+    return review
+
+# Patch an app ratings
+def patchratings(id, user, ratings, oreview, nreview):
+    patch = ("'patch", ("'old", "0" if isNil(oreview) else cadr(content(oreview))), ("'new", "0" if isNil(nreview) else cadr(content(nreview))))
+    patchentry = mkentry(car(id), car(id), user.get(()), now(), patch);
+    debug('reviews.py::patchratings::patchentry', patchentry)
+    return ratings.patch(id, patchentry)
+
+# Put reviews into the cache
+def putreviews(id, reviews, cache):
+    debug('reviews.py::putreviews::id', id)
+    debug('reviews.py::putreviews::reviews', reviews)
+    val = ((("'feed", ("'title", "Your Reviews"), ("'id", cadr(id))) + reviews),)
+    return cache.put(id, val)
+
+# Put a review into a user's reviews
+def putreview(id, review, reviews):
+    if isNil(reviews):
+        return review
+    if car(id) == entryid(reviews):
+        return cons(car(review), cdr(reviews))
+    return cons(car(reviews), putreview(id, review, cdr(reviews)))
+
+# Put a review into the user's reviews
+def put(id, review, user, cache, apps, ratings):
+    debug('reviews.py::put::id', id)
+    debug('reviews.py::put::review', review)
+
+    # Get the requested app
+    app = apps.get(id)
+    if isNil(app):
+        debug('reviews.py::put', 'app not found', id)
+        return False
+
+    reviewentry = mkentry(title(review), car(id), user.get(()), now(), content(review))
+    debug('reviews.py::put::reviewentry', reviewentry)
+
+    # Get old review
+    reviews = getreviews(reviewsid(user), cache)
+    oreview = getreview(id, reviews)
+
+    # Update the user's reviews record
+    nreviews = putreview(id, reviewentry, reviews)
+    putreviews(reviewsid(user), nreviews, cache)
+
+    # Update the app's ratings
+    return patchratings(id, user, ratings, oreview, review)
+
+# Delete a review from a reviews record
+def deletereview(id, reviews):
+    if isNil(reviews):
+        return ()
+    if car(id) == entryid(reviews):
+        return cdr(reviews)
+    return cons(car(reviews), deletereview(id, cdr(reviews)))
+
+# Delete reviews from the user's reviews record
+def delete(id, user, cache, apps, ratings):
+    debug('reviews.py::delete::id', id)
+    if isNil(id):
+        return cache.delete(reviewsid(user))
+
+    # Get the requested app
+    app = apps.get(id)
+    if isNil(app):
+        debug('reviews.py::delete', 'app not found', id)
+        return False
+
+    # Get the review
+    reviews = getreviews(reviewsid(user), cache)
+    review = getreview(id, reviews)
+    if isNil(review):
+        debug('reviews.py::delete', 'review not found', id)
+        return False
+
+    # Update the user's reviews record
+    nreviews = deletereview(id, reviews)
+    putreviews(reviewsid(user), nreviews, cache)
+
+    # Update the app's ratings
+    return patchratings(id, user, ratings, review, None)
+
diff --git a/hosting/server/search.py b/hosting/server/search.py
new file mode 100644
index 0000000..b1a7d36
--- /dev/null
+++ b/hosting/server/search.py
@@ -0,0 +1,57 @@
+#  Licensed to the Apache Software Foundation (ASF) under one
+#  or more contributor license agreements.  See the NOTICE file
+#  distributed with this work for additional information
+#  regarding copyright ownership.  The ASF licenses this file
+#  to you under the Apache License, Version 2.0 (the
+#  "License"); you may not use this file except in compliance
+#  with the License.  You may obtain a copy of the License at
+#  
+#    http://www.apache.org/licenses/LICENSE-2.0
+#    
+#  Unless required by applicable law or agreed to in writing,
+#  software distributed under the License is distributed on an
+#  "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+#  KIND, either express or implied.  See the License for the
+#  specific language governing permissions and limitations
+#  under the License.
+
+# Search implementation
+from util import *
+from atomutil import *
+from sys import debug
+
+# Merge ratings into a list of apps
+def mergeratings(entries, ratings):
+    debug('search.py::mergeratings::entries', entries)
+
+    def mergerating(app):
+        debug('search.py::mergerating::app', app)
+        id = (entryid(app),)
+        info = content(app)
+        rating = ratings.get(id)
+        rates = content(rating)
+        mergedentry = mkentry(title(app), car(id), author(app), updated(app), ("'info",) + (() if isNil(info) else cdr(info)) + (() if isNil(rates) else cdr(rates)))
+        return mergedentry
+
+    mergedentries = tuple(filter(lambda e: not isNil(e), map(lambda e: car(mergerating((e,))), entries)))
+    debug('search.py::mergeratings::mergedentries', mergedentries)
+    return mergedentries
+
+# Search apps
+def get(id, user, cache, db, apps, ratings):
+    debug('search.py::get::id', id)
+    q = assoc("'q", id)
+    if isNil(q):
+        return None
+
+    # Run the search
+    foundentries = db.get((("'regex", '("apps" .* "app.info")'), ("'textsearch", cadr(q)), ("'limit", 25)))
+    debug('search.py::get::foundentries', foundentries)
+
+    # Merge app ratings
+    appentries = mergeratings(tuple(map(lambda v: car(v), () if isNil(foundentries) else foundentries)), ratings)
+
+    results = ((("'feed", ("'title", "Search Results"), ("'id", 'search')) + appentries),)
+    debug('search.py::get::results', results)
+    return results
+
diff --git a/hosting/server/selector.py b/hosting/server/selector.py
index 7fcdd65..33cd951 100644
--- a/hosting/server/selector.py
+++ b/hosting/server/selector.py
@@ -21,8 +21,10 @@
 # Get the database to use for a particular key
 def get(id, db):
     if isNil(id):
-        return db[0]
+        return db
+    if not isNil(filter(lambda i: isList(i) and not isNil(i) and car(i) == "'limit", id)):
+        return db
     if cadr(id)[0:1].lower() < 'm':
-        return db[0]
-    return db[1]
+        return (db[0],)
+    return (db[1],)
 
diff --git a/hosting/server/server.composite b/hosting/server/server.composite
index 7e4d719..b3b95fb 100644
--- a/hosting/server/server.composite
+++ b/hosting/server/server.composite
@@ -37,12 +37,17 @@
         <implementation.widget location="/index.html"/>
         <reference name="user" target="User"/>
         <reference name="accounts" target="Accounts"/>
+        <reference name="pictures" target="Pictures"/>
         <reference name="dashboards" target="Dashboards"/>
         <reference name="apps" target="Apps"/>
         <reference name="store" target="AppStore"/>
+        <reference name="search" target="Search"/>
         <reference name="palettes" target="Palettes"/>
+        <reference name="icons" target="Icons"/>
         <reference name="composites" target="Composites"/>
         <reference name="pages" target="Pages"/>
+        <reference name="reviews" target="Reviews"/>
+        <reference name="ratings" target="Ratings"/>
         <reference name="log" target="Log"/>
     </component>
     
@@ -60,6 +65,12 @@
         <reference name="cache" target="Cache"/>
     </component>
     
+    <component name="Pictures">
+        <implementation.python script="pictures.py"/>
+        <reference name="user" target="User"/>
+        <reference name="cache" target="Cache"/>
+    </component>
+    
     <component name="Authenticator">
         <implementation.python script="authn.py"/>
         <reference name="cache" target="Cache"/>
@@ -70,6 +81,7 @@
         <reference name="user" target="User"/>
         <reference name="cache" target="Cache"/>
         <reference name="apps" target="Apps"/>
+        <reference name="ratings" target="Ratings"/>
     </component>
     
     <component name="AppStore">
@@ -77,6 +89,16 @@
         <reference name="user" target="User"/>
         <reference name="cache" target="Cache"/>
         <reference name="apps" target="Apps"/>
+        <reference name="ratings" target="Ratings"/>
+    </component>
+    
+    <component name="Search">
+        <implementation.python script="search.py"/>
+        <reference name="user" target="User"/>
+        <reference name="cache" target="Cache"/>
+        <reference name="db" target="Database"/>
+        <reference name="apps" target="Apps"/>
+        <reference name="ratings" target="Ratings"/>
     </component>
     
     <component name="Apps">
@@ -87,25 +109,49 @@
         <reference name="store" target="AppStore"/>
         <reference name="composites" target="Composites"/>
         <reference name="pages" target="Pages"/>
+        <reference name="icons" target="Icons"/>
     </component>
     
     <component name="Composites">
         <implementation.python script="composites.py"/>
         <reference name="user" target="User"/>
-        <reference name="cache" target="Doccache"/>
+        <reference name="cache" target="Cache"/>
         <reference name="apps" target="Apps"/>
     </component>
     
     <component name="Pages">
         <implementation.python script="pages.py"/>
         <reference name="user" target="User"/>
-        <reference name="cache" target="Doccache"/>
+        <reference name="cache" target="Cache"/>
+        <reference name="apps" target="Apps"/>
+    </component>
+    
+    <component name="Icons">
+        <implementation.python script="icons.py"/>
+        <reference name="user" target="User"/>
+        <reference name="cache" target="Cache"/>
+        <reference name="apps" target="Apps"/>
+    </component>
+    
+    <component name="Reviews">
+        <implementation.python script="reviews.py"/>
+        <reference name="user" target="User"/>
+        <reference name="cache" target="Cache"/>
+        <reference name="apps" target="Apps"/>
+        <reference name="ratings" target="Ratings"/>
+    </component>
+    
+    <component name="Ratings">
+        <implementation.python script="ratings.py"/>
+        <reference name="user" target="User"/>
+        <reference name="cache" target="Cache"/>
+        <reference name="db" target="Database"/>
         <reference name="apps" target="Apps"/>
     </component>
     
     <component name="Palettes">
         <implementation.python script="palettes.py"/>
-        <reference name="cache" target="Doccache"/>
+        <reference name="cache" target="Cache"/>
     </component>
     
     <component name="Cache">
@@ -116,14 +162,6 @@
         <reference name="l2writer" target="Database"/>
     </component>
 
-    <component name="Doccache">
-        <implementation.cpp path="../../components/cache" library="libdatacache"/>
-        <reference name="l1reader" target="Memcache"/>
-        <reference name="l1writer" target="Memcache"/>
-        <reference name="l2reader" target="Documents"/>
-        <reference name="l2writer" target="Documents"/>
-    </component>
-
     <component name="Memcache">
         <implementation.cpp path="../../components/cache" library="libmemcache"/>
         <property name="server">localhost:11211</property>
@@ -131,16 +169,11 @@
     </component>
 
     <component name="Database">
-        <implementation.cpp path="../../components/filedb" library="libfiledb"/>
-        <property name="dbname">data</property>
-        <property name="format">scheme</property>
+        <implementation.cpp path="../../components/sqldb" library="libsqldb"/>
+        <property name="conninfo">host=localhost port=6432 dbname=db</property>
+        <property name="table">data</property>
     </component>
 
-    <component name="Documents">
-        <implementation.cpp path="../../components/filedb" library="libfiledb"/>
-        <property name="dbname">data</property>
-        <property name="format">xml</property>
-    </component>
 
     <component name="Log">
         <implementation.python script="log.py"/>
diff --git a/hosting/server/ssl-proxy-start b/hosting/server/ssl-proxy-start
index e38f540..9678ed9 100755
--- a/hosting/server/ssl-proxy-start
+++ b/hosting/server/ssl-proxy-start
@@ -35,6 +35,17 @@
 ../../components/cache/memcached-start tmp 11211
 ../../components/cache/memcached-start tmp 11212
 
+# Configure database
+../../components/sqldb/pgsql-conf tmp
+
+# Start database
+../../components/sqldb/pgsql-start tmp
+
+# Load database tables
+./drop-tables 1>/dev/null 2>/dev/null
+./create-tables >/dev/null
+./load-tables >/dev/null
+
 # Configure server
 ../../modules/http/httpd-conf tmp www.example.com 9090 htdocs
 ../../modules/http/httpd-event-conf tmp
@@ -217,7 +228,7 @@
 
 #    cat >tmp/proxy/conf/mod-security-audit-log.conf <<EOF
 ## Generated by: start $*
-#SecAuditLog "|$here/../../components/log/scribe-cat $host secaudit secaudit"
+#SecAuditLog "|$here/../../components/log/scribe-cat localhost secaudit secaudit"
 #
 #EOF
 
diff --git a/hosting/server/ssl-start b/hosting/server/ssl-start
index d699089..a3bde7f 100755
--- a/hosting/server/ssl-start
+++ b/hosting/server/ssl-start
@@ -22,7 +22,6 @@
 # 127.0.0.1 www.example.com
 
 here=`echo "import os; print os.path.realpath('$0')" | python`; here=`dirname $here`
-host=`hostname`
 
 # Create SSL certificates
 ../../modules/http/ssl-ca-conf tmp www.example.com
@@ -40,6 +39,17 @@
 ../../components/cache/memcached-start tmp 11211
 ../../components/cache/memcached-start tmp 11212
 
+# Configure database
+../../components/sqldb/pgsql-conf tmp
+
+# Start database
+../../components/sqldb/pgsql-start tmp
+
+# Load database tables
+./drop-tables 1>/dev/null 2>/dev/null
+./create-tables >/dev/null
+./load-tables >/dev/null
+
 # Clear document cache
 rm -rf tmp/cache
 
@@ -79,20 +89,20 @@
 if [ -x ../../components/log/scribe-cat ]; then
     cat >tmp/conf/log.conf <<EOF
 # Generated by: ssl-start $*
-ErrorLog "|$here/../../components/log/scribe-cat $host server"
-CustomLog "|$here/../../components/log/scribe-cat $host server" combined
+ErrorLog "|$here/../../components/log/scribe-cat localhost server"
+CustomLog "|$here/../../components/log/scribe-cat localhost server" combined
 
 EOF
 
     cat >tmp/conf/log-ssl.conf <<EOF
 # Generated by: ssl-start $*
-CustomLog "|$here/../../components/log/scribe-cat $host server" sslcombined
+CustomLog "|$here/../../components/log/scribe-cat localhost server" sslcombined
 
 EOF
 
     cat >tmp/conf/mod-security-log.conf <<EOF
 # Generated by: ssl-start $*
-SecAuditLog "|$here/../../components/log/scribe-cat $host secaudit"
+SecAuditLog "|$here/../../components/log/scribe-cat localhost secaudit"
 
 EOF
 
diff --git a/hosting/server/start b/hosting/server/start
index d4443cb..9598634 100755
--- a/hosting/server/start
+++ b/hosting/server/start
@@ -22,7 +22,6 @@
 # 127.0.0.1 www.example.com
 
 here=`echo "import os; print os.path.realpath('$0')" | python`; here=`dirname $here`
-host=`hostname`
 
 # Configure and start logging
 if [ -x ../../components/log/scribe-cat ]; then
@@ -36,6 +35,17 @@
 ../../components/cache/memcached-start tmp 11211
 ../../components/cache/memcached-start tmp 11212
 
+# Configure database
+../../components/sqldb/pgsql-conf tmp
+
+# Start database
+../../components/sqldb/pgsql-start tmp
+
+# Load database tables
+./drop-tables 1>/dev/null 2>/dev/null
+./create-tables >/dev/null
+./load-tables >/dev/null
+
 # Clear document cache
 rm -rf tmp/cache
 
@@ -55,8 +65,8 @@
     cat >tmp/conf/log.conf <<EOF
 # Generated by: start $*
 LogLevel notice
-ErrorLog "|$here/../../components/log/scribe-cat $host server"
-CustomLog "|$here/../../components/log/scribe-cat $host server" combined
+ErrorLog "|$here/../../components/log/scribe-cat localhost server"
+CustomLog "|$here/../../components/log/scribe-cat localhost server" combined
 
 EOF
 
diff --git a/hosting/server/stop b/hosting/server/stop
index 82ecd10..eca57cd 100755
--- a/hosting/server/stop
+++ b/hosting/server/stop
@@ -23,6 +23,8 @@
 ../../components/cache/memcached-stop tmp 11211
 ../../components/cache/memcached-stop tmp 11212
 
+../../components/sqldb/pgsql-stop tmp
+
 if [ -x ../../components/log/scribe-cat ]; then
     ../../components/log/scribed-client-stop tmp
     ../../components/log/scribed-central-stop tmp
diff --git a/hosting/server/store.py b/hosting/server/store.py
index 054f546..058505b 100644
--- a/hosting/server/store.py
+++ b/hosting/server/store.py
@@ -17,6 +17,7 @@
 
 # Stores collection implementation
 from util import *
+from atomutil import *
 from sys import debug
 
 # Convert a particular store tag to a store id
@@ -26,8 +27,10 @@
 # Get a store from the cache
 def getstore(id, cache):
     debug('store.py::getstore::id', id)
+
+    # Lookup the requested store
     val = cache.get(id)
-    if isNil(val) or val is None:
+    if isNil(val):
         return ()
     store = cdddr(car(val))
     if not isNil(store) and isList(car(cadr(car(store)))):
@@ -47,7 +50,7 @@
     return cache.put(id, val)
 
 # Put an app into a store
-def put(id, app, user, cache, apps):
+def put(id, app, user, cache, apps, ratings):
     debug('store.py::put::id', id)
     debug('store.py::put::app', app)
     tag = car(id)
@@ -56,31 +59,61 @@
     def putapp(appid, app, store):
         if isNil(store):
             return app
-        if car(appid) == cadr(assoc("'id", car(store))):
+        if car(appid) == entryid(store):
             return cons(car(app), cdr(store))
         return cons(car(store), putapp(appid, app, cdr(store)))
 
-    appentry = (("'entry", assoc("'title", car(app)), ("'id", car(appid)), ("'author", user.get(())), assoc("'updated", car(app)), assoc("'content", car(app))),)
+    appentry = mkentry(title(app), car(appid), author(app), updated(app), content(app))
     debug('store.py::put::appentry', appentry)
 
     store = putapp(appid, appentry, getstore(storeid(tag), cache))
     return putstore(storeid(tag), store, cache)
 
+# Merge app info and ratings into a list of apps
+def mergeapps(entries, apps, ratings):
+    debug('store.py::mergeapps::entries', entries)
+
+    def mergeapp(entry):
+        debug('store.py::mergeapp::entry', entry)
+        id = (entryid(entry),)
+        app = apps.get(id)
+        if isNil(app):
+            return ((),)
+        info = content(app)
+        rating = ratings.get(id)
+        rates = content(rating)
+        mergedentry = mkentry(title(app), car(id), author(app), updated(app), ("'info",) + (() if isNil(info) else cdr(info)) + (() if isNil(rates) else cdr(rates)))
+        return mergedentry
+
+    mergedentries = tuple(filter(lambda e: not isNil(e), map(lambda e: car(mergeapp((e,))), entries)))
+    debug('store.py::mergeapps::mergedentries', mergedentries)
+    return mergedentries
+
 # Get apps from a store
-def get(id, user, cache, apps):
+def get(id, user, cache, apps, ratings):
     debug('store.py::get::id', id)
     tag = car(id)
-    appid = cdr(id)
 
+    # Collect the top rated apps
+    if tag == 'top':
+        topratings = ratings.get(()) 
+        topapps = mergeapps(cdddr(car(topratings)), apps, ratings)
+        topstore = ((("'feed", ("'title", 'App Store'), ("'id", tag)) + topapps),)
+        debug('store.py::get::store', topstore)
+        return topstore
+
+    # Collect the featured apps
+    appid = cdr(id)
     def findapp(appid, store):
         if isNil(store):
             return None
-        if car(appid) == cadr(assoc("'id", car(store))):
+        if car(appid) == entryid(store):
             return (car(store),)
         return findapp(appid, cdr(store))
 
     if isNil(appid):
-        store = ((("'feed", ("'title", "App Store"), ("'id", tag)) + getstore(storeid(tag), cache)),)
+        storeapps = mergeapps(getstore(storeid(tag), cache), apps, ratings)
+        store = ((("'feed", ("'title", "App Store"), ("'id", tag)) + storeapps),)
         debug('store.py::get::store', store)
         return store
 
@@ -89,7 +122,7 @@
     return app
 
 # Delete apps from a store
-def delete(id, user, cache, apps):
+def delete(id, user, cache, apps, ratings):
     debug('store.py::delete::id', id)
     tag = car(id)
     appid = cdr(id)
@@ -100,7 +133,7 @@
     def deleteapp(appid, store):
         if isNil(store):
             return ()
-        if car(appid) == cadr(assoc("'id", car(store))):
+        if car(appid) == entryid(store):
             return cdr(store)
         return cons(car(store), deleteapp(appid, cdr(store)))
 
diff --git a/hosting/server/test.py b/hosting/server/test.py
index 2575fb7..5670ec2 100755
--- a/hosting/server/test.py
+++ b/hosting/server/test.py
@@ -20,21 +20,34 @@
 
 import sys
 sys.debug = lambda *l: sys.stderr.write('python::debug ' + repr(l) + '\n')
+from sys import debug
 import time
-time.strftime = lambda f: 'Jan 01, 2012'
+time.strftime = lambda f, t: '2012-01-01T00:00:00+00:00'
+try:
+    import PIL
+except:
+    PIL = None
 
 import unittest
 from test.property import *
 from test.reference import *
 from test.cache import *
 
+from util import *
+from atomutil import *
 import user
 import accounts
+import pictures
 import pages
+import icons
 import composites
 import apps
 import store
 import dashboards
+import reviews
+import ratings
+import selector
+import search
 
 def testUser():
     # Return current user
@@ -43,11 +56,11 @@
 
 def testAccounts():
     # Get default account
-    defaccount = (("'entry", ("'title", 'jdoe@example.com'), ("'id", 'jdoe@example.com'), ("'updated", 'Jan 01, 2012')),)
+    defaccount = (("'entry", ("'title", 'jdoe@example.com'), ("'id", 'jdoe@example.com'), ("'author", 'jdoe@example.com'), ("'updated", '2012-01-01T00:00:00+00:00'), ("'content",)),)
     assert accounts.get((), mkref('user', lambda id: 'jdoe@example.com'), mkcache('cache', {})) == defaccount
 
     # Get user's account
-    jdoe = (("'entry", ("'title", 'John Doe'), ("'id", 'jdoe@example.com'), ("'updated", 'Jan 02, 2012'), ("'content", ("'key", 'value'))),)
+    jdoe = (("'entry", ("'title", 'John Doe'), ("'id", 'jdoe@example.com'), ("'author", 'jdoe@example.com'), ("'updated", '2012-01-01T00:00:00+00:00'), ("'content", ("'key", 'value'))),)
     assert accounts.get((), mkref('user', lambda id: 'jdoe@example.com'), mkcache('cache', {('accounts', 'jdoe@example.com', 'user.account') : jdoe})) == jdoe
 
     # Put and get account
@@ -56,27 +69,41 @@
     assert accounts.get((), mkref('user', lambda id: 'jdoe@example.com'), cache1) == jdoe
     return True
 
+def testPictures():
+    if PIL is None:
+        return True
+    img16 = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAYAAAAf8/9hAAAABmJLR0QA/wD/AP+gvaeTAAAACXBIWXMAAAsTAAALEwEAmpwYAAAAOUlEQVQ4y2N0b3/7nwEH2FkpzGg7hQGn/OEcBkYmBgrBqAFUMIBiQDCeL3qY4ZTX33FqNB0Mj3QAAFC7Dhs7i7zzAAAAAElFTkSuQmCC'
+    img50 = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAADIAAAAyCAYAAAAeP4ixAAAAg0lEQVR4nO3YsRVFUBAG4Xu1oBCZTEwxGlDBa0AxxDKZQtRARAfrzWG+aLP/TLq5/e1HCjAPZb7uZkwhG0uf7o0iYuAfDKExhMYQGkNoDKExhMYQGkNoDKExRPqI/MTzbOvqkI1qWn3QYRlCYwiNITSG0BhCYwiNITSG0BhC85oQKcgJzukOV+8REuQAAAAASUVORK5CYII='
+
+    # Put and get picture
+    pic16 = (("'entry", ("'title", 'jdoe@example.com'), ("'id", 'jdoe@example.com'), ("'author", 'jdoe@example.com'), ("'updated", '2012-01-01T00:00:00+00:00'), ("'content", ("'picture", ("'image", img16)))),)
+    pic50 = (("'entry", ("'title", 'jdoe@example.com'), ("'id", 'jdoe@example.com'), ("'author", 'jdoe@example.com'), ("'updated", '2012-01-01T00:00:00+00:00'), ("'content", ("'picture", ("'image", img50)))),)
+    cache1 = mkcache('cache', {})
+    assert pictures.put((), pic16, mkref('user', lambda id: 'jdoe@example.com'), cache1) == True
+    assert pictures.get((), mkref('user', lambda id: 'jdoe@example.com'), cache1) == pic50
+    return True
+
 def testPages():
     # Get default page
-    defpage = (("'entry", ("'title", 'app1'), ("'id", 'app1'), ("'author", 'jdoe@example.com'), ("'updated", 'Jan 01, 2012')),)
-    app1 = (("'entry", ("'title", 'app1'), ("'id", 'app1'), ("'author", 'jdoe@example.com'), ("'updated", 'Jan 03, 2012'), ("'content", ())),)
+    defpage = (("'entry", ("'title", 'app1'), ("'id", 'app1'), ("'author", 'jdoe@example.com'), ("'updated", '2012-01-01T00:00:00+00:00'), ("'content",)),)
+    app1 = (("'entry", ("'title", 'app1'), ("'id", 'app1'), ("'author", 'jdoe@example.com'), ("'updated", '2012-01-03T00:00:00+00:00'), ("'content",)),)
     assert pages.get(('app1',), mkref('user', lambda id: 'jdoe@example.com'), mkcache('cache', {}), mkref('apps', lambda id: None)) == defpage
-    defpagefromapp = (("'entry", ("'title", 'app1'), ("'id", 'app1'), ("'author", 'jdoe@example.com'), ("'updated", 'Jan 03, 2012')),)
+    defpagefromapp = (("'entry", ("'title", 'app1'), ("'id", 'app1'), ("'author", 'jdoe@example.com'), ("'updated", '2012-01-01T00:00:00+00:00'), ("'content",)),)
     assert pages.get(('app1',), mkref('user', lambda id: 'jdoe@example.com'), mkcache('cache', {}), mkref('apps', lambda id: app1)) == defpagefromapp
 
     # Get a page
-    page1 = (("'entry", ("'title", 'app1'), ("'id", 'app1'), ("'author", 'jdoe@example.com'), ("'updated", 'Jan 02, 2012'), ("'content", ())),)
+    page1 = (("'entry", ("'title", 'app1'), ("'id", 'app1'), ("'author", 'jdoe@example.com'), ("'updated", '2012-01-02T00:00:00+00:00'), ("'content",)),)
     assert pages.get(('app1',), mkref('user', lambda id: 'jdoe@example.com'), mkcache('cache', {('apps', 'app1', 'htdocs', 'app.html') : page1}), mkref('apps', lambda id: app1)) == page1
 
     # Put and get a page
     cache1 = mkcache('cache', {})
-    page1updated = (("'entry", ("'title", 'app1'), ("'id", 'app1'), ("'author", 'jdoe@example.com'), ("'updated", 'Jan 01, 2012'), ("'content", ())),)
+    page1updated = (("'entry", ("'title", 'app1'), ("'id", 'app1'), ("'author", 'jdoe@example.com'), ("'updated", '2012-01-01T00:00:00+00:00'), ("'content",)),)
     assert pages.put(('app1',), page1, mkref('user', lambda id: 'jdoe@example.com'), cache1, mkref('apps', lambda id: app1)) == True
     assert pages.get(('app1',), mkref('user', lambda id: 'jdoe@example.com'), cache1, mkref('apps', lambda id: app1)) == page1updated
     
     # Reject put from user other than the author
-    app1otherauthor = (("'entry", ("'title", 'app1'), ("'id", 'app1'), ("'author", 'jane@example.com'), ("'updated", 'Jan 03, 2012'), ("'content", ())),)
-    page1otherauthor = (("'entry", ("'title", 'app1'), ("'id", 'app1'), ("'author", 'jane@example.com'), ("'updated", 'Jan 02, 2012')),)
+    app1otherauthor = (("'entry", ("'title", 'app1'), ("'id", 'app1'), ("'author", 'jane@example.com'), ("'updated", '2012-01-03T00:00:00+00:00'), ("'content",)),)
+    page1otherauthor = (("'entry", ("'title", 'app1'), ("'id", 'app1'), ("'author", 'jane@example.com'), ("'updated", '2012-01-02T00:00:00+00:00')),)
     assert pages.put(('app1',), page1, mkref('user', lambda id: 'jdoe@example.com'), cache1, mkref('apps', lambda id: app1otherauthor)) == False
     assert pages.put(('app1',), page1otherauthor, mkref('user', lambda id: 'jdoe@example.com'), cache1, mkref('apps', lambda id: app1otherauthor)) == False
     assert pages.get(('app1',), mkref('user', lambda id: 'jdoe@example.com'), cache1, mkref('apps', lambda id: app1)) == page1updated
@@ -90,27 +117,82 @@
     assert pages.get(('app1',), mkref('user', lambda id: 'jdoe@example.com'), cache1, mkref('apps', lambda id: app1)) == defpagefromapp
     return True
 
+def testIcons():
+    if PIL is None:
+        return True
+    img16 = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAYAAAAf8/9hAAAABmJLR0QA/wD/AP+gvaeTAAAACXBIWXMAAAsTAAALEwEAmpwYAAAAOUlEQVQ4y2N0b3/7nwEH2FkpzGg7hQGn/OEcBkYmBgrBqAFUMIBiQDCeL3qY4ZTX33FqNB0Mj3QAAFC7Dhs7i7zzAAAAAElFTkSuQmCC'
+    img50 = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAADIAAAAyCAYAAAAeP4ixAAAAg0lEQVR4nO3YsRVFUBAG4Xu1oBCZTEwxGlDBa0AxxDKZQtRARAfrzWG+aLP/TLq5/e1HCjAPZb7uZkwhG0uf7o0iYuAfDKExhMYQGkNoDKExhMYQGkNoDKExRPqI/MTzbOvqkI1qWn3QYRlCYwiNITSG0BhCYwiNITSG0BhC85oQKcgJzukOV+8REuQAAAAASUVORK5CYII='
+
+    # Get default icon
+    deficon = (("'entry", ("'title", 'app1'), ("'id", 'app1'), ("'author", 'jdoe@example.com'), ("'updated", '2012-01-01T00:00:00+00:00'), ("'content",)),)
+    app1 = (("'entry", ("'title", 'app1'), ("'id", 'app1'), ("'author", 'jdoe@example.com'), ("'updated", '2012-01-03T00:00:00+00:00'), ()),)
+    assert icons.get(('app1',), mkref('user', lambda id: 'jdoe@example.com'), mkcache('cache', {}), mkref('apps', lambda id: None)) == deficon
+    deficonfromapp = (("'entry", ("'title", 'app1'), ("'id", 'app1'), ("'author", 'jdoe@example.com'), ("'updated", '2012-01-01T00:00:00+00:00'), ("'content",)),)
+    assert icons.get(('app1',), mkref('user', lambda id: 'jdoe@example.com'), mkcache('cache', {}), mkref('apps', lambda id: app1)) == deficonfromapp
+
+    # Get a icon
+    icon1 = (("'entry", ("'title", 'app1'), ("'id", 'app1'), ("'author", 'jdoe@example.com'), ("'updated", '2012-01-02T00:00:00+00:00'), ("'content", ("'icon", ("'image", img16)))),)
+    assert icons.get(('app1',), mkref('user', lambda id: 'jdoe@example.com'), mkcache('cache', {('apps', 'app1', 'app.icon') : icon1}), mkref('apps', lambda id: app1)) == icon1
+
+    # Put and get a icon
+    cache1 = mkcache('cache', {})
+    icon1updated = (("'entry", ("'title", 'app1'), ("'id", 'app1'), ("'author", 'jdoe@example.com'), ("'updated", '2012-01-01T00:00:00+00:00'), ("'content", ("'icon", ("'image", img50)))),)
+    assert icons.put(('app1',), icon1, mkref('user', lambda id: 'jdoe@example.com'), cache1, mkref('apps', lambda id: app1)) == True
+    assert icons.get(('app1',), mkref('user', lambda id: 'jdoe@example.com'), cache1, mkref('apps', lambda id: app1)) == icon1updated
+    
+    # Reject put from user other than the author
+    app1otherauthor = (("'entry", ("'title", 'app1'), ("'id", 'app1'), ("'author", 'jane@example.com'), ("'updated", '2012-01-03T00:00:00+00:00'), ("'content",)),)
+    icon1otherauthor = (("'entry", ("'title", 'app1'), ("'id", 'app1'), ("'author", 'jane@example.com'), ("'updated", '2012-01-02T00:00:00+00:00')),)
+    assert icons.put(('app1',), icon1, mkref('user', lambda id: 'jdoe@example.com'), cache1, mkref('apps', lambda id: app1otherauthor)) == False
+    assert icons.put(('app1',), icon1otherauthor, mkref('user', lambda id: 'jdoe@example.com'), cache1, mkref('apps', lambda id: app1otherauthor)) == False
+    assert icons.get(('app1',), mkref('user', lambda id: 'jdoe@example.com'), cache1, mkref('apps', lambda id: app1)) == icon1updated
+
+    # Reject delete from user other than the author
+    assert icons.delete(('app1',), mkref('user', lambda id: 'jdoe@example.com'), cache1, mkref('apps', lambda id: app1otherauthor)) == False
+    assert icons.get(('app1',), mkref('user', lambda id: 'jdoe@example.com'), cache1, mkref('apps', lambda id: app1)) == icon1updated
+
+    # Put an upload token in an icon 
+    icon1token = (("'entry", ("'title", 'app1'), ("'id", 'app1'), ("'author", 'jdoe@example.com'), ("'updated", '2012-01-01T00:00:00+00:00'), ("'content", ("'icon", ("'token", '1234')))),)
+    assert icons.put(('app1',), icon1token, mkref('user', lambda id: 'jdoe@example.com'), cache1, mkref('apps', lambda id: app1)) == True
+    assert icons.get(('app1',), mkref('user', lambda id: 'another@example.com'), cache1, mkref('apps', lambda id: app1)) == icon1updated
+    icon1updatedwithtoken = (("'entry", ("'title", 'app1'), ("'id", 'app1'), ("'author", 'jdoe@example.com'), ("'updated", '2012-01-01T00:00:00+00:00'), ("'content", ("'icon", ("'image", img50), ("'token", '1234')))),)
+    assert icons.get(('app1',), mkref('user', lambda id: 'jdoe@example.com'), cache1, mkref('apps', lambda id: app1)) == icon1updatedwithtoken
+
+    # Reject upload with invalid token
+    icon1badtoken = (("'entry", ("'title", 'app1'), ("'id", 'app1'), ("'author", 'jdoe@example.com'), ("'updated", '2012-01-01T00:00:00+00:00'), ("'content", ("'icon", ("'image", img50), ("'token", '4567')))),)
+    assert icons.put(('app1',), icon1badtoken, mkref('user', lambda id: 'jdoe@example.com'), cache1, mkref('apps', lambda id: app1)) == False
+
+    # Upload with valid token
+    icon1oktoken = (("'entry", ("'title", 'app1'), ("'id", 'app1'), ("'author", 'jdoe@example.com'), ("'updated", '2012-01-01T00:00:00+00:00'), ("'content", ("'icon", ("'image", img50), ("'token", '1234')))),)
+    assert icons.put(('app1',), icon1oktoken, mkref('user', lambda id: 'jdoe@example.com'), cache1, mkref('apps', lambda id: app1)) == True
+    assert icons.get(('app1',), mkref('user', lambda id: 'jdoe@example.com'), cache1, mkref('apps', lambda id: app1)) == icon1updated
+
+    # Delete a icon
+    assert icons.delete(('app1',), mkref('user', lambda id: 'jdoe@example.com'), cache1, mkref('apps', lambda id: app1)) == True
+    assert icons.get(('app1',), mkref('user', lambda id: 'jdoe@example.com'), cache1, mkref('apps', lambda id: app1)) == deficonfromapp
+    return True
+
 def testComposites():
     # Get default composite
-    defcomposite = (("'entry", ("'title", 'app1'), ("'id", 'app1'), ("'author", 'jdoe@example.com'), ("'updated", 'Jan 01, 2012')),)
-    app1 = (("'entry", ("'title", 'app1'), ("'id", 'app1'), ("'author", 'jdoe@example.com'), ("'updated", 'Jan 03, 2012'), ("'content", ())),)
+    defcomposite = (("'entry", ("'title", 'app1'), ("'id", 'app1'), ("'author", 'jdoe@example.com'), ("'updated", '2012-01-01T00:00:00+00:00'), ("'content",)),)
+    app1 = (("'entry", ("'title", 'app1'), ("'id", 'app1'), ("'author", 'jdoe@example.com'), ("'updated", '2012-01-03T00:00:00+00:00'), ("'content",)),)
     assert composites.get(('app1',), mkref('user', lambda id: 'jdoe@example.com'), mkcache('cache', {}), mkref('apps', lambda id: None)) == defcomposite
-    defcompositefromapp = (("'entry", ("'title", 'app1'), ("'id", 'app1'), ("'author", 'jdoe@example.com'), ("'updated", 'Jan 03, 2012')),)
+    defcompositefromapp = (("'entry", ("'title", 'app1'), ("'id", 'app1'), ("'author", 'jdoe@example.com'), ("'updated", '2012-01-01T00:00:00+00:00'), ("'content",)),)
     assert composites.get(('app1',), mkref('user', lambda id: 'jdoe@example.com'), mkcache('cache', {}), mkref('apps', lambda id: app1)) == defcompositefromapp
 
     # Get a composite
-    composite1 = (("'entry", ("'title", 'app1'), ("'id", 'app1'), ("'author", 'jdoe@example.com'), ("'updated", 'Jan 02, 2012'), ("'content", ())),)
+    composite1 = (("'entry", ("'title", 'app1'), ("'id", 'app1'), ("'author", 'jdoe@example.com'), ("'updated", '2012-01-02T00:00:00+00:00'), ("'content",)),)
     assert composites.get(('app1',), mkref('user', lambda id: 'jdoe@example.com'), mkcache('cache', {('apps', 'app1', 'app.composite') : composite1}), mkref('apps', lambda id: app1)) == composite1
 
     # Put and get a composite
     cache1 = mkcache('cache', {})
-    composite1updated = (("'entry", ("'title", 'app1'), ("'id", 'app1'), ("'author", 'jdoe@example.com'), ("'updated", 'Jan 01, 2012'), ("'content", ())),)
+    composite1updated = (("'entry", ("'title", 'app1'), ("'id", 'app1'), ("'author", 'jdoe@example.com'), ("'updated", '2012-01-01T00:00:00+00:00'), ("'content",)),)
     assert composites.put(('app1',), composite1, mkref('user', lambda id: 'jdoe@example.com'), cache1, mkref('apps', lambda id: app1)) == True
     assert composites.get(('app1',), mkref('user', lambda id: 'jdoe@example.com'), cache1, mkref('apps', lambda id: app1)) == composite1updated
     
     # Reject put from user other than the author
-    app1otherauthor = (("'entry", ("'title", 'app1'), ("'id", 'app1'), ("'author", 'jane@example.com'), ("'updated", 'Jan 03, 2012'), ("'content", ())),)
-    composite1otherauthor = (("'entry", ("'title", 'app1'), ("'id", 'app1'), ("'author", 'jane@example.com'), ("'updated", 'Jan 02, 2012')),)
+    app1otherauthor = (("'entry", ("'title", 'app1'), ("'id", 'app1'), ("'author", 'jane@example.com'), ("'updated", '2012-01-03T00:00:00+00:00'), ("'content",)),)
+    composite1otherauthor = (("'entry", ("'title", 'app1'), ("'id", 'app1'), ("'author", 'jane@example.com'), ("'updated", '2012-01-02T00:00:00+00:00')),)
     assert composites.put(('app1',), composite1, mkref('user', lambda id: 'jdoe@example.com'), cache1, mkref('apps', lambda id: app1otherauthor)) == False
     assert composites.put(('app1',), composite1otherauthor, mkref('user', lambda id: 'jdoe@example.com'), cache1, mkref('apps', lambda id: app1otherauthor)) == False
     assert composites.get(('app1',), mkref('user', lambda id: 'jdoe@example.com'), cache1, mkref('apps', lambda id: app1)) == composite1updated
@@ -126,171 +208,298 @@
 
 def testApps():
     # Get default app
-    defapp = (("'entry", ("'title", 'app1'), ("'id", 'app1'), ("'author", 'jdoe@example.com'), ("'updated", 'Jan 01, 2012'), ("'content", ("'stats", ("'description", '')))),)
-    assert apps.get(('app1',), mkref('user', lambda id: 'jdoe@example.com'), mkcache('cache', {}), mkref('dashboard', lambda id: None), mkref('store', lambda id: None), mkref('composites', lambda id: None), mkref('pages', lambda id: None)) == defapp
+    assert apps.get(('app1',), mkref('user', lambda id: 'jdoe@example.com'), mkcache('cache', {}), mkref('dashboard', lambda id: None), mkref('store', lambda id: None), mkref('composites', lambda id: None), mkref('pages', lambda id: None), mkref('icons', lambda id: None)) is None
 
     # Get an app
-    app1 = (("'entry", ("'title", 'app1'), ("'id", 'app1'), ("'author", 'jdoe@example.com'), ("'updated", 'Jan 01, 2012'), ("'content", ("'stats", ("'description", '')))),)
-    assert apps.get(('app1',), mkref('user', lambda id: 'jdoe@example.com'), mkcache('cache', {('apps', 'app1', 'app.stats') : app1}), mkref('dashboard', lambda id: None), mkref('store', lambda id: None), mkref('composites', lambda id: None), mkref('pages', lambda id: None)) == app1
+    app1 = (("'entry", ("'title", 'app1'), ("'id", 'app1'), ("'author", 'jdoe@example.com'), ("'updated", '2012-01-01T00:00:00+00:00'), ("'content", ("'info", ("'description", '')))),)
+    assert apps.get(('app1',), mkref('user', lambda id: 'jdoe@example.com'), mkcache('cache', {('apps', 'app1', 'app.info') : app1}), mkref('dashboard', lambda id: None), mkref('store', lambda id: None), mkref('composites', lambda id: None), mkref('pages', lambda id: None), mkref('icons', lambda id: None)) == app1
 
     # Put and get an app
     cache1 = mkcache('cache', {})
-    assert apps.put(('app1',), app1, mkref('user', lambda id: 'jdoe@example.com'), cache1, mkref('dashboard', lambda id, app: True), mkref('store', lambda id, app: True), mkref('composites', lambda id, app: True), mkref('pages', lambda id, app: True)) == True
-    assert apps.get(('app1',), mkref('user', lambda id: 'jdoe@example.com'), cache1, mkref('dashboard', lambda id: None), mkref('store', lambda id: None), mkref('composites', lambda id: None), mkref('pages', lambda id: None)) == app1
+    assert apps.put(('app1',), app1, mkref('user', lambda id: 'jdoe@example.com'), cache1, mkref('dashboard', lambda id, app: True), mkref('store', lambda id, app: True), mkref('composites', lambda id, app: True), mkref('pages', lambda id, app: True), mkref('icons', lambda id, app: True)) == True
+    assert apps.get(('app1',), mkref('user', lambda id: 'jdoe@example.com'), cache1, mkref('dashboard', lambda id: None), mkref('store', lambda id: None), mkref('composites', lambda id: None), mkref('pages', lambda id: None), mkref('icons', lambda id: None)) == app1
     return True
     
     # Reject put from user other than the author
-    app1otherauthor = (("'entry", ("'title", 'app1'), ("'id", 'app1'), ("'author", 'jane@example.com'), ("'updated", 'Jan 03, 2012'), ("'content", ())),)
-    assert apps.put(('app1',), app1, mkref('user', lambda id: 'jane@example.com'), cache1, mkref('dashboard', lambda id, app: True), mkref('store', lambda id, app: True), mkref('composites', lambda id, app: True), mkref('pages', lambda id, app: True)) == false
-    assert apps.get(('app1',), mkref('user', lambda id: 'jane@example.com'), cache1, mkref('dashboard', lambda id: None), mkref('store', lambda id: None), mkref('composites', lambda id: None), mkref('pages', lambda id: None)) == app1
+    app1otherauthor = (("'entry", ("'title", 'app1'), ("'id", 'app1'), ("'author", 'jane@example.com'), ("'updated", '2012-01-03T00:00:00+00:00'), ("'content",)),)
+    assert apps.put(('app1',), app1, mkref('user', lambda id: 'jane@example.com'), cache1, mkref('dashboard', lambda id, app: True), mkref('store', lambda id, app: True), mkref('composites', lambda id, app: True), mkref('pages', lambda id, app: True), mkref('icons', lambda id, app: True)) == false
+    assert apps.get(('app1',), mkref('user', lambda id: 'jane@example.com'), cache1, mkref('dashboard', lambda id: None), mkref('store', lambda id: None), mkref('composites', lambda id: None), mkref('pages', lambda id: None), mkref('icons', lambda id: None)) == app1
 
     # Reject delete from user other than the author
-    assert apps.delete(('app1',), mkref('user', lambda id: 'jane@example.com'), cache1, mkref('dashboard', lambda id: None), mkref('store', lambda id: None), mkref('composites', lambda id: None), mkref('pages', lambda id: None)) == False
-    assert apps.get(('app1',), mkref('user', lambda id: 'jane@example.com'), cache1, mkref('dashboard', lambda id: None), mkref('store', lambda id: None), mkref('composites', lambda id: None), mkref('pages', lambda id: None)) == app1
+    assert apps.delete(('app1',), mkref('user', lambda id: 'jane@example.com'), cache1, mkref('dashboard', lambda id: None), mkref('store', lambda id: None), mkref('composites', lambda id: None), mkref('pages', lambda id: None), mkref('icons', lambda id: None)) == False
+    assert apps.get(('app1',), mkref('user', lambda id: 'jane@example.com'), cache1, mkref('dashboard', lambda id: None), mkref('store', lambda id: None), mkref('composites', lambda id: None), mkref('pages', lambda id: None), mkref('icons', lambda id: None)) == app1
 
     # Delete an app
-    assert apps.delete(('app1',), mkref('user', lambda id: 'jdoe@example.com'), cache1, mkref('dashboard', lambda id: None), mkref('store', lambda id: None), mkref('composites', lambda id: None), mkref('pages', lambda id: None)) == True
-    assert apps.get(('app1',), mkref('user', lambda id: 'jdoe@example.com'), mkcache('cache', {}), mkref('dashboard', lambda id: None), mkref('store', lambda id: None), mkref('composites', lambda id: None), mkref('pages', lambda id: None)) == defapp
+    assert apps.delete(('app1',), mkref('user', lambda id: 'jdoe@example.com'), cache1, mkref('dashboard', lambda id: None), mkref('store', lambda id: None), mkref('composites', lambda id: None), mkref('pages', lambda id: None), mkref('icons', lambda id: None)) == True
+    assert apps.get(('app1',), mkref('user', lambda id: 'jdoe@example.com'), mkcache('cache', {}), mkref('dashboard', lambda id: None), mkref('store', lambda id: None), mkref('composites', lambda id: None), mkref('pages', lambda id: None), mkref('icons', lambda id: None)) is None
     return True
 
 def testStore():
+    getapp = lambda id: (("'entry", ("'title", car(id)), ("'id", car(id)), ("'author", 'jdoe@example.com'), ("'updated", '2012-01-02T00:00:00+00:00'), ("'content", ("'info", ("'description", car(id))))),)
+
     # Get default store
-    defstore = (("'feed", ("'title", 'App Store'), ("'id", 'top')),)
-    assert store.get(('top',), mkref('user', lambda id: 'jdoe@example.com'), mkcache('cache', {}), mkref('apps', lambda id: None)) == defstore
+    defstore = (("'feed", ("'title", 'App Store'), ("'id", 'featured')),)
+    assert store.get(('featured',), mkref('user', lambda id: 'jdoe@example.com'), mkcache('cache', {}), mkref('apps', getapp), mkref('ratings', lambda id: None)) == defstore
 
     # Get a store
-    store1= (("'feed", ("'title", 'App Store'), ("'id", 'top'), ("'entry", ("'title", 'app1'), ("'id", 'app1'), ("'author", 'jdoe@example.com'), ("'updated", 'Jan 02, 2012'), ("'content", ("'stats", ("'description", 'app1')))), ("'entry", ("'title", 'app2'), ("'id", 'app2'), ("'author", 'jdoe@example.com'), ("'updated", 'Jan 02, 2012'), ("'content", ("'stats", ("'description", 'app2'))))),)
-    assert store.get(('top',), mkref('user', lambda id: 'jdoe@example.com'), mkcache('cache', {('store', 'top', 'store.apps') : store1}), mkref('apps', lambda id: None)) == store1
+    store1= (("'feed", ("'title", 'App Store'), ("'id", 'featured'), ("'entry", ("'title", 'app1'), ("'id", 'app1'), ("'author", 'jdoe@example.com'), ("'updated", '2012-01-02T00:00:00+00:00'), ("'content", ("'info", ("'description", 'app1')))), ("'entry", ("'title", 'app2'), ("'id", 'app2'), ("'author", 'jdoe@example.com'), ("'updated", '2012-01-02T00:00:00+00:00'), ("'content", ("'info", ("'description", 'app2'))))),)
+    assert store.get(('featured',), mkref('user', lambda id: 'jdoe@example.com'), mkcache('cache', {('store', 'featured', 'store.apps') : store1}), mkref('apps', getapp), mkref('ratings', lambda id: None)) == store1
     
-    store1compact = (("'feed", ("'title", 'App Store'), ("'id", 'top'), ("'entry", ((("'title", 'app1'), ("'id", 'app1'), ("'author", 'jdoe@example.com'), ("'updated", 'Jan 02, 2012'), ("'content", ("'stats", ("'description", 'app1')))), (("'title", 'app2'), ("'id", 'app2'), ("'author", 'jdoe@example.com'), ("'updated", 'Jan 02, 2012'), ("'content", ("'stats", ("'description", 'app2'))))))),)
-    assert store.get(('top',), mkref('user', lambda id: 'jdoe@example.com'), mkcache('cache', {('store', 'top', 'store.apps') : store1compact}), mkref('apps', lambda id: None)) == store1
+    store1compact = (("'feed", ("'title", 'App Store'), ("'id", 'featured'), ("'entry", ((("'title", 'app1'), ("'id", 'app1'), ("'author", 'jdoe@example.com'), ("'updated", '2012-01-02T00:00:00+00:00'), ("'content", ("'info", ("'description", 'app1')))), (("'title", 'app2'), ("'id", 'app2'), ("'author", 'jdoe@example.com'), ("'updated", '2012-01-02T00:00:00+00:00'), ("'content", ("'info", ("'description", 'app2'))))))),)
+    assert store.get(('featured',), mkref('user', lambda id: 'jdoe@example.com'), mkcache('cache', {('store', 'featured', 'store.apps') : store1compact}), mkref('apps', getapp), mkref('ratings', lambda id: None)) == store1
 
     # Put an app in an empty store
     cache1 = mkcache('cache', {})
-    app1 = (("'entry", ("'title", 'app1'), ("'id", 'app1'), ("'author", 'jdoe@example.com'), ("'updated", 'Jan 02, 2012'), ("'content", ("'stats", ("'description", 'app1')))),)
-    store1withapp1 = (("'feed", ("'title", 'App Store'), ("'id", 'top'), ("'entry", ("'title", 'app1'), ("'id", 'app1'), ("'author", 'jdoe@example.com'), ("'updated", 'Jan 02, 2012'), ("'content", ("'stats", ("'description", 'app1'))))),)
-    assert store.put(('top', 'app1'), app1, mkref('user', lambda id: 'jdoe@example.com'), cache1, mkref('apps', lambda id: None)) == True
-    assert store.get(('top',), mkref('user', lambda id: 'jdoe@example.com'), cache1, mkref('apps', lambda id: None)) == store1withapp1
-    assert store.put(('top', 'app1'), app1, mkref('user', lambda id: 'jdoe@example.com'), cache1, mkref('apps', lambda id: None)) == True
-    assert store.get(('top',), mkref('user', lambda id: 'jdoe@example.com'), cache1, mkref('apps', lambda id: None)) == store1withapp1
+    app1 = (("'entry", ("'title", 'app1'), ("'id", 'app1'), ("'author", 'jdoe@example.com'), ("'updated", '2012-01-02T00:00:00+00:00'), ("'content", ("'info", ("'description", 'app1')))),)
+    store1withapp1 = (("'feed", ("'title", 'App Store'), ("'id", 'featured'), ("'entry", ("'title", 'app1'), ("'id", 'app1'), ("'author", 'jdoe@example.com'), ("'updated", '2012-01-02T00:00:00+00:00'), ("'content", ("'info", ("'description", 'app1'))))),)
+    assert store.put(('featured', 'app1'), app1, mkref('user', lambda id: 'jdoe@example.com'), cache1, mkref('apps', getapp), mkref('ratings', lambda id: None)) == True
+    assert store.get(('featured',), mkref('user', lambda id: 'jdoe@example.com'), cache1, mkref('apps', getapp), mkref('ratings', lambda id: None)) == store1withapp1
+    assert store.put(('featured', 'app1'), app1, mkref('user', lambda id: 'jdoe@example.com'), cache1, mkref('apps', getapp), mkref('ratings', lambda id: None)) == True
+    assert store.get(('featured',), mkref('user', lambda id: 'jdoe@example.com'), cache1, mkref('apps', getapp), mkref('ratings', lambda id: None)) == store1withapp1
 
     # Put a second app in the store
-    app2 = (("'entry", ("'title", 'app2'), ("'id", 'app2'), ("'author", 'jdoe@example.com'), ("'updated", 'Jan 02, 2012'), ("'content", ("'stats", ("'description", 'app2')))),)
-    store1withapp2 = (("'feed", ("'title", 'App Store'), ("'id", 'top'), ("'entry", ("'title", 'app1'), ("'id", 'app1'), ("'author", 'jdoe@example.com'), ("'updated", 'Jan 02, 2012'), ("'content", ("'stats", ("'description", 'app1')))), ("'entry", ("'title", 'app2'), ("'id", 'app2'), ("'author", 'jdoe@example.com'), ("'updated", 'Jan 02, 2012'), ("'content", ("'stats", ("'description", 'app2'))))),)
-    assert store.put(('top', 'app2'), app2, mkref('user', lambda id: 'jdoe@example.com'), cache1, mkref('apps', lambda id: None)) == True
-    assert store.get(('top',), mkref('user', lambda id: 'jdoe@example.com'), cache1, mkref('apps', lambda id: None)) == store1withapp2
-    assert store.put(('top', 'app1'), app1, mkref('user', lambda id: 'jdoe@example.com'), cache1, mkref('apps', lambda id: None)) == True
-    assert store.get(('top',), mkref('user', lambda id: 'jdoe@example.com'), cache1, mkref('apps', lambda id: None)) == store1withapp2
-    assert store.put(('top', 'app2'), app2, mkref('user', lambda id: 'jdoe@example.com'), cache1, mkref('apps', lambda id: None)) == True
-    assert store.get(('top',), mkref('user', lambda id: 'jdoe@example.com'), cache1, mkref('apps', lambda id: None)) == store1withapp2
+    app2 = (("'entry", ("'title", 'app2'), ("'id", 'app2'), ("'author", 'jdoe@example.com'), ("'updated", '2012-01-02T00:00:00+00:00'), ("'content", ("'info", ("'description", 'app2')))),)
+    store1withapp2 = (("'feed", ("'title", 'App Store'), ("'id", 'featured'), ("'entry", ("'title", 'app1'), ("'id", 'app1'), ("'author", 'jdoe@example.com'), ("'updated", '2012-01-02T00:00:00+00:00'), ("'content", ("'info", ("'description", 'app1')))), ("'entry", ("'title", 'app2'), ("'id", 'app2'), ("'author", 'jdoe@example.com'), ("'updated", '2012-01-02T00:00:00+00:00'), ("'content", ("'info", ("'description", 'app2'))))),)
+    assert store.put(('featured', 'app2'), app2, mkref('user', lambda id: 'jdoe@example.com'), cache1, mkref('apps', getapp), mkref('ratings', lambda id: None)) == True
+    assert store.get(('featured',), mkref('user', lambda id: 'jdoe@example.com'), cache1, mkref('apps', getapp), mkref('ratings', lambda id: None)) == store1withapp2
+    assert store.put(('featured', 'app1'), app1, mkref('user', lambda id: 'jdoe@example.com'), cache1, mkref('apps', getapp), mkref('ratings', lambda id: None)) == True
+    assert store.get(('featured',), mkref('user', lambda id: 'jdoe@example.com'), cache1, mkref('apps', getapp), mkref('ratings', lambda id: None)) == store1withapp2
+    assert store.put(('featured', 'app2'), app2, mkref('user', lambda id: 'jdoe@example.com'), cache1, mkref('apps', getapp), mkref('ratings', lambda id: None)) == True
+    assert store.get(('featured',), mkref('user', lambda id: 'jdoe@example.com'), cache1, mkref('apps', getapp), mkref('ratings', lambda id: None)) == store1withapp2
 
     # Put a third app in the store
-    app3 = (("'entry", ("'title", 'app3'), ("'id", 'app3'), ("'author", 'jdoe@example.com'), ("'updated", 'Jan 02, 2012'), ("'content", ("'stats", ("'description", 'app3')))),)
-    store1withapp3 = (("'feed", ("'title", 'App Store'), ("'id", 'top'), ("'entry", ("'title", 'app1'), ("'id", 'app1'), ("'author", 'jdoe@example.com'), ("'updated", 'Jan 02, 2012'), ("'content", ("'stats", ("'description", 'app1')))), ("'entry", ("'title", 'app2'), ("'id", 'app2'), ("'author", 'jdoe@example.com'), ("'updated", 'Jan 02, 2012'), ("'content", ("'stats", ("'description", 'app2')))), ("'entry", ("'title", 'app3'), ("'id", 'app3'), ("'author", 'jdoe@example.com'), ("'updated", 'Jan 02, 2012'), ("'content", ("'stats", ("'description", 'app3'))))),)
-    assert store.put(('top', 'app3'), app3, mkref('user', lambda id: 'jdoe@example.com'), cache1, mkref('apps', lambda id: None)) == True
-    assert store.get(('top',), mkref('user', lambda id: 'jdoe@example.com'), cache1, mkref('apps', lambda id: None)) == store1withapp3
-    assert store.put(('top', 'app1'), app1, mkref('user', lambda id: 'jdoe@example.com'), cache1, mkref('apps', lambda id: None)) == True
-    assert store.get(('top',), mkref('user', lambda id: 'jdoe@example.com'), cache1, mkref('apps', lambda id: None)) == store1withapp3
-    assert store.put(('top', 'app2'), app2, mkref('user', lambda id: 'jdoe@example.com'), cache1, mkref('apps', lambda id: None)) == True
-    assert store.get(('top',), mkref('user', lambda id: 'jdoe@example.com'), cache1, mkref('apps', lambda id: None)) == store1withapp3
-    assert store.put(('top', 'app3'), app3, mkref('user', lambda id: 'jdoe@example.com'), cache1, mkref('apps', lambda id: None)) == True
-    assert store.get(('top',), mkref('user', lambda id: 'jdoe@example.com'), cache1, mkref('apps', lambda id: None)) == store1withapp3
+    app3 = (("'entry", ("'title", 'app3'), ("'id", 'app3'), ("'author", 'jdoe@example.com'), ("'updated", '2012-01-02T00:00:00+00:00'), ("'content", ("'info", ("'description", 'app3')))),)
+    store1withapp3 = (("'feed", ("'title", 'App Store'), ("'id", 'featured'), ("'entry", ("'title", 'app1'), ("'id", 'app1'), ("'author", 'jdoe@example.com'), ("'updated", '2012-01-02T00:00:00+00:00'), ("'content", ("'info", ("'description", 'app1')))), ("'entry", ("'title", 'app2'), ("'id", 'app2'), ("'author", 'jdoe@example.com'), ("'updated", '2012-01-02T00:00:00+00:00'), ("'content", ("'info", ("'description", 'app2')))), ("'entry", ("'title", 'app3'), ("'id", 'app3'), ("'author", 'jdoe@example.com'), ("'updated", '2012-01-02T00:00:00+00:00'), ("'content", ("'info", ("'description", 'app3'))))),)
+    assert store.put(('featured', 'app3'), app3, mkref('user', lambda id: 'jdoe@example.com'), cache1, mkref('apps', getapp), mkref('ratings', lambda id: None)) == True
+    assert store.get(('featured',), mkref('user', lambda id: 'jdoe@example.com'), cache1, mkref('apps', getapp), mkref('ratings', lambda id: None)) == store1withapp3
+    assert store.put(('featured', 'app1'), app1, mkref('user', lambda id: 'jdoe@example.com'), cache1, mkref('apps', getapp), mkref('ratings', lambda id: None)) == True
+    assert store.get(('featured',), mkref('user', lambda id: 'jdoe@example.com'), cache1, mkref('apps', getapp), mkref('ratings', lambda id: None)) == store1withapp3
+    assert store.put(('featured', 'app2'), app2, mkref('user', lambda id: 'jdoe@example.com'), cache1, mkref('apps', getapp), mkref('ratings', lambda id: None)) == True
+    assert store.get(('featured',), mkref('user', lambda id: 'jdoe@example.com'), cache1, mkref('apps', getapp), mkref('ratings', lambda id: None)) == store1withapp3
+    assert store.put(('featured', 'app3'), app3, mkref('user', lambda id: 'jdoe@example.com'), cache1, mkref('apps', getapp), mkref('ratings', lambda id: None)) == True
+    assert store.get(('featured',), mkref('user', lambda id: 'jdoe@example.com'), cache1, mkref('apps', getapp), mkref('ratings', lambda id: None)) == store1withapp3
 
     # Get an app from the store
-    assert store.get(('top','app1'), mkref('user', lambda id: 'jdoe@example.com'), cache1, mkref('apps', lambda id: None)) == app1
-    assert store.get(('top','app2'), mkref('user', lambda id: 'jdoe@example.com'), cache1, mkref('apps', lambda id: None)) == app2
-    assert store.get(('top','app3'), mkref('user', lambda id: 'jdoe@example.com'), cache1, mkref('apps', lambda id: None)) == app3
+    assert store.get(('featured','app1'), mkref('user', lambda id: 'jdoe@example.com'), cache1, mkref('apps', getapp), mkref('ratings', lambda id: None)) == app1
+    assert store.get(('featured','app2'), mkref('user', lambda id: 'jdoe@example.com'), cache1, mkref('apps', getapp), mkref('ratings', lambda id: None)) == app2
+    assert store.get(('featured','app3'), mkref('user', lambda id: 'jdoe@example.com'), cache1, mkref('apps', getapp), mkref('ratings', lambda id: None)) == app3
 
     # Put a third app in the store, starting from a compacted list
-    cache2 = mkcache('cache', {('store', 'top', 'store.apps') : store1compact})
-    assert store.put(('top', 'app3'), app3, mkref('user', lambda id: 'jdoe@example.com'), cache2, mkref('apps', lambda id: None)) == True
-    assert store.get(('top',), mkref('user', lambda id: 'jdoe@example.com'), cache2, mkref('apps', lambda id: None)) == store1withapp3
+    cache2 = mkcache('cache', {('store', 'featured', 'store.apps') : store1compact})
+    assert store.put(('featured', 'app3'), app3, mkref('user', lambda id: 'jdoe@example.com'), cache2, mkref('apps', getapp), mkref('ratings', lambda id: None)) == True
+    assert store.get(('featured',), mkref('user', lambda id: 'jdoe@example.com'), cache2, mkref('apps', getapp), mkref('ratings', lambda id: None)) == store1withapp3
 
     # Delete the apps
-    assert store.delete(('top', 'app2'), mkref('user', lambda id: 'jdoe@example.com'), cache1, mkref('apps', lambda id: None)) == True
-    assert store.delete(('top', 'app4'), mkref('user', lambda id: 'jdoe@example.com'), cache1, mkref('apps', lambda id: None)) == False
-    assert store.delete(('top', 'app1'), mkref('user', lambda id: 'jdoe@example.com'), cache1, mkref('apps', lambda id: None)) == True
-    assert store.delete(('top', 'app3'), mkref('user', lambda id: 'jdoe@example.com'), cache1, mkref('apps', lambda id: None)) == True
-    assert store.get(('top',), mkref('user', lambda id: 'jdoe@example.com'), cache1, mkref('apps', lambda id: None)) == defstore
+    assert store.delete(('featured', 'app2'), mkref('user', lambda id: 'jdoe@example.com'), cache1, mkref('apps', getapp), mkref('ratings', lambda id: None)) == True
+    assert store.delete(('featured', 'app4'), mkref('user', lambda id: 'jdoe@example.com'), cache1, mkref('apps', getapp), mkref('ratings', lambda id: None)) == False
+    assert store.delete(('featured', 'app1'), mkref('user', lambda id: 'jdoe@example.com'), cache1, mkref('apps', getapp), mkref('ratings', lambda id: None)) == True
+    assert store.delete(('featured', 'app3'), mkref('user', lambda id: 'jdoe@example.com'), cache1, mkref('apps', getapp), mkref('ratings', lambda id: None)) == True
+    assert store.get(('featured',), mkref('user', lambda id: 'jdoe@example.com'), cache1, mkref('apps', getapp), mkref('ratings', lambda id: None)) == defstore
 
     # Delete a store
-    assert store.delete(('top',), mkref('user', lambda id: 'jdoe@example.com'), cache2, mkref('apps', lambda id: None)) == True
-    assert store.get(('top',), mkref('user', lambda id: 'jdoe@example.com'), cache2, mkref('apps', lambda id: None)) == defstore
+    assert store.delete(('featured',), mkref('user', lambda id: 'jdoe@example.com'), cache2, mkref('apps', getapp), mkref('ratings', lambda id: None)) == True
+    assert store.get(('featured',), mkref('user', lambda id: 'jdoe@example.com'), cache2, mkref('apps', getapp), mkref('ratings', lambda id: None)) == defstore
     return True
 
 def testDashboards():
+    getapp = lambda id: (("'entry", ("'title", car(id)), ("'id", car(id)), ("'author", 'jdoe@example.com'), ("'updated", '2012-01-01T00:00:00+00:00'), ("'content", ("'info", ("'description", car(id))))),)
+
     # Get default dashboard
     defdashboard = (("'feed", ("'title", 'Your Apps'), ("'id", 'jdoe@example.com')),)
-    assert dashboards.get((), mkref('user', lambda id: 'jdoe@example.com'), mkcache('cache', {}), mkref('apps', lambda id: None)) == defdashboard
+    assert dashboards.get((), mkref('user', lambda id: 'jdoe@example.com'), mkcache('cache', {}), mkref('apps', getapp), mkref('ratings', lambda id: None)) == defdashboard
 
     # Get the user's dashboard
-    dash1= (("'feed", ("'title", 'Your Apps'), ("'id", 'jdoe@example.com'), ("'entry", ("'title", 'app1'), ("'id", 'app1'), ("'author", 'jdoe@example.com'), ("'updated", 'Jan 02, 2012'), ("'content", ("'stats", ("'description", 'app1')))), ("'entry", ("'title", 'app2'), ("'id", 'app2'), ("'author", 'jdoe@example.com'), ("'updated", 'Jan 02, 2012'), ("'content", ("'stats", ("'description", 'app2'))))),)
-    assert dashboards.get((), mkref('user', lambda id: 'jdoe@example.com'), mkcache('cache', {('dashboards', 'jdoe@example.com', 'user.apps') : dash1}), mkref('apps', lambda id: None)) == dash1
+    dash1= (("'feed", ("'title", 'Your Apps'), ("'id", 'jdoe@example.com'), ("'entry", ("'title", 'app1'), ("'id", 'app1'), ("'author", 'jdoe@example.com'), ("'updated", '2012-01-01T00:00:00+00:00'), ("'content", ("'info", ("'description", 'app1')))), ("'entry", ("'title", 'app2'), ("'id", 'app2'), ("'author", 'jdoe@example.com'), ("'updated", '2012-01-01T00:00:00+00:00'), ("'content", ("'info", ("'description", 'app2'))))),)
+    assert dashboards.get((), mkref('user', lambda id: 'jdoe@example.com'), mkcache('cache', {('dashboards', 'jdoe@example.com', 'user.apps') : dash1}), mkref('apps', getapp), mkref('ratings', lambda id: None)) == dash1
     
-    dash1compact = (("'feed", ("'title", 'Your Apps'), ("'id", 'jdoe@example.com'), ("'entry", ((("'title", 'app1'), ("'id", 'app1'), ("'author", 'jdoe@example.com'), ("'updated", 'Jan 02, 2012'), ("'content", ("'stats", ("'description", 'app1')))), (("'title", 'app2'), ("'id", 'app2'), ("'author", 'jdoe@example.com'), ("'updated", 'Jan 02, 2012'), ("'content", ("'stats", ("'description", 'app2'))))))),)
-    assert dashboards.get((), mkref('user', lambda id: 'jdoe@example.com'), mkcache('cache', {('dashboards', 'jdoe@example.com', 'user.apps') : dash1compact}), mkref('apps', lambda id: None)) == dash1
+    dash1compact = (("'feed", ("'title", 'Your Apps'), ("'id", 'jdoe@example.com'), ("'entry", ((("'title", 'app1'), ("'id", 'app1'), ("'author", 'jdoe@example.com'), ("'updated", '2012-01-01T00:00:00+00:00'), ("'content", ("'info", ("'description", 'app1')))), (("'title", 'app2'), ("'id", 'app2'), ("'author", 'jdoe@example.com'), ("'updated", '2012-01-01T00:00:00+00:00'), ("'content", ("'info", ("'description", 'app2'))))))),)
+    assert dashboards.get((), mkref('user', lambda id: 'jdoe@example.com'), mkcache('cache', {('dashboards', 'jdoe@example.com', 'user.apps') : dash1compact}), mkref('apps', getapp), mkref('ratings', lambda id: None)) == dash1
 
     # Put an app in an empty dashboard
     cache1 = mkcache('cache', {})
-    app1 = (("'entry", ("'title", 'app1'), ("'id", 'app1'), ("'author", 'jdoe@example.com'), ("'updated", 'Jan 02, 2012'), ("'content", ("'stats", ("'description", 'app1')))),)
-    dash1withapp1 = (("'feed", ("'title", 'Your Apps'), ("'id", 'jdoe@example.com'), ("'entry", ("'title", 'app1'), ("'id", 'app1'), ("'author", 'jdoe@example.com'), ("'updated", 'Jan 02, 2012'), ("'content", ("'stats", ("'description", 'app1'))))),)
-    assert dashboards.put(('app1',), app1, mkref('user', lambda id: 'jdoe@example.com'), cache1, mkref('apps', lambda id: None)) == True
-    assert dashboards.get((), mkref('user', lambda id: 'jdoe@example.com'), cache1, mkref('apps', lambda id: None)) == dash1withapp1
-    assert dashboards.put(('app1',), app1, mkref('user', lambda id: 'jdoe@example.com'), cache1, mkref('apps', lambda id: None)) == True
-    assert dashboards.get((), mkref('user', lambda id: 'jdoe@example.com'), cache1, mkref('apps', lambda id: None)) == dash1withapp1
+    app1 = (("'entry", ("'title", 'app1'), ("'id", 'app1'), ("'author", 'jdoe@example.com'), ("'updated", '2012-01-01T00:00:00+00:00'), ("'content", ("'info", ("'description", 'app1')))),)
+    dash1withapp1 = (("'feed", ("'title", 'Your Apps'), ("'id", 'jdoe@example.com'), ("'entry", ("'title", 'app1'), ("'id", 'app1'), ("'author", 'jdoe@example.com'), ("'updated", '2012-01-01T00:00:00+00:00'), ("'content", ("'info", ("'description", 'app1'))))),)
+    assert dashboards.put(('app1',), app1, mkref('user', lambda id: 'jdoe@example.com'), cache1, mkref('apps', getapp), mkref('ratings', lambda id: None)) == True
+    assert dashboards.get((), mkref('user', lambda id: 'jdoe@example.com'), cache1, mkref('apps', getapp), mkref('ratings', lambda id: None)) == dash1withapp1
+    assert dashboards.put(('app1',), app1, mkref('user', lambda id: 'jdoe@example.com'), cache1, mkref('apps', getapp), mkref('ratings', lambda id: None)) == True
+    assert dashboards.get((), mkref('user', lambda id: 'jdoe@example.com'), cache1, mkref('apps', getapp), mkref('ratings', lambda id: None)) == dash1withapp1
 
     # Put a second app in the dashboard
-    app2 = (("'entry", ("'title", 'app2'), ("'id", 'app2'), ("'author", 'jdoe@example.com'), ("'updated", 'Jan 02, 2012'), ("'content", ("'stats", ("'description", 'app2')))),)
-    dash1withapp2 = (("'feed", ("'title", 'Your Apps'), ("'id", 'jdoe@example.com'), ("'entry", ("'title", 'app1'), ("'id", 'app1'), ("'author", 'jdoe@example.com'), ("'updated", 'Jan 02, 2012'), ("'content", ("'stats", ("'description", 'app1')))), ("'entry", ("'title", 'app2'), ("'id", 'app2'), ("'author", 'jdoe@example.com'), ("'updated", 'Jan 02, 2012'), ("'content", ("'stats", ("'description", 'app2'))))),)
-    assert dashboards.put(('app2',), app2, mkref('user', lambda id: 'jdoe@example.com'), cache1, mkref('apps', lambda id: None)) == True
-    assert dashboards.get((), mkref('user', lambda id: 'jdoe@example.com'), cache1, mkref('apps', lambda id: None)) == dash1withapp2
-    assert dashboards.put(('app1',), app1, mkref('user', lambda id: 'jdoe@example.com'), cache1, mkref('apps', lambda id: None)) == True
-    assert dashboards.get((), mkref('user', lambda id: 'jdoe@example.com'), cache1, mkref('apps', lambda id: None)) == dash1withapp2
-    assert dashboards.put(('app2',), app2, mkref('user', lambda id: 'jdoe@example.com'), cache1, mkref('apps', lambda id: None)) == True
-    assert dashboards.get((), mkref('user', lambda id: 'jdoe@example.com'), cache1, mkref('apps', lambda id: None)) == dash1withapp2
+    app2 = (("'entry", ("'title", 'app2'), ("'id", 'app2'), ("'author", 'jdoe@example.com'), ("'updated", '2012-01-01T00:00:00+00:00'), ("'content", ("'info", ("'description", 'app2')))),)
+    dash1withapp2 = (("'feed", ("'title", 'Your Apps'), ("'id", 'jdoe@example.com'), ("'entry", ("'title", 'app1'), ("'id", 'app1'), ("'author", 'jdoe@example.com'), ("'updated", '2012-01-01T00:00:00+00:00'), ("'content", ("'info", ("'description", 'app1')))), ("'entry", ("'title", 'app2'), ("'id", 'app2'), ("'author", 'jdoe@example.com'), ("'updated", '2012-01-01T00:00:00+00:00'), ("'content", ("'info", ("'description", 'app2'))))),)
+    assert dashboards.put(('app2',), app2, mkref('user', lambda id: 'jdoe@example.com'), cache1, mkref('apps', getapp), mkref('ratings', lambda id: None)) == True
+    assert dashboards.get((), mkref('user', lambda id: 'jdoe@example.com'), cache1, mkref('apps', getapp), mkref('ratings', lambda id: None)) == dash1withapp2
+    assert dashboards.put(('app1',), app1, mkref('user', lambda id: 'jdoe@example.com'), cache1, mkref('apps', getapp), mkref('ratings', lambda id: None)) == True
+    assert dashboards.get((), mkref('user', lambda id: 'jdoe@example.com'), cache1, mkref('apps', getapp), mkref('ratings', lambda id: None)) == dash1withapp2
+    assert dashboards.put(('app2',), app2, mkref('user', lambda id: 'jdoe@example.com'), cache1, mkref('apps', getapp), mkref('ratings', lambda id: None)) == True
+    assert dashboards.get((), mkref('user', lambda id: 'jdoe@example.com'), cache1, mkref('apps', getapp), mkref('ratings', lambda id: None)) == dash1withapp2
 
     # Put a third app in the dashboard
-    app3 = (("'entry", ("'title", 'app3'), ("'id", 'app3'), ("'author", 'jdoe@example.com'), ("'updated", 'Jan 02, 2012'), ("'content", ("'stats", ("'description", 'app3')))),)
-    dash1withapp3 = (("'feed", ("'title", 'Your Apps'), ("'id", 'jdoe@example.com'), ("'entry", ("'title", 'app1'), ("'id", 'app1'), ("'author", 'jdoe@example.com'), ("'updated", 'Jan 02, 2012'), ("'content", ("'stats", ("'description", 'app1')))), ("'entry", ("'title", 'app2'), ("'id", 'app2'), ("'author", 'jdoe@example.com'), ("'updated", 'Jan 02, 2012'), ("'content", ("'stats", ("'description", 'app2')))), ("'entry", ("'title", 'app3'), ("'id", 'app3'), ("'author", 'jdoe@example.com'), ("'updated", 'Jan 02, 2012'), ("'content", ("'stats", ("'description", 'app3'))))),)
-    assert dashboards.put(('app3',), app3, mkref('user', lambda id: 'jdoe@example.com'), cache1, mkref('apps', lambda id: None)) == True
-    assert dashboards.get((), mkref('user', lambda id: 'jdoe@example.com'), cache1, mkref('apps', lambda id: None)) == dash1withapp3
-    assert dashboards.put(('app1',), app1, mkref('user', lambda id: 'jdoe@example.com'), cache1, mkref('apps', lambda id: None)) == True
-    assert dashboards.get((), mkref('user', lambda id: 'jdoe@example.com'), cache1, mkref('apps', lambda id: None)) == dash1withapp3
-    assert dashboards.put(('app2',), app2, mkref('user', lambda id: 'jdoe@example.com'), cache1, mkref('apps', lambda id: None)) == True
-    assert dashboards.get((), mkref('user', lambda id: 'jdoe@example.com'), cache1, mkref('apps', lambda id: None)) == dash1withapp3
-    assert dashboards.put(('app3',), app3, mkref('user', lambda id: 'jdoe@example.com'), cache1, mkref('apps', lambda id: None)) == True
-    assert dashboards.get((), mkref('user', lambda id: 'jdoe@example.com'), cache1, mkref('apps', lambda id: None)) == dash1withapp3
+    app3 = (("'entry", ("'title", 'app3'), ("'id", 'app3'), ("'author", 'jdoe@example.com'), ("'updated", '2012-01-01T00:00:00+00:00'), ("'content", ("'info", ("'description", 'app3')))),)
+    dash1withapp3 = (("'feed", ("'title", 'Your Apps'), ("'id", 'jdoe@example.com'), ("'entry", ("'title", 'app1'), ("'id", 'app1'), ("'author", 'jdoe@example.com'), ("'updated", '2012-01-01T00:00:00+00:00'), ("'content", ("'info", ("'description", 'app1')))), ("'entry", ("'title", 'app2'), ("'id", 'app2'), ("'author", 'jdoe@example.com'), ("'updated", '2012-01-01T00:00:00+00:00'), ("'content", ("'info", ("'description", 'app2')))), ("'entry", ("'title", 'app3'), ("'id", 'app3'), ("'author", 'jdoe@example.com'), ("'updated", '2012-01-01T00:00:00+00:00'), ("'content", ("'info", ("'description", 'app3'))))),)
+    assert dashboards.put(('app3',), app3, mkref('user', lambda id: 'jdoe@example.com'), cache1, mkref('apps', getapp), mkref('ratings', lambda id: None)) == True
+    assert dashboards.get((), mkref('user', lambda id: 'jdoe@example.com'), cache1, mkref('apps', getapp), mkref('ratings', lambda id: None)) == dash1withapp3
+    assert dashboards.put(('app1',), app1, mkref('user', lambda id: 'jdoe@example.com'), cache1, mkref('apps', getapp), mkref('ratings', lambda id: None)) == True
+    assert dashboards.get((), mkref('user', lambda id: 'jdoe@example.com'), cache1, mkref('apps', getapp), mkref('ratings', lambda id: None)) == dash1withapp3
+    assert dashboards.put(('app2',), app2, mkref('user', lambda id: 'jdoe@example.com'), cache1, mkref('apps', getapp), mkref('ratings', lambda id: None)) == True
+    assert dashboards.get((), mkref('user', lambda id: 'jdoe@example.com'), cache1, mkref('apps', getapp), mkref('ratings', lambda id: None)) == dash1withapp3
+    assert dashboards.put(('app3',), app3, mkref('user', lambda id: 'jdoe@example.com'), cache1, mkref('apps', getapp), mkref('ratings', lambda id: None)) == True
+    assert dashboards.get((), mkref('user', lambda id: 'jdoe@example.com'), cache1, mkref('apps', getapp), mkref('ratings', lambda id: None)) == dash1withapp3
 
     # Get an app from the user's dashboard
-    assert dashboards.get(('app1',), mkref('user', lambda id: 'jdoe@example.com'), cache1, mkref('apps', lambda id: None)) == app1
-    assert dashboards.get(('app2',), mkref('user', lambda id: 'jdoe@example.com'), cache1, mkref('apps', lambda id: None)) == app2
-    assert dashboards.get(('app3',), mkref('user', lambda id: 'jdoe@example.com'), cache1, mkref('apps', lambda id: None)) == app3
+    assert dashboards.get(('app1',), mkref('user', lambda id: 'jdoe@example.com'), cache1, mkref('apps', getapp), mkref('ratings', lambda id: None)) == app1
+    assert dashboards.get(('app2',), mkref('user', lambda id: 'jdoe@example.com'), cache1, mkref('apps', getapp), mkref('ratings', lambda id: None)) == app2
+    assert dashboards.get(('app3',), mkref('user', lambda id: 'jdoe@example.com'), cache1, mkref('apps', getapp), mkref('ratings', lambda id: None)) == app3
 
     # Put a third app in the dashboard, starting from a compacted list
     cache2 = mkcache('cache', {('dashboards', 'jdoe@example.com', 'user.apps') : dash1compact})
-    assert dashboards.put(('app3',), app3, mkref('user', lambda id: 'jdoe@example.com'), cache2, mkref('apps', lambda id: None)) == True
-    assert dashboards.get((), mkref('user', lambda id: 'jdoe@example.com'), cache2, mkref('apps', lambda id: None)) == dash1withapp3
+    assert dashboards.put(('app3',), app3, mkref('user', lambda id: 'jdoe@example.com'), cache2, mkref('apps', getapp), mkref('ratings', lambda id: None)) == True
+    assert dashboards.get((), mkref('user', lambda id: 'jdoe@example.com'), cache2, mkref('apps', getapp), mkref('ratings', lambda id: None)) == dash1withapp3
 
     # Delete the apps
-    assert dashboards.delete(('app2',), mkref('user', lambda id: 'jdoe@example.com'), cache1, mkref('apps', lambda id: None)) == True
-    assert dashboards.delete(('app4',), mkref('user', lambda id: 'jdoe@example.com'), cache1, mkref('apps', lambda id: None)) == False
-    assert dashboards.delete(('app1',), mkref('user', lambda id: 'jdoe@example.com'), cache1, mkref('apps', lambda id: None)) == True
-    assert dashboards.delete(('app3',), mkref('user', lambda id: 'jdoe@example.com'), cache1, mkref('apps', lambda id: None)) == True
-    assert dashboards.get((), mkref('user', lambda id: 'jdoe@example.com'), cache1, mkref('apps', lambda id: None)) == defdashboard
+    assert dashboards.delete(('app2',), mkref('user', lambda id: 'jdoe@example.com'), cache1, mkref('apps', getapp), mkref('ratings', lambda id: None)) == True
+    assert dashboards.delete(('app4',), mkref('user', lambda id: 'jdoe@example.com'), cache1, mkref('apps', getapp), mkref('ratings', lambda id: None)) == False
+    assert dashboards.delete(('app1',), mkref('user', lambda id: 'jdoe@example.com'), cache1, mkref('apps', getapp), mkref('ratings', lambda id: None)) == True
+    assert dashboards.delete(('app3',), mkref('user', lambda id: 'jdoe@example.com'), cache1, mkref('apps', getapp), mkref('ratings', lambda id: None)) == True
+    assert dashboards.get((), mkref('user', lambda id: 'jdoe@example.com'), cache1, mkref('apps', getapp), mkref('ratings', lambda id: None)) == defdashboard
 
     # Delete the dashboard
-    assert dashboards.delete((), mkref('user', lambda id: 'jdoe@example.com'), cache2, mkref('apps', lambda id: None)) == True
-    assert dashboards.get((), mkref('user', lambda id: 'jdoe@example.com'), cache2, mkref('apps', lambda id: None)) == defdashboard
+    assert dashboards.delete((), mkref('user', lambda id: 'jdoe@example.com'), cache2, mkref('apps', getapp), mkref('ratings', lambda id: None)) == True
+    assert dashboards.get((), mkref('user', lambda id: 'jdoe@example.com'), cache2, mkref('apps', getapp), mkref('ratings', lambda id: None)) == defdashboard
+    return True
+
+def testReviews():
+    app1 = (("'entry", ("'title", 'app1'), ("'id", 'app1'), ("'author", 'jdoe@example.com'), ("'updated", '2012-01-01T00:00:00+00:00'), ("'content", ("'info", ("'description", '')))),)
+
+    # Get default reviews
+    defreviews = (("'feed", ("'title", 'Your Reviews'), ("'id", 'jdoe@example.com')),)
+    assert reviews.get((), mkref('user', lambda id: 'jdoe@example.com'), mkcache('cache', {}), mkref('apps', lambda id: app1), mkref('ratings', lambda id, patch: True)) == defreviews
+
+    # Get the user's reviews
+    reviews1= (("'feed", ("'title", 'Your Reviews'), ("'id", 'jdoe@example.com'), ("'entry", ("'title", 'app1'), ("'id", 'app1'), ("'author", 'jdoe@example.com'), ("'updated", '2012-01-01T00:00:00+00:00'), ("'content", ("'rating", '1'))), ("'entry", ("'title", 'app2'), ("'id", 'app2'), ("'author", 'jdoe@example.com'), ("'updated", '2012-01-01T00:00:00+00:00'), ("'content", ("'rating", '2')))),)
+    assert reviews.get((), mkref('user', lambda id: 'jdoe@example.com'), mkcache('cache', {('reviews', 'jdoe@example.com', 'user.reviews') : reviews1}), mkref('apps', lambda id: app1), mkref('ratings', lambda id, patch: True)) == reviews1
+    
+    reviews1compact = (("'feed", ("'title", 'Your Reviews'), ("'id", 'jdoe@example.com'), ("'entry", ((("'title", 'app1'), ("'id", 'app1'), ("'author", 'jdoe@example.com'), ("'updated", '2012-01-01T00:00:00+00:00'), ("'content", ("'rating", '1'))), (("'title", 'app2'), ("'id", 'app2'), ("'author", 'jdoe@example.com'), ("'updated", '2012-01-01T00:00:00+00:00'), ("'content", ("'rating", '2')))))),)
+    assert reviews.get((), mkref('user', lambda id: 'jdoe@example.com'), mkcache('cache', {('reviews', 'jdoe@example.com', 'user.reviews') : reviews1compact}), mkref('apps', lambda id: app1), mkref('ratings', lambda id, patch: True)) == reviews1
+
+    # Put a review in an empty reviews record
+    cache1 = mkcache('cache', {})
+    app1review = (("'entry", ("'title", 'app1'), ("'id", 'app1'), ("'author", 'jdoe@example.com'), ("'updated", '2012-01-01T00:00:00+00:00'), ("'content", ("'rating", '1'))),)
+    reviews1withapp1 = (("'feed", ("'title", 'Your Reviews'), ("'id", 'jdoe@example.com'), ("'entry", ("'title", 'app1'), ("'id", 'app1'), ("'author", 'jdoe@example.com'), ("'updated", '2012-01-01T00:00:00+00:00'), ("'content", ("'rating", '1')))),)
+    assert reviews.put(('app1',), app1review, mkref('user', lambda id: 'jdoe@example.com'), cache1, mkref('apps', lambda id: app1), mkref('ratings', lambda id, patch: True)) == True
+    assert reviews.get((), mkref('user', lambda id: 'jdoe@example.com'), cache1, mkref('apps', lambda id: app1), mkref('ratings', lambda id, patch: True)) == reviews1withapp1
+    assert reviews.put(('app1',), app1review, mkref('user', lambda id: 'jdoe@example.com'), cache1, mkref('apps', lambda id: app1), mkref('ratings', lambda id, patch: True)) == True
+    assert reviews.get((), mkref('user', lambda id: 'jdoe@example.com'), cache1, mkref('apps', lambda id: app1), mkref('ratings', lambda id, patch: True)) == reviews1withapp1
+
+    # Put a second review in the reviews record
+    app2review = (("'entry", ("'title", 'app2'), ("'id", 'app2'), ("'author", 'jdoe@example.com'), ("'updated", '2012-01-01T00:00:00+00:00'), ("'content", ("'rating", '2'))),)
+    reviews1withapp2 = (("'feed", ("'title", 'Your Reviews'), ("'id", 'jdoe@example.com'), ("'entry", ("'title", 'app1'), ("'id", 'app1'), ("'author", 'jdoe@example.com'), ("'updated", '2012-01-01T00:00:00+00:00'), ("'content", ("'rating", '1'))), ("'entry", ("'title", 'app2'), ("'id", 'app2'), ("'author", 'jdoe@example.com'), ("'updated", '2012-01-01T00:00:00+00:00'), ("'content", ("'rating", '2')))),)
+    assert reviews.put(('app2',), app2review, mkref('user', lambda id: 'jdoe@example.com'), cache1, mkref('apps', lambda id: app1), mkref('ratings', lambda id, patch: True)) == True
+    assert reviews.get((), mkref('user', lambda id: 'jdoe@example.com'), cache1, mkref('apps', lambda id: app1), mkref('ratings', lambda id, patch: True)) == reviews1withapp2
+    assert reviews.put(('app1',), app1review, mkref('user', lambda id: 'jdoe@example.com'), cache1, mkref('apps', lambda id: app1), mkref('ratings', lambda id, patch: True)) == True
+    assert reviews.get((), mkref('user', lambda id: 'jdoe@example.com'), cache1, mkref('apps', lambda id: app1), mkref('ratings', lambda id, patch: True)) == reviews1withapp2
+    assert reviews.put(('app2',), app2review, mkref('user', lambda id: 'jdoe@example.com'), cache1, mkref('apps', lambda id: app1), mkref('ratings', lambda id, patch: True)) == True
+    assert reviews.get((), mkref('user', lambda id: 'jdoe@example.com'), cache1, mkref('apps', lambda id: app1), mkref('ratings', lambda id, patch: True)) == reviews1withapp2
+
+    # Put a third review in the reviews record
+    app3review = (("'entry", ("'title", 'app3'), ("'id", 'app3'), ("'author", 'jdoe@example.com'), ("'updated", '2012-01-01T00:00:00+00:00'), ("'content", ("'rating", '3'))),)
+    reviews1withapp3 = (("'feed", ("'title", 'Your Reviews'), ("'id", 'jdoe@example.com'), ("'entry", ("'title", 'app1'), ("'id", 'app1'), ("'author", 'jdoe@example.com'), ("'updated", '2012-01-01T00:00:00+00:00'), ("'content", ("'rating", '1'))), ("'entry", ("'title", 'app2'), ("'id", 'app2'), ("'author", 'jdoe@example.com'), ("'updated", '2012-01-01T00:00:00+00:00'), ("'content", ("'rating", '2'))), ("'entry", ("'title", 'app3'), ("'id", 'app3'), ("'author", 'jdoe@example.com'), ("'updated", '2012-01-01T00:00:00+00:00'), ("'content", ("'rating", '3')))),)
+    assert reviews.put(('app3',), app3review, mkref('user', lambda id: 'jdoe@example.com'), cache1, mkref('apps', lambda id: app1), mkref('ratings', lambda id, patch: True)) == True
+    assert reviews.get((), mkref('user', lambda id: 'jdoe@example.com'), cache1, mkref('apps', lambda id: app1), mkref('ratings', lambda id, patch: True)) == reviews1withapp3
+    assert reviews.put(('app1',), app1review, mkref('user', lambda id: 'jdoe@example.com'), cache1, mkref('apps', lambda id: app1), mkref('ratings', lambda id, patch: True)) == True
+    assert reviews.get((), mkref('user', lambda id: 'jdoe@example.com'), cache1, mkref('apps', lambda id: app1), mkref('ratings', lambda id, patch: True)) == reviews1withapp3
+    assert reviews.put(('app2',), app2review, mkref('user', lambda id: 'jdoe@example.com'), cache1, mkref('apps', lambda id: app1), mkref('ratings', lambda id, patch: True)) == True
+    assert reviews.get((), mkref('user', lambda id: 'jdoe@example.com'), cache1, mkref('apps', lambda id: app1), mkref('ratings', lambda id, patch: True)) == reviews1withapp3
+    assert reviews.put(('app3',), app3review, mkref('user', lambda id: 'jdoe@example.com'), cache1, mkref('apps', lambda id: app1), mkref('ratings', lambda id, patch: True)) == True
+    assert reviews.get((), mkref('user', lambda id: 'jdoe@example.com'), cache1, mkref('apps', lambda id: app1), mkref('ratings', lambda id, patch: True)) == reviews1withapp3
+
+    # Get a review from the user's reviews
+    assert reviews.get(('app1',), mkref('user', lambda id: 'jdoe@example.com'), cache1, mkref('apps', lambda id: app1), mkref('ratings', lambda id, patch: True)) == app1review
+    assert reviews.get(('app2',), mkref('user', lambda id: 'jdoe@example.com'), cache1, mkref('apps', lambda id: app1), mkref('ratings', lambda id, patch: True)) == app2review
+    assert reviews.get(('app3',), mkref('user', lambda id: 'jdoe@example.com'), cache1, mkref('apps', lambda id: app1), mkref('ratings', lambda id, patch: True)) == app3review
+
+    # Put a third review in the user's reviews record, starting from a compacted list
+    cache2 = mkcache('cache', {('reviews', 'jdoe@example.com', 'user.reviews') : reviews1compact})
+    assert reviews.put(('app3',), app3review, mkref('user', lambda id: 'jdoe@example.com'), cache2, mkref('apps', lambda id: app1), mkref('ratings', lambda id, patch: True)) == True
+    assert reviews.get((), mkref('user', lambda id: 'jdoe@example.com'), cache2, mkref('apps', lambda id: app1), mkref('ratings', lambda id, patch: True)) == reviews1withapp3
+
+    # Delete the reviews
+    assert reviews.delete(('app2',), mkref('user', lambda id: 'jdoe@example.com'), cache1, mkref('apps', lambda id: app1), mkref('ratings', lambda id, patch: True)) == True
+    assert reviews.delete(('app4',), mkref('user', lambda id: 'jdoe@example.com'), cache1, mkref('apps', lambda id: app1), mkref('ratings', lambda id, patch: True)) == False
+    assert reviews.delete(('app1',), mkref('user', lambda id: 'jdoe@example.com'), cache1, mkref('apps', lambda id: app1), mkref('ratings', lambda id, patch: True)) == True
+    assert reviews.delete(('app3',), mkref('user', lambda id: 'jdoe@example.com'), cache1, mkref('apps', lambda id: app1), mkref('ratings', lambda id, patch: True)) == True
+    assert reviews.get((), mkref('user', lambda id: 'jdoe@example.com'), cache1, mkref('apps', lambda id: app1), mkref('ratings', lambda id, patch: True)) == defreviews
+
+    # Delete the reviews record
+    assert reviews.delete((), mkref('user', lambda id: 'jdoe@example.com'), cache2, mkref('apps', lambda id: app1), mkref('ratings', lambda id, patch: True)) == True
+    assert reviews.get((), mkref('user', lambda id: 'jdoe@example.com'), cache2, mkref('apps', lambda id: app1), mkref('ratings', lambda id, patch: True)) == defreviews
+    return True
+
+def testRatings():
+    # Get default ratings
+    defratings = (("'entry", ("'title", 'app1'), ("'id", 'app1'), ("'author", 'jdoe@example.com'), ("'updated", '2012-01-01T00:00:00+00:00'), ("'content",)),)
+    app1 = (("'entry", ("'title", 'app1'), ("'id", 'app1'), ("'author", 'jdoe@example.com'), ("'updated", '2012-01-03T00:00:00+00:00'), ("'content",)),)
+    assert ratings.get(('app1',), mkref('user', lambda id: 'jdoe@example.com'), mkcache('cache', {}), mkref('db', lambda id: None), mkref('apps', lambda id: None)) == defratings
+    defratingsfromapp = (("'entry", ("'title", 'app1'), ("'id", 'app1'), ("'author", 'jdoe@example.com'), ("'updated", '2012-01-01T00:00:00+00:00'), ("'content",)),)
+    assert ratings.get(('app1',), mkref('user', lambda id: 'jdoe@example.com'), mkcache('cache', {}), mkref('db', lambda id: None), mkref('apps', lambda id: app1)) == defratingsfromapp
+
+    # Get an app ratings
+    ratings1 = (("'entry", ("'title", 'app1'), ("'id", 'app1'), ("'author", 'jdoe@example.com'), ("'updated", '2012-01-02T00:00:00+00:00'), ("'content",)),)
+    assert ratings.get(('app1',), mkref('user', lambda id: 'jdoe@example.com'), mkcache('cache', {('ratings', 'app1', 'app.ratings') : ratings1}), mkref('db', lambda id: None), mkref('apps', lambda id: app1)) == ratings1
+
+    # Put and get an app ratings
+    cache1 = mkcache('cache', {})
+    ratings1updated = (("'entry", ("'title", 'app1'), ("'id", 'app1'), ("'author", 'jdoe@example.com'), ("'updated", '2012-01-01T00:00:00+00:00'), ("'content",)),)
+    assert ratings.put(('app1',), ratings1, mkref('user', lambda id: 'admin'), cache1, mkref('db', lambda id: None), mkref('apps', lambda id: app1)) == True
+    assert ratings.get(('app1',), mkref('user', lambda id: 'jdoe@example.com'), cache1, mkref('db', lambda id: None), mkref('apps', lambda id: app1)) == ratings1updated
+    
+    # Reject put from user other than admin
+    ratings1otheruser = (("'entry", ("'title", 'app1'), ("'id", 'app1'), ("'author", 'jdoe@example.com'), ("'updated", '2012-01-04T00:00:00+00:00'), ("'content",)),)
+    assert ratings.put(('app1',), ratings1otheruser, mkref('user', lambda id: 'jdoe@example.com'), cache1, mkref('db', lambda id: None), mkref('apps', lambda id: app1)) == False
+    assert ratings.get(('app1',), mkref('user', lambda id: 'jdoe@example.com'), cache1, mkref('db', lambda id: None), mkref('apps', lambda id: app1)) == ratings1updated
+
+    # Patch an app ratings
+    app2 = (("'entry", ("'title", 'app2'), ("'id", 'app2'), ("'author", 'jdoe@example.com'), ("'updated", '2012-01-03T00:00:00+00:00'), ("'content",)),)
+    ratings2patch = (("'entry", ("'title", 'app2'), ("'id", 'app2'), ("'author", 'jdoe@example.com'), ("'updated", '2012-01-01T00:00:00+00:00'), ("'content", ("'patch", ("'old", '3'), ("'new", '4')))),)
+    assert ratings.patch(('app2',), ratings2patch, mkref('user', lambda id: 'jdoe@example.com'), cache1, mkref('db', lambda id: None), mkref('apps', lambda id: app2)) == True
+    ratings2patched = cache1.get(("ratings", 'app2', "app.ratings"))
+    assert(cadr(content(ratings2patched)).find('(define orating 3)') != -1)
+    assert(cadr(content(ratings2patched)).find('(define nrating 4)') != -1)
+
+    # Reject delete from user other than admin
+    assert ratings.delete(('app1',), mkref('user', lambda id: 'jdoe@example.com'), cache1, mkref('db', lambda id: None), mkref('apps', lambda id: app1)) == False
+    assert ratings.get(('app1',), mkref('user', lambda id: 'jdoe@example.com'), cache1, mkref('db', lambda id: None), mkref('apps', lambda id: app1)) == ratings1updated
+
+    # Delete an app ratings
+    assert ratings.delete(('app1',), mkref('user', lambda id: 'admin'), cache1, mkref('db', lambda id: None), mkref('apps', lambda id: app1)) == True
+    assert ratings.get(('app1',), mkref('user', lambda id: 'jdoe@example.com'), cache1, mkref('db', lambda id: None), mkref('apps', lambda id: app1)) == defratingsfromapp
+    return True
+
+def testSelector():
+    assert selector.get(('apps', 'abc', 'app.info'), (1, 2)) == (1,)
+    assert selector.get(('apps', 'nbc', 'app.info'), (1, 2)) == (2,)
+    assert selector.get(('apps', 'nbc', 'app.info', ("'xyz", 'utv')), (1, 2)) == (2,)
+    assert selector.get(('apps', 'nbc', 'app.info', ("'limit", '10')), (1, 2)) == (1, 2)
+    return True
+
+def testSearch():
+    assert search.get((("'q", 'abc def'),), mkref('user', lambda id: 'jdoe@example.com'), mkcache('cache', {}), mkref('db', lambda id: ((("'entry", ("'title", 'app1'), ("'id", 'app1'), ("'author", 'jdoe@example.com'), ("'updated", '2012-01-02T00:00:00+00:00'), ("'content", ("'info", ("'description", 'app1')))),), (("'entry", ("'title", 'app2'), ("'id", 'app2'), ("'author", 'jdoe@example.com'), ("'updated", '2012-01-02T00:00:00+00:00'), ("'content", ("'info", ("'description", 'app2')))),))), mkref('apps', lambda id: None), mkref('ratings', lambda id: None)) == (("'feed", ("'title", 'Search Results'), ("'id", 'search'), ("'entry", ("'title", 'app1'), ("'id", 'app1'), ("'author", 'jdoe@example.com'), ("'updated", '2012-01-02T00:00:00+00:00'), ("'content", ("'info", ("'description", 'app1')))), ("'entry", ("'title", 'app2'), ("'id", 'app2'), ("'author", 'jdoe@example.com'), ("'updated", '2012-01-02T00:00:00+00:00'), ("'content", ("'info", ("'description", 'app2'))))),)
     return True
 
 if __name__ == '__main__':
     print 'Testing...'
     testUser()
     testAccounts()
+    testPictures()
     testPages()
     testComposites()
+    testIcons()
     testApps()
     testStore()
     testDashboards()
+    testReviews()
+    testRatings()
+    testSelector()
+    testSearch()
     print 'OK'
 
diff --git a/hosting/server/test/cache.py b/hosting/server/test/cache.py
index 98fa174..54462ab 100644
--- a/hosting/server/test/cache.py
+++ b/hosting/server/test/cache.py
@@ -31,6 +31,10 @@
         self.values[id] = value
         return True
 
+    def patch(self, id, value):
+        self.values[id] = value
+        return True
+
     def post(self, id):
         return self.put(id)
 
diff --git a/hosting/server/test/reference.py b/hosting/server/test/reference.py
index fe4a66a..df422b2 100644
--- a/hosting/server/test/reference.py
+++ b/hosting/server/test/reference.py
@@ -26,7 +26,7 @@
         return self.l(*args)
 
     def __getattr__(self, name):
-        if name == "get" or name == "put":
+        if name == 'get' or name == 'put' or name == 'patch' or name == 'post' or name == 'delete':
             return self
         raise AttributeError()
 
diff --git a/hosting/server/util.py b/hosting/server/util.py
index 24467fd..791951a 100644
--- a/hosting/server/util.py
+++ b/hosting/server/util.py
@@ -60,7 +60,7 @@
 def isNil(l):
     if isinstance(l, streampair):
         return l.isNil()
-    return l == ()
+    return l is None or l == ()
 
 def isSymbol(v):
     return isinstance(v, basestring) and v[0:1] == "'"
@@ -131,12 +131,25 @@
 # Scheme-like associations
 def assoc(k, l):
     if l == ():
-        return None
-
+        return ()
     if k == car(car(l)):
         return car(l)
     return assoc(k, cdr(l))
 
+def delAssoc(k, l):
+    if l == ():
+        return ()
+    if k == car(car(l)):
+        return delAssoc(k, cdr(l))
+    return cons(car(l), delAssoc(k, cdr(l)))
+
+def substAssoc(k, n, l, a = False):
+    if l == ():
+        return (n,) if a else ()
+    if k == car(car(l)):
+        return cons(n, substAssoc(k, n, cdr(l), False))
+    return cons(car(l), substAssoc(k, n, cdr(l), a))
+
 # Currying / partial function application
 def curry(f, *args):
     return lambda *a: f(*(args + a))
diff --git a/modules/http/base64-encode b/modules/http/base64-encode
new file mode 100755
index 0000000..a4422ec
--- /dev/null
+++ b/modules/http/base64-encode
@@ -0,0 +1,32 @@
+#!/bin/sh
+
+#  Licensed to the Apache Software Foundation (ASF) under one
+#  or more contributor license agreements.  See the NOTICE file
+#  distributed with this work for additional information
+#  regarding copyright ownership.  The ASF licenses this file
+#  to you under the Apache License, Version 2.0 (the
+#  "License"); you may not use this file except in compliance
+#  with the License.  You may obtain a copy of the License at
+#  
+#    http://www.apache.org/licenses/LICENSE-2.0
+#    
+#  Unless required by applicable law or agreed to in writing,
+#  software distributed under the License is distributed on an
+#  "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+#  KIND, either express or implied.  See the License for the
+#  specific language governing permissions and limitations
+#  under the License.
+
+# Encode a file to base64
+here=`echo "import os; print os.path.realpath('$0')" | python`; here=`dirname $here`
+
+src="$1"
+b64="$2"
+
+uname=`uname -s`
+if [ $uname = "Darwin" ]; then
+    base64 -i ${src} -o ${b64}
+else
+    base64 -w 0 < ${src} > ${b64}
+fi
+
diff --git a/modules/js/htdocs/atomutil.js b/modules/js/htdocs/atomutil.js
index 068b5de..3d4e9d0 100644
--- a/modules/js/htdocs/atomutil.js
+++ b/modules/js/htdocs/atomutil.js
@@ -42,7 +42,7 @@
     var u = isNil(lu)? mklist() : mklist(mklist(element, "'updated", elementValue(car(lu))));
 
     var lc = filter(selector(mklist(element, "'content")), e);
-    var c = isNil(lc)? mklist() : mklist(mklist(element, "'content", elementValue(car(lc))));
+    var c = isNil(lc)? mklist() : isAttribute(elementValue(car(lc)))? mklist() : mklist(mklist(element, "'content", elementValue(car(lc))));
 
     return append(append(append(mklist(element, "'entry", t, i), a), u), c);
 };
@@ -127,9 +127,9 @@
     return append(append(append(append(
             mklist(element, "'entry", mklist(attribute, "'xmlns", "http://www.w3.org/2005/Atom"),
                 mklist(element, "'title", mklist(attribute, "'type", "text"), title), mklist(element, "'id", id)),
-                isNil(author)? mklist() : mklist(element, "'author",
-                    (email? mklist(element, "'email", elementValue(author)) : mklist(element, "'name", elementValue(author))))),
-                isNil(updated)? mklist() : mklist(element, "'updated", elementValue(updated))),
+                isNil(author)? mklist() : mklist(mklist(element, "'author",
+                    (email? mklist(element, "'email", elementValue(author)) : mklist(element, "'name", elementValue(author)))))),
+                isNil(updated)? mklist() : mklist(mklist(element, "'updated", elementValue(updated)))),
                 isNil(content)? mklist() :
                     mklist(append(mklist(element, "'content", mklist(attribute, "'type", text? "text" : "application/xml")),
                         text? mklist(elementValue(content)) : elementChildren(content)))),
diff --git a/modules/js/htdocs/component.js b/modules/js/htdocs/component.js
index c3799ef..a34ebfa 100644
--- a/modules/js/htdocs/component.js
+++ b/modules/js/htdocs/component.js
@@ -38,11 +38,11 @@
  */
 JSONClient.escapeJSONChar = function(c) {
     if(c == "\"" || c == "\\") return "\\" + c;
-    if (c == "\b") return "\\b";
-    if (c == "\f") return "\\f";
-    if (c == "\n") return "\\n";
-    if (c == "\r") return "\\r";
-    if (c == "\t") return "\\t";
+    if(c == "\b") return "\\b";
+    if(c == "\f") return "\\f";
+    if(c == "\n") return "\\n";
+    if(c == "\r") return "\\r";
+    if(c == "\t") return "\\t";
     var hex = c.charCodeAt(0).toString(16);
     if(hex.length == 1) return "\\u000" + hex;
     if(hex.length == 2) return "\\u00" + hex;
@@ -110,11 +110,6 @@
 }
 
 /**
- * JSON-RPC request counter.
- */
-HTTPBindingClient.jsonrpcID = 1;
-
-/**
  * HTTPBindingClient implementation
  */
 
@@ -129,7 +124,7 @@
             args.push(arguments[i]);
 
         var cb = null;
-        if (typeof args[args.length - 1] == "function")
+        if(typeof args[args.length - 1] == "function")
             cb = args.pop();
 
         var req = HTTPBindingClient.makeJSONRequest(methodName, args, cb);
@@ -140,12 +135,17 @@
 };
 
 /**
+ * JSON-RPC request counter.
+ */
+HTTPBindingClient.jsonrpcID = 1;
+
+/**
  * Make a JSON-RPC request.
  */
 HTTPBindingClient.makeJSONRequest = function(methodName, args, cb) {
     var req = {};
     req.id = HTTPBindingClient.jsonrpcID++;
-    if (cb)
+    if(cb)
         req.cb = cb;
     var obj = {};
     obj.id = req.id;
@@ -164,8 +164,8 @@
         try {
             var contentType = http.getResponseHeader("Content-Type");
             var parts = contentType.split(/\s*;\s*/);
-            for (var i = 0; i < parts.length; i++) {
-                if (parts[i].substring(0, 8) == "charset=")
+            for(var i = 0; i < parts.length; i++) {
+                if(parts[i].substring(0, 8) == "charset=")
                     return parts[i].substring(8, parts[i].length);
             }
         } catch (e) {}
@@ -184,48 +184,203 @@
 };
 
 /**
+ * Schedule async requests, and limit to 4 concurrent running requests.
+ */
+HTTPBindingClient.queuedRequests = new Array();
+HTTPBindingClient.runningRequests = 0;
+HTTPBindingClient.scheduleAsyncRequest = function(f) {
+    // Queue the request function
+    HTTPBindingClient.queuedRequests.push(f);
+
+    // Execute requests in the queue
+    (function runAsyncRequests() {
+        // Stop now if we already have enough requests running or there's no request in the queue
+        //debug('runAsyncRequests', 'running', HTTPBindingClient.runningRequests, 'queued', HTTPBindingClient.queuedRequests.length);
+        if(HTTPBindingClient.runningRequests >= 4 || HTTPBindingClient.queuedRequests.length == 0)
+            return true;
+
+        // Run the first request in the queue
+        var req = HTTPBindingClient.queuedRequests.shift();
+        HTTPBindingClient.runningRequests++;
+        setTimeout(function runAsyncRequest() {
+            try {
+                return req(function asyncRequestDone() {
+                    // Execute any requests left in the queue
+                    HTTPBindingClient.runningRequests--;
+                    runAsyncRequests();
+                    return true;
+                });
+            } catch(e) {
+                // Execute any requests left in the queue
+                HTTPBindingClient.runningRequests--;
+                runAsyncRequests();
+            }
+        }, 0);
+
+        // Execute any requests left in the queue
+        runAsyncRequests();
+    })();
+    return true;
+};
+
+/**
+ * Get a cache item from local storage.
+ */
+HTTPBindingClient.getCacheItem = function(k) {
+    var ls = lstorage || localStorage;
+    return ls.getItem('cache.d.' + k);
+};
+
+/**
+ * Set a cache item in local storage.
+ */
+HTTPBindingClient.setCacheItem = function(k, v) {
+    HTTPBindingClient.collectCacheItems();
+    var ls = lstorage || localStorage;
+    try {
+        var s = ls.getItem('cache.size');
+        var size = parseInt(s);
+        var ov = ls.getItem('cache.d.' + k);
+        var nsize = size - (ov != null? ov.length : 0) + (v != null? v.length : 0);
+        if (nsize != size)
+            ls.setItem('cache.size', nsize.toString());
+    } catch(e) {}
+    return ls.setItem('cache.d.' + k, v);
+};
+
+/**
+* Remove a cache item from local storage.
+*/
+HTTPBindingClient.removeCacheItem = function(k) {
+    var ls = lstorage || localStorage;
+    try {
+        var s = ls.getItem('cache.size');
+        var size = parseInt(s);
+        var ov = ls.getItem('cache.d' + k);
+        if (ov != null) {
+            var nsize = size - ov.length;
+            ls.setItem('cache.size', nsize.toString());
+        }
+    } catch(e) {}
+    return ls.removeItem('cache.d.' + k);
+};
+
+/**
+ * Keep local storage cache entries under 2MB.
+ */
+HTTPBindingClient.maxCacheSize = /* 20000; */ 2097152;
+HTTPBindingClient.collectCacheSize = /* 10000; */ 1048576;
+HTTPBindingClient.collectCacheItems = function() {
+    var ls = window.lstorage || localStorage;
+    var nkeys = window.lstorage? function() { return ls.length(); } : function() { return ls.length; };
+    try {
+        // Get the current cache size
+        var size = 0;
+        var s = ls.getItem('cache.size');
+        if(s == null) {
+            // Calculate and store initial cache size
+            //debug('calculating cache size');
+            var n = nkeys();
+            for(var i = 0; i < n; i++) {
+                var k = ls.key(i);
+                if(k == null || k.substr(0, 8) != 'cache.d.')
+                    continue;
+                var v = ls.getItem(k);
+                if(v == null)
+                    continue;
+                size += v.length;
+            }
+            ls.setItem('cache.size', size.toString());
+        } else
+            size = parseInt(s);
+
+        // Nothing to do if it's below the max size
+        //debug('cache.size', size);
+        if (size <= HTTPBindingClient.maxCacheSize)
+            return false;
+
+        // Collect random cache entries until we reach our min size
+        //debug('collecting cache items');
+        var keys = new Array();
+        var n = nkeys();
+        for(var i = 0; i < n; i++) {
+            var k = ls.key(i);
+            if(k == null || k.substr(0, 8) != 'cache.d.')
+                continue;
+            keys.push(k);
+        }
+        while (keys.length != 0 && size >= HTTPBindingClient.collectCacheSize) {
+            var r = Math.floor(keys.length * Math.random());
+            if (r == keys.length)
+                continue;
+            var k = keys[r];
+            var v = ls.getItem(k);
+            //debug('collect cache item', k);
+            ls.removeItem(k);
+            keys.splice(r, 1);
+            if (v != null)
+                size = size - v.length;
+        }
+
+        // Store the new cache size
+        //debug('updated cache.size', size);
+        ls.setItem('cache.size', size.toString());
+        return true;
+    } catch(e) {}
+    return false;
+};
+
+/**
  * Apply a function remotely using JSON-RPC.
  */
 HTTPBindingClient.prototype.jsonApply = function(req) {
-    // Connect to the service
-    var http = HTTPBindingClient.getHTTPRequest();
     var hascb = req.cb? true : false;
-    http.open("POST", this.uri, hascb);
-    http.setRequestHeader("Accept", "*/*");
-    http.setRequestHeader("Content-Type", "application/json-rpc");
 
-    // Construct call back if we have one
+    // Call asynchronously with a callback
     if(hascb) {
-        http.onreadystatechange = function() {
-            if(http.readyState == 4) {
-                // Pass the result or exception
-                if(http.status == 200) {
-                    var res = null;
-                    try {
-                        res = HTTPBindingClient.jsonResult(http);
+        var u = this.uri;
+        return HTTPBindingClient.scheduleAsyncRequest(function jsonApplyRequest(done) {
+            var http = new XMLHttpRequest();
+            http.open("POST", u, true);
+            http.setRequestHeader("Accept", "*/*");
+            http.setRequestHeader("Content-Type", "application/json-rpc");
+            http.onreadystatechange = function() {
+                if(http.readyState == 4) {
+                    // Pass the result or exception
+                    if(http.status == 200) {
+                        var res = null;
                         try {
-                            req.cb(res);
-                        } catch(cbe) {}
-                    } catch(e) {
+                            res = HTTPBindingClient.jsonResult(http);
+                            try {
+                                req.cb(res);
+                            } catch(cbe) {}
+                        } catch(e) {
+                            try {
+                                req.cb(null, e);
+                            } catch(cbe) {}
+                        }
+                    } else {
                         try {
-                            req.cb(null, e);
+                            req.cb(null, HTTPBindingClient.Exception(http.status, http.statusText));
                         } catch(cbe) {}
                     }
-                } else
-                    try {
-                        req.cb(null, HTTPBindingClient.Exception(http.status, http.statusText));
-                    } catch(cbe) {}
-            }
-        };
+                    return done();
+                }
+            };
 
-        // Send the request
-        http.send(req.data);
-        return req.id;
+            // Send the request
+            http.send(req.data);
+            return req.id;
+        });
     }
 
-    // Send the request and return the result or exception
+    // Call synchronously and return the result or exception
+    var http = new XMLHttpRequest();
+    http.open("POST", this.uri, false);
+    http.setRequestHeader("Accept", "*/*");
+    http.setRequestHeader("Content-Type", "application/json-rpc");
     http.send(req.data);
-    if (http.status == 200)
+    if(http.status == 200)
         return HTTPBindingClient.jsonResult(http);
     throw new HTTPBindingClient.Exception(http.status, http.statusText);
 };
@@ -234,167 +389,104 @@
 /**
  * REST GET method.
  */
-HTTPBindingClient.prototype.get = function(id, cb) {
+HTTPBindingClient.prototype.get = function(id, cb, mode) {
     var u = id? (this.uri? this.uri + '/' + id : id) : this.uri;
     var hascb = cb? true : false;
 
     // Get from local storage first
-    var ls = window.lstorage || localStorage;
     var item = null;
-    try { item = ls.getItem(u); } catch(e) {}
-    //log('localStorage.getItem', u, item);
-    if (item != null && item != '') {
-        if (!hascb)
-            return item;
+    if(mode != 'remote') {
+        item = HTTPBindingClient.getCacheItem(u);
+        if(item != null && item != '') {
+            if(!hascb)
+                return item;
 
-        // Pass local result to callback
-        try {
-            cb(item);
-        } catch (cbe) {}
+            // Pass local result to callback
+            try {
+                cb(item);
+            } catch (cbe) {}
+        }
     }
 
-    // Connect to the service
-    var http = HTTPBindingClient.getHTTPRequest();
-    http.open("GET", u, hascb);
-    http.setRequestHeader("Accept", "*/*");
-
-    // Construct call back if we have one
-    if (hascb) {
-        http.onreadystatechange = function() {
-            //log('readystate', http.readyState, 'status', http.status, 'headers', http.getAllResponseHeaders());
-            if (http.readyState == 4) {
-                // Pass result if different from local result
-                if (http.status == 200) {
-
-                    if (http.getResponseHeader("X-Login") != null) { 
-                        // Detect redirect to a login page
-                        try {
-                            var le = new HTTPBindingClient.Exception(403, 'X-Login');
-                            if (window.onloginredirect)
-                                window.onloginredirect(le);
-                            return cb(null, le);
-                        } catch(cbe) {}
-
-                    } else if (http.responseText == '' || http.getResponseHeader("Content-Type") == null) {
-                        // Report empty response
-                        try {
-                            return cb(null, new HTTPBindingClient.Exception(403, 'No-Content'));
-                        } catch(cbe) {}
-
-                    } else {
-                        if (item == null || http.responseText != item) {
-                            // Store retrieved entry in local storage
-                            if (http.responseText != null) {
-                                //log('localStorage.setItem', u, http.responseText);
-                                try { ls.setItem(u, http.responseText); } catch(e) {}
-                            }
+    // Call asynchronously with a callback
+    if(hascb) {
+        return HTTPBindingClient.scheduleAsyncRequest(function getRequest(done) {
+            var http = new XMLHttpRequest();
+            http.open("GET", u, true);
+            http.setRequestHeader("Accept", "*/*");
+            http.onreadystatechange = function() {
+                //debug('readystate', http.readyState, 'status', http.status, 'headers', http.getAllResponseHeaders());
+                if(http.readyState == 4) {
+                    // Pass result if different from local result
+                    if(http.status == 200) {
+                        var xl = http.getResponseHeader("X-Login");
+                        if(xl != null && xl != '') {
+                            // Detect redirect to a login page
                             try {
-                                return cb(http.responseText);
+                                var le = new HTTPBindingClient.Exception(403, 'X-Login');
+                                if(window.onloginredirect)
+                                    window.onloginredirect(le);
+                                cb(null, le);
+                                return done();
+                            } catch(cbe) {}
+
+                        }
+                        var ct = http.getResponseHeader("Content-Type");
+                        if(http.responseText == '' || ct == null || ct == '') {
+                            // Report empty response
+                            try {
+                                cb(null, new HTTPBindingClient.Exception(403, 'No-Content'));
+                                return done();
+                            } catch(cbe) {}
+
+                        } else {
+                            if(item == null || http.responseText != item) {
+                                // Store retrieved entry in local storage
+                                if(http.responseText != null)
+                                    HTTPBindingClient.setCacheItem(u, http.responseText);
+                                try {
+                                    cb(http.responseText);
+                                    return done();
+                                } catch(cbe) {}
+                            }
+                        }
+                    }
+                    else {
+                        // Pass exception if we didn't have a local result
+                        if(item == null) {
+                            try {
+                                cb(null, new HTTPBindingClient.Exception(http.status, http.statusText));
+                                return done();
                             } catch(cbe) {}
                         }
                     }
+                    return done();
                 }
-                else {
-                    // Pass exception if we didn't have a local result
-                    if (item == null) {
-                        try {
-                            return cb(null, new HTTPBindingClient.Exception(http.status, http.statusText));
-                        } catch(cbe) {}
-                    }
-                }
-            }
-        };
+            };
 
-        // Send the request
-        http.send(null);
-        return true;
+            // Send the request
+            http.send(null);
+            return true;
+        });
     }
 
-    // Send the request and return the result or exception
-    http.send(null);
-    if (http.status == 200) {
-        if (http.getResponseHeader("X-Login") != null) {
-
-            // Detect redirect to a login page
-            var le = new HTTPBindingClient.Exception(403, 'X-Login');
-            if (window.onloginredirect)
-                window.onloginredirect(le);
-            throw le;
-
-        } else if (http.responseText == '' || http.getResponseHeader("Content-Type") == null) {
-
-            // Report empty response
-            throw new HTTPBindingClient.Exception(403, 'No-Content');
-        }
-        return http.responseText;
-    }
-    throw new HTTPBindingClient.Exception(http.status, http.statusText);
-};
-
-/**
- * REST GET method, does not use the local cache.
- */
-HTTPBindingClient.prototype.getnocache = function(id, cb) {
-    var u = id? (this.uri? this.uri + '/' + id : id) : this.uri;
-    var hascb = cb? true : false;
-
-    // Connect to the service
-    var http = HTTPBindingClient.getHTTPRequest();
-    http.open("GET", u, hascb);
+    // Call synchronously and return the result or exception
+    var http = new XMLHttpRequest();
+    http.open("GET", u, false);
     http.setRequestHeader("Accept", "*/*");
-
-    // Construct call back if we have one
-    if (hascb) {
-        http.onreadystatechange = function() {
-            if (http.readyState == 4) {
-                if (http.status == 200) {
-
-                    if (http.getResponseHeader("X-Login") != null) {
-                        // Detect redirect to a login page
-                        try {
-                            var le = new HTTPBindingClient.Exception(403, 'X-Login');
-                            if (window.onloginredirect)
-                                window.onloginredirect(le);
-                            return cb(null, le);
-                        } catch(cbe) {}
-
-                    } else if (http.responseText == '' || http.getResponseHeader("Content-Type") == null) {
-                        // Report empty response
-                        try {
-                            return cb(null, new HTTPBindingClient.Exception(403, 'No-Content'));
-                        } catch(cbe) {}
-
-                    } else {
-                        try {
-                            return cb(http.responseText);
-                        } catch(cbe) {}
-                    }
-                } else {
-                    try {
-                        return cb(null, new HTTPBindingClient.Exception(http.status, http.statusText));
-                    } catch(cbe) {}
-                }
-            }
-        };
-
-        // Send the request
-        http.send(null);
-        return true;
-    }
-
-    // Send the request and return the result or exception
     http.send(null);
-    if (http.status == 200) {
-        if (http.getResponseHeader("X-Login") != null) {
-
+    if(http.status == 200) {
+        var xl = http.getResponseHeader("X-Login");
+        if(xl != null && xl != '') {
             // Detect redirect to a login page
             var le = new HTTPBindingClient.Exception(403, 'X-Login');
-            if (window.onloginredirect)
+            if(window.onloginredirect)
                 window.onloginredirect(le);
             throw le;
 
-        } else if (http.responseText == '' || http.getResponseHeader("Content-Type") == null) {
-
+        }
+        var ct = http.getResponseHeader("Content-Type");
+        if(http.responseText == '' || ct == null || ct == '') {
             // Report empty response
             throw new HTTPBindingClient.Exception(403, 'No-Content');
         }
@@ -407,132 +499,184 @@
  * REST POST method.
  */
 HTTPBindingClient.prototype.post = function (entry, cb) {
-
-    // Connect to the service
-    var http = HTTPBindingClient.getHTTPRequest();
     var hascb = cb? true : false;
-    http.open("POST", this.uri, hascb);
-    http.setRequestHeader("Accept", "*/*");
-    http.setRequestHeader("Content-Type", "application/atom+xml");
 
-    // Construct call back if we have one
-    if (hascb) {
-        http.onreadystatechange = function() {
-            // Pass the result or exception
-            if (http.readyState == 4) {
-                if (http.status == 201) {
-                    try {
-                        cb(http.responseText);
-                    } catch(cbe) {}
+    // Call asynchronously with a callback
+    if(hascb) {
+        var u = this.uri;
+        return HTTPBindingClient.scheduleAsyncRequest(function postRequest(done) {
+            var http = new XMLHttpRequest();
+            http.open("POST", u, true);
+            http.setRequestHeader("Accept", "*/*");
+            http.setRequestHeader("Content-Type", "application/atom+xml");
+            http.onreadystatechange = function() {
+                if(http.readyState == 4) {
+                    if(http.status == 201) {
+                        // Successful result
+                        try {
+                            cb(http.responseText);
+                        } catch(cbe) {}
+                    }
+                    else {
+                        // Report status code as an exception
+                        try {
+                            cb(null, new HTTPBindingClient.Exception(http.status, http.statusText));
+                        } catch(cbe) {}
+                    }
+                    return done();
                 }
-                else {
-                    try {
-                        cb(null, new HTTPBindingClient.Exception(http.status, http.statusText));
-                    } catch(cbe) {}
-                }
-            }
-        };
-        // Send the request
-        http.send(entry);
-        return true;
+            };
+            // Send the request
+            http.send(entry);
+            return true;
+        });
     }
 
-    // Send the request and return the result or exception
+    // Call synchronously
+    var http = new XMLHttpRequest();
+    var hascb = cb? true : false;
+    http.open("POST", this.uri, false);
+    http.setRequestHeader("Accept", "*/*");
+    http.setRequestHeader("Content-Type", "application/atom+xml");
     http.send(entry);
-    if (http.status == 201)
+    if(http.status == 201)
         return http.responseText;
+
+    // Return status code as an exception
     throw new HTTPBindingClient.Exception(http.status, http.statusText);
 };
 
 /**
  * REST PUT method.
  */
-HTTPBindingClient.prototype.put = function (id, entry, cb) {
-    var u = this.uri + '/' + id;
+HTTPBindingClient.prototype.put = function(id, entry, cb, mode) {
+    var u = id? (this.uri? this.uri + '/' + id : id) : this.uri;
+    var hascb = cb? true : false;
 
     // Update local storage
-    var ls = window.lstorage || localStorage;
-    try { ls.setItem(u, entry); } catch(e) {}
-    //log('localStorage.setItem', u, entry);
-
-    // Connect to the service
-    var http = HTTPBindingClient.getHTTPRequest();
-    var hascb = cb? true : false;
-    http.open("PUT", u, hascb);
-    http.setRequestHeader("Accept", "*/*");
-    http.setRequestHeader("Content-Type", "application/atom+xml");
-
-    // Construct call back if we have one
-    if (hascb) {
-        http.onreadystatechange = function() {
-            if (http.readyState == 4) {
-                // Pass any exception
-                if (http.status == 200) {
-                    try {
-                        cb();
-                    } catch(cbe) {}
-                } else {
-                    try {
-                        cb(new HTTPBindingClient.Exception(http.status, http.statusText));
-                    } catch(cbe) {}
-                }
-            }
-        };
-        // Send the request
-        http.send(entry);
-        return true;
+    var oentry = null;
+    if(mode != 'remote') {
+        oentry = HTTPBindingClient.getCacheItem(u);
+        HTTPBindingClient.setCacheItem(u, entry);
     }
 
-    // Send the request and return any exception
+    // Call asynchronously with a callback
+    if(hascb) {
+        return HTTPBindingClient.scheduleAsyncRequest(function putRequest(done) {
+            var http = new XMLHttpRequest();
+            http.open("PUT", u, true);
+            http.setRequestHeader("Accept", "*/*");
+            http.setRequestHeader("Content-Type", "application/atom+xml");
+            http.onreadystatechange = function() {
+                if(http.readyState == 4) {
+                    if(http.status == 200) {
+                        // Successful result
+                        try {
+                            cb();
+                        } catch(cbe) {}
+                    } else {
+                        if(http.status == 404) {
+                            // Undo local storage update
+                            if(mode != 'remote') {
+                                try {
+                                    if(oentry != null)
+                                        HTTPBindingClient.setCacheItem(u, oentry);
+                                    else
+                                        HTTPBindingClient.removeCacheItem(u);
+                                } catch(e) {}
+                            }
+                        }
+
+                        // Report status code as an exception
+                        try {
+                            cb(new HTTPBindingClient.Exception(http.status, http.statusText));
+                        } catch(cbe) {}
+                    }
+                    return done();
+                }
+            };
+
+            // Send the request
+            http.send(entry);
+            return true;
+        });
+    }
+
+    // Call synchronously
+    var http = new XMLHttpRequest();
+    http.open("PUT", u, false);
+    http.setRequestHeader("Accept", "*/*");
+    http.setRequestHeader("Content-Type", "application/atom+xml");
     http.send(entry);
-    if (http.status == 200)
+    if(http.status == 200)
         return true;
+    if(http.status == 404) {
+        // Undo local storage update
+        if(mode != 'remote') {
+            try {
+                if(oentry != null)
+                    HTTPBindingClient.setCacheItem(u, oentry);
+                else
+                    HTTPBindingClient.removeCacheItem(u);
+            } catch(e) {}
+        }
+    }
+
+    // Return status code as an exception
     throw new HTTPBindingClient.Exception(http.status, http.statusText);
 };
 
 /**
  * REST DELETE method.
  */
-HTTPBindingClient.prototype.del = function (id, cb) {       
-    var u = this.uri + '/' + id;
+HTTPBindingClient.prototype.del = function(id, cb, mode) {
+    var u = id? (this.uri? this.uri + '/' + id : id) : this.uri;
+    var hascb = cb? true : false;
 
     // Update local storage
     var ls = window.lstorage || localStorage;
-    try { ls.removeItem(u); } catch(e) {}
-    //log('localStorage.removeItem', u);
+    if(mode != 'remote')
+        HTTPBindingClient.removeCacheItem(u);
 
-    // Connect to the service
-    var http = HTTPBindingClient.getHTTPRequest();
-    var hascb = cb? true : false;
-    http.open("DELETE", u, hascb);        
-    http.setRequestHeader("Accept", "*/*");
+    // Call asynchronously with a callback
+    if(hascb) {
+        return HTTPBindingClient.scheduleAsyncRequest(function delRequest(done) {
+            var http = new XMLHttpRequest();
+            http.open("DELETE", u, true);        
+            http.setRequestHeader("Accept", "*/*");
+            http.onreadystatechange = function() {
+                if(http.readyState == 4) {
+                    if(http.status == 200) {
+                        // Successful result
+                        try {
+                            cb();
+                        } catch(cbe) {}
+                    }
+                    else {
+                        // Report status code as an exception
+                        try {
+                            cb(new HTTPBindingClient.Exception(http.status, http.statusText));
+                        } catch(cbe) {}
+                    }
+                    return done();
+                }
+            };
 
-    // Construct call back if we have one
-    if (cb) {
-        http.onreadystatechange = function() {
-            if (http.readyState == 4) {
-                // Pass any exception
-                if (http.status == 200) {
-                    try {
-                        cb();
-                    } catch(cbe) {}
-                }
-                else {
-                    try {
-                        cb(new HTTPBindingClient.Exception(http.status, http.statusText));
-                    } catch(cbe) {}
-                }
-            }
-        };
-        // Send the request
-        http.send(null);
-        return true;
+            // Send the request
+            http.send(null);
+            return true;
+        });
     }
 
-    // Send the request and return any exception
+    // Call synchronously
+    var http = new XMLHttpRequest();
+    http.open("DELETE", u, false);        
+    http.setRequestHeader("Accept", "*/*");
     http.send(null);
-    if (http.status == 200)
+    if(http.status == 200)
         return true;
+
+    // Report status code as an exception
     throw new HTTPBindingClient.Exception(http.status, http.statusText);
 };
 
@@ -552,38 +696,6 @@
 };
 
 /**
- * XMLHttpRequest wrapper.
- */
-HTTPBindingClient.msxmlNames = [ "MSXML2.XMLHTTP.5.0", "MSXML2.XMLHTTP.4.0", "MSXML2.XMLHTTP.3.0", "MSXML2.XMLHTTP", "Microsoft.XMLHTTP" ];
-
-HTTPBindingClient.getHTTPRequest = function() {
-    if (HTTPBindingClient.httpFactory)
-        return HTTPBindingClient.httpFactory();
-
-    // Mozilla XMLHttpRequest
-    try {
-        HTTPBindingClient.httpFactory = function() {
-            return new XMLHttpRequest();
-        };
-        return HTTPBindingClient.httpFactory();
-    } catch(e) {}
-
-    // Microsoft MSXML ActiveX
-    for (var i = 0; i < HTTPBindingClient.msxmlNames.length; i++) {
-        try {
-            HTTPBindingClient.httpFactory = function() {
-                return new ActiveXObject(HTTPBindingClient.msxmlNames[i]);
-            };
-            return HTTPBindingClient.httpFactory();
-        } catch (e) {}
-    }
-
-    // Can't create XMLHttpRequest
-    HTTPBindingClient.httpFactory = null;
-    throw new HTTPBindingClient.Exception(0, "Can't create XMLHttpRequest object");
-};
-
-/**
  * Public API.
  */
 
@@ -600,7 +712,7 @@
  * Return a component proxy.
  */
 sca.component = function(name, domain) {
-    if (!domain)
+    if(!domain)
         return new HTTPBindingClient(name, '/c/' + name, domain);
     return new HTTPBindingClient(name, '/' + domain + '/c/' + name, domain);
 };
@@ -609,7 +721,7 @@
  * Return a reference proxy.
  */
 sca.reference = function(comp, rname) {
-    if (!comp.domain)
+    if(!comp.domain)
         return new HTTPBindingClient(comp.name + '/' + rname, '/r/' + comp.name + '/' + rname, comp.domain);
     return new HTTPBindingClient(comp.name + '/' + rname, '/' + comp.domain + '/r/' + comp.name + '/' + rname, comp.domain);
 };
@@ -622,13 +734,13 @@
         return function() {
             var args = new Array();
             args[0] = name;
-            for (i = 0, n = arguments.length; i < n; i++)
+            for(i = 0, n = arguments.length; i < n; i++)
                 args[i + 1] = arguments[i];
             return this.apply.apply(this, args);
         };
     }
 
-    for (f = 1; f < arguments.length; f++) {
+    for(f = 1; f < arguments.length; f++) {
         var fn = arguments[f];
         ref[fn]= defapply(fn);
     }
diff --git a/modules/js/htdocs/ui.css b/modules/js/htdocs/ui.css
index ddc21b2..cdc9e31 100644
--- a/modules/js/htdocs/ui.css
+++ b/modules/js/htdocs/ui.css
@@ -17,10 +17,14 @@
  * under the License.
  */
 
-body {
+/**
+ * Main body.
+ */
+body, .body {
 margin-top: 0px; margin-bottom: 2px; margin-left: 2px; margin-right: 2px;
-font-family: Arial; font-style: normal; font-variant: normal; font-size: 14px;
+font-family: "Lucida Grande", Helvetica, Arial; font-style: normal; font-variant: normal; font-size: 14px;
 background-color: #ffffff; opacity: 1;
+-webkit-font-smoothing: subpixel-antialiased;
 -webkit-text-size-adjust: none;
 -webkit-touch-callout: none;
 -webkit-tap-highlight-color: rgba(0,0,0,0);
@@ -30,35 +34,33 @@
 }
 
 .delayed {
-visibility: hidden;
+display: none;
 }
 
-.fixed {
-position: fixed;
+.installer {
+position: relative; width: 0px; height: 0px; display: none; visibility: hidden; background: transparent;
 }
 
-.devicewidth {
-position: absolute; top: 0px; left: 0px; width: 100%; height: 5000px; overflow: hidden;
+.filler {
+position: relative; left: 0px; top: 0px; width: 1px; background: transparent; z-index: -20;
 }
 
-.mainbody {
-position: absolute; top: 0px; left: 0px; width: 100%; height: 5000px; overflow: visible;
--webkit-backface-visibility: hidden;
-}
-
+/**
+ * Main view.
+ */
 .viewcontainer3dm {
-position: absolute; left: 0px; top: 35px; width: 100%;
+position: fixed; left: 0px; top: 70px; right: 0px; bottom: 25px; padding-top: 2px; padding-bottom: 2px;
 -webkit-backface-visibility: hidden;
 }
 
 .viewcontainer3d {
-position: absolute; left: 0px; top: 35px; width: 100%;
+position: fixed; left: 0px; top: 70px; right: 0px; bottom: 25px; padding-top: 2px; padding-bottom: 2px;
 -webkit-backface-visibility: hidden;
 }
 
 .leftviewloading3dm {
-position: absolute; top: 0px; left: 0px; width: 100%; height: 5000px; overflow: hidden;
-z-index: -10; background-color: #ffffff;
+position: absolute; left: 0px; top: 0px; right: 0px; bottom: 0px;
+z-index: -5; background-color: #ffffff;
 -webkit-transform: translate(100%, 0px);
 -moz-transform: translate(100%, 0px);
 -ms-transform: translate(100%, 0px);
@@ -68,8 +70,8 @@
 }
 
 .rightviewloading3dm {
-position: absolute; top: 0px; left: 0px; width: 100%; height: 5000px; overflow: hidden;
-z-index: -10; background-color: #ffffff;
+position: absolute; left: 0px; top: 0px; right: 0px; bottom: 0px;
+z-index: -5; background-color: #ffffff;
 -webkit-transform: translate(-100%, 0px);
 -moz-transform: translate(-100%, 0px);
 -ms-transform: translate(-100%, 0px);
@@ -79,8 +81,8 @@
 }
 
 .viewloading3d {
-position: absolute; top: 0px; left: 0px; width: 100%; height: 5000px; overflow: hidden;
-visibility: hidden; z-index: -10; background-color: #ffffff;
+position: absolute; left: 0px; top: 0px; right: 0px; bottom: 0px;
+z-index: -5; background-color: #ffffff;
 -webkit-transform: translate(100%, 0px);
 -moz-transform: translate(100%, 0px);
 -ms-transform: translate(100%, 0px);
@@ -90,7 +92,7 @@
 }
 
 .viewloaded3dm {
-position: absolute; top: 0px; left: 0px; width: 100%; height: 5000px; overflow: visible;
+position: absolute; left: 0px; top: 0px; right: 0px; bottom: 0px;
 z-index: 0; background-color: #ffffff;
 -webkit-transition: -webkit-transform 0.4s ease-in-out;
 -moz-transition: -moz-transform 0.4s ease-in-out;
@@ -106,7 +108,7 @@
 }
 
 .viewloaded3d {
-position: absolute; top: 0px; left: 0px; width: 100%; height: 5000px; overflow: visible;
+position: absolute; left: 0px; top: 0px; right: 0px; bottom: 0px;
 z-index: 0; background-color: #ffffff;
 -webkit-transform: translate(0px, 0px);
 -moz-transform: translate(0px, 0px);
@@ -117,8 +119,8 @@
 }
 
 .viewunloading3dm {
-position: absolute; top: 0px; left: 0px; width: 100%; height: 5000px; overflow: hidden;
-z-index: 0; background-color: #ffffff;
+position: absolute; left: 0px; top: 0px; right: 0px; bottom: 0px;
+z-index: -10; background-color: #ffffff;
 -webkit-transform: translate(0px, 0px);
 -moz-transform: translate(0px, 0px);
 -ms-transform: translate(0px, 0px);
@@ -128,7 +130,7 @@
 }
 
 .leftviewunloaded3dm {
-position: absolute; top: 0px; left: 0px; width: 100%; height: 5000px; overflow: hidden;
+position: absolute; left: 0px; top: 0px; right: 0px; bottom: 0px;
 z-index: -10; background-color: #ffffff;
 -webkit-transition: -webkit-transform 0.4s ease-in-out;
 -moz-transition: -moz-transform 0.4s ease-in-out;
@@ -144,7 +146,7 @@
 }
 
 .rightviewunloaded3dm {
-position: absolute; top: 0px; left: 0px; width: 100%; height: 5000px; overflow: hidden;
+position: absolute; left: 0px; top: 0px; right: 0px; bottom: 0px;
 z-index: -10; background-color: #ffffff;
 -webkit-transition: -webkit-transform 0.4s ease-in-out;
 -moz-transition: -moz-transform 0.4s ease-in-out;
@@ -159,44 +161,109 @@
 -webkit-backface-visibility: hidden;
 }
 
-.body {
-width: 100%; height: 5000px; overflow: visible;
+.viewunloaded3d {
+position: absolute; left: 0px; top: 0px; right: 0px; bottom: 0px;
+z-index: -10; background-color: #ffffff;
+-webkit-transform: translate(-100%, 0px);
+-moz-transform: translate(-100%, 0px);
+-ms-transform: translate(-100%, 0px);
+-o-transform: translate(-100%, 0px);
+transform: translate(-100%, 0px);
 -webkit-backface-visibility: hidden;
 }
 
+.viewcontent {
+position: absolute; left: 0px; top: 2px; right: 0px; bottom: 0px;
+overflow-x: hidden; overflow-y: scroll; -webkit-overflow-scrolling: touch;
+-webkit-backface-visibility: hidden;
+}
+
+.viewform {
+position: absolute; left: 0px; top: 2px; right: 0px; bottom: 0px;
+overflow-x: hidden; overflow-y: scroll; -webkit-overflow-scrolling: touch;
+-webkit-backface-visibility: hidden;
+}
+
+/**
+ * View header and footer.
+ */
 .viewhead {
-position: absolute; left: 0px; top: 35px; height: 35px; line-height: 35px; width: 100%; z-index: 8;
-font-size: 110%; font-weight: bold; background-color: #f1f1f1; color: #000000;
+position: fixed; left: 0px; top: 35px; height: 35px; line-height: 35px; width: 100%; z-index: 8;
+font-size: 15px; font-weight: bold; background-color: #f5f5f5; color: #000000;
 border-top: 1px; border-bottom: 1px; border-left: 0px; border-right: 0px;
 border-style: solid; border-top-color: #e5e5e5; border-bottom-color: #e5e5e5;
-overflow: hidden;
--webkit-backface-visibility: hidden;
-}
-
-.viewheadbackground {
-position: absolute; left: 0px; top: 35px; height: 35px; line-height: 35px; width: 2500px; z-index: 7;
-background-color: #f1f1f1;
-border-top: 1px; border-bottom: 1px; border-left: 0px; border-right: 0px;
-border-style: solid; border-top-color: #e5e5e5; border-bottom-color: #e5e5e5;
-overflow: hidden;
 -webkit-backface-visibility: hidden;
 }
 
 .viewfoot {
-position: absolute; left: 0px; bottom: 0px; height: 25px; line-height: 25px; width: 100%; z-index: 8;
+position: fixed; left: 0px; bottom: 0px; height: 25px; line-height: 25px; width: 100%; z-index: 8;
 font-size: 12px; background-color: #ffffff; color: #404040;
 border-top: 1px; border-bottom: 1px; border-left: 0px; border-right: 0px;
 border-style: solid; border-top-color: #e5e5e5; border-bottom-color: #ffffff;
-overflow: hidden;
 -webkit-backface-visibility: hidden;
 }
 
-.status {
-position: absolute; left: 0px; bottom: 0px; height: 35px; line-height: 35px; width: 100%; z-index: 9;
-font-size: 12px; background-color: #ffffff; color: #404040;
+.note {
+font-size: 12px; color: #808080;
+text-transform: none;
+}
+
+/**
+ * Menu toolbar.
+ */
+.tbarmenu {
+position: fixed; top: 0px; left: 0px; z-index: 10; width: 100%; margin: 0px; padding: 0px; border-collapse: separate;
+height: 35px; line-height: 35px; background-color: #2c2c2c;
+border-top: 1px; border-bottom: 1px; border-left: 0px; border-right: 0px; border-style: solid; border-top-color: #2c2c2c; border-bottom-color: #2c2c2c;
+-webkit-backface-visibility: hidden;
+}
+
+.tbarleft {
+padding-left: 2px; padding-right: 6px; white-space: nowrap; float: left;
+text-transform: uppercase;
+}
+
+.tbarright {
+padding-left: 6px; padding-right: 2px; white-space: nowrap; float: right;
+}
+
+.tbaramenu {
+font-size: 15px; color: #cccccc; text-decoration: none; white-space: nowrap;
+}
+
+.tbarsmenu {
+font-size: 15px; font-weight: bold; color: #ffffff; text-decoration: none; white-space: nowrap;
+}
+
+.amenu {
+padding-left: 2px; padding-right: 6px; white-space: nowrap; color: #808080; text-decoration: none; float: left;
+}
+
+.smenu {
+padding-left: 2px; padding-right: 6px; white-space: nowrap; color: #000000; text-decoration: none; float: left;
+}
+
+.cmenu {
+font-size: 15px; padding-left: 6px; padding-right: 6px; white-space: nowrap; color: #000000; text-decoration: none; float: left;
+padding-left: 45px;
+}
+
+.bcmenu {
+font-size: 22px; padding-left: 2px; padding-right: 6px; white-space: nowrap; color: #000000; text-decoration: none;
+}
+
+.rmenu {
+padding-left: 2px; padding-right: 2px; white-space: nowrap; white-space: nowrap; float: right;
+}
+
+/**
+ * Status bar.
+ */
+.status3d {
+position: absolute; left: 0px; bottom: 0px; height: 25px; line-height: 25px; width: 100%; z-index: 9;
+background-color: #ffffff; color: #404040;
 border-top: 0px; border-bottom: 0px; border-left: 0px; border-right: 0px;
 border-style: solid; border-top-color: #e5e5e5; border-bottom-color: #ffffff;
-overflow: hidden;
 -webkit-transform: translate(0px, 0px);
 -moz-transform: translate(0px, 0px);
 -ms-transform: translate(0px, 0px);
@@ -205,108 +272,164 @@
 -webkit-backface-visibility: hidden;
 }
 
-.statusout3 {
-position: absolute; left: 0px; bottom: 0px; height: 35px; line-height: 35px; width: 100%; z-index: 9;
-font-size: 12px; background-color: #ffffff; color: #404040;
+.statusout3d {
+position: absolute; left: 0px; bottom: 0px; height: 25px; line-height: 25px; width: 100%; z-index: 9;
+background-color: #ffffff; color: #404040;
 border-top: 0px; border-bottom: 0px; border-left: 0px; border-right: 0px;
 border-style: solid; border-top-color: #e5e5e5; border-bottom-color: #ffffff;
-overflow: hidden;
--webkit-transition: -webkit-transform 0.4s ease-in-out 3s;
--moz-transition: -moz-transform 0.4s ease-in-out 3s;
--ms-transition: -ms-transform 0.4s ease-in-out 3s;
--o-transition: -o-transform 0.4s ease-in-out 3s;
-transition: transform 0.4s ease-in-out 3s;
--webkit-transform: translate(0px, 35px);
--moz-transform: translate(0px, 35px);
--ms-transform: translate(0px, 35px);
--o-transform: translate(0px, 35px);
-transform: translate(0px, 35px);
+-webkit-transition: -webkit-transform 0.4s ease-in-out;
+-moz-transition: -moz-transform 0.4s ease-in-out;
+-ms-transition: -ms-transform 0.4s ease-in-out;
+-o-transition: -o-transform 0.4s ease-in-out;
+transition: transform 0.4s ease-in-out;
+-webkit-transform: translate(0px, 25px);
+-moz-transform: translate(0px, 25px);
+-ms-transform: translate(0px, 25px);
+-o-transform: translate(0px, 25px);
+transform: translate(0px, 25px);
 -webkit-backface-visibility: hidden;
 }
 
-.statusout1 {
-position: absolute; left: 0px; bottom: 0px; height: 35px; line-height: 35px; width: 100%; z-index: 9;
-font-size: 12px; background-color: #ffffff; color: #404040;
+.status3dm {
+position: absolute; left: 0px; bottom: 0px; height: 25px; line-height: 25px; width: 100%; z-index: 9;
+background-color: #ffffff; color: #404040;
 border-top: 0px; border-bottom: 0px; border-left: 0px; border-right: 0px;
 border-style: solid; border-top-color: #e5e5e5; border-bottom-color: #ffffff;
-overflow: hidden;
--webkit-transition: -webkit-transform 0.4s ease-in-out 1s;
--moz-transition: -moz-transform 0.4s ease-in-out 1s;
--ms-transition: -ms-transform 0.4s ease-in-out 1s;
--o-transition: -o-transform 0.4s ease-in-out 1s;
-transition: transform 0.4s ease-in-out 1s;
--webkit-transform: translate(0px, 35px);
--moz-transform: translate(0px, 35px);
--ms-transform: translate(0px, 35px);
--o-transform: translate(0px, 35px);
-transform: translate(0px, 35px);
+-webkit-transform: translate(0px, 0px);
+-moz-transform: translate(0px, 0px);
+-ms-transform: translate(0px, 0px);
+-o-transform: translate(0px, 0px);
+transform: translate(0px, 0px);
+-webkit-backface-visibility: hidden;
+}
+
+.statusout3dm {
+position: absolute; left: 0px; bottom: 0px; height: 25px; line-height: 25px; width: 100%; z-index: 9;
+background-color: #ffffff; color: #404040;
+border-top: 0px; border-bottom: 0px; border-left: 0px; border-right: 0px;
+border-style: solid; border-top-color: #e5e5e5; border-bottom-color: #ffffff;
+-webkit-transition: -webkit-transform 0.4s ease-in-out;
+-moz-transition: -moz-transform 0.4s ease-in-out;
+-ms-transition: -ms-transform 0.4s ease-in-out;
+-o-transition: -o-transform 0.4s ease-in-out;
+transition: transform 0.4s ease-in-out;
+-webkit-transform: translate(0px, 25px);
+-moz-transform: translate(0px, 25px);
+-ms-transform: translate(0px, 25px);
+-o-transform: translate(0px, 25px);
+transform: translate(0px, 25px);
 -webkit-backface-visibility: hidden;
 }
 
 .okstatus {
-font-size: 110%; font-weight: bold; padding-left: 6px; padding-right: 6px; white-space: nowrap; text-decoration: none;
-background-color: #f1f1f1; color: #000000;
+font-size: 14px; padding-left: 6px; padding-right: 6px; white-space: nowrap; text-decoration: none;
+background-color: #f5f5f5; color: #000000;
 width: 100%; margin-left: auto; margin-right: auto; text-align: center; float: right;
 }
 
 .errorstatus {
-font-size: 110%; font-weight: bold; padding-left: 6px; padding-right: 6px; white-space: nowrap; text-decoration: none;
+font-size: 14px; padding-left: 6px; padding-right: 6px; white-space: nowrap; text-decoration: none;
 background-color: #d14836; color: #ffffff;
 width: 100%; margin-left: auto; margin-right: auto; text-align: center; float: right;
 }
 
-.viewfootbackground {
-position: absolute; left: 0px; bottom: 0px; height: 25px; line-height: 25px; width: 2500px; z-index: 7;
-background-color: #ffffff;
-border-top: 1px; border-bottom: 1px; border-left: 0px; border-right: 0px;
-border-style: solid; border-top-color: #e5e5e5; border-bottom-color: #ffffff;
-overflow: hidden;
--webkit-backface-visibility: hidden;
+/**
+ * Working status animation.
+ */
+.working {
+position: fixed; z-index: 15; display: none; width: 60px; height: 60px; left: 50%; top: 50%; border-bottom-color: transparent;
+margin: -50px 0 0 -50px; border: 20px solid #c0c0c0; border-right-color: transparent;
+border-radius: 50%; -webkit-border-radius: 50%; -moz-border-radius: 50%;
+-webkit-box-shadow: 0 0 45px 5px #f5f5f5; -moz-box-shadow: 0 0 45px 5px #f5f5f5; -ms-box-shadow: 0 0 45px 5px #f5f5f5;
+-o-box-shadow: 0 0 45px 5px #f5f5f5; box-shadow: 0 0 45px 5px #f5f5f5;
+-webkit-animation: spin 1s linear infinite;
+-moz-animation: spin 1s linear infinite;
+-ms-animation: spin 1s linear infinite;
+-o-animation: spin 1s linear infinite;
+animation: spin 1s linear infinite;
 }
 
-.viewcontent {
-position: absolute; left: 0px; top: 38px; width: 100%;
--webkit-backface-visibility: hidden;
+@-webkit-keyframes spin {
+from { -webkit-transform: rotate(0deg); opacity: 0.4; }
+50% { -webkit-transform: rotate(180deg); opacity: 1; }
+to { -webkit-transform: rotate(360deg); opacity: 0.4; }
 }
 
-.viewform {
-position: absolute; left: 0px; top: 40px; width: 100%;
+@-moz-keyframes spin {
+from { -moz-transform: rotate(0deg); opacity: 0.4; }
+50% { -moz-transform: rotate(180deg); opacity: 1; }
+to { -moz-transform: rotate(360deg); opacity: 0.4; }
 }
 
-table {
-border: 0px; border-collapse: collapse; border-color: #a2bae7; border-style: solid;
-font-family: Arial; font-style: normal; font-variant: normal; font-size: 14px;
-overflow: visible;
+@-ms-keyframes spin {
+from { -ms-transform: rotate(0deg); opacity: 0.4; }
+50% { -ms-transform: rotate(180deg); opacity: 1; }
+to { -ms-transform: rotate(360deg); opacity: 0.4; }
 }
 
-.trb {
-border-bottom: 1px; border-bottom-style: solid; border-color: #dcdcdc;
+@-o-keyframes spin {
+from { -o-transform: rotate(0deg); opacity: 0.4; }
+50% { -o-transform: rotate(180deg); opacity: 1; }
+to { -o-transform: rotate(360deg); opacity: 0.4; }
 }
 
-th {
-font-size: 110%; font-weight: bold; background-color: #f1f1f1; color: #000000;
-text-align: left; padding-left: 2px; padding-right: 8px; padding-top: 0px; padding-bottom: 0px; vertical-align: middle; white-space: nowrap;
-border-top: 1px; border-bottom: 1px; border-left: 1px; border-right: 1px; border-style: solid; border-top-color: #e5e5e5; border-bottom-color: #e5e5e5; border-left-color: #e5e5e5; border-right-color: #e5e5e5;
-overflow: hidden;
+@keyframes spin {
+from { transform: rotate(0deg); opacity: 0.4; }
+50% { transform: rotate(180deg); opacity: 1; }
+to { transform: rotate(360deg); opacity: 0.4; }
 }
 
-.section {
-font-size: 110%; font-weight: bold; background-color: #f1f1f1; color: #000000; height: 30px; line-height: 30px;
-text-align: left; padding-top: 0px; padding-bottom: 0px; padding-left: 2px; padding-right: 2px; white-space: nowrap;
-border-top: 1px; border-bottom: 1px; border-left: 0px; border-right: 0px; border-style: solid; border-top-color: #e5e5e5; border-bottom-color: #e5e5e5; border-left-color: #e5e5e5; border-right-color: #e5e5e5;
-overflow: hidden;
+/**
+ * Refreshing status animation.
+ */
+.refreshing {
+z-index: 14; display: none; width: 10px; height: 10px; border-bottom-color: transparent; vertical-align: top;
+border: 5px solid #c0c0c0; border-right-color: transparent;
+border-radius: 50%; -webkit-border-radius: 50%; -moz-border-radius: 50%;
+-webkit-box-shadow: 0 0 10px 5px #f5f5f5; -moz-box-shadow: 0 0 10px 5px #f5f5f5; -ms-box-shadow: 0 0 10px 5px #f5f5f5;
+-o-box-shadow: 0 0 10px 5px #f5f5f5; box-shadow: 0 0 10px 5px #f5f5f5;
+-webkit-animation: spin 1s linear infinite;
+-moz-animation: spin 1s linear infinite;
+-ms-animation: spin 1s linear infinite;
+-o-animation: spin 1s linear infinite;
+animation: spin 1s linear infinite;
 }
 
-.hsection {
-width: 100%; height: 0px; visibility: hidden;
-border-top: 0px; border-bottom: 0px; border-left: 0px; border-right: 0px; border-style: solid; border-bottom-color: #000000; background-color: #ffffff;
-padding-left: 2px; padding-right: 2px; padding-top: 0px; padding-bottom: 0px; margin-bottom: 0px; margin-left: auto; margin-right: auto; text-align: center;
+/**
+ * Scrollbars.
+ */
+.flatscrollbars::-webkit-scrollbar{
+width: 10px; height: 10px; background-color: #ffffff;
+box-shadow: inset 1px 1px 0 rgba(0,0,0,.1), inset -1px -1px 0 rgba(0,0,0,.07);
 }
 
-.fsection{
-width: 100%; height: 0px; visibility: hidden;
-border-top: 0px; border-bottom: 0px; border-left: 0px; border-right: 0px; border-style: solid; border-top-color: #a2bae7;
-padding: 0px; margin-top: 0px; margin-left: auto; margin-right: auto; text-align: center;
+.flatscrollbars::-webkit-scrollbar:hover{
+background-color: #eeeeee;
+}
+
+.flatscrollbars::-webkit-resizer{
+-webkit-border-radius: 4px;
+background-color: #666666;
+}
+
+.flatscrollbars::-webkit-scrollbar-thumb{
+min-height: 0.8em; min-width: 0.8em; background-color: rgba(0, 0, 0, .2);
+box-shadow: inset 1px 1px 0 rgba(0,0,0,.1), inset -1px -1px 0 rgba(0,0,0,.07);
+}
+
+.flatscrollbars::-webkit-scrollbar-thumb:hover{
+background-color: #bbbbbb;
+}
+
+.flatscrollbars::-webkit-scrollbar-thumb:active{
+background-color: #888888;
+}
+
+/**
+ * Base HTML elements.
+ */
+.text {
+padding-top: 3px; padding-bottom: 4px; vertical-align: middle; white-space: nowrap;
 }
 
 .bluetext {
@@ -317,31 +440,146 @@
 color: #d14836;
 }
 
-.mirror {
-display: inline-block;
--webkit-transform: scaleX(-1);
--moz-transform: scaleX(-1);
--ms-transform: scaleX(-1);
--o-transform: scaleX(-1);
-transform: scaleX(-1);
-}
-
 .greentext {
-color: #009900;
+color: #3d9400;
 }
 
-.text {
-padding-top: 3px; padding-bottom: 4px; vertical-align: middle; white-space: nowrap;
+h1 {
+font-size: 20px; font-weight: bold; vertical-align: middle; margin-top: 5px; margin-bottom: 5px; margin-left: 2px; margin-right: 2px; white-space: nowrap;
+}
+
+h2 {
+font-size: 18px; font-weight: bold; vertical-align: middle; margin-top: 5px; margin-bottom: 5px; margin-left: 2px; margin-right: 2px; white-space: nowrap;
+}
+
+.hw1 {
+font-size: 20px; font-weight: bold; vertical-align: middle; margin-top: 5px; margin-bottom: 5px; margin-left: 2px; margin-right: 2px; white-space: normal;
+}
+
+.hw2 {
+font-size: 18px; font-weight: bold; vertical-align: middle; margin-top: 5px; margin-bottom: 5px; margin-left: 2px; margin-right: 2px; white-space: normal;
+}
+
+.hd1 {
+font-size: 20px; font-weight: bold; white-space: nowrap; padding-top: 3px; padding-bottom: 4px; vertical-align: middle;
+}
+
+.hd2 {
+font-size: 18px; font-weight: bold; white-space: nowrap; padding-top: 3px; padding-bottom: 4px; vertical-align: middle;
+}
+
+.section {
+font-size: 15px; font-weight: bold; background-color: #f5f5f5; color: #000000; height: 30px; line-height: 30px; width: 100%; display: block;
+text-align: left; padding-top: 0px; padding-bottom: 0px; padding-left: 2px; padding-right: 2px; white-space: nowrap;
+border-top: 1px; border-bottom: 1px; border-left: 0px; border-right: 0px; border-style: solid; border-top-color: #e5e5e5; border-bottom-color: #e5e5e5; border-left-color: #e5e5e5; border-right-color: #e5e5e5;
 }
 
 .link {
 padding-top: 3px; padding-bottom: 4px; vertical-align: middle; white-space: nowrap;
 }
 
+a:link {
+color: #357ae8; text-decoration: none; white-space: nowrap; cursor: pointer;
+}
+
+a:visited {
+color: #357ae8; text-decoration: none; white-space: nowrap; cursor: pointer;
+}
+
+.label{
+font-weight: bold; padding-top: 6px;
+}
+
+.lightlabel{
+font-weight: normal; color: #808080; padding-top: 6px;
+}
+
+input {
+font-family: "Lucida Grande", Helvetica, Arial; font-style: normal; font-variant: normal; font-size: 15px;
+vertical-align: middle;
+outline: none;
+-webkit-text-size-adjust: 100%;
+}
+
+textarea {
+font-family: "Lucida Grande", Helvetica, Arial; font-style: normal; font-variant: normal; font-size: 15px;
+outline: none;
+overflow: auto; resize: none;
+}
+
+.flatentry {
+font-family: "Lucida Grande", Helvetica, Arial; font-style: normal; font-variant: normal; font-size: 15px;
+vertical-align: middle;
+-webkit-appearance: none; appearance: none;
+border: 1px solid #d9d9d9!important; border-top: 1px solid silver!important;
+box-sizing: border-box; -moz-box-sizing: border-box; -webkit-box-sizing: border-box;
+border-radius: 1px; -webkit-border-radius: 1px; -moz-border-radius: 1px;
+margin: 1px!important; padding: 3px 1px 3px 3px;
+}
+
+.readentry {
+font-family: "Lucida Grande", Helvetica, Arial; font-style: normal; font-variant: normal; font-size: 15px;
+vertical-align: middle; color: #808080;
+-webkit-appearance: none; appearance: none;
+border: 0px;
+box-sizing: border-box; -moz-box-sizing: border-box; -webkit-box-sizing: border-box;
+border-radius: 1px; -webkit-border-radius: 1px; -moz-border-radius: 1px;
+margin: 1px!important; padding: 3px 1px 3px 3px;
+overflow: hidden; text-overflow: ellipsis;
+}
+
+.searchentry {
+font-family: "Lucida Grande", Helvetica, Arial; font-style: normal; font-variant: normal; font-size: 15px;
+vertical-align: middle;
+-webkit-appearance: searchfield; -moz-appearance: searchfield; -ms-appearance: searchfield; appearance: searchfield;
+border: 1px solid #d9d9d9!important; border-top: 1px solid silver!important;
+box-sizing: border-box; -moz-box-sizing: border-box; -webkit-box-sizing: border-box;
+border-radius: 1px; -webkit-border-radius: 1px; -moz-border-radius: 1px;
+margin: 1px!important; padding: 3px 1px 3px 3px;
+}
+
 .checkbox {
 padding-top: 3px; padding-bottom: 4px; vertical-align: middle; white-space: nowrap;
 }
 
+.flatcheckbox {
+}
+
+.editablewidget {
+-webkit-user-select: text; -moz-user-select: text; -ms-user-select: text; user-select: text;
+outline: none; -moz-outline-style: none;
+}
+
+.noneditablewidget {
+outline: none; -moz-outline-style: none;
+}
+
+iframe {
+border: 0px; margin: 0px; padding: 0px;
+}
+
+img {
+border: 0px;
+}
+
+/**
+ * Tables.
+ */
+table {
+border: 0px; border-collapse: collapse; border-color: #a2bae7; border-style: solid;
+font-family: "Lucida Grande", Helvetica, Arial; font-style: normal; font-variant: normal; font-size: 14px;
+}
+
+.table {
+width: 100%;
+}
+
+th {
+font-size: 15px; font-weight: bold; background-color: #f5f5f5; color: #000000;
+text-align: left; padding-left: 2px; padding-right: 8px; padding-top: 0px; padding-bottom: 0px; vertical-align: middle; white-space: nowrap;
+border-top: 1px; border-bottom: 1px; border-left: 1px; border-right: 1px; border-style: solid; border-top-color: #e5e5e5; border-bottom-color: #e5e5e5; border-left-color: #e5e5e5; border-right-color: #e5e5e5;
+}
+
 .thl {
 border-left: 0px;
 }
@@ -386,9 +624,12 @@
 border-left: 1px; border-top: 1px; border-bottom: 1px; border-style: solid; border-color: #dcdcdc; vertical-align: middle;
 }
 
+.list {
+width: 100%;
+}
+
 .datatable {
-border-top: 1px; border-bottom: 1px; border-style: solid; border-color: #dcdcdc;
-overflow: visible;
+border-top: 1px; border-bottom: 1px; border-style: solid; border-color: #dcdcdc; width: 100%;
 }
 
 .databg {
@@ -397,179 +638,33 @@
 filter: alpha(opacity=60);
 }
 
-.guide {
-border: 1px; border-style: solid; border-color: #c0c0c0;
-}
-
-iframe {
-border: 0px; margin: 0px; padding: 0px;
-}
-
-.fakeframe {
-padding: 3px; background-color: #dcdcdc; color: #000000;
-}
-
-input {
-vertical-align: middle;
-font-family: Arial; font-style: normal; font-variant: normal; font-size: 15px;
-outline: none;
--webkit-text-size-adjust: 100%;
-}
-
+/**
+ * Buttons.
+ */
 button {
 vertical-align: middle;
-font-family: Arial; font-style: normal; font-variant: normal; font-size: 15px;
+font-family: "Lucida Grande", Helvetica, Arial; font-style: normal; font-variant: normal; font-size: 15px;
 outline: none;
 -webkit-text-size-adjust: 100%;
 }
 
-textarea {
-font-family: Arial; font-style: normal; font-variant: normal; font-size: 15px;
-outline: none;
-overflow: auto; resize: none;
-}
-
-.flatentry {
-font-family: Arial; font-style: normal; font-variant: normal; font-size: 15px;
--webkit-appearance: none; appearance: none;
-border: 1px solid #d9d9d9!important; border-top: 1px solid silver!important;
-box-sizing: border-box; -moz-box-sizing: border-box; -webkit-box-sizing: border-box;
-border-radius: 1px; -webkit-border-radius: 1px; -moz-border-radius: 1px;
-margin: 1px!important; padding: 3px 1px 3px 3px;
-}
-
-.graphentry {
-font-family: Arial; font-style: normal; font-variant: normal; font-size: 13px;
--webkit-appearance: none; appearance: none;
-border: 1px solid #d9d9d9!important; border-top: 1px solid silver!important;
-box-sizing: border-box; -moz-box-sizing: border-box; -webkit-box-sizing: border-box;
-border-radius: 1px; -webkit-border-radius: 1px; -moz-border-radius: 1px;
-margin: 0px; padding: 0px;
-}
-
-.flatcheckbox {
-}
-
-.editablewidget {
--webkit-user-select: text; -moz-user-select: text; -ms-user-select: text; user-select: text;
-outline: none; -moz-outline-style: none;
-}
-
-.noneditablewidget {
-outline: none; -moz-outline-style: none;
-}
-
-.editablesvg {
-background-color: transparent;
-font-family: inherit; font-style: inherit; font-variant: inherit; font-size: inherit; font-weight: inherit;
-padding: 0px; margin: 0px;
-overflow: auto; resize: none;
-outline: none; -moz-outline-style: none;
--webkit-appearance: none; -webkit-text-size-adjust: 100%;
-border: 0px;
-}
-
-a:link {
-color: #357ae8; text-decoration: none; white-space: nowrap; cursor: pointer;
-}
-
-a:visited {
-color: #357ae8; text-decoration: none; white-space: nowrap; cursor: pointer;
-}
-
-.tbarmenu {
-position: absolute; top: 0px; left: 0px; z-index: 10; width: 100%; margin: 0px; padding: 0px; border-collapse: separate;
-height: 35px; line-height: 35px; background-color: #2c2c2c;
-border-top: 1px; border-bottom: 1px; border-left: 0px; border-right: 0px; border-style: solid; border-top-color: #2c2c2c; border-bottom-color: #2c2c2c;
--webkit-backface-visibility: hidden;
-}
-
-.tbarbackground {
-position: absolute; top: 0px; left: 0px; z-index: 9; width: 2500px; margin: 0px; padding: 0px; border-collapse: separate;
-height: 35px; line-height: 35px; background-color: #2c2c2c;
-border-top: 1px; border-bottom: 1px; border-left: 0px; border-right: 0px; border-style: solid; border-top-color: #2c2c2c; border-bottom-color: #2c2c2c;
-overflow: hidden;
--webkit-backface-visibility: hidden;
-}
-
-.tbarleft {
-padding-left: 2px; padding-right: 6px; white-space: nowrap; float: left;
-}
-
-.tbarright {
-padding-left: 6px; padding-right: 2px; white-space: nowrap; float: right;
-}
-
-.tbaramenu {
-font-size: 110%; color: #cccccc; text-decoration: none; white-space: nowrap;
-}
-
-.tbarsmenu {
-font-size: 110%; font-weight: bold; color: #ffffff; text-decoration: none; white-space: nowrap;
-}
-
-.amenu {
-padding-left: 2px; padding-right: 6px; white-space: nowrap; color: #808080; text-decoration: none; float: left;
-}
-
-.smenu {
-padding-left: 2px; padding-right: 6px; white-space: nowrap; color: #000000; text-decoration: none; float: left;
-}
-
-.cmenu {
-font-size: 18px; padding-left: 6px; padding-right: 6px; white-space: nowrap; color: #000000; text-decoration: none;
-width: 100%; margin-left: auto; margin-right: auto; text-align: center; float: right;
-}
-
-.bcmenu {
-font-size: 22px; padding-left: 2px; padding-right: 6px; white-space: nowrap; color: #000000; text-decoration: none;
-}
-
-.rmenu {
-padding-left: 2px; padding-right: 2px; white-space: nowrap; white-space: nowrap; float: right;
-}
-
-h1 {
-font-size: 150%; font-weight: bold; vertical-align: middle; margin-top: 5px; margin-bottom: 5px; margin-left: 2px; margin-right: 2px; white-space: nowrap;
-}
-
-h2 {
-font-size: 120%; font-weight: bold; vertical-align: middle; margin-top: 5px; margin-bottom: 5px; margin-left: 2px; margin-right: 2px; white-space: nowrap;
-}
-
-.hd1 {
-font-size: 150%; font-weight: bold; white-space: nowrap;
-}
-
-.hd2 {
-font-size: 120%; font-weight: bold; white-space: nowrap;
-}
-
-img {
-border: 0px;
-}
-
 .plusminus {
 font-size: 18px; font-family: "Courier New";
 }
 
-.imgbutton {
-width: 142px; height: 64px; margin-left: 20px; margin-right: 20px; padding: 0px; border: 1px; cursor: pointer;
-}
-
 .graybutton {
-display: inline-block; text-align: center; color: #444; font-weight: bold;
-padding-top: 0px; padding-bottom: 0px; padding-left: 4px; padding-right: 4px;
+display: inline-block; text-align: center; color: #444; font-weight: bold; font-size: 14px; text-transform: uppercase;
+padding-top: 0px; padding-bottom: 0px; padding-left: 4px; padding-right: 4px; margin-left: 2px; margin-right: 2px;
 height: 28px; line-height: 28px; min-width: 30px;
 -webkit-border-radius: 2px; -moz-border-radius: 2px; border-radius: 2px;
-border: 1px solid gainsboro; background-color: whiteSmoke;
-background-image: -webkit-gradient(linear,left top,left bottom,from(whiteSmoke),to(#f1f1f1));
-background-image: -webkit-linear-gradient(top,whiteSmoke,#f1f1f1);
-background-image: -moz-linear-gradient(top,whiteSmoke,#f1f1f1);
-background-image: -ms-linear-gradient(top,whiteSmoke,#f1f1f1);
-background-image: -o-linear-gradient(top,whiteSmoke,#f1f1f1);
-background-image: linear-gradient(top,whiteSmoke,#f1f1f1);
 cursor: default;
+border: 1px solid gainsboro; background-color: #f5f5f5;
+background-image: -webkit-gradient(linear,left top,left bottom,from(#f5f5f5),to(#f1f1f1));
+background-image: -webkit-linear-gradient(top,#f5f5f5,#f1f1f1);
+background-image: -moz-linear-gradient(top,#f5f5f5,#f1f1f1);
+background-image: -ms-linear-gradient(top,#f5f5f5,#f1f1f1);
+background-image: -o-linear-gradient(top,#f5f5f5,#f1f1f1);
+background-image: linear-gradient(top,#f5f5f5,#f1f1f1);
 }
 
 .graybutton:hover {
@@ -583,22 +678,63 @@
 }
 
 .graybutton:active {
-background-color: #f6f6f6;
-background-image: -webkit-gradient(linear,left top,left bottom,from(#f6f6f6),to(#f1f1f1));
-background-image: -webkit-linear-gradient(top,#f6f6f6,#f1f1f1);
-background-image: -moz-linear-gradient(top,#f6f6f6,#f1f1f1);
-background-image: -ms-linear-gradient(top,#f6f6f6,#f1f1f1);
-background-image: -o-linear-gradient(top,#f6f6f6,#f1f1f1);
-background-image: linear-gradient(top,#f6f6f6,#f1f1f1);
--webkit-box-shadow: inset 0 1px 2px rgba(0, 0, 0, 0.1); -moz-box-shadow: inset 0 1px 2px rgba(0, 0, 0, 0.1); box-shadow: inset 0 1px 2px rgba(0, 0, 0, 0.1);
+-webkit-box-shadow: inset 0 1px 2px rgba(0, 0, 0, 0.1); -moz-box-shadow: inset 0 1px 2px rgba(0, 0, 0, 0.1); -ms-box-shadow: inset 0 1px 2px rgba(0, 0, 0, 0.1);
+-o-box-shadow: inset 0 1px 2px rgba(0, 0, 0, 0.1); box-shadow: inset 0 1px 2px rgba(0, 0, 0, 0.1);
 }
 
 .graybutton:disabled {
 color: #c0c0c0;
 }
 
+.graybutton:hover:disabled {
+color: #c0c0c0;
+}
+
+.lightbutton {
+display: inline-block; text-align: center; color: #444; font-weight: normal; font-size: 14px;
+padding-top: 0px; padding-bottom: 0px; padding-left: 4px; padding-right: 4px; margin-left: 2px; margin-right: 2px;
+height: 28px; line-height: 28px; min-width: 30px;
+-webkit-border-radius: 2px; -moz-border-radius: 2px; border-radius: 2px;
+cursor: default;
+border: 1px solid gainsboro; background-color: #f5f5f5;
+background-image: -webkit-gradient(linear,left top,left bottom,from(#f5f5f5),to(#f1f1f1));
+background-image: -webkit-linear-gradient(top,#f5f5f5,#f1f1f1);
+background-image: -moz-linear-gradient(top,#f5f5f5,#f1f1f1);
+background-image: -ms-linear-gradient(top,#f5f5f5,#f1f1f1);
+background-image: -o-linear-gradient(top,#f5f5f5,#f1f1f1);
+background-image: linear-gradient(top,#f5f5f5,#f1f1f1);
+}
+
+.lightbutton:hover {
+border: 1px solid #c6c6c6; color: #333; text-shadow: 0 1px rgba(0, 0, 0, 0.3); background-color: #f8f8f8;
+background-image: -webkit-gradient(linear,left top,left bottom,from(#f8f8f8),to(#f1f1f1));
+background-image: -webkit-linear-gradient(top,#f8f8f8,#f1f1f1);
+background-image: -moz-linear-gradient(top,#f8f8f8,#f1f1f1);
+background-image: -ms-linear-gradient(top,#f8f8f8,#f1f1f1);
+background-image: -o-linear-gradient(top,#f8f8f8,#f1f1f1);
+background-image: linear-gradient(top,#f8f8f8,#f1f1f1);
+}
+
+.lightbutton:active {
+-webkit-box-shadow: inset 0 1px 2px rgba(0, 0, 0, 0.1); -moz-box-shadow: inset 0 1px 2px rgba(0, 0, 0, 0.1); -ms-box-shadow: inset 0 1px 2px rgba(0, 0, 0, 0.1);
+-o-box-shadow: inset 0 1px 2px rgba(0, 0, 0, 0.1); box-shadow: inset 0 1px 2px rgba(0, 0, 0, 0.1);
+}
+
+.lightbutton:disabled {
+color: #c0c0c0;
+}
+
+.lightbutton:hover:disabled {
+color: #c0c0c0;
+}
+
 .bluebutton {
-border: 1px solid #3079ed; color: white; text-shadow: 0 1px rgba(0, 0, 0, 0.1); background-color: #4d90fe;
+display: inline-block; text-align: center; font-weight: bold; font-size: 14px; text-transform: uppercase;
+padding-top: 0px; padding-bottom: 0px; padding-left: 4px; padding-right: 4px; margin-left: 2px; margin-right: 2px;
+height: 28px; line-height: 28px; min-width: 30px;
+-webkit-border-radius: 2px; -moz-border-radius: 2px; border-radius: 2px;
+cursor: default;
+border: 1px solid #3079ed; color: #ffffff; text-shadow: 0 1px rgba(0, 0, 0, 0.1); background-color: #4d90fe;
 background-image: -webkit-gradient(linear,left top,left bottom,from(#4d90fe),to(#4787ed));
 background-image: -webkit-linear-gradient(top,#4d90fe,#4787ed);
 background-image: -moz-linear-gradient(top,#4d90fe,#4787ed);
@@ -607,12 +743,8 @@
 background-image: linear-gradient(top,#4d90fe,#4787ed);
 }
 
-.bluebutton:disabled {
-color: #c0c0c0;
-}
-
 .bluebutton:hover {
-border: 1px solid #2f5bb7; color: white; text-shadow: 0 1px rgba(0, 0, 0, 0.3); background-color: #357ae8;
+border: 1px solid #2f5bb7; color: #ffffff; text-shadow: 0 1px rgba(0, 0, 0, 0.3); background-color: #357ae8;
 background-image: -webkit-gradient(linear,left top,left bottom,from(#4d90fe),to(#357ae8));
 background-image: -webkit-linear-gradient(top,#4d90fe,#357ae8);
 background-image: -moz-linear-gradient(top,#4d90fe,#357ae8);
@@ -621,89 +753,306 @@
 background-image: linear-gradient(top,#4d90fe,#357ae8);
 }
 
+.bluebutton:active {
+-webkit-box-shadow: inset 0 1px 2px rgba(0, 0, 0, 0.1); -moz-box-shadow: inset 0 1px 2px rgba(0, 0, 0, 0.1); -ms-box-shadow: inset 0 1px 2px rgba(0, 0, 0, 0.1);
+-o-box-shadow: inset 0 1px 2px rgba(0, 0, 0, 0.1); box-shadow: inset 0 1px 2px rgba(0, 0, 0, 0.1);
+}
+
+.bluebutton:disabled {
+color: #c0c0c0;
+}
+
 .bluebutton:hover:disabled {
 color: #c0c0c0;
 }
 
+.greenbutton {
+display: inline-block; text-align: center; font-weight: bold; font-size: 14px; text-transform: uppercase;
+padding-top: 0px; padding-bottom: 0px; padding-left: 4px; padding-right: 4px; margin-left: 2px; margin-right: 2px;
+height: 28px; line-height: 28px; min-width: 30px;
+-webkit-border-radius: 2px; -moz-border-radius: 2px; border-radius: 2px;
+cursor: default;
+border: 1px solid #29691d; color: #ffffff; text-shadow: 0 1px rgba(0, 0, 0, 0.1); background-color: #3d9400;
+background-image: -webkit-gradient(linear,left top,left bottom,from(#3d9400),to(#398a00));
+background-image: -webkit-linear-gradient(top,#3d9400,#398a00);
+background-image: -moz-linear-gradient(top,#3d9400,#398a00);
+background-image: -ms-linear-gradient(top,#3d9400,#398a00);
+background-image: -o-linear-gradient(top,#3d9400,#398a00);
+background-image: linear-gradient(top,#3d9400,#398a00);
+}
+
+.greenbutton:hover {
+border: 1px solid #2d6200; color: #ffffff; text-shadow: 0 1px rgba(0, 0, 0, 0.3); background-color: #097b24;
+background-image: -webkit-gradient(linear,left top,left bottom,from(#3d9400),to(#097b24));
+background-image: -webkit-linear-gradient(top,#3d9400,#097b24);
+background-image: -moz-linear-gradient(top,#3d9400,#097b24);
+background-image: -ms-linear-gradient(top,#3d9400,#097b24);
+background-image: -o-linear-gradient(top,#3d9400,#097b24);
+background-image: linear-gradient(top,#3d9400,#097b24);
+}
+
+.greenbutton:active {
+-webkit-box-shadow: inset 0 1px 2px rgba(0, 0, 0, 0.1); -moz-box-shadow: inset 0 1px 2px rgba(0, 0, 0, 0.1); -ms-box-shadow: inset 0 1px 2px rgba(0, 0, 0, 0.1);
+-o-box-shadow: inset 0 1px 2px rgba(0, 0, 0, 0.1); box-shadow: inset 0 1px 2px rgba(0, 0, 0, 0.1);
+}
+
+.greenbutton:disabled {
+color: #c0c0c0;
+}
+
+.greenbutton:hover:disabled {
+color: #c0c0c0;
+}
+
 .redbutton {
-border: 1px solid transparent; color: white; text-shadow: 0 1px rgba(0, 0, 0, 0.1); background-color: #d14836;
+display: inline-block; text-align: center; font-weight: bold; font-size: 14px; text-transform: uppercase;
+padding-top: 0px; padding-bottom: 0px; padding-left: 4px; padding-right: 4px; margin-left: 2px; margin-right: 2px;
+height: 28px; line-height: 28px; min-width: 30px;
+-webkit-border-radius: 2px; -moz-border-radius: 2px; border-radius: 2px;
+cursor: default;
+border: 1px solid transparent; color: #ffffff; text-shadow: 0 1px rgba(0, 0, 0, 0.1); background-color: #d14836;
 background-image: -webkit-gradient(linear,left top,left bottom,from(#dd4b39),to(#d14836));
 background-image: -webkit-linear-gradient(top,#dd4b39,#d14836);
 background-image: -moz-linear-gradient(top,#dd4b39,#d14836);
 background-image: -ms-linear-gradient(top,#dd4b39,#d14836);
 background-image: -o-linear-gradient(top,#dd4b39,#d14836);
 background-image: linear-gradient(top,#dd4b39,#d14836);
--webkit-box-shadow: inset 0 1px 2px rgba(0, 0, 0, 0.3); -moz-box-shadow: inset 0 1px 2px rgba(0, 0, 0, 0.3); -ms-box-shadow: inset 0 1px 2px rgba(0, 0, 0, 0.3);
--o-box-shadow: inset 0 1px 2px rgba(0, 0, 0, 0.3); box-shadow: inset 0 1px 2px rgba(0, 0, 0, 0.3);
+}
+
+.redbutton:hover {
+border: 1px solid #b0281a; border-bottom-color: #af301f; color: #ffffff; text-shadow: 0 1px rgba(0, 0, 0, 0.3); background-color: #c53727;
+background-image: -webkit-gradient(linear,left top,left bottom,from(#dd4b39),to(#c53727));
+background-image: -webkit-linear-gradient(top,#dd4b39,#c53727);
+background-image: -moz-linear-gradient(top,#dd4b39,#c53727);
+background-image: -ms-linear-gradient(top,#dd4b39,#c53727);
+background-image: -o-linear-gradient(top,#dd4b39,#c53727);
+background-image: linear-gradient(top,#dd4b39,#c53727);
+}
+
+.redbutton:active {
+-webkit-box-shadow: inset 0 1px 2px rgba(0, 0, 0, 0.1); -moz-box-shadow: inset 0 1px 2px rgba(0, 0, 0, 0.1); -ms-box-shadow: inset 0 1px 2px rgba(0, 0, 0, 0.1);
+-o-box-shadow: inset 0 1px 2px rgba(0, 0, 0, 0.1); box-shadow: inset 0 1px 2px rgba(0, 0, 0, 0.1);
 }
 
 .redbutton:disabled {
 color: #c0c0c0;
 }
 
-.redbutton:hover {
-border: 1px solid #b0281a; border-bottom-color: #af301f; color: white; background-color: #c53727;
-background-image: -webkit-linear-gradient(top,#dd4b39,#c53727);
-background-image: -moz-linear-gradient(top,#dd4b39,#c53727);
-background-image: -ms-linear-gradient(top,#dd4b39,#c53727);
-background-image: -o-linear-gradient(top,#dd4b39,#c53727);
-background-image: linear-gradient(top,#dd4b39,#c53727);
--webkit-box-shadow: 0 1px 1px rgba(0, 0, 0, 0.2); -moz-box-shadow: 0 1px 1px rgba(0, 0, 0, 0.2); -ms-box-shadow: 0 1px 1px rgba(0, 0, 0, 0.2);
--o-box-shadow: 0 1px 1px rgba(0, 0, 0, 0.2); box-shadow: 0 1px 1px rgba(0, 0, 0, 0.2);
-}
-
 .redbutton:hover:disabled {
 color: #c0c0c0;
 }
 
+/**
+ * Store.
+ */
+.imgbutton {
+width: 142px; height: 64px; margin-left: 20px; margin-right: 20px; padding: 0px; border: 1px; cursor: pointer;
+}
+
 .box {
-width: 150px; display: inline-block;
+width: 148px; display: inline-block; overflow: hidden; text-overflow: ellipsis;
 border: 1px; border-style: solid; border-color: #dcdcdc; border-collapse: collapse;
 white-space: nowrap; margin: 2px; padding: 2px; vertical-align: top;
 }
 
 .appicon {
-float: left;
-height: 50px; width: 50px; vertical-align: top; margin: 0px; padding: 0px;
+float: left; width: 50px; vertical-align: top; padding-right: 4px; padding-top: 2px; padding-bottom: 4px;
+overflow: hidden; text-overflow: ellipsis;
+}
+
+.appdetails {
+font-size: 13px; color: #808080; overflow: hidden; text-overflow: ellipsis;
 }
 
 .apptitle {
-font-weight: bold;
+font-size: 14px; font-weight: bold; overflow: hidden; text-overflow: ellipsis;
 }
 
-.note {
-font-size: 12px; color: #808080;
-}
-
+/**
+ * Editor.
+ */
 .pagediv {
-position: absolute; display: block; overflow: visible;
+position: absolute; left: 0px; top: 0px; width: 1024px; height: 1024px;
+z-index: 2; overflow: hidden;
+}
+
+.playdiv {
+position: absolute; left: 0px; top: 0px; width: 1024px; height: 1024px;
+z-index: 1; overflow: hidden;
+}
+
+.guide {
+border: 1px; border-style: solid; border-color: #fcff00;
+}
+
+.draggable3d {
+position: absolute;
+-webkit-transition: -webkit-transform 0.016s linear;
+-moz-transition: -moz-transform 0.016s linear;
+-ms-transition: -ms-transform 0.016s linear;
+-o-transition: -o-transform 0.016s linear;
+transition: transform 0.016s linear;
 -webkit-backface-visibility: hidden;
 }
 
-.graphdiv {
-position: absolute; display: block; overflow: visible;
+.draggable3dm {
+position: absolute;
+-webkit-transition: -webkit-transform 0.016s linear;
+-moz-transition: -moz-transform 0.016s linear;
+-ms-transition: -ms-transform 0.016s linear;
+-o-transition: -o-transform 0.016s linear;
+transition: transform 0.016s linear;
 -webkit-backface-visibility: hidden;
 }
 
+/**
+ * Palette.
+ */
+.palettecontainer3dm {
+position: absolute; left: 0px; top: 0px; width: 0px; bottom: 0px; padding-top: 2px; padding-bottom: 2px;
+-webkit-backface-visibility: hidden;
+}
+
+.palettecontainer3d {
+position: absolute; left: 0px; top: 0px; width: 0px; bottom: 0px; padding-top: 2px; padding-bottom: 2px;
+-webkit-backface-visibility: hidden;
+}
+
+.paletteloading3d {
+position: absolute; left: 0px; top: 0px; bottom: 0px;
+z-index: 6; background-color: #ffffff;
+-webkit-transform: translate(-150px, 0px);
+-moz-transform: translate(-150px, 0px);
+-ms-transform: translate(-150px, 0px);
+-o-transform: translate(-150px, 0px);
+transform: translate(-150px, 0px);
+-webkit-backface-visibility: hidden;
+}
+
+.paletteloading3dm {
+position: absolute; left: 0px; top: 0px; bottom: 0px;
+z-index: 6; background-color: #ffffff;
+-webkit-transform: translate(-150px, 0px);
+-moz-transform: translate(-150px, 0px);
+-ms-transform: translate(-150px, 0px);
+-o-transform: translate(-150px, 0px);
+transform: translate(-150px, 0px);
+-webkit-backface-visibility: hidden;
+}
+
+.paletteloaded3d {
+position: absolute; left: 0px; top: 0px; bottom: 0px;
+z-index: 6; background-color: #ffffff;
+-webkit-transform: translate(0px, 0px);
+-moz-transform: translate(0px, 0px);
+-ms-transform: translate(0px, 0px);
+-o-transform: translate(0px, 0px);
+transform: translate(0px, 0px);
+-webkit-backface-visibility: hidden;
+}
+
+.paletteloaded3dm {
+position: absolute; left: 0px; top: 0px; bottom: 0px;
+z-index: 6; background-color: #ffffff;
+-webkit-transition: -webkit-transform 0.4s ease-in-out;
+-moz-transition: -moz-transform 0.4s ease-in-out;
+-ms-transition: -ms-transform 0.4s ease-in-out;
+-o-transition: -o-transform 0.4s ease-in-out;
+transition: transform 0.4s ease-in-out;
+-webkit-transform: translate(0px, 0px);
+-moz-transform: translate(0px, 0px);
+-ms-transform: translate(0px, 0px);
+-o-transform: translate(0px, 0px);
+transform: translate(0px, 0px);
+-webkit-backface-visibility: hidden;
+}
+
+.paletteunloading3dm {
+position: absolute; left: 0px; top: 0px; bottom: 0px;
+z-index: 6; background-color: #ffffff;
+-webkit-transform: translate(0px, 0px);
+-moz-transform: translate(0px, 0px);
+-ms-transform: translate(0px, 0px);
+-o-transform: translate(0px, 0px);
+transform: translate(0px, 0px);
+-webkit-backface-visibility: hidden;
+}
+
+.paletteunloaded3dm {
+position: absolute; left: 0px; top: 0px; bottom: 0px;
+z-index: 6; background-color: #ffffff;
+-webkit-transition: -webkit-transform 0.4s ease-in-out;
+-moz-transition: -moz-transform 0.4s ease-in-out;
+-ms-transition: -ms-transform 0.4s ease-in-out;
+-o-transition: -o-transform 0.4s ease-in-out;
+transition: transform 0.4s ease-in-out;
+-webkit-transform: translate(-150px, 0px);
+-moz-transform: translate(-150px, 0px);
+-ms-transform: translate(-150px, 0px);
+-o-transform: translate(-150px, 0px);
+transform: translate(-150px, 0px);
+-webkit-backface-visibility: hidden;
+}
+
+.paletteunloaded {
+position: absolute; left: 0px; top: 0px; bottom: 0px;
+z-index: 6; background-color: #ffffff;
+-webkit-transform: translate(-150px, 0px);
+-moz-transform: translate(-150px, 0px);
+-ms-transform: translate(-150px, 0px);
+-o-transform: translate(-150px, 0px);
+transform: translate(-150px, 0px);
+-webkit-backface-visibility: hidden;
+}
+
+.palettecontent {
+position: absolute; left: 0px; top: 0px; bottom: 0px; width: 150px;
+padding-right: 4px; padding-top: 4px; padding-bottom: 6px; z-index: 6;
+border-top: 0px; border-bottom: 0px; border-left: 0px; border-right: 1px;
+background-color: #ffffff; border-style: solid; border-color: #e5e5e5;
+overflow-y: scroll; overflow-x: hidden; -webkit-overflow-scrolling: touch;
+}
+
+.palettetable {
+border-top: 1px; border-bottom: 1px; border-left: 1px; border-right: 1px; border-style: solid; border-color: #dcdcdc;
+background-color: #ffffff; width: 100%;
+}
+
+.palettetd {
+border-top: 1px; border-bottom: 1px; border-left: 1px; border-right: 1px; border-style: solid; border-color: #dcdcdc; vertical-align: middle;
+height: 25px; padding-bottom: 6px; padding-top: 4px;
+}
+
+/**
+ * Graph.
+ */
 .g {
-position: absolute; display: block; overflow: visible;
+position: absolute; display: block;
 -webkit-backface-visibility: hidden;
 }
 
 .path {
-position: absolute; background: transparent; display: block; overflow: visible;
-visibility: visible;
+position: absolute; background: transparent; display: block;
 -webkit-backface-visibility: hidden;
 }
 
 .gtitle {
 position: absolute;
 margin: 4px; padding: 0px; line-height: 15px; vertical-align: middle; white-space: nowrap;
-font-family: Arial; font-style: normal; font-variant: normal; font-size: 13px; cursor: default;
-background: transparent; display: block; overflow: visible;
+font-family: "Lucida Grande", Helvetica, Arial; font-style: normal; font-variant: normal; font-size: 14px; cursor: default;
+background: transparent; display: block;
 -webkit-text-size-adjust: none;
 -webkit-touch-callout: none;
 -webkit-tap-highlight-color: rgba(0,0,0,0);
 -webkit-user-select: none; -moz-user-select: none; -ms-user-select: none; user-select: none;
 }
 
+.graphentry {
+font-family: "Lucida Grande", Helvetica, Arial; font-style: normal; font-variant: normal; font-size: 14px;
+-webkit-appearance: none; appearance: none;
+border: 1px solid #d9d9d9!important; border-top: 1px solid silver!important;
+box-sizing: border-box; -moz-box-sizing: border-box; -webkit-box-sizing: border-box;
+border-radius: 1px; -webkit-border-radius: 1px; -moz-border-radius: 1px;
+margin: 0px; padding: 0px;
+}
+
diff --git a/modules/js/htdocs/ui.js b/modules/js/htdocs/ui.js
index fb53598..0c80b92 100644
--- a/modules/js/htdocs/ui.js
+++ b/modules/js/htdocs/ui.js
@@ -31,6 +31,8 @@
         return null;
     for (var i in node.childNodes) {
         var child = node.childNodes[i];
+        if (isNil(child))
+            continue;
         if (child.id == id)
             return child;
         var gchild = ui.elementByID(child, id);
@@ -46,7 +48,16 @@
 function $(id) {
     if (id == document)
         return document;
-    return ui.elementByID($(document), id);
+    return memo(document, '$' + id, function() {
+        return ui.elementByID($(document), id);
+    });
+}
+
+/**
+ * Un-memoize elements previously found by id.
+ */
+ui.unmemo$ = function(prefix) {
+    return prefix? unmemo(document, '$' + prefix) : unmemo(document);
 };
 
 /**
@@ -114,10 +125,26 @@
 };
 
 /**
- * Convert a base64-encoded image to a data URL.
+ * Convert a base64-encoded PNG image to a data URL.
  */
-ui.b64img = function(b64) {
-    return 'data:image/png;base64,' + b64;
+ui.b64png = function(b64) {
+    return 'data:image/png;base64,' + b64.trim();
+};
+
+/**
+ * Convert a base64-encoded JPEG image to a data URL.
+ */
+ui.b64jpeg = function(b64) {
+    return 'data:image/jpeg;base64,' + b64.trim();
+};
+
+/**
+ * Convert a data URL to a base64-encoded image.
+ */
+ui.imgb64 = function(img) {
+    if (img.startsWith('data:'))
+        return img.split(',')[1]
+    return '';
 };
 
 /**
@@ -131,6 +158,16 @@
 };
 
 /**
+ * Include a CSS stylesheet.
+ */
+ui.includeCSS = function(s) {
+    var e = ui.declareCSS(s);
+    var head = document.getElementsByTagName('head')[0];
+    head.appendChild(e);
+    return e;
+};
+
+/**
  * Declare a script.
  */
 ui.declareScript = function(s) {
@@ -151,15 +188,15 @@
  * Evaluate a script.
  */
 ui.evalScript = function(s) {
-    return eval('(function() {\n' + s + '\n})();');
+    return eval('(function evalscript() { try { \n' + s + '\n} catch(e) { debug(e.stack); throw e; }})();');
 };
 
 /**
  * Include a script.
  */
 ui.includeScript = function(s) {
-    //log('include', s);
-    return eval(s);
+    //debug('include', s);
+    return eval('try { \n' + s + '\n} catch(e) { debug(e.stack); throw e; }');
 };
 
 /**
@@ -171,13 +208,139 @@
     if (ui.mobiledetected)
         return ui.mobile;
     var ua = navigator.userAgent;
-    if (ua.match(/iPhone/i) || ua.match(/iPad/i) || ua.match(/Android/i) || ua.match(/Blackberry/i) || ua.match(/WebOs/i))
+    if (ua.match(/iPhone/i) || ua.match(/iPad/i) || ua.match(/iPod/i) || ua.match(/Android/i) || ua.match(/Blackberry/i) || ua.match(/WebOs/i))
         ui.mobile = true;
     ui.mobiledetected = true;
     return ui.mobile;
 };
 
 /**
+ * Return true if the client is Webkit based.
+ */
+ui.isWebkit = function() {
+    return navigator.userAgent.match(/WebKit/i);
+};
+
+/**
+ * Return the Webkit version.
+ */
+ui.webkitVersion = function() {
+    return Number(navigator.userAgent.replace(/.*AppleWebKit\/(\d+\.\d+).*/, '$1'));
+};
+
+/**
+ * Return true if the client is Firefox.
+ */
+ui.isFirefox = function() {
+    return navigator.userAgent.match(/Firefox/i);
+};
+
+/**
+ * Return the Firefox version.
+ */
+ui.firefoxVersion = function() {
+    return Number(navigator.userAgent.replace(/.*Firefox\/(\d+\.\d+).*/, '$1'));
+};
+
+/**
+ * Return true if the client is Safari.
+ */
+ui.isSafari = function() {
+    return navigator.userAgent.match(/Safari/i);
+};
+
+/**
+ * Return true if the client is Chrome.
+ */
+ui.isChrome = function() {
+    return navigator.userAgent.match(/Chrome/i);
+};
+
+/**
+ * Return true if the client is Internet Explorer.
+ */
+ui.isMSIE = function() {
+    return navigator.userAgent.match(/MSIE/i);
+};
+
+/**
+ * Return the Internet Explorer version.
+ */
+ui.msieVersion = function() {
+    return Number(navigator.userAgent.replace(/.*MSIE (\d+\.\d+).*/, '$1'));
+};
+
+/**
+ * Run a UI rendering function asynchronously.
+ */
+ui.asyncFrame = null;
+ui.async = function(f) {
+    if (isNil(ui.asyncFrame))
+        // Use requestAnimationFrame when available, fallback to setTimeout
+        ui.asyncFrame = window.requestAnimationFrame || window.webkitRequestAnimationFrame ||
+            window.mozRequestAnimationFrame || window.oRequestAnimationFrame || window.msRequestAnimationFrame ||
+            function(f) {
+                return window.setTimeout(f, 16);
+            };
+    return ui.asyncFrame.call(window, f);
+};
+
+/**
+ * Delay the execution of a function.
+ */
+ui.delayed = {}
+ui.delay = function(f, t) {
+    var id =  window.setTimeout(function() {
+        delete ui.delayed[id];
+        return f();
+    }, isNil(t)? 16 : t);
+    ui.delayed[id] = id;
+    return id;
+};
+
+/**
+ * Cancel the execution of a delayed function.
+ */
+ui.cancelDelay = function(id) {
+    delete ui.delayed[id];
+    return window.clearTimeout(id);
+};
+
+/**
+ * Run a UI animation.
+ */
+ui.animationFrame = null;
+ui.animation = function(f) {
+    if (isNil(ui.animationFrame))
+        // Use requestAnimationFrame when available, fallback to setInterval
+        ui.animationFrame = window.requestAnimationFrame || window.webkitRequestAnimationFrame ||
+            window.mozRequestAnimationFrame || window.oRequestAnimationFrame || window.msRequestAnimationFrame ||
+            function(f) {
+                if (!('interval' in f) || isNil(f.interval)) {
+                    // First call, setup the interval
+                    f.interval = window.setInterval(function animation() {
+                        f.clearInterval = true;
+                        try {
+                            f();
+                        } catch(ex) {}
+                        // If the animation function didn't call ui.animation again to
+                        // request another animation frame, clear the interval
+                        if (f.clearInterval) {
+                            f.clearInterval = false;
+                            window.clearInterval(f.interval);
+                            f.interval = null;
+                        }
+                    }, 16);
+                } else {
+                    // Called to request another animation frame, do not clear the
+                    // interval
+                    f.clearInterval = false;
+                }
+            };
+    return ui.animationFrame.call(window, f);
+};
+
+/**
  * Convert a CSS position to a numeric position.
  */
 ui.numpos = function(p) {
@@ -192,46 +355,49 @@
 };
 
 /**
- * Default orientation change behavior.
+ * Default page load behavior.
  */
-ui.onorientationchange = function(e) {
+ui.filler = null;
+ui.onload = function() {
 
-    // Scroll to the top and hide the address bar
+    // Add a filler div to make sure we can scroll
+    if (ui.isMobile()) {
+        ui.filler = document.createElement('div');
+        ui.filler.id = 'filler';
+        ui.filler.className = 'filler';
+        ui.filler.style.height = ui.pixpos(window.orientation == 0? screen.height : screen.width * 2);
+        document.body.appendChild(ui.filler);
+    } else {
+        // Style scroll bars
+        var h = document.getElementsByTagName('html');
+        if (!isNil(h))
+            h[0].className = h[0].className? h[0].classname + ' flatscrollbars' : 'flatscrollbars';
+    }
+
+    // Scroll to hide the address bar
+    document.body.style.display = 'block';
     window.scrollTo(0, 0);
 
-    // Change fixed position elements to absolute then back to fixed
-    // to make sure they're correctly layed out after the orientation
-    // change
-    map(function(e) {
-            e.style.position = 'absolute';
-            return e;
-        }, ui.elementsByClassName(document, 'fixed'));
+    // Set unload handler
+    window.onunload = function() {
+        window.scrollTo(0, 0);
+        return true;
+    };
 
-    setTimeout(function() {
-        map(function(e) {
-                e.style.position = 'fixed';
-                return e;
-            }, ui.elementsByClassName(document, 'fixed'));
-        }, 0);
     return true;
 };
 
 /**
- * Default page load behavior.
+ * Default orientation change behavior.
  */
-ui.onload = function() {
+ui.onorientationchange = function(e) {
 
-    // Scroll to the top and hide the address bar
+    // Adjust filler height
+    if (!isNil(ui.filler))
+        ui.filler.style.height = ui.pixpos(window.orientation == 0? screen.height : screen.width);
+
+    // Scroll to hide the address bar
     window.scrollTo(0, 0);
-
-    // Initialize fixed position elements only after the page is loaded,
-    // to workaround layout issues with fixed position on mobile devices
-    setTimeout(function() {
-        map(function(e) {
-                e.style.position = 'fixed';
-                return e;
-            }, ui.elementsByClassName(document, 'fixed'));
-        }, 0);
     return true;
 };
 
@@ -239,7 +405,7 @@
  * Navigate to a new document.
  */
 ui.navigate = function(url, win) {
-    //log('navigate', url, win);
+    //debug('navigate', url, win);
 
     // Open a new window
     if (win == '_blank') {
@@ -264,6 +430,27 @@
     if (win == '_view') {
         if (!window.top.onnavigate)
             return window.top.open(url, '_self');
+
+        // Cleanup window event handlers
+        window.onclick = null;
+        if (!ui.isMobile()) {
+            window.onmousedown = null;
+            window.onmouseup = null;
+            window.onmousemove = null;
+        } else {
+            window.ontouchstart = null;
+            window.ontouchend = null;
+            window.ontouchmove = null;
+        }
+
+        // Cleanup memoized element lookups
+        ui.unmemo$();
+
+        // Cancel any timers
+        for (d in ui.delayed)
+            ui.cancelDelay(d);
+
+        // Navigate
         window.top.onnavigate(url);
         return false;
     }
@@ -369,7 +556,7 @@
     }
 
     return '<table class="datatable ' + (window.name == 'dataFrame'? ' databg' : '') + '" style="width: 100%;">' + rows(l, 0) + '</table>';
-}
+};
 
 /**
  * Convert a list of elements to an HTML single column table.
@@ -405,5 +592,67 @@
     }
 
     return '<table class="datatable ' + (window.name == 'dataFrame'? ' databg' : '') + '" style="width: 100%;">' + rows(l, 0) + '</table>';
-}
+};
+
+/**
+ * Read a file and convert it to a data url.
+ */
+ui.readfile = function(file, onerror, onprogress, onload) {
+    var reader = new FileReader();
+    reader.onerror = function(e) {
+        return onerror();
+    };
+    reader.onprogress = function(e) {
+        return onprogress(e.lengthComputable? Math.round((e.loaded / e.total) * 90) : 50);
+    };
+    reader.onload = function(r) {
+        return onload(r.target.result);
+    };
+    return reader.readAsDataURL(file);
+};
+
+/**
+ * Read an image url and convert it to a data url.
+ */
+ui.readimageurl = function(url, onerror, onprogress, onload, width, height) {
+    // Create a canvas to draw the image
+    var canvas = document.createElement('canvas');
+    if (width)
+        canvas.width = width;
+    if (height)
+        canvas.height = height;
+
+    // Create an image
+    var img = new Image();
+    img.onerror = function(e) {
+        return onerror();
+    };
+    img.onload = function() {
+        // Draw the image
+        var ctx = canvas.getContext('2d');
+        if (width || height)
+            ctx.drawImage(img, 0, 0, width, height);
+        else
+            ctx.drawImage(img, 0, 0);
+
+        // Convert new canvas image to a data url
+        return onload(canvas.toDataURL('image/png'));
+    };
+
+    // Load the image
+    onprogress(90);
+    img.src = url;
+    return true;
+};
+
+/**
+ * Read an image file or url and convert it to a data url.
+ */
+ui.readimage = function(img, onerror, onprogress, onload, width, height) {
+    if (isString(img))
+        return ui.readimageurl(img, onerror, onprogress, onload, width, height);
+    return ui.readfile(img, onerror, onprogress, function(url) {
+            return ui.readimageurl(url, onerror, onprogress, onload, width, height);
+        }, width, height);
+};
 
diff --git a/modules/js/htdocs/util.js b/modules/js/htdocs/util.js
index 0f7de94..1cf086c 100644
--- a/modules/js/htdocs/util.js
+++ b/modules/js/htdocs/util.js
@@ -296,9 +296,19 @@
 /**
  * Un-memoize stored results.
  */
-function unmemo(obj) {
-    obj.memo = {};
-    return true;
+function unmemo(obj, prefix) {
+    if (!prefix) {
+        obj.memo = {};
+        return true;
+    }
+    if (!('memo' in obj)) {
+        obj.memo = {};
+        return true;
+    }
+    for (key in obj.memo) {
+        if (key.substring(0, prefix.length) == prefix)
+            delete obj.memo[key];
+    }
 }
 
 /**
@@ -308,6 +318,32 @@
 lstorage.enabled = true;
 
 /**
+ * Get a key.
+ */
+lstorage.key = function(i) {
+    if (!lstorage.enabled)
+        return null;
+    try {
+        return localStorage.key(i);
+    } catch(e) {
+        return null;
+    }
+};
+
+/**
+ * Return the number of keys.
+ */
+lstorage.length = function() {
+    if (!lstorage.enabled)
+        return 0;
+    try {
+        return localStorage.length;
+    } catch(e) {
+        return 0;
+    }
+};
+
+/**
  * Get an item.
  */
 lstorage.getItem = function(k) {
@@ -318,7 +354,7 @@
     } catch(e) {
         return null;
     }
-}
+};
 
 /**
  * Set an item.
@@ -331,7 +367,7 @@
     } catch(e) {
         return null;
     }
-}
+};
 
 /**
  * Remove an item.
@@ -344,7 +380,7 @@
     } catch(e) {
         return null;
     }
-}
+};
 
 /**
  * Returns a list of the properties of an object.
@@ -372,6 +408,14 @@
 }
 
 /**
+ * Convert a host name to a top domain name.
+ */
+function topdomainname(host) {
+    var d = reverse(domainname(host).split('.'));
+    return reverse(mklist(car(d), cadr(d))).join('.');
+}
+
+/**
  * Return true if a host name is a subdomain.
  */
 function issubdomain(host) {
@@ -379,24 +423,13 @@
 }
 
 /**
- * Return true if the document cookie contains auth information.
- */
-function hasauthcookie() {
-    return !isNil(document.cookie) &&
-        (document.cookie.indexOf('TuscanyOpenAuth=') != -1 ||
-         document.cookie.indexOf('TuscanyOAuth1=') != -1 ||
-         document.cookie.indexOf('TuscanyOAuth2=') != -1 ||
-         document.cookie.indexOf('TuscanyOpenIDAuth=') != -1);
-}
-
-/**
  * Clear auth information from the document cookie.
  */
 function clearauthcookie() {
-    document.cookie = 'TuscanyOpenAuth=; expires=' + new Date(1970,01,01).toGMTString() + '; domain=.' + domainname(window.location.hostname) + '; path=/';
-    document.cookie = 'TuscanyOAuth1=; expires=' + new Date(1970,01,01).toGMTString() + '; domain=.' + domainname(window.location.hostname) + '; path=/';
-    document.cookie = 'TuscanyOAuth2=; expires=' + new Date(1970,01,01).toGMTString() + '; domain=.' + domainname(window.location.hostname) + '; path=/';
-    document.cookie = 'TuscanyOpenIDAuth=; expires=' + new Date(1970,01,01).toGMTString() + '; domain=.' + domainname(window.location.hostname) + '; path=/';
+    document.cookie = 'TuscanyOpenAuth=; expires=' + new Date(1970,01,01).toGMTString() + '; domain=.' + domainname(window.location.hostname) + '; path=/; secure; httponly';
+    document.cookie = 'TuscanyOAuth1=; expires=' + new Date(1970,01,01).toGMTString() + '; domain=.' + domainname(window.location.hostname) + '; path=/; secure; httponly';
+    document.cookie = 'TuscanyOAuth2=; expires=' + new Date(1970,01,01).toGMTString() + '; domain=.' + domainname(window.location.hostname) + '; path=/; secure; httponly';
+    document.cookie = 'TuscanyOpenIDAuth=; expires=' + new Date(1970,01,01).toGMTString() + '; domain=.' + domainname(window.location.hostname) + '; path=/; secure; httponly';
     return true;
 }
 
@@ -414,6 +447,64 @@
 }
 
 /**
+ * Parse an XML dateTime.
+ */
+function xmldatetime(xml) {
+    var re = /^([0-9]{4,})-([0-9]{2})-([0-9]{2})T([0-9]{2}):([0-9]{2}):([0-9]{2})(\.[0-9]+)?(Z|([+-])([0-9]{2}):([0-9]{2}))?$/;
+    var match = xml.match(re);
+    if (!match)
+        return new Date();
+    return new Date(Date.UTC(match[1], parseInt(match[2]) - 1, match[3],
+                match[9]? parseInt(match[4]) + parseInt(match[10]) * (match[9] == '+'? 1 : -1) : match[4],
+                match[9]? parseInt(match[5]) + parseInt(match[11]) * (match[9] == '+'? 1 : -1) : match[5],
+                match[6], 0));
+}
+
+/**
+ * Encode a string to a url-safe base64 format.
+ */
+function safeb64encode(s) {
+    return btoa(s).replace(/\+/g, '-').replace(/\//g, '_').replace(/\=+$/, '');
+}
+
+/**
+ * Decode a url-safe base64 encoded string.
+ */
+function safeb64decode(s) {
+    return atob((s.replace(/\-/g, '+').replace(/\_/g, '/') + '===').substring(0, s.length + (s.length % 4)));
+}
+
+/**
+ * Return a uuid4.
+ */
+function uuid4() {
+    if (window.crypto && window.crypto.getRandomValues) {
+        var b = new Uint16Array(8);
+        window.crypto.getRandomValues(b);
+        function s4(n) {
+            var s = '000' + n.toString(16);
+            return s.substr(s.length - 4);
+        }
+        return s4(b[0]) + s4(b[1]) + '-' + s4(b[2]) + '-' + s4(b[3]) + '-' + s4(b[4]) + '-' + s4(b[5]) + s4(b[6]) + s4(b[7]);
+    } else {
+        return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function(c) {
+            var r = Math.random() * 16 | 0
+            return (c == 'x'? r : (r & 0x3 | 0x8)).toString(16);
+        });
+    }
+}
+
+/**
+ * Convert an hexadecimal string to ascii.
+ */
+function hex2ascii(x) {
+    var a = '';
+    for (var i = 0; i < x.length; i += 2)
+        a += String.fromCharCode(parseInt(x.substr(i, 2), 16));
+    return a;
+}
+
+/**
  * Functions with side effects. Use with moderation.
  */