diff --git a/.babelrc b/.babelrc
new file mode 100644
index 0000000000000000000000000000000000000000..2b7bafa5fa47672487a998df854e36347be43f58
--- /dev/null
+++ b/.babelrc
@@ -0,0 +1,3 @@
+{
+  "presets": ["@babel/preset-env", "@babel/preset-react"]
+}
diff --git a/.eslintignore b/.eslintignore
new file mode 100644
index 0000000000000000000000000000000000000000..0cdbd5053baa592f554a6970d34509f8c6964fa6
--- /dev/null
+++ b/.eslintignore
@@ -0,0 +1,3 @@
+dist/
+config/
+coverage/
diff --git a/.eslintrc b/.eslintrc
index b8f48e4c3b0426f3960e245402a3ddc870552458..4e5af05be9a51f0f56fa8316f5dd7eee98fe5823 100644
--- a/.eslintrc
+++ b/.eslintrc
@@ -1,17 +1,26 @@
 {
-  "extends": "airbnb-base/legacy",
+  "env": {
+    "jest/globals": true
+  },
+  "extends": ["airbnb","react-app"],
   "globals": {
-    "Mirador": true,
-    "jQuery": true
+    "page": true,
+    "document": true
   },
+  "parser": "babel-eslint",
+  "plugins": ["jest"],
   "rules": {
-    "no-param-reassign": [
-      2, {
-        "props": true,
-        "ignorePropertyModificationsFor": [
-          "$"
-        ]
+    "no-console": "off",
+    "react/jsx-filename-extension": [1, { "extensions": [".js", ".jsx"] }],
+    "require-jsdoc": ["error", {
+      "require": {
+        "FunctionDeclaration": true,
+        "MethodDefinition": true,
+        "ClassDeclaration": true,
+        "ArrowFunctionExpression": true,
+        "FunctionExpression": true
       }
-    ]
+    }],
+    "react/prefer-stateless-function": "off"
   }
 }
diff --git a/.gitignore b/.gitignore
index 70eb458983ac168d5f566e6ce8677c2b89607f5f..e7275d549b84616856341947282a2b97a73beb97 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,18 +1,4 @@
-.grunt/
-_SpecRunner.html
-bkp
-css/discovery.css
-css/mirador.css
-css/jquery-ui-scoped.css
-dev.html
-discovery.html
-js/discovery
-node_modules/
-npm-debug.log
+dist/
 coverage/
-data/ecodices
-data/BnF
-data/Harvard
-build/
-.idea/
-bower_components/
+node_modules/
+package-lock.json
diff --git a/LICENSE b/LICENSE
new file mode 100644
index 0000000000000000000000000000000000000000..b108f546f889bb2445d813a1f6ed6aa306860421
--- /dev/null
+++ b/LICENSE
@@ -0,0 +1,13 @@
+Copyright 2018 The Board of Trustees of the Leland Stanford Junior University
+
+Licensed 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.
diff --git a/README.md b/README.md
index 091b80ee92f20b4abca6219606a0861bfdb42a54..3081cc045533e031045332c39568d5a1f6ef1e82 100644
--- a/README.md
+++ b/README.md
@@ -1,26 +1,64 @@
-[![Build Status](https://travis-ci.org/ProjectMirador/mirador.svg?branch=master)](https://travis-ci.org/ProjectMirador/mirador?branch=master)
-[![Stories in Ready](https://badge.waffle.io/ProjectMirador/mirador.svg?label=ready&title=Ready)](http://waffle.io/iiif/mirador)
+## Running Mirador locally
 
-# Mirador
-![mirador banner](https://projectmirador.github.io/mirador/img/banner.jpg)
-**Mirador is a multi-repository, configurable, extensible, and easy-to-integrate viewer and annotation creation and comparison environment for IIIF resources, ranging from deep-zooming artwork, to complex manuscript objects. It provides a tiling windowed environment for comparing multiple image-based resources, synchronised structural and visual navigation of content using openSeadragon, Open Annotation compliant annotation creation and viewing on deep-zoomable canvases, metadata display, bookreading, and bookmarking.**
+1. Run `npm install` to install the dependencies.
 
-### [See a Demo](http://projectmirador.org/demo/)
-### [Getting Started](http://projectmirador.org/docs/docs/getting-started.html)
+## Starting the project
 
-### Run in Development
-Mirador uses [Node.js](https://nodejs.org/) and a build system to assemble, test, and manage the development resources. If you have never used these tools before, you may need to install them.
+```sh
+$ npm start
+```
 
- 1. Install [Node.js](https://nodejs.org/)
- 2. Install the Grunt command line runner i.e. `npm install -g grunt-cli`
- 1. Clone the Mirador repository
- 1. Change into the Mirador directory
- 1. Install all dependencies with `npm install`. Run `npm start`.
- 
-### Run Tests
-`npm test`
+Then navigate to [http://127.0.0.1:4444/__tests__/integration/mirador/](http://127.0.0.1:4444/__tests__/integration/mirador/)
 
-For more information, see the [Docs](http://projectmirador.org/docs/docs/getting-started.html), submit an [issue](https://github.com/projectmirador/mirador/issues), or ask on [Slack](http://bit.ly/iiif-slack).
+### Instantiating Mirador
 
-### Project Diagnostics
- [![Coverage Status](https://coveralls.io/repos/github/ProjectMirador/mirador/badge.svg?branch=master&upToDate=true)](https://coveralls.io/github/ProjectMirador/mirador?branch=master&upToDate=true)
+```javascript
+var miradorInstance = Mirador.viewer({
+  id: 'mirador' // id selector where Mirador should be instantiated
+});
+
+> miradorInstance
+{ actions, store }
+```
+
+### Example Action
+
+Add a window:
+```javascript
+store.dispatch(actions.addWindow());
+```
+
+To focus a window run:
+
+```javascript
+store.dispatch(actions.focusWindow('window-1'))
+```
+
+### Check current state
+
+```javascript
+store.getState()
+```
+
+## Running the tests
+
+```sh
+$ npm test # For headless CI=true npm test
+```
+
+or to continually watch the source files
+
+```sh
+$ npm run test:watch
+```
+
+## Linting the project
+
+```sh
+$ npm run lint
+```
+
+## Debugging
+Useful browser extensions for debugging/development purposes
+ - [React DevTools](https://github.com/facebook/react-devtools)
+ - [Redux DevTools](https://github.com/zalmoxisus/redux-devtools-extension)
diff --git a/__tests__/fixtures/2.json b/__tests__/fixtures/2.json
new file mode 100644
index 0000000000000000000000000000000000000000..b62a7061aaacec6c3117906d07b1ac7688a9c18c
--- /dev/null
+++ b/__tests__/fixtures/2.json
@@ -0,0 +1,41 @@
+{
+  "@context": "http://iiif.io/api/presentation/2/context.json", 
+  "@id": "http://iiif.io/api/presentation/2.0/example/fixtures/2/manifest.json", 
+  "@type": "sc:Manifest", 
+  "label": "Test 2 Manifest: Metadata Pairs", 
+  "metadata": [
+    {
+      "label": "date", 
+      "value": "some date"
+    }
+  ], 
+  "within": "http://iiif.io/api/presentation/2.0/example/fixtures/collection.json", 
+  "sequences": [
+    {
+      "@type": "sc:Sequence", 
+      "label": "Test 2 Sequence 1", 
+      "canvases": [
+        {
+          "@id": "http://iiif.io/api/presentation/2.0/example/fixtures/canvas/2/c1.json", 
+          "@type": "sc:Canvas", 
+          "label": "Test 2 Canvas: 1", 
+          "height": 1800, 
+          "width": 1200, 
+          "images": [
+            {
+              "@type": "oa:Annotation", 
+              "motivation": "sc:painting", 
+              "resource": {
+                "@id": "http://iiif.io/api/presentation/2.0/example/fixtures/resources/page1-full.png", 
+                "@type": "dctypes:Image", 
+                "height": 1800, 
+                "width": 1200
+              }, 
+              "on": "http://iiif.io/api/presentation/2.0/example/fixtures/canvas/2/c1.json"
+            }
+          ]
+        }
+      ]
+    }
+  ]
+}
diff --git a/__tests__/fixtures/24.json b/__tests__/fixtures/24.json
new file mode 100644
index 0000000000000000000000000000000000000000..d307ecca6584a952a8e97722b20ef9a35c85c6df
--- /dev/null
+++ b/__tests__/fixtures/24.json
@@ -0,0 +1,41 @@
+{
+  "@context": "http://iiif.io/api/presentation/2/context.json", 
+  "@id": "http://iiif.io/api/presentation/2.0/example/fixtures/24/manifest.json", 
+  "@type": "sc:Manifest", 
+  "label": "Test 24 Manifest: Image with IIIF Service - adapted with real image", 
+  "within": "http://iiif.io/api/presentation/2.0/example/fixtures/collection.json", 
+  "sequences": [
+    {
+      "@type": "sc:Sequence", 
+      "label": "Test 24 Sequence 1", 
+      "canvases": [
+        {
+          "@id": "http://iiif.io/api/presentation/2.0/example/fixtures/canvas/24/c1.json", 
+          "@type": "sc:Canvas", 
+          "label": "Test 24 Canvas: 1", 
+          "height": 1800, 
+          "width": 1200, 
+          "images": [
+            {
+              "@type": "oa:Annotation", 
+              "motivation": "sc:painting", 
+              "resource": {
+                "@id": "https://stacks.stanford.edu/image/iiif/hg676jb4964%2F0380_796-44/full/full/0/default.jpg", 
+                "@type": "dctypes:Image", 
+                "format": "image/jpeg", 
+                "height": 3820, 
+                "width": 5426, 
+                "service": {
+                  "@context": "http://iiif.io/api/image/2/context.json", 
+                  "@id": "https://stacks.stanford.edu/image/iiif/hg676jb4964%2F0380_796-44", 
+                  "profile": "http://iiif.io/api/image/2/level2.json"
+                }
+              }, 
+              "on": "http://iiif.io/api/presentation/2.0/example/fixtures/canvas/24/c1.json"
+            }
+          ]
+        }
+      ]
+    }
+  ]
+}
diff --git a/__tests__/integration/mirador/basic.test.js b/__tests__/integration/mirador/basic.test.js
new file mode 100644
index 0000000000000000000000000000000000000000..421aa78d4cebe045b39d12569f2b1573f4ee0ecc
--- /dev/null
+++ b/__tests__/integration/mirador/basic.test.js
@@ -0,0 +1,21 @@
+describe('Basic end to end Mirador', () => {
+  beforeAll(async () => {
+    await page.goto('http://127.0.0.1:4488/__tests__/integration/mirador/');
+  });
+  it('has the correct page title', async () => {
+    const title = await page.title();
+    expect(title).toBe('Mirador');
+  });
+  it('loads a manifest and displays it', async () => {
+    await expect(page).toFill('#manifestURL', 'https://purl.stanford.edu/sn904cj3429/iiif/manifest');
+    await expect(page).toClick('#fetchBtn');
+    // TODO: Refactor the app so we get rid of the wait
+    await page.waitFor(1000);
+    await expect(page).toMatchElement('li', { text: 'https://purl.stanford.edu/sn904cj3429/iiif/manifest' });
+    await expect(page).toMatchElement(
+      'h3',
+      "Peter's San Francisco Locator. The Birds-Eye-View Map of the Exposition City. Published by Locator Publishing Co",
+    );
+    await expect(page).toMatchElement('div', /Color/);
+  });
+});
diff --git a/__tests__/integration/mirador/config_updating_from_instance.test.js b/__tests__/integration/mirador/config_updating_from_instance.test.js
new file mode 100644
index 0000000000000000000000000000000000000000..6190e905366e9b79e6a1db58045b0307d6c52667
--- /dev/null
+++ b/__tests__/integration/mirador/config_updating_from_instance.test.js
@@ -0,0 +1,17 @@
+/* global miradorInstance */
+
+describe('Config updating from instance', () => {
+  beforeAll(async () => {
+    await page.goto('http://127.0.0.1:4488/__tests__/integration/mirador/');
+  });
+  it('can modify the config via api', async () => {
+    await page.evaluate(() => {
+      const a = miradorInstance.actions.updateConfig({ foo: 'bat' });
+      miradorInstance.store.dispatch(a);
+    });
+    const config = await page.evaluate(() => (
+      miradorInstance.store.getState().config
+    ));
+    await expect(config.foo).toBe('bat');
+  });
+});
diff --git a/__tests__/integration/mirador/index.html b/__tests__/integration/mirador/index.html
new file mode 100644
index 0000000000000000000000000000000000000000..baad43c8443d36f967984cbaf95e85cd4d12b662
--- /dev/null
+++ b/__tests__/integration/mirador/index.html
@@ -0,0 +1,18 @@
+<!DOCTYPE html>
+<html lang="en">
+  <head>
+    <meta charset="utf-8">
+    <meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
+    <meta name="theme-color" content="#000000">
+    <title>Mirador</title>
+  </head>
+  <body>
+    <div id="mirador"></div>
+    <script>document.write("<script type='text/javascript' src='../../../dist/mirador.min.js?v=" + Date.now() + "'><\/script>");</script>
+    <script type="text/javascript">
+     var miradorInstance = Mirador.viewer({
+       id: 'mirador'
+     });
+    </script>
+  </body>
+</html>
diff --git a/__tests__/integration/mirador/plugins.html b/__tests__/integration/mirador/plugins.html
new file mode 100644
index 0000000000000000000000000000000000000000..d19bb7095af9a39367083da7f555e913b8c0ee13
--- /dev/null
+++ b/__tests__/integration/mirador/plugins.html
@@ -0,0 +1,100 @@
+<!DOCTYPE html>
+<html lang="en">
+  <head>
+    <meta charset="utf-8">
+    <meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
+    <meta name="theme-color" content="#000000">
+    <title>Mirador</title>
+  </head>
+  <body>
+    <div id="mirador"></div>
+    <script src="../../../node_modules/react/umd/react.development.js"></script>
+    <script src="../../../node_modules/react-dom/umd/react-dom.development.js"></script>
+    <script>document.write("<script type='text/javascript' src='../../../dist/mirador.min.js?v=" + Date.now() + "'><\/script>");</script>
+    <script type="text/javascript">
+      class MiradorShareButton extends React.Component {
+        constructor(props) {
+          super(props);
+      
+          this.handleClick = this.handleClick.bind(this);
+        }
+        handleClick() {
+          alert('Share this stuff')
+        }
+        render() {
+          return React.createElement('button', { className: 'share', onClick: this.handleClick}, 'Share');
+        }
+      }
+      const miradorShareButton = {
+        name: 'miradorShareButton',
+        component: MiradorShareButton,
+        parent: 'WindowTopBarButtons',
+      }
+      Mirador.plugins.miradorShareButton = miradorShareButton;
+      class MiradorRuler extends React.Component {
+        constructor(props) {
+          super(props);
+          this._isMounted = false;
+          this.state = {
+            zooming: false,
+          }
+          this.zoomToColor = this.zoomToColor.bind(this);
+        }
+        componentDidMount() {
+          this._isMounted = true;
+          const that = this;
+          this.props.pluginParent().viewer.addHandler('zoom', (e) => {
+            if (that._isMounted) {
+              that.setState({
+                zooming: true
+              })
+            }
+          })
+          // Super hacky don't do this for real
+          function resetStyle() {
+            if (that._isMounted) {
+              that.setState({
+                zooming: false
+              })
+            }
+            setTimeout(resetStyle, 750)
+          }
+          resetStyle();
+        }
+        componentWillUnmount() {
+          this._isMounted = false;
+          if (this.props.pluginParent()) {
+            this.props.pluginParent().viewer.removeHandler('zoom');
+          }
+        }
+        zoomToColor(zooming) {
+          if (zooming) {
+            return 'red'
+          }
+          return 'black'
+        }
+        render() {
+          return React.createElement('div', {className: 'mirador-ruler', style: { position: 'absolute', bottom: 0, color: this.zoomToColor(this.state.zooming)}}, 'I am a ruler')
+        }
+      }
+      const miradorRuler = {
+        name: 'miradorRuler',
+        component: MiradorRuler,
+        parent: 'OpenSeadragonViewer',
+        mapStateToProps: ({ manifests }, props) => {
+          return {
+            manifests // return the part of the state I need here.
+          }
+        },
+        mapDispatchToProps: (dispatch) => {
+          return {}
+        },
+      }
+      Mirador.plugins.miradorRuler = miradorRuler;
+      var miradorInstance = Mirador.viewer({
+        id: 'mirador',
+        plugins: ['miradorShareButton', 'miradorRuler']
+      });
+    </script>
+  </body>
+</html>
diff --git a/__tests__/integration/mirador/plugins.test.js b/__tests__/integration/mirador/plugins.test.js
new file mode 100644
index 0000000000000000000000000000000000000000..5c3e94634e4e9c79da7fbff2e60675fe68bde749
--- /dev/null
+++ b/__tests__/integration/mirador/plugins.test.js
@@ -0,0 +1,21 @@
+describe('Mirador plugin use', () => {
+  beforeAll(async () => {
+    await page.goto('http://127.0.0.1:4488/__tests__/integration/mirador/plugins.html');
+    await expect(page).toFill('#manifestURL', 'https://purl.stanford.edu/sn904cj3429/iiif/manifest');
+    await expect(page).toClick('#fetchBtn');
+    // TODO: Refactor the app so we get rid of the wait
+    await page.waitFor(1000);
+    await expect(page).toClick('li button');
+  });
+  it('displays "Share Button" plugin', async () => {
+    await expect(page).toMatchElement('button', { text: 'Share' });
+    page.on('dialog', async (dialog) => {
+      expect(dialog.message()).toBe('Share this stuff');
+      await dialog.dismiss();
+    });
+    await expect(page).toClick('button.share');
+  });
+  it('displays "Ruler" plugin', async () => {
+    await expect(page).toMatchElement('.mirador-ruler');
+  });
+});
diff --git a/__tests__/integration/mirador/window_actions.test.js b/__tests__/integration/mirador/window_actions.test.js
new file mode 100644
index 0000000000000000000000000000000000000000..e16d8b4206dc738002ccb6c64f828c386f9bdf4b
--- /dev/null
+++ b/__tests__/integration/mirador/window_actions.test.js
@@ -0,0 +1,15 @@
+describe('Window actions', () => {
+  beforeAll(async () => {
+    await page.goto('http://127.0.0.1:4488/__tests__/integration/mirador/');
+  });
+  it('opens a window and closes it', async () => {
+    await expect(page).toFill('#manifestURL', 'https://purl.stanford.edu/sn904cj3429/iiif/manifest');
+    await expect(page).toClick('#fetchBtn');
+    // TODO: Refactor the app so we get rid of the wait
+    await page.waitFor(1000);
+    await expect(page).toClick('li button');
+    await expect(page).toMatchElement('.mirador-window');
+    await expect(page).toClick('.mirador-window-close');
+    await expect(page).not.toMatchElement('.mirador-window');
+  });
+});
diff --git a/__tests__/integration/vanilla-js.test.js b/__tests__/integration/vanilla-js.test.js
new file mode 100644
index 0000000000000000000000000000000000000000..e74fd045d388b8d05f90abdf623b6007a988af72
--- /dev/null
+++ b/__tests__/integration/vanilla-js.test.js
@@ -0,0 +1,17 @@
+describe('Plain JavaScript example', () => {
+  beforeAll(async () => {
+    await page.goto('http://127.0.0.1:4488/__tests__/integration/vanilla-js/');
+  });
+  it('has the correct page title', async () => {
+    const title = await page.title();
+    expect(title).toBe('Examples');
+  });
+  it('loads a manifest and displays it', async () => {
+    await expect(page).toFill('#manifestURL', 'https://purl.stanford.edu/sn904cj3429/iiif/manifest');
+    await expect(page).toClick('#fetchBtn');
+    // TODO: Refactor the app so we get rid of the wait
+    await page.waitFor(1000);
+    const manifest = await page.$eval('#exampleManifest', e => e.innerHTML);
+    await expect(manifest).toMatch(/http:\/\/iiif\.io\/api\/presentation\/2\/context\.json/);
+  });
+});
diff --git a/__tests__/integration/vanilla-js/README.md b/__tests__/integration/vanilla-js/README.md
new file mode 100644
index 0000000000000000000000000000000000000000..abe90a01e6af8c2423709eabc5bbdbc9c349897b
--- /dev/null
+++ b/__tests__/integration/vanilla-js/README.md
@@ -0,0 +1,8 @@
+## Notes
+To load external manifests, you will need to run the example pages from a serve. One easy way to do this is to run PythonHTTPServer:
+`python -m SimpleHTTPServer`
+
+To inspect the Mirador 3 prototype in the web console with (code completion), you can refer to it as:
+`m3core`
+
+This name is found in the `minimal_redux_poc/webpack.config.js` as the value of the `library` property.
diff --git a/__tests__/integration/vanilla-js/index.html b/__tests__/integration/vanilla-js/index.html
new file mode 100644
index 0000000000000000000000000000000000000000..432e8d0558ed56ebbf57b6a1758edea77066d11d
--- /dev/null
+++ b/__tests__/integration/vanilla-js/index.html
@@ -0,0 +1,44 @@
+<!DOCTYPE html>
+<html>
+  <head>
+    <meta charset="UTF-8">
+    <title>Examples</title>
+    <script src="../../../dist/m3core.umd.js"></script>
+  </head>
+  <body>
+
+  <input id="manifestURL" type="text"/><button id="fetchBtn" type="submit">Fetch Manifest</button>
+  <pre id="exampleManifest">
+
+  </pre>
+
+  <script>
+    document.getElementById("fetchBtn").addEventListener("click", function(){
+      let val = document.getElementById("manifestURL").value;
+      let f = m3core.actions.fetchManifest(val);
+      m3core.store.dispatch(f);
+    });
+
+    m3core.store.subscribe(() => {
+      let contents = ''
+      let state = m3core.store.getState();
+      let manifest = state.manifests[document.getElementById("manifestURL").value];
+      switch (manifest.isFetching) {
+        case true:
+          contents = 'spinner';
+          break;
+        case false:
+          if(manifest.error){
+            contents = manifest.error.message;
+          } else {
+            contents = JSON.stringify(manifest.manifestation.__jsonld, 0, 3);
+          }
+          break;
+        default: contents = ''
+      }
+      document.getElementById("exampleManifest").innerHTML = contents;
+    });
+
+  </script>
+  </body>
+</html>
diff --git a/__tests__/src/actions/index.test.js b/__tests__/src/actions/index.test.js
new file mode 100644
index 0000000000000000000000000000000000000000..1dd4c73127380b22a29b4a904e46290115d45955
--- /dev/null
+++ b/__tests__/src/actions/index.test.js
@@ -0,0 +1,209 @@
+import configureMockStore from 'redux-mock-store';
+import thunk from 'redux-thunk';
+
+import * as actions from '../../../src/actions/index';
+import ActionTypes from '../../../src/action-types';
+
+const middlewares = [thunk];
+const mockStore = configureMockStore(middlewares);
+
+describe('actions', () => {
+  describe('addWindow', () => {
+    it('should create a new window with merged defaults', () => {
+      const options = {
+        id: 'helloworld',
+        canvasIndex: 1,
+      };
+
+      const expectedAction = {
+        type: ActionTypes.ADD_WINDOW,
+        payload: {
+          id: 'helloworld',
+          canvasIndex: 1,
+          collectionIndex: 0,
+          manifestId: null,
+          rangeId: null,
+          xywh: [0, 0, 400, 400],
+          rotation: null,
+        },
+      };
+      expect(actions.addWindow(options)).toEqual(expectedAction);
+    });
+  });
+  describe('removeWindow', () => {
+    it('removes the window and returns windowId', () => {
+      const id = 'abc123';
+      const expectedAction = {
+        type: ActionTypes.REMOVE_WINDOW,
+        windowId: id,
+      };
+      expect(actions.removeWindow(id)).toEqual(expectedAction);
+    });
+  });
+  describe('nextCanvas', () => {
+    it('moves to the next canvas', () => {
+      const id = 'abc123';
+      const expectedAction = {
+        type: ActionTypes.NEXT_CANVAS,
+        windowId: id,
+      };
+      expect(actions.nextCanvas(id)).toEqual(expectedAction);
+    });
+  });
+  describe('previousCanvas', () => {
+    it('moves to the previous canvas', () => {
+      const id = 'abc123';
+      const expectedAction = {
+        type: ActionTypes.PREVIOUS_CANVAS,
+        windowId: id,
+      };
+      expect(actions.previousCanvas(id)).toEqual(expectedAction);
+    });
+  });
+  describe('requestManifest', () => {
+    it('requests a manifest given a url', () => {
+      const id = 'abc123';
+      const expectedAction = {
+        type: ActionTypes.REQUEST_MANIFEST,
+        manifestId: id,
+      };
+      expect(actions.requestManifest(id)).toEqual(expectedAction);
+    });
+  });
+  describe('receiveManifest', () => {
+    it('moves to the previous canvas', () => {
+      const id = 'abc123';
+      const json = {
+        id,
+        content: 'lots of metadata, canvases, and other IIIFy things',
+      };
+      const expectedAction = {
+        type: ActionTypes.RECEIVE_MANIFEST,
+        manifestId: id,
+        manifestJson: json,
+      };
+      expect(actions.receiveManifest(id, json)).toEqual(expectedAction);
+    });
+  });
+  describe('fetchManifest', () => {
+    let store = null;
+    beforeEach(() => {
+      store = mockStore({});
+    });
+    describe('success response', () => {
+      beforeEach(() => {
+        fetch.mockResponseOnce(JSON.stringify({ data: '12345' })); // eslint-disable-line no-undef
+      });
+      it('dispatches the REQUEST_MANIFEST action', () => {
+        store.dispatch(actions.fetchManifest('https://purl.stanford.edu/sn904cj3429/iiif/manifest'));
+        expect(store.getActions()).toEqual([
+          { manifestId: 'https://purl.stanford.edu/sn904cj3429/iiif/manifest', type: 'REQUEST_MANIFEST' },
+        ]);
+      });
+      it('dispatches the REQUEST_MANIFEST and then RECEIVE_MANIFEST', () => {
+        store.dispatch(actions.fetchManifest('https://purl.stanford.edu/sn904cj3429/iiif/manifest'))
+          .then(() => {
+            const expectedActions = store.getActions();
+            expect(expectedActions).toEqual([
+              { manifestId: 'https://purl.stanford.edu/sn904cj3429/iiif/manifest', type: 'REQUEST_MANIFEST' },
+              { manifestId: 'https://purl.stanford.edu/sn904cj3429/iiif/manifest', manifestJson: { data: '12345' }, type: 'RECEIVE_MANIFEST' },
+            ]);
+          });
+      });
+    });
+    describe('error response', () => {
+      it('dispatches the REQUEST_MANIFEST and then RECEIVE_MANIFEST', () => {
+        store.dispatch(actions.fetchManifest('https://purl.stanford.edu/sn904cj3429/iiif/manifest'))
+          .then(() => {
+            const expectedActions = store.getActions();
+            expect(expectedActions).toEqual([
+              { manifestId: 'https://purl.stanford.edu/sn904cj3429/iiif/manifest', type: 'REQUEST_MANIFEST' },
+              { manifestId: 'https://purl.stanford.edu/sn904cj3429/iiif/manifest', error: new Error('invalid json response body at undefined reason: Unexpected end of JSON input'), type: 'RECEIVE_MANIFEST_FAILURE' },
+            ]);
+          });
+      });
+    });
+  });
+  describe('removeManifest', () => {
+    it('removes an existing manifest', () => {
+      const expectedAction = {
+        type: ActionTypes.REMOVE_MANIFEST,
+        manifestId: 'foo',
+      };
+      expect(actions.removeManifest('foo')).toEqual(expectedAction);
+    });
+  });
+  describe('requestInfoResponse', () => {
+    it('requests an infoResponse from given a url', () => {
+      const id = 'abc123';
+      const expectedAction = {
+        type: ActionTypes.REQUEST_INFO_RESPONSE,
+        infoId: id,
+      };
+      expect(actions.requestInfoResponse(id)).toEqual(expectedAction);
+    });
+  });
+  describe('receiveInfoResponse', () => {
+    it('recieves an infoResponse', () => {
+      const id = 'abc123';
+      const json = {
+        id,
+        content: 'image information request',
+      };
+      const expectedAction = {
+        type: ActionTypes.RECEIVE_INFO_RESPONSE,
+        infoId: id,
+        infoJson: json,
+      };
+      expect(actions.receiveInfoResponse(id, json)).toEqual(expectedAction);
+    });
+  });
+  describe('fetchInfoResponse', () => {
+    let store = null;
+    beforeEach(() => {
+      store = mockStore({});
+    });
+    describe('success response', () => {
+      beforeEach(() => {
+        fetch.mockResponseOnce(JSON.stringify({ data: '12345' })); // eslint-disable-line no-undef
+      });
+      it('dispatches the REQUEST_MANIFEST action', () => {
+        store.dispatch(actions.fetchInfoResponse('https://stacks.stanford.edu/image/iiif/sn904cj3429%2F12027000/info.json'));
+        expect(store.getActions()).toEqual([
+          { infoId: 'https://stacks.stanford.edu/image/iiif/sn904cj3429%2F12027000/info.json', type: 'REQUEST_INFO_RESPONSE' },
+        ]);
+      });
+      it('dispatches the REQUEST_MANIFEST and then RECEIVE_MANIFEST', () => {
+        store.dispatch(actions.fetchInfoResponse('https://stacks.stanford.edu/image/iiif/sn904cj3429%2F12027000/info.json'))
+          .then(() => {
+            const expectedActions = store.getActions();
+            expect(expectedActions).toEqual([
+              { infoId: 'https://stacks.stanford.edu/image/iiif/sn904cj3429%2F12027000/info.json', type: 'REQUEST_INFO_RESPONSE' },
+              { infoId: 'https://stacks.stanford.edu/image/iiif/sn904cj3429%2F12027000/info.json', infoJson: { data: '12345' }, type: 'RECEIVE_INFO_RESPONSE' },
+            ]);
+          });
+      });
+    });
+    describe('error response', () => {
+      it('dispatches the REQUEST_INFO_RESPONSE and then RECEIVE_INFO_RESPONSE', () => {
+        store.dispatch(actions.fetchInfoResponse('https://stacks.stanford.edu/image/iiif/sn904cj3429%2F12027000/info.json'))
+          .then(() => {
+            const expectedActions = store.getActions();
+            expect(expectedActions).toEqual([
+              { infoId: 'https://stacks.stanford.edu/image/iiif/sn904cj3429%2F12027000/info.json', type: 'REQUEST_INFO_RESPONSE' },
+              { infoId: 'https://stacks.stanford.edu/image/iiif/sn904cj3429%2F12027000/info.json', error: new Error('invalid json response body at undefined reason: Unexpected end of JSON input'), type: 'RECEIVE_INFO_RESPONSE_FAILURE' },
+            ]);
+          });
+      });
+    });
+  });
+  describe('removeInfoResponse', () => {
+    it('removes an existing infoResponse', () => {
+      const expectedAction = {
+        type: ActionTypes.REMOVE_INFO_RESPONSE,
+        infoId: 'foo',
+      };
+      expect(actions.removeInfoResponse('foo')).toEqual(expectedAction);
+    });
+  });
+});
diff --git a/__tests__/src/components/Display.test.js b/__tests__/src/components/Display.test.js
new file mode 100644
index 0000000000000000000000000000000000000000..0ef95154d5265e706f9bf8b22d5789373d80e88a
--- /dev/null
+++ b/__tests__/src/components/Display.test.js
@@ -0,0 +1,29 @@
+import React from 'react';
+import { shallow } from 'enzyme';
+import Display from '../../../src/components/Display';
+import ManifestMetadata from '../../../src/components/ManifestMetadata';
+import fixture from '../../fixtures/2.json';
+
+describe('Display', () => {
+  it('renders without an error', () => {
+    const wrapper = shallow(<Display manifest={{}} />);
+    expect(wrapper.contains(<div className="Display"><div id="exampleManifest" className=""><ManifestMetadata manifest={{}} /></div></div>)).toBe(true);
+  });
+  it('sets class based on manifest state', () => {
+    let wrapper = shallow(<Display manifest={{ isFetching: true }} />);
+    expect(wrapper.find('.mirador-fetching').length).toBe(1);
+
+    wrapper = shallow(<Display manifest={{ error: true }} />);
+    expect(wrapper.find('.mirador-error').length).toBe(1);
+  });
+  it('displays content', () => {
+    let wrapper = shallow(<Display manifest={{ isFetching: true }} />);
+    expect(wrapper.text()).toBe('☕');
+
+    wrapper = shallow(<Display manifest={{ error: { message: 'bad things' } }} />);
+    expect(wrapper.text()).toBe('bad things');
+
+    wrapper = shallow(<Display manifest={{ json: fixture }} />);
+    expect(wrapper.find(ManifestMetadata).length).toBe(1);
+  });
+});
diff --git a/__tests__/src/components/ManifestListItem.test.js b/__tests__/src/components/ManifestListItem.test.js
new file mode 100644
index 0000000000000000000000000000000000000000..2be1cd2218fd179df06542eefbea5ead915ca256
--- /dev/null
+++ b/__tests__/src/components/ManifestListItem.test.js
@@ -0,0 +1,19 @@
+import React from 'react';
+import { shallow } from 'enzyme';
+import { store } from '../../../src/store';
+import ManifestListItem from '../../../src/components/ManifestListItem';
+
+describe('ManifestListItem', () => {
+  it('renders without an error', () => {
+    const wrapper = shallow(<ManifestListItem manifest="http://example.com" store={store} />).dive();
+    expect(wrapper.find('li.mirador-manifest-list-item').length).toBe(1);
+    expect(wrapper.find('button').length).toBe(1);
+    expect(wrapper.find('button').text()).toEqual('http://example.com');
+  });
+  it('updates and adds window when button clicked', () => {
+    const wrapper = shallow(<ManifestListItem manifest="http://example.com" store={store} />).dive();
+    expect(store.getState().windows.length).toEqual(0);
+    wrapper.find('button').simulate('click');
+    expect(store.getState().windows.length).toEqual(1);
+  });
+});
diff --git a/__tests__/src/components/ManifestMetadata.test.js b/__tests__/src/components/ManifestMetadata.test.js
new file mode 100644
index 0000000000000000000000000000000000000000..404ab7c47741ce9b68a86ee7f0761446e3e86352
--- /dev/null
+++ b/__tests__/src/components/ManifestMetadata.test.js
@@ -0,0 +1,20 @@
+import React from 'react';
+import { shallow } from 'enzyme';
+import { actions, store } from '../../../src/store';
+import ManifestMetadata from '../../../src/components/ManifestMetadata';
+import fixture from '../../fixtures/2.json';
+
+describe('ManifestMetadata', () => {
+  let wrapper;
+  let manifest;
+  beforeEach(() => {
+    store.dispatch(actions.receiveManifest('foo', fixture));
+    manifest = store.getState().manifests.foo;
+    wrapper = shallow(<ManifestMetadata manifest={manifest} />);
+  });
+
+  it('renders without an error', () => {
+    expect(wrapper.find('h3').text()).toBe('Test 2 Manifest: Metadata Pairs');
+    expect(wrapper.find('.mirador-description').length).toBe(1);
+  });
+});
diff --git a/__tests__/src/components/Window.test.js b/__tests__/src/components/Window.test.js
new file mode 100644
index 0000000000000000000000000000000000000000..deedb4a02ed0cdc508df53b18d5d9f8a46c01608
--- /dev/null
+++ b/__tests__/src/components/Window.test.js
@@ -0,0 +1,67 @@
+import React from 'react';
+import { mount, shallow } from 'enzyme';
+import { actions, store } from '../../../src/store';
+import Window from '../../../src/components/Window';
+import fixture from '../../fixtures/24.json';
+
+describe('Window', () => {
+  let wrapper;
+  let window;
+  describe('with a manifest present', () => {
+    beforeEach(() => {
+      store.dispatch(actions.receiveManifest('foo', fixture));
+      store.dispatch(actions.addWindow({ manifestId: 'foo' }));
+      [window] = store.getState().windows;
+      wrapper = mount(
+        <Window store={store} id={window.id} />,
+        // We need to attach this to something created by our JSDOM instance.
+        // Also need to provide context of the store so that connected sub components
+        // can render effectively.
+        { attachTo: document.getElementById('main'), context: { store } },
+      );
+    });
+
+    afterEach(() => {
+      store.dispatch(actions.removeManifest('foo'));
+    });
+
+    it('returns the width and height style attribute', () => {
+      wrapper = shallow(<Window store={store} id={window.id} />, { context: { store } });
+      expect(wrapper.dive().instance().styleAttributes())
+        .toEqual({ width: '400px', height: '400px' });
+    });
+
+    it('renders without an error', () => {
+      expect(wrapper.find('.mirador-window').prop('style')).toHaveProperty('width', '400px');
+      expect(wrapper.find('.mirador-window').prop('style')).toHaveProperty('height', '400px');
+      expect(wrapper.find('div.mirador-window').length).toBe(1);
+      expect(wrapper.find('div.mirador-window img').prop('src')).toBe('http://placekitten.com/200/300');
+    });
+
+    it('renders the viewer', () => {
+      expect(wrapper.find('WindowViewer').length).toBe(1);
+    });
+  });
+  describe('without a manifest present', () => {
+    beforeEach(() => {
+      store.dispatch(actions.addWindow({ manifestId: 'foo' }));
+      [window] = store.getState().windows;
+      wrapper = shallow(<Window store={store} id={window.id} />, { context: { store } }).dive();
+    });
+
+    it('returns the width and height style attribute', () => {
+      expect(wrapper.instance().styleAttributes())
+        .toEqual({ width: '400px', height: '400px' });
+    });
+
+    it('renders without an error', () => {
+      expect(wrapper.find('.mirador-window').prop('style')).toHaveProperty('width', '400px');
+      expect(wrapper.find('.mirador-window').prop('style')).toHaveProperty('height', '400px');
+      expect(wrapper.find('div.mirador-window img').length).toBe(0);
+    });
+
+    it('does not render the viewer', () => {
+      expect(wrapper.find('WindowViewer').length).toBe(0);
+    });
+  });
+});
diff --git a/__tests__/src/components/WindowBackground.test.js b/__tests__/src/components/WindowBackground.test.js
new file mode 100644
index 0000000000000000000000000000000000000000..f3fe49abf9c40a65321ae5c3756756aaea7f392c
--- /dev/null
+++ b/__tests__/src/components/WindowBackground.test.js
@@ -0,0 +1,23 @@
+import React from 'react';
+import { shallow } from 'enzyme';
+import { actions, store } from '../../../src/store';
+import WindowBackground from '../../../src/components/WindowBackground';
+import fixture from '../../fixtures/24.json';
+
+describe('WindowBackground', () => {
+  let wrapper;
+  beforeEach(() => {
+    store.dispatch(actions.receiveManifest('foo', fixture));
+    store.dispatch(actions.addWindow({ manifestId: 'foo' }));
+    const manifest = store.getState().manifests.foo;
+    wrapper = shallow(<WindowBackground manifest={manifest} />);
+  });
+
+  it('renders without an error', () => {
+    expect(wrapper.find('img').prop('src')).toBe('http://placekitten.com/200/300');
+  });
+
+  it('without a manifest, renders an empty', () => {
+    expect(shallow(<WindowBackground manifest={null} />).html()).toBe('<div></div>');
+  });
+});
diff --git a/__tests__/src/components/WindowTopBar.test.js b/__tests__/src/components/WindowTopBar.test.js
new file mode 100644
index 0000000000000000000000000000000000000000..c03aeae9695da95d4bf727b3e77d1ba55ccc543f
--- /dev/null
+++ b/__tests__/src/components/WindowTopBar.test.js
@@ -0,0 +1,28 @@
+import React from 'react';
+import { mount } from 'enzyme';
+import { actions, store } from '../../../src/store';
+import WindowTopBar from '../../../src/components/WindowTopBar';
+import fixture from '../../fixtures/24.json';
+
+describe('Window', () => {
+  let wrapper;
+  let window;
+  beforeEach(() => {
+    store.dispatch(actions.receiveManifest('foo', fixture));
+    store.dispatch(actions.addWindow({ manifestId: 'foo' }));
+    const manifest = store.getState().manifests.foo;
+    [window] = store.getState().windows;
+    wrapper = mount(
+      <WindowTopBar store={store} manifest={manifest} windowId={window.id} />,
+      // We need to attach this to something created by our JSDOM instance.
+      // Also need to provide context of the store so that connected sub components
+      // can render effectively.
+      { attachTo: document.getElementById('main'), context: { store } },
+    );
+  });
+
+  it('renders without an error', () => {
+    expect(wrapper.find('div.mirador-window-top-bar h3').text()).toBe('Test 24 Manifest: Image with IIIF Service - adapted with real image');
+    expect(wrapper.find('button.mirador-window-close'));
+  });
+});
diff --git a/__tests__/src/components/WindowViewer.test.js b/__tests__/src/components/WindowViewer.test.js
new file mode 100644
index 0000000000000000000000000000000000000000..2bed4303aa1be9c591fa3df8d8a0b33db9605b0d
--- /dev/null
+++ b/__tests__/src/components/WindowViewer.test.js
@@ -0,0 +1,32 @@
+import React from 'react';
+import { mount } from 'enzyme';
+import { actions, store } from '../../../src/store';
+import WindowViewer from '../../../src/components/WindowViewer';
+import fixture from '../../fixtures/24.json';
+
+describe('WindowViewer', () => {
+  let wrapper;
+  let window;
+  beforeEach(() => {
+    store.dispatch(actions.receiveManifest('foo', fixture));
+    store.dispatch(actions.addWindow({ manifestId: 'foo' }));
+    const manifest = store.getState().manifests.foo;
+    [window] = store.getState().windows;
+    wrapper = mount(
+      <WindowViewer manifest={manifest} window={window} />,
+      // We need to attach this to something created by our JSDOM instance.
+      // Also need to provide context of the store so that connected sub components
+      // can render effectively.
+      { attachTo: document.getElementById('main'), context: { store } },
+    );
+  });
+
+  it('OpenSeaDragon instantiates', () => {
+    // Hacky as heck thing we have to do, as `#find` (and other methods on ReactWrapper)
+    // do not effectively find elements (even though they are there)
+    expect(wrapper.render().find('.openseadragon-canvas').length).toBe(1);
+  });
+  it('has navigation controls', () => {
+    expect(wrapper.find('.mirador-osd-navigation').length).toBe(1);
+  });
+});
diff --git a/__tests__/src/components/Workspace.test.js b/__tests__/src/components/Workspace.test.js
new file mode 100644
index 0000000000000000000000000000000000000000..c93549885a40ad040d261befa94b8ae94aa825a5
--- /dev/null
+++ b/__tests__/src/components/Workspace.test.js
@@ -0,0 +1,20 @@
+import React from 'react';
+import { shallow } from 'enzyme';
+import { actions, store } from '../../../src/store';
+import Workspace from '../../../src/components/Workspace';
+import fixture from '../../fixtures/2.json';
+
+describe('Workspace', () => {
+  let wrapper;
+  beforeEach(() => {
+    store.dispatch(actions.receiveManifest('foo', fixture));
+    store.dispatch(actions.addWindow({ manifestId: 'foo' }));
+    wrapper = shallow(<Workspace store={store} />).dive();
+  });
+
+  it('renders without an error', () => {
+    const window = store.getState().windows[0];
+    expect(wrapper.find('div.mirador-workspace').length).toBe(1);
+    expect(wrapper.find(`#${window.id}`).length).toBe(1);
+  });
+});
diff --git a/__tests__/src/reducers/config.test.js b/__tests__/src/reducers/config.test.js
new file mode 100644
index 0000000000000000000000000000000000000000..11836e06ef338f4cf4272d39f594a07e0d7c2f71
--- /dev/null
+++ b/__tests__/src/reducers/config.test.js
@@ -0,0 +1,42 @@
+import reducer from '../../../src/reducers/config';
+import ActionTypes from '../../../src/action-types';
+
+describe('config reducer', () => {
+  describe('SET_CONFIG', () => {
+    it('should handle SET_CONFIG', () => {
+      expect(reducer({}, {
+        type: ActionTypes.SET_CONFIG,
+        config: { manifestVersion: 'v3' },
+      })).toEqual({
+        manifestVersion: 'v3',
+      });
+    });
+    it('does not deep merge', () => {
+      expect(reducer({ stuff: { foo: 'bar' } }, {
+        type: ActionTypes.SET_CONFIG,
+        config: { stuff: { foo: 'bat' } },
+      })).toEqual({
+        stuff: { foo: 'bat' },
+      });
+    });
+  });
+  describe('UPDATE_CONFIG', () => {
+    it('should handle UPDATE_CONFIG', () => {
+      expect(reducer({}, {
+        type: ActionTypes.UPDATE_CONFIG,
+        config: { manifestVersion: 'v3' },
+      })).toEqual({
+        manifestVersion: 'v3',
+      });
+    });
+    it('does a deep merge', () => {
+      expect(reducer({ stuff: { foo: 'bar' }, hello: 'world' }, {
+        type: ActionTypes.UPDATE_CONFIG,
+        config: { stuff: { foo: 'bat' } },
+      })).toEqual({
+        stuff: { foo: 'bat' },
+        hello: 'world',
+      });
+    });
+  });
+});
diff --git a/__tests__/src/reducers/infoResponse.test.js b/__tests__/src/reducers/infoResponse.test.js
new file mode 100644
index 0000000000000000000000000000000000000000..7841eb8b5e882383bbc88822d720f92afc38558c
--- /dev/null
+++ b/__tests__/src/reducers/infoResponse.test.js
@@ -0,0 +1,70 @@
+import reducer from '../../../src/reducers/infoResponses';
+import ActionTypes from '../../../src/action-types';
+
+describe('manifests reducer', () => {
+  it('should handle REQUEST_INFO_RESPONSE', () => {
+    expect(reducer({}, {
+      type: ActionTypes.REQUEST_INFO_RESPONSE,
+      infoId: 'abc123',
+    })).toEqual({
+      abc123: {
+        isFetching: true,
+      },
+    });
+  });
+  it('should handle RECEIVE_INFO_RESPONSE', () => {
+    expect(reducer(
+      {
+        abc123: {
+          isFetching: true,
+        },
+      },
+      {
+        type: ActionTypes.RECEIVE_INFO_RESPONSE,
+        infoId: 'abc123',
+        infoJson: {
+          id: 'abc123',
+          '@type': 'sc:Manifest',
+          content: 'lots of canvases and metadata and such',
+        },
+      },
+    )).toMatchObject({
+      abc123: {
+        isFetching: false,
+        json: {},
+      },
+    });
+  });
+  it('should handle RECEIVE_INFO_RESPONSE_FAILURE', () => {
+    expect(reducer(
+      {
+        abc123: {
+          isFetching: true,
+        },
+      },
+      {
+        type: ActionTypes.RECEIVE_INFO_RESPONSE_FAILURE,
+        infoId: 'abc123',
+        error: "This institution didn't enable CORS.",
+      },
+    )).toEqual({
+      abc123: {
+        isFetching: false,
+        error: "This institution didn't enable CORS.",
+      },
+    });
+  });
+  it('should handle REMOVE_INFO_RESPONSE', () => {
+    expect(reducer(
+      {
+        abc123: {
+          stuff: 'foo',
+        },
+      },
+      {
+        type: ActionTypes.REMOVE_INFO_RESPONSE,
+        infoId: 'abc123',
+      },
+    )).toEqual({});
+  });
+});
diff --git a/__tests__/src/reducers/manifests.test.js b/__tests__/src/reducers/manifests.test.js
new file mode 100644
index 0000000000000000000000000000000000000000..88e14f61bb441e076d6b4b1057e35261af9cc3a4
--- /dev/null
+++ b/__tests__/src/reducers/manifests.test.js
@@ -0,0 +1,70 @@
+import reducer from '../../../src/reducers/manifests';
+import ActionTypes from '../../../src/action-types';
+
+describe('manifests reducer', () => {
+  it('should handle REQUEST_MANIFEST', () => {
+    expect(reducer({}, {
+      type: ActionTypes.REQUEST_MANIFEST,
+      manifestId: 'abc123',
+    })).toEqual({
+      abc123: {
+        isFetching: true,
+      },
+    });
+  });
+  it('should handle RECEIVE_MANIFEST', () => {
+    expect(reducer(
+      {
+        abc123: {
+          isFetching: true,
+        },
+      },
+      {
+        type: ActionTypes.RECEIVE_MANIFEST,
+        manifestId: 'abc123',
+        manifestJson: {
+          id: 'abc123',
+          '@type': 'sc:Manifest',
+          content: 'lots of canvases and metadata and such',
+        },
+      },
+    )).toMatchObject({
+      abc123: {
+        isFetching: false,
+        manifestation: {},
+      },
+    });
+  });
+  it('should handle RECEIVE_MANIFEST_FAILURE', () => {
+    expect(reducer(
+      {
+        abc123: {
+          isFetching: true,
+        },
+      },
+      {
+        type: ActionTypes.RECEIVE_MANIFEST_FAILURE,
+        manifestId: 'abc123',
+        error: "This institution didn't enable CORS.",
+      },
+    )).toEqual({
+      abc123: {
+        isFetching: false,
+        error: "This institution didn't enable CORS.",
+      },
+    });
+  });
+  it('should handle REMOVE_MANIFEST', () => {
+    expect(reducer(
+      {
+        abc123: {
+          stuff: 'foo',
+        },
+      },
+      {
+        type: ActionTypes.REMOVE_MANIFEST,
+        manifestId: 'abc123',
+      },
+    )).toEqual({});
+  });
+});
diff --git a/__tests__/src/reducers/windows.test.js b/__tests__/src/reducers/windows.test.js
new file mode 100644
index 0000000000000000000000000000000000000000..a4d485654395cbe83efa46e7533113bb572e4221
--- /dev/null
+++ b/__tests__/src/reducers/windows.test.js
@@ -0,0 +1,54 @@
+import reducer from '../../../src/reducers/windows';
+import ActionTypes from '../../../src/action-types';
+
+describe('windows reducer', () => {
+  it('should handle ADD_WINDOW', () => {
+    expect(reducer([], {
+      type: ActionTypes.ADD_WINDOW,
+    })).toEqual([
+      {},
+    ]);
+  });
+  it('should handle REMOVE_WINDOW', () => {
+    expect(reducer([
+      {
+        id: 'abc123',
+      },
+    ], {
+      type: ActionTypes.REMOVE_WINDOW,
+      windowId: 'abc123',
+    })).toEqual([]);
+  });
+  it('should handle NEXT_CANVAS', () => {
+    expect(reducer([
+      {
+        id: 'abc123',
+        canvasIndex: 1,
+      },
+    ], {
+      type: ActionTypes.NEXT_CANVAS,
+      windowId: 'abc123',
+    })).toEqual([
+      {
+        id: 'abc123',
+        canvasIndex: 2,
+      },
+    ]);
+  });
+  it('should handle PREVIOUS_CANVAS', () => {
+    expect(reducer([
+      {
+        id: 'abc123',
+        canvasIndex: 4,
+      },
+    ], {
+      type: ActionTypes.PREVIOUS_CANVAS,
+      windowId: 'abc123',
+    })).toEqual([
+      {
+        id: 'abc123',
+        canvasIndex: 3,
+      },
+    ]);
+  });
+});
diff --git a/__tests__/src/reducers/workspace.test.js b/__tests__/src/reducers/workspace.test.js
new file mode 100644
index 0000000000000000000000000000000000000000..e1e24a7fd857c4254e389bfd9f0ba42e91d5f26f
--- /dev/null
+++ b/__tests__/src/reducers/workspace.test.js
@@ -0,0 +1,13 @@
+import reducer from '../../../src/reducers/workspace';
+import ActionTypes from '../../../src/action-types';
+
+describe('workspace reducer', () => {
+  it('should handle FOCUS_WINDOW', () => {
+    expect(reducer([], {
+      type: ActionTypes.FOCUS_WINDOW,
+      windowId: 'abc123',
+    })).toEqual({
+      focusedWindowId: 'abc123',
+    });
+  });
+});
diff --git a/config/paths.js b/config/paths.js
new file mode 100644
index 0000000000000000000000000000000000000000..2060badce016cbe023d2af06e9a9015114a77858
--- /dev/null
+++ b/config/paths.js
@@ -0,0 +1,98 @@
+const path = require('path');
+const fs = require('fs');
+const url = require('url');
+
+// Make sure any symlinks in the project folder are resolved:
+// https://github.com/facebook/create-react-app/issues/637
+const appDirectory = fs.realpathSync(process.cwd());
+
+/**
+ *
+ * @param relativePath
+ * @returns {string}
+ */
+const resolveApp = relativePath => path.resolve(appDirectory, relativePath);
+
+const envPublicUrl = process.env.PUBLIC_URL;
+
+/**
+ *
+ * @param inputPath
+ * @param needsSlash
+ * @returns {*}
+ */
+function ensureSlash(inputPath, needsSlash) {
+  const hasSlash = inputPath.endsWith('/');
+  if (hasSlash && !needsSlash) {
+    return inputPath.substr(0, inputPath.length - 1);
+  } else if (!hasSlash && needsSlash) {
+    return `${inputPath}/`;
+  } else {
+    return inputPath;
+  }
+}
+
+/**
+ *
+ * @param appPackageJson
+ * @returns {string | *}
+ */
+const getPublicUrl = appPackageJson => envPublicUrl || require(appPackageJson).homepage;
+
+/**
+ *
+ * @param appPackageJson
+ * @returns {*}
+ */
+function getServedPath(appPackageJson) {
+  const publicUrl = getPublicUrl(appPackageJson);
+  const servedUrl =
+    envPublicUrl || (publicUrl ? url.parse(publicUrl).pathname : '/');
+  return ensureSlash(servedUrl, true);
+}
+
+const moduleFileExtensions = [
+  'web.mjs',
+  'mjs',
+  'web.js',
+  'js',
+  'web.ts',
+  'ts',
+  'web.tsx',
+  'tsx',
+  'json',
+  'web.jsx',
+  'jsx',
+];
+
+/**
+ *
+ * @param resolveFn
+ * @param filePath
+ * @returns {*}
+ */
+const resolveModule = (resolveFn, filePath) => {
+  const extension = moduleFileExtensions.find(extension => fs.existsSync(resolveFn(`${filePath}.${extension}`)));
+
+  if (extension) {
+    return resolveFn(`${filePath}.${extension}`);
+  }
+
+  return resolveFn(`${filePath}.js`);
+};
+
+module.exports = {
+  dotenv: resolveApp('.env'),
+  appPath: resolveApp('.'),
+  appBuild: resolveApp('build'),
+  appDist: resolveApp('dist'),
+  appIndexJs: resolveModule(resolveApp, 'src/index'),
+  appPackageJson: resolveApp('package.json'),
+  appSrc: resolveApp('src'),
+  testsSetup: resolveModule(resolveApp, 'src/setupTests'),
+  appNodeModules: resolveApp('node_modules'),
+  publicUrl: getPublicUrl(resolveApp('package.json')),
+  servedPath: getServedPath(resolveApp('package.json')),
+};
+
+module.exports.moduleFileExtensions = moduleFileExtensions;
diff --git a/jest-puppeteer.config.js b/jest-puppeteer.config.js
new file mode 100644
index 0000000000000000000000000000000000000000..e8fa2f0c870485fc52504f2d9988144620d6cb1c
--- /dev/null
+++ b/jest-puppeteer.config.js
@@ -0,0 +1,9 @@
+module.exports = {
+  launch: {
+    headless: process.env.CI === 'true',
+  },
+  server: {
+    command: 'npm run server -- -p 4488',
+    port: 4488,
+  },
+};
diff --git a/jest.json b/jest.json
new file mode 100644
index 0000000000000000000000000000000000000000..433054597fb59f8bb7db77770ec184b049572868
--- /dev/null
+++ b/jest.json
@@ -0,0 +1,15 @@
+{
+  "collectCoverageFrom": [
+    "src/**/*.{js,jsx}"
+  ],
+  "coverageDirectory": "<rootDir>/coverage",
+  "coverageReporters": ["html"],
+  "setupFiles": [
+    "<rootDir>/setupJest.js"
+  ],
+  "testMatch": [
+    "<rootDir>/**/__tests__/**/*.{js,jsx}",
+    "<rootDir>/src/**/?(*.)(spec|test|unit).{js,jsx}"
+  ],
+  "preset": "jest-puppeteer"
+}
diff --git a/package.json b/package.json
index 929e59f379186b063ad0154ffd4e202f75cb2461..255066f6dbe593c0782533ffd7e00212908216e1 100644
--- a/package.json
+++ b/package.json
@@ -1,90 +1,73 @@
 {
-  "name": "mirador",
-  "version": "2.6.1",
-  "description": "Multi-window image viewer, a web-based tool to support researcher goals",
-  "files": ["dist/*"],
-  "repository": {
-    "type": "git",
-    "url": "https://github.com/ProjectMirador/mirador"
-  },
-  "license": "Apache-2.0",
-  "devDependencies": {
-    "eslint": "^3.17.1",
-    "eslint-config-airbnb-base": "^11.1.1",
-    "eslint-plugin-import": "^2.2.0",
-    "gitbook": "3.2.0",
-    "gitbook-cli": "2.3.0",
-    "gitbook-plugin-search": "2.2.1",
-    "grunt": ">=0.4.5 <1.0.0",
-    "grunt-cli": "*",
-    "grunt-contrib-clean": "*",
-    "grunt-contrib-compress": "*",
-    "grunt-contrib-concat": "*",
-    "grunt-contrib-connect": "*",
-    "grunt-contrib-copy": "*",
-    "grunt-contrib-cssmin": "*",
-    "grunt-contrib-jasmine": "^1.2.0",
-    "grunt-contrib-jshint": "*",
-    "grunt-contrib-less": "^1.4.0",
-    "grunt-contrib-uglify": "*",
-    "grunt-contrib-watch": "*",
-    "grunt-css-selectors": "^1.3.0",
-    "grunt-git-describe": "*",
-    "grunt-githooks": "^0.3.1",
-    "grunt-template-jasmine-istanbul": "^0.3.3",
-    "gruntify-eslint": "^3.1.0",
-    "jasmine-core": "^2.1.3",
-    "jasmine-jquery": "^2.0.5",
-    "jstree": "^3.3.4",
-    "karma": "^1.5",
-    "karma-chrome-launcher": "^0.1.7",
-    "karma-coverage": "^0.2.7",
-    "karma-coveralls": "^1.1.2",
-    "karma-firefox-launcher": "^0.1.3",
-    "karma-jasmine": "^0.3.5",
-    "karma-json-fixtures-preprocessor": "0.0.6",
-    "karma-phantomjs-launcher": "^1.0",
-    "karma-safari-launcher": "^0.1.1",
-    "karma-sauce-launcher": "^0.2.10",
-    "karma-spec-reporter": "^0.0.16",
-    "sinon": "^1.17.6"
-  },
+  "name": "minimal_redux_poc",
+  "version": "1.0.0",
+  "description": "",
+  "main": "index.js",
   "scripts": {
-    "start": "./node_modules/.bin/grunt serve",
-    "test": "./node_modules/.bin/karma start ./karma.conf.js",
-    "travis": "./node_modules/.bin/grunt ci --verbose --force & npm run test",
-    "update_demo": "./bin/update_demo.sh",
-    "release": "./bin/create_release.sh",
-    "lint": "./node_modules/.bin/eslint js/src"
+    "lint": "node_modules/.bin/eslint ./",
+    "server": "node_modules/.bin/http-server",
+    "test": "npm run build && npm run lint && jest -c jest.json",
+    "test:coverage": "jest -c jest.json --coverage",
+    "test:watch": "jest -c jest.json --watch",
+    "build": "webpack --mode=production",
+    "build:dev": "webpack --mode=development",
+    "build:watch": "webpack --watch --mode=development",
+    "start": "npm run build:dev && concurrently \"npm run build:watch\" \"npm run server -- -p 4444\""
   },
+  "license": "Apache-2.0",
+  "contributors": [
+    "Drew Winget <scipioaffricanus@gmail.com> (https://aeschylus.net/)",
+    "Jack Reed <phillipjreed@gmail.com> (https://www.jack-reed.com)"
+  ],
+  "repository": "https://github.com/ProjectMirador/research-and-demos/tree/master/minimal_redux_poc",
   "dependencies": {
-    "bootbox": "^4.4.0",
-    "bootstrap": "^3.3.7",
-    "d3": "^3.5.17",
-    "font-awesome": "^4.7.0",
-    "handlebars": "^4.0.11",
-    "i18next": "^4.2.0",
-    "i18next-browser-languagedetector": "^1.0.1",
-    "i18next-xhr-backend": "^1.3.0",
-    "iiif-evented-canvas": "^0.0.3-beta",
-    "iiif-layout-functions": "0.0.2-alpha",
-    "javascript-state-machine": "^2.3.4",
-    "jquery": "^3.1.1",
-    "jquery-migrate": "^3.0.0",
-    "jquery-plugin": "git+https://github.com/cowboy/jquery-tiny-pubsub.git",
-    "jquery-ui-dist": "^1.12.1",
-    "jquery.scrollto": "^1.4.12",
-    "material-design-icons": "^3.0.1",
-    "mousetrap": "^1.4.6",
-    "openseadragon": "^2.2.1",
-    "paper": "^0.10.2",
-    "qtip2": "^3.0.3",
-    "sanitize-html": "practicefusion/sanitize-html#include-dist-files",
-    "select2": "^4.0.3",
-    "simple-pagination.js": "^1.6.0",
-    "spectrum-colorpicker": "^1.8.0",
-    "tinymce": "^4.1.7",
-    "urijs": "^1.16.1"
+    "css-ns": "^1.2.2",
+    "deepmerge": "^3.0.0",
+    "manifesto.js": "^3.0.9",
+    "node-fetch": "2.1.1",
+    "node-sass": "^4.9.2",
+    "openseadragon": "^2.4.0",
+    "prop-types": "^15.6.2",
+    "react": "^16.4.0",
+    "react-dom": "^16.4.0",
+    "react-redux": "^5.0.7",
+    "redux": "3.7.2",
+    "redux-devtools-extension": "^2.13.2",
+    "redux-thunk": "2.2.0",
+    "sass-loader": "^7.1.0"
   },
-  "engine": "node < 7.0.0"
+  "devDependencies": {
+    "@babel/core": "^7.2.0",
+    "@babel/preset-env": "^7.2.0",
+    "@babel/preset-react": "^7.0.0",
+    "babel-core": "^7.0.0-bridge.0",
+    "babel-eslint": "9.0.0",
+    "babel-jest": "^23.6.0",
+    "babel-loader": "^8.0.4",
+    "babel-plugin-named-asset-import": "^0.2.3",
+    "concurrently": "^4.0.1",
+    "css-loader": "^1.0.0",
+    "enzyme": "^3.4.4",
+    "enzyme-adapter-react-16": "^1.2.0",
+    "eslint": "^5.10.0",
+    "eslint-config-airbnb": "^17.1.0",
+    "eslint-config-react-app": "^3.0.5",
+    "eslint-loader": "^2.1.1",
+    "eslint-plugin-flowtype": "^3.2.0",
+    "eslint-plugin-import": "^2.14.0",
+    "eslint-plugin-jest": "^22.1.2",
+    "eslint-plugin-jsx-a11y": "^6.1.2",
+    "eslint-plugin-react": "^7.11.1",
+    "http-server": "^0.11.1",
+    "jest": "^22.4.4",
+    "jest-fetch-mock": "^1.5.0",
+    "jest-puppeteer": "^3.0.1",
+    "puppeteer": "^1.4.0",
+    "react-dev-utils": "^6.1.1",
+    "redux-mock-store": "^1.5.1",
+    "style-loader": "^0.22.1",
+    "terser-webpack-plugin": "^1.2.1",
+    "webpack": "^4.27.1",
+    "webpack-cli": "^3.1.2"
+  }
 }
diff --git a/setupJest.js b/setupJest.js
new file mode 100644
index 0000000000000000000000000000000000000000..d5399cbfdb67ee85a39a98a555cb8c2275f5d535
--- /dev/null
+++ b/setupJest.js
@@ -0,0 +1,21 @@
+// Setup Jest to mock fetch
+
+import { JSDOM } from 'jsdom'; // eslint-disable-line import/no-extraneous-dependencies
+import fetch from 'jest-fetch-mock'; // eslint-disable-line import/no-extraneous-dependencies
+import Enzyme from 'enzyme'; // eslint-disable-line import/no-extraneous-dependencies
+import Adapter from 'enzyme-adapter-react-16'; // eslint-disable-line import/no-extraneous-dependencies
+
+const jsdom = new JSDOM('<!doctype html><html><body><div id="main"></div></body></html>');
+const { window } = jsdom;
+
+window.HTMLCanvasElement.prototype.getContext = () => {};
+jest.setMock('node-fetch', fetch);
+global.fetch = require('jest-fetch-mock'); // eslint-disable-line import/no-extraneous-dependencies
+
+global.window = window;
+global.document = window.document;
+global.navigator = {
+  userAgent: 'node.js',
+};
+
+Enzyme.configure({ adapter: new Adapter() });
diff --git a/src/action-types.js b/src/action-types.js
new file mode 100644
index 0000000000000000000000000000000000000000..9783cb967f276c8c5974510e9448cb22646dc649
--- /dev/null
+++ b/src/action-types.js
@@ -0,0 +1,21 @@
+const ActionTypes = {
+  FOCUS_WINDOW: 'FOCUS_WINDOW',
+  ADD_MANIFEST: 'ADD_MANIFEST',
+  ADD_WINDOW: 'ADD_WINDOW',
+  NEXT_CANVAS: 'NEXT_CANVAS',
+  PREVIOUS_CANVAS: 'PREVIOUS_CANVAS',
+  REMOVE_WINDOW: 'REMOVE_WINDOW',
+  PICK_WINDOWING_SYSTEM: 'PICK_WINDOWING_SYSTEM',
+  REQUEST_MANIFEST: 'REQUEST_MANIFEST',
+  RECEIVE_MANIFEST: 'RECEIVE_MANIFEST',
+  RECEIVE_MANIFEST_FAILURE: 'RECEIVE_MANIFEST_FAILURE',
+  SET_CONFIG: 'SET_CONFIG',
+  UPDATE_CONFIG: 'UPDATE_CONFIG',
+  REMOVE_MANIFEST: 'REMOVE_MANIFEST',
+  REQUEST_INFO_RESPONSE: 'REQUEST_INFO_RESPONSE',
+  RECEIVE_INFO_RESPONSE: 'RECEIVE_INFO_RESPONSE',
+  RECEIVE_INFO_RESPONSE_FAILURE: 'RECEIVE_INFO_RESPONSE_FAILURE',
+  REMOVE_INFO_RESPONSE: 'REMOVE_INFO_RESPONSE',
+};
+
+export default ActionTypes;
diff --git a/src/actions/index.js b/src/actions/index.js
new file mode 100644
index 0000000000000000000000000000000000000000..ec56ae64a9757709b08108efe0f7ad38a45055b9
--- /dev/null
+++ b/src/actions/index.js
@@ -0,0 +1,227 @@
+import fetch from 'node-fetch';
+import ActionTypes from '../action-types';
+
+/**
+ * Action Creators for Mirador
+ * @namespace ActionCreators
+ */
+
+
+/**
+ * setConfig - action creator
+ *
+ * @param  {Object} config
+* @memberof ActionCreators
+ */
+export function setConfig(config) {
+  return { type: ActionTypes.SET_CONFIG, config };
+}
+
+/**
+ * updateConfig - action creator
+ *
+ * @param  {Object} config
+* @memberof ActionCreators
+ */
+export function updateConfig(config) {
+  return { type: ActionTypes.UPDATE_CONFIG, config };
+}
+
+/**
+ * focusWindow - action creator
+ *
+ * @param  {String} windowId
+ * @memberof ActionCreators
+ */
+export function focusWindow(windowId) {
+  return { type: ActionTypes.FOCUS_WINDOW, windowId };
+}
+
+/**
+ * addWindow - action creator
+ *
+ * @param  {Object} options
+ * @memberof ActionCreators
+ */
+export function addWindow(options) {
+  const defaultOptions = {
+    // TODO: Windows should be a hash with id's as keys for easy lookups
+    // https://redux.js.org/faq/organizing-state#how-do-i-organize-nested-or-duplicate-data-in-my-state
+    id: `window-${new Date().valueOf()}`,
+    canvasIndex: 0,
+    collectionIndex: 0,
+    manifestId: null,
+    rangeId: null,
+    xywh: [0, 0, 400, 400],
+    rotation: null,
+  };
+  return { type: ActionTypes.ADD_WINDOW, payload: Object.assign({}, defaultOptions, options) };
+}
+
+/**
+ * removeWindow - action creator
+ *
+ * @param  {String} windowId
+ * @memberof ActionCreators
+ */
+export function removeWindow(windowId) {
+  return { type: ActionTypes.REMOVE_WINDOW, windowId };
+}
+
+/**
+ * nextCanvas - action creator
+ *
+ * @param  {String} windowId
+ * @memberof ActionCreators
+ */
+export function nextCanvas(windowId) {
+  return { type: ActionTypes.NEXT_CANVAS, windowId };
+}
+
+/**
+ * previousCanvas - action creator
+ *
+ * @param  {String} windowId
+ * @memberof ActionCreators
+ */
+export function previousCanvas(windowId) {
+  return { type: ActionTypes.PREVIOUS_CANVAS, windowId };
+}
+
+/**
+ * requestManifest - action creator
+ *
+ * @param  {String} manifestId
+ * @memberof ActionCreators
+ */
+export function requestManifest(manifestId) {
+  return {
+    type: ActionTypes.REQUEST_MANIFEST,
+    manifestId,
+  };
+}
+
+/**
+ * receiveManifest - action creator
+ *
+ * @param  {String} windowId
+ * @param  {Object} manifestJson
+ * @memberof ActionCreators
+ */
+export function receiveManifest(manifestId, manifestJson) {
+  return {
+    type: ActionTypes.RECEIVE_MANIFEST,
+    manifestId,
+    manifestJson,
+  };
+}
+
+/**
+ * receiveManifestFailure - action creator
+ *
+ * @param  {String} windowId
+ * @param  {String} error
+ * @memberof ActionCreators
+ */
+export function receiveManifestFailure(manifestId, error) {
+  return {
+    type: ActionTypes.RECEIVE_MANIFEST_FAILURE,
+    manifestId,
+    error,
+  };
+}
+
+/**
+ * fetchManifest - action creator
+ *
+ * @param  {String} manifestId
+ * @memberof ActionCreators
+ */
+export function fetchManifest(manifestId) {
+  return ((dispatch) => {
+    dispatch(requestManifest(manifestId));
+    return fetch(manifestId)
+      .then(response => response.json())
+      .then(json => dispatch(receiveManifest(manifestId, json)))
+      .catch(error => dispatch(receiveManifestFailure(manifestId, error)));
+  });
+}
+
+/**
+ * removeManifest - action creator
+ *
+ * @param  {String} manifestId
+ * @memberof ActionCreators
+ */
+export function removeManifest(manifestId) {
+  return { type: ActionTypes.REMOVE_MANIFEST, manifestId };
+}
+
+/**
+ * requestInfoResponse - action creator
+ *
+ * @param  {String} infoId
+ * @memberof ActionCreators
+ */
+export function requestInfoResponse(infoId) {
+  return {
+    type: ActionTypes.REQUEST_INFO_RESPONSE,
+    infoId,
+  };
+}
+
+/**
+ * receiveInfoResponse - action creator
+ *
+ * @param  {String} infoId
+ * @param  {Object} manifestJson
+ * @memberof ActionCreators
+ */
+export function receiveInfoResponse(infoId, infoJson) {
+  return {
+    type: ActionTypes.RECEIVE_INFO_RESPONSE,
+    infoId,
+    infoJson,
+  };
+}
+
+/**
+ * receiveInfoResponseFailure - action creator
+ *
+ * @param  {String} infoId
+ * @param  {String} error
+ * @memberof ActionCreators
+ */
+export function receiveInfoResponseFailure(infoId, error) {
+  return {
+    type: ActionTypes.RECEIVE_INFO_RESPONSE_FAILURE,
+    infoId,
+    error,
+  };
+}
+
+/**
+ * fetchInfoResponse - action creator
+ *
+ * @param  {String} infoId
+ * @memberof ActionCreators
+ */
+export function fetchInfoResponse(infoId) {
+  return ((dispatch) => {
+    dispatch(requestInfoResponse(infoId));
+    return fetch(infoId)
+      .then(response => response.json())
+      .then(json => dispatch(receiveInfoResponse(infoId, json)))
+      .catch(error => dispatch(receiveInfoResponseFailure(infoId, error)));
+  });
+}
+
+/**
+ * removeInfoResponse - action creator
+ *
+ * @param  {String} infoId
+ * @memberof ActionCreators
+ */
+export function removeInfoResponse(infoId) {
+  return { type: ActionTypes.REMOVE_INFO_RESPONSE, infoId };
+}
diff --git a/src/components/App.js b/src/components/App.js
new file mode 100644
index 0000000000000000000000000000000000000000..1c9743ca35587e370f9a13301d37639b229e2d9f
--- /dev/null
+++ b/src/components/App.js
@@ -0,0 +1,119 @@
+import React, { Component } from 'react';
+import { connect } from 'react-redux';
+import PropTypes from 'prop-types';
+import { actions } from '../store';
+import Display from './Display';
+import ManifestForm from './ManifestForm';
+import ManifestListItem from './ManifestListItem';
+import Workspace from './Workspace';
+import ns from '../config/css-ns';
+
+/**
+ * This is the top level Mirador component.
+ * @prop {Object} manifests
+ */
+class App extends Component {
+  /**
+   * constructor -
+   */
+  constructor(props) {
+    super(props);
+    this.state = {
+      lastRequested: '',
+    };
+
+    this.setLastRequested = this.setLastRequested.bind(this);
+  }
+
+  /**
+   * setLastRequested - Sets the state lastRequested
+   *
+   * @private
+   */
+  setLastRequested(requested) {
+    this.setState({
+      lastRequested: requested,
+    });
+  }
+
+  /**
+   * computedContent - computes the content to be displayed based on logic
+   *
+   * @return {type}  description
+   * @private
+   */
+  computedContent() {
+    const { manifests } = this.props;
+    const { lastRequested } = this.state;
+    const manifest = manifests[lastRequested];
+    if (manifest) {
+      if (manifest.isFetching) {
+        return '☕';
+      }
+      if (manifest.error) {
+        return manifest.error.message;
+      }
+      return JSON.stringify(manifest.json, 0, 2);
+    }
+    return 'Nothing Selected Yet';
+  }
+
+  /**
+   * render
+   * @return {String} - HTML markup for the component
+   */
+  render() {
+    const { manifests } = this.props;
+    const { lastRequested } = this.state;
+    const manifestList = Object.keys(manifests).map(manifest => (
+      <ManifestListItem
+        key={manifest}
+        manifest={manifest}
+      />
+    ));
+    return (
+      <div className={ns('app')}>
+        <Workspace />
+        <div className={ns('control-panel')}>
+          <ManifestForm setLastRequested={this.setLastRequested} />
+          <ul>{manifestList}</ul>
+
+          <Display
+            manifest={manifests[lastRequested]}
+          />
+        </div>
+      </div>
+    );
+  }
+}
+
+App.propTypes = {
+  manifests: PropTypes.instanceOf(Object).isRequired,
+};
+
+/**
+ * mapStateToProps - to hook up connect
+ * @memberof App
+ * @private
+ */
+const mapStateToProps = state => (
+  {
+    manifests: state.manifests,
+  }
+);
+
+/**
+ * mapDispatchToProps - used to hook up connect to action creators
+ * @memberof App
+ * @private
+ */
+const mapDispatchToProps = dispatch => ({
+  fetchManifest: manifestUrl => (
+    dispatch(actions.fetchManifest(manifestUrl))
+  ),
+});
+
+export default connect(
+  mapStateToProps,
+  mapDispatchToProps,
+)(App);
diff --git a/src/components/Display.js b/src/components/Display.js
new file mode 100644
index 0000000000000000000000000000000000000000..f9db0dd00071959859696844e527ac7f0f7cd039
--- /dev/null
+++ b/src/components/Display.js
@@ -0,0 +1,64 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import ManifestMetadata from './ManifestMetadata';
+import ns from '../config/css-ns';
+
+/**
+ * Determines how to best display the content (or lack thereof) the manifest
+ * @private
+ */
+const displayContent = (manifest) => {
+  if (manifest) {
+    if (manifest.isFetching) {
+      return '☕';
+    }
+    if (manifest.error) {
+      return manifest.error.message;
+    }
+    return <ManifestMetadata manifest={manifest} />;
+  }
+  return 'Nothing Selected Yet';
+};
+
+/**
+ * Determines which classes should be used for display, based on the state of
+ * the manifest
+ * @memberof Display
+ * @private
+ */
+const stateClass = (manifest) => {
+  if (manifest) {
+    if (manifest.isFetching) {
+      return 'fetching';
+    }
+    if (manifest.error) {
+      return 'error';
+    }
+    return '';
+  }
+  return 'empty';
+};
+
+
+/**
+ * Displays a manifest
+ * @param {object} props
+ * @param {object} [props.manifest = undefined]
+ */
+const Display = ({ manifest }) => (
+  <div className="Display">
+    <div id="exampleManifest" className={ns(stateClass(manifest))}>
+      {displayContent(manifest)}
+    </div>
+  </div>
+);
+
+Display.propTypes = {
+  manifest: PropTypes.object, // eslint-disable-line react/forbid-prop-types
+};
+
+Display.defaultProps = {
+  manifest: undefined,
+};
+
+export default Display;
diff --git a/src/components/ManifestForm.js b/src/components/ManifestForm.js
new file mode 100644
index 0000000000000000000000000000000000000000..54f1557e96ec08d11bc9792c6c526d7cdd132d69
--- /dev/null
+++ b/src/components/ManifestForm.js
@@ -0,0 +1,99 @@
+import React, { Component } from 'react';
+import { connect } from 'react-redux';
+import PropTypes from 'prop-types';
+import { actions } from '../store';
+
+/**
+ * Provides a form for user input of a manifest url
+ * @prop {Function} fetchManifest
+ * @prop {Function} setLastRequested
+ */
+class ManifestForm extends Component {
+  /**
+   * constructor -
+   */
+  constructor(props) {
+    super(props);
+    this.state = {
+      formValue: '',
+    };
+
+    this.formSubmit = this.formSubmit.bind(this);
+    this.handleInputChange = this.handleInputChange.bind(this);
+  }
+
+  /**
+   * formSubmit - triggers manifest update and sets lastRequested
+   * @param  {Event} event
+   * @private
+   */
+  formSubmit(event) {
+    const { fetchManifest, setLastRequested } = this.props;
+    const { formValue } = this.state;
+    event.preventDefault();
+    fetchManifest(formValue);
+    setLastRequested(formValue);
+  }
+
+  /**
+   * handleInputChange - sets state based on input change.
+   * @param  {Event} event
+   * @private
+   */
+  handleInputChange(event) {
+    const that = this;
+    event.preventDefault();
+    that.setState({
+      formValue: event.target.value,
+    });
+  }
+
+  /**
+   * render
+   * @return {String} - HTML markup for the component
+   */
+  render() {
+    const { formValue } = this.state;
+    return (
+      <form onSubmit={this.formSubmit}>
+        <input
+          value={formValue}
+          id="manifestURL"
+          type="text"
+          onChange={this.handleInputChange}
+        />
+        <button id="fetchBtn" type="submit">FetchManifest</button>
+      </form>
+    );
+  }
+}
+
+ManifestForm.propTypes = {
+  fetchManifest: PropTypes.func.isRequired,
+  setLastRequested: PropTypes.func.isRequired,
+};
+
+/**
+ * mapStateToProps - to hook up connect
+ * @memberof ManifestForm
+ * @private
+ */
+const mapStateToProps = () => (
+  {}
+);
+
+/**
+ * mapDispatchToProps - used to hook up connect to action creators
+ * @memberof ManifestForm
+ * @private
+ */
+const mapDispatchToProps = dispatch => ({
+  fetchManifest: manifestUrl => (
+    dispatch(actions.fetchManifest(manifestUrl))
+  ),
+});
+
+export default connect(
+  mapStateToProps,
+  mapDispatchToProps,
+)(ManifestForm);
diff --git a/src/components/ManifestListItem.js b/src/components/ManifestListItem.js
new file mode 100644
index 0000000000000000000000000000000000000000..b67886e8d869abb540943b088ef0246b6dcef84e
--- /dev/null
+++ b/src/components/ManifestListItem.js
@@ -0,0 +1,62 @@
+import React from 'react';
+import { connect } from 'react-redux';
+import PropTypes from 'prop-types';
+import { actions } from '../store';
+import ns from '../config/css-ns';
+
+
+/**
+ * Handling open button click
+ */
+const handleOpenButtonClick = (event, manifest, addWindow) => {
+  addWindow({ manifestId: manifest });
+};
+/**
+ * Represents an item in a list of currently-loaded or loading manifests
+ * @param {object} props
+ * @param {object} [props.manifest = string]
+ */
+
+/**
+ * Determines which classes should be used for display, based on the state of
+ * the manifest
+ * @memberof ManifestListItem
+ * @private
+ */
+const ManifestListItem = ({ manifest, addWindow }) => (
+  <li className={ns('manifest-list-item')}>
+    <button type="button" onClick={event => handleOpenButtonClick(event, manifest, addWindow)}>
+      {manifest}
+    </button>
+  </li>
+);
+
+ManifestListItem.propTypes = {
+  manifest: PropTypes.string.isRequired, // eslint-disable-line react/forbid-prop-types
+  addWindow: PropTypes.func.isRequired,
+};
+
+/**
+ * mapStateToProps - to hook up connect
+ * @memberof ManifestListItem
+ * @private
+ */
+const mapStateToProps = () => (
+  {}
+);
+
+/**
+ * mapDispatchToProps - used to hook up connect to action creators
+ * @memberof ManifestListItem
+ * @private
+ */
+const mapDispatchToProps = dispatch => ({
+  addWindow: options => (
+    dispatch(actions.addWindow(options))
+  ),
+});
+
+export default connect(
+  mapStateToProps,
+  mapDispatchToProps,
+)(ManifestListItem);
diff --git a/src/components/ManifestMetadata.js b/src/components/ManifestMetadata.js
new file mode 100644
index 0000000000000000000000000000000000000000..2f79254a7dfc59c14e0ba3acb81d939237cb6261
--- /dev/null
+++ b/src/components/ManifestMetadata.js
@@ -0,0 +1,35 @@
+import React, { Component } from 'react';
+import PropTypes from 'prop-types';
+import ns from '../config/css-ns';
+
+/**
+ * ManifestMetadata
+ * @param {object} window
+ */
+export default class ManifestMetadata extends Component {
+  /**
+   * Renders things
+   * @param {object} props
+   */
+  render() {
+    const { manifest } = this.props;
+    return (
+      <div>
+        <h3>
+          {manifest.manifestation.getLabel().map(label => label.value)[0]}
+        </h3>
+        <div className={ns('description')}>
+          {manifest.manifestation.getDescription().map(label => label.value)}
+        </div>
+      </div>
+    );
+  }
+}
+
+ManifestMetadata.propTypes = {
+  manifest: PropTypes.object, // eslint-disable-line react/forbid-prop-types
+};
+
+ManifestMetadata.defaultProps = {
+  manifest: null,
+};
diff --git a/src/components/OpenSeadragonViewer.js b/src/components/OpenSeadragonViewer.js
new file mode 100644
index 0000000000000000000000000000000000000000..3709ab33b168ac179a9c375ab803db1fa3d09016
--- /dev/null
+++ b/src/components/OpenSeadragonViewer.js
@@ -0,0 +1,89 @@
+import React, { Component, Fragment } from 'react';
+import PropTypes from 'prop-types';
+import OpenSeadragon from 'openseadragon';
+import miradorWithPlugins from '../lib/miradorWithPlugins';
+import ns from '../config/css-ns';
+
+/**
+ * Represents a OpenSeadragonViewer in the mirador workspace. Responsible for mounting
+ * and rendering OSD.
+ */
+class OpenSeadragonViewer extends Component {
+  /**
+   * @param {Object} props
+   */
+  constructor(props) {
+    super(props);
+
+    this.viewer = null;
+    this.ref = React.createRef();
+  }
+
+  /**
+   * React lifecycle event
+   */
+  componentDidMount() {
+    const { tileSources } = this.props;
+    if (!this.ref.current) {
+      return;
+    }
+    this.viewer = new OpenSeadragon({
+      id: this.ref.current.id,
+      preserveViewport: true,
+      blendTime: 0.1,
+      alwaysBlend: false,
+      showNavigationControl: false,
+    });
+    tileSources.forEach(tileSource => this.addTileSource(tileSource));
+  }
+
+  /**
+   */
+  componentDidUpdate() {
+    const { tileSources } = this.props;
+    tileSources.forEach(tileSource => this.addTileSource(tileSource));
+  }
+
+  /**
+   */
+  componentWillUnmount() {
+    this.viewer.removeAllHandlers();
+  }
+
+  /**
+   */
+  addTileSource(tileSource) {
+    this.viewer.addTiledImage({
+      tileSource,
+      success: (event) => {
+      },
+    });
+  }
+
+  /**
+   * Renders things
+   */
+  render() {
+    const { window } = this.props;
+    return (
+      <Fragment>
+        <div
+          className={ns('osd-container')}
+          id={`${window.id}-osd`}
+          ref={this.ref}
+        />
+      </Fragment>
+    );
+  }
+}
+
+OpenSeadragonViewer.defaultProps = {
+  tileSources: [],
+};
+
+OpenSeadragonViewer.propTypes = {
+  tileSources: PropTypes.arrayOf(PropTypes.object),
+  window: PropTypes.object.isRequired, // eslint-disable-line react/forbid-prop-types
+};
+
+export default miradorWithPlugins(OpenSeadragonViewer);
diff --git a/src/components/ViewerNavigation.js b/src/components/ViewerNavigation.js
new file mode 100644
index 0000000000000000000000000000000000000000..a6d989f143db068813cd8de574dcf5242ede5b6a
--- /dev/null
+++ b/src/components/ViewerNavigation.js
@@ -0,0 +1,91 @@
+import React, { Component } from 'react';
+import PropTypes from 'prop-types';
+import { connect } from 'react-redux';
+import miradorWithPlugins from '../lib/miradorWithPlugins';
+import { actions } from '../store';
+import ns from '../config/css-ns';
+
+/**
+ */
+class ViewerNavigation extends Component {
+  /**
+   */
+  constructor(props) {
+    super(props);
+
+    this.nextCanvas = this.nextCanvas.bind(this);
+    this.previousCanvas = this.previousCanvas.bind(this);
+  }
+
+  /**
+   */
+  nextCanvas() {
+    const { window, nextCanvas } = this.props;
+    if (this.hasNextCanvas()) nextCanvas(window.id);
+  }
+
+  /**
+   */
+  hasNextCanvas() {
+    const { window, canvases } = this.props;
+    return window.canvasIndex < canvases.length - 1;
+  }
+
+  /**
+   */
+  previousCanvas() {
+    const { window, previousCanvas } = this.props;
+    if (this.hasPreviousCanvas()) previousCanvas(window.id);
+  }
+
+  /**
+   */
+  hasPreviousCanvas() {
+    const { window } = this.props;
+    return window.canvasIndex > 0;
+  }
+
+  /**
+   * Renders things
+   */
+  render() {
+    return (
+      <div className={ns('osd-navigation')}>
+        <button
+          type="button"
+          disabled={!this.hasPreviousCanvas()}
+          onClick={this.previousCanvas}
+        >
+        &#8249;
+        </button>
+        <button
+          type="button"
+          disabled={!this.hasNextCanvas()}
+          onClick={this.nextCanvas}
+        >
+        &#8250;
+        </button>
+      </div>
+    );
+  }
+}
+
+ViewerNavigation.propTypes = {
+  canvases: PropTypes.array.isRequired, // eslint-disable-line react/forbid-prop-types
+  nextCanvas: PropTypes.func.isRequired,
+  previousCanvas: PropTypes.func.isRequired,
+  window: PropTypes.object.isRequired, // eslint-disable-line react/forbid-prop-types
+};
+
+
+/**
+ * mapDispatchToProps - used to hook up connect to action creators
+ * @memberof ManifestForm
+ * @private
+ */
+const mapDispatchToProps = dispatch => ({
+  nextCanvas: windowId => dispatch(actions.nextCanvas(windowId)),
+  previousCanvas: windowId => dispatch(actions.previousCanvas(windowId)),
+});
+
+export default connect(null, mapDispatchToProps)(miradorWithPlugins(ViewerNavigation));
diff --git a/src/components/Window.js b/src/components/Window.js
new file mode 100644
index 0000000000000000000000000000000000000000..f28a3ddeff9f276da7380a7457e338c9dc1fa92d
--- /dev/null
+++ b/src/components/Window.js
@@ -0,0 +1,82 @@
+import React, { Component } from 'react';
+import { connect } from 'react-redux';
+import PropTypes from 'prop-types';
+import ns from '../config/css-ns';
+import WindowBackground from './WindowBackground';
+import WindowTopBar from './WindowTopBar';
+import WindowViewer from './WindowViewer';
+
+/**
+ * Represents a Window in the mirador workspace
+ * @param {object} window
+ */
+class Window extends Component {
+  /**
+   * Return style attributes
+   */
+  styleAttributes() {
+    const { window } = this.props;
+    return { width: `${window.xywh[2]}px`, height: `${window.xywh[3]}px` };
+  }
+
+  /**
+   * renderViewer
+   *
+   * @return {String, null}
+   */
+  renderViewer() {
+    const { manifest, window } = this.props;
+    if (manifest) {
+      return (
+        <WindowViewer
+          window={window}
+          manifest={manifest}
+        />
+      );
+    }
+    return null;
+  }
+
+  /**
+   * Renders things
+   */
+  render() {
+    const { manifest, window } = this.props;
+    return (
+      <div className={ns('window')} style={this.styleAttributes()}>
+        <WindowTopBar
+          windowId={window.id}
+          manifest={manifest}
+        />
+        <WindowBackground
+          manifest={manifest}
+        />
+        {this.renderViewer()}
+      </div>
+    );
+  }
+}
+
+Window.propTypes = {
+  window: PropTypes.object.isRequired, // eslint-disable-line react/forbid-prop-types
+  manifest: PropTypes.object, // eslint-disable-line react/forbid-prop-types
+};
+
+Window.defaultProps = {
+  manifest: null,
+};
+
+/**
+ * mapStateToProps - used to hook up connect to action creators
+ * @memberof Window
+ * @private
+ */
+const mapStateToProps = ({ windows, manifests }, props) => {
+  const window = windows.find(win => props.id === win.id);
+  return {
+    window,
+    manifest: manifests[window.manifestId],
+  };
+};
+
+export default connect(mapStateToProps)(Window);
diff --git a/src/components/WindowBackground.js b/src/components/WindowBackground.js
new file mode 100644
index 0000000000000000000000000000000000000000..9ff70f1904aa9b247af5da7b9a03871ef7bdecb9
--- /dev/null
+++ b/src/components/WindowBackground.js
@@ -0,0 +1,61 @@
+import React, { Component } from 'react';
+import PropTypes from 'prop-types';
+
+/**
+ * Represents a WindowBackground in the mirador workspace
+ * @param {object} window
+ */
+class WindowBackground extends Component {
+  /**
+   * Fetches IIIF thumbnail URL
+   */
+  thumbnail() {
+    const { manifest } = this.props;
+    const thumb = manifest.manifestation.getThumbnail() || { id: 'http://placekitten.com/200/300' };
+    return thumb.id;
+  }
+
+
+  /**
+   * content - based off of manifest state
+   *
+   * @return {type}
+   */
+  content(manifest) {
+    if (!manifest) return null;
+    switch (manifest.isFetching) {
+      case true:
+        return 'spinner';
+      case false:
+        if (manifest.manifestation) {
+          return <img src={this.thumbnail()} alt="" />;
+        }
+        break;
+      default:
+        return null;
+    }
+    return null;
+  }
+
+  /**
+   * Renders things
+   */
+  render() {
+    const { manifest } = this.props;
+    return (
+      <div>
+        {this.content(manifest)}
+      </div>
+    );
+  }
+}
+
+WindowBackground.propTypes = {
+  manifest: PropTypes.object, // eslint-disable-line react/forbid-prop-types
+};
+
+WindowBackground.defaultProps = {
+  manifest: null,
+};
+
+export default WindowBackground;
diff --git a/src/components/WindowTopBar.js b/src/components/WindowTopBar.js
new file mode 100644
index 0000000000000000000000000000000000000000..19b7e35664508b44497f789a65e4c051f9df3c55
--- /dev/null
+++ b/src/components/WindowTopBar.js
@@ -0,0 +1,63 @@
+import React, { Component } from 'react';
+import { connect } from 'react-redux';
+import PropTypes from 'prop-types';
+import { actions } from '../store';
+import WindowTopBarButtons from './WindowTopBarButtons';
+import miradorWithPlugins from '../lib/miradorWithPlugins';
+import ns from '../config/css-ns';
+
+/**
+ * WindowTopBar
+ */
+class WindowTopBar extends Component {
+  /**
+   * titleContent
+   *
+   * @return {String}
+   */
+  titleContent() {
+    const { manifest } = this.props;
+    if (manifest && manifest.manifestation) {
+      return manifest.manifestation.getLabel().map(label => label.value)[0];
+    }
+    return '';
+  }
+
+  /**
+   * render
+   * @return
+   */
+  render() {
+    const { removeWindow, windowId } = this.props;
+    return (
+      <div className={ns('window-top-bar')}>
+        <h3>{this.titleContent()}</h3>
+        <WindowTopBarButtons windowId={windowId} />
+        <button type="button" className={ns('window-close')} aria-label="Close Window" onClick={() => removeWindow(windowId)}>&times;</button>
+      </div>
+    );
+  }
+}
+
+/**
+ * mapDispatchToProps - used to hook up connect to action creators
+ * @memberof ManifestListItem
+ * @private
+ */
+const mapDispatchToProps = dispatch => ({
+  removeWindow: windowId => (
+    dispatch(actions.removeWindow(windowId))
+  ),
+});
+
+WindowTopBar.propTypes = {
+  manifest: PropTypes.object, // eslint-disable-line react/forbid-prop-types
+  removeWindow: PropTypes.func.isRequired,
+  windowId: PropTypes.string.isRequired,
+};
+
+WindowTopBar.defaultProps = {
+  manifest: null,
+};
+
+export default connect(null, mapDispatchToProps)(miradorWithPlugins(WindowTopBar));
diff --git a/src/components/WindowTopBarButtons.js b/src/components/WindowTopBarButtons.js
new file mode 100644
index 0000000000000000000000000000000000000000..6bcdef6b45cd95ca78df88b7dd221d597ef62b65
--- /dev/null
+++ b/src/components/WindowTopBarButtons.js
@@ -0,0 +1,19 @@
+import React, { Component, Fragment } from 'react';
+import miradorWithPlugins from '../lib/miradorWithPlugins';
+/**
+ *
+ */
+class WindowTopBarButtons extends Component {
+  /**
+   * render
+   *
+   * @return {type}  description
+   */
+  render() {
+    return (
+      <Fragment />
+    );
+  }
+}
+
+export default miradorWithPlugins(WindowTopBarButtons);
diff --git a/src/components/WindowViewer.js b/src/components/WindowViewer.js
new file mode 100644
index 0000000000000000000000000000000000000000..77f77b7fd45e794a444861f6f1f7816f42e2f4b8
--- /dev/null
+++ b/src/components/WindowViewer.js
@@ -0,0 +1,121 @@
+import React, { Component, Fragment } from 'react';
+import PropTypes from 'prop-types';
+import { connect } from 'react-redux';
+import { actions } from '../store';
+import miradorWithPlugins from '../lib/miradorWithPlugins';
+import OpenSeadragonViewer from './OpenSeadragonViewer';
+import ViewerNavigation from './ViewerNavigation';
+
+/**
+ * Represents a WindowViewer in the mirador workspace. Responsible for mounting
+ * OSD and Navigation
+ */
+class WindowViewer extends Component {
+  /**
+   * @param {Object} props
+   */
+  constructor(props) {
+    super(props);
+
+    const { manifest } = this.props;
+    this.canvases = manifest.manifestation.getSequences()[0].getCanvases();
+  }
+
+  /**
+   * componentDidMount - React lifecycle method
+   * Request the initial canvas on mount
+   */
+  componentDidMount() {
+    const { fetchInfoResponse } = this.props;
+    fetchInfoResponse(this.imageInformationUri());
+  }
+
+  /**
+   * componentDidUpdate - React lifecycle method
+   * Request a new canvas if it is needed
+   */
+  componentDidUpdate(prevProps) {
+    const { window, fetchInfoResponse } = this.props;
+    if (prevProps.window.canvasIndex !== window.canvasIndex && !this.infoResponseIsInStore()) {
+      fetchInfoResponse(this.imageInformationUri());
+    }
+  }
+
+  /**
+   * infoResponseIsInStore - checks whether or not an info response is already
+   * in the store. No need to request it again.
+   * @return [Boolean]
+   */
+  infoResponseIsInStore() {
+    const { infoResponses } = this.props;
+    const currentInfoResponse = infoResponses[this.imageInformationUri()];
+    return (currentInfoResponse !== undefined
+      && currentInfoResponse.isFetching === false
+      && currentInfoResponse.json !== undefined);
+  }
+
+  /**
+   * Constructs an image information URI to request from a canvas
+   */
+  imageInformationUri() {
+    const { window } = this.props;
+    return `${this.canvases[window.canvasIndex].getImages()[0].getResource().getServices()[0].id}/info.json`;
+  }
+
+  /**
+   * Return an image information response from the store for the correct image
+   */
+  tileInfoFetchedFromStore() {
+    const { infoResponses } = this.props;
+    return [infoResponses[this.imageInformationUri()]]
+      .filter(infoResponse => (infoResponse !== undefined
+        && infoResponse.isFetching === false
+        && infoResponse.error === undefined))
+      .map(infoResponse => infoResponse.json);
+  }
+
+  /**
+   * Renders things
+   */
+  render() {
+    const { window } = this.props;
+    return (
+      <Fragment>
+        <OpenSeadragonViewer
+          tileSources={this.tileInfoFetchedFromStore()}
+          window={window}
+        />
+        <ViewerNavigation window={window} canvases={this.canvases} />
+      </Fragment>
+    );
+  }
+}
+
+/**
+ * mapStateToProps - to hook up connect
+ * @memberof WindowViewer
+ * @private
+ */
+const mapStateToProps = state => (
+  {
+    infoResponses: state.infoResponses,
+  }
+);
+
+/**
+ * mapDispatchToProps - used to hook up connect to action creators
+ * @memberof WindowViewer
+ * @private
+ */
+const mapDispatchToProps = dispatch => ({
+  fetchInfoResponse: infoId => dispatch(actions.fetchInfoResponse(infoId)),
+});
+
+WindowViewer.propTypes = {
+  infoResponses: PropTypes.object.isRequired, // eslint-disable-line react/forbid-prop-types
+  fetchInfoResponse: PropTypes.func.isRequired,
+  manifest: PropTypes.object.isRequired, // eslint-disable-line react/forbid-prop-types
+  window: PropTypes.object.isRequired, // eslint-disable-line react/forbid-prop-types
+};
+
+export default connect(mapStateToProps, mapDispatchToProps)(miradorWithPlugins(WindowViewer));
diff --git a/src/components/Workspace.js b/src/components/Workspace.js
new file mode 100644
index 0000000000000000000000000000000000000000..f9b36c36e59018a8b55199770500083e7e1c0eb8
--- /dev/null
+++ b/src/components/Workspace.js
@@ -0,0 +1,40 @@
+import React from 'react';
+import { connect } from 'react-redux';
+import PropTypes from 'prop-types';
+import Window from './Window';
+import ns from '../config/css-ns';
+
+/**
+ * Represents a work area that contains any number of windows
+ * @memberof Workspace
+ * @private
+ */
+const Workspace = ({ windows }) => (
+  <div className={ns('workspace')}>
+    {
+      windows.map(window => (
+        <Window
+          key={window.id}
+          id={window.id}
+        />
+      ))
+    }
+  </div>
+);
+
+Workspace.propTypes = {
+  windows: PropTypes.instanceOf(Array).isRequired,
+};
+
+/**
+ * mapStateToProps - to hook up connect
+ * @memberof Workspace
+ * @private
+ */
+const mapStateToProps = state => (
+  {
+    windows: state.windows,
+  }
+);
+
+export default connect(mapStateToProps)(Workspace);
diff --git a/src/config/css-ns.js b/src/config/css-ns.js
new file mode 100644
index 0000000000000000000000000000000000000000..77ad4b4c914bd35951b7f64799d611f67694d6be
--- /dev/null
+++ b/src/config/css-ns.js
@@ -0,0 +1,10 @@
+import { createCssNs } from 'css-ns';
+
+/**
+ * export ns - sets up css namespacing for everything to be `mirador-`
+ */
+const ns = className => createCssNs({
+  namespace: 'mirador',
+})(className);
+
+export default ns;
diff --git a/src/config/settings.js b/src/config/settings.js
new file mode 100644
index 0000000000000000000000000000000000000000..5532a6a728ebfed6ed0f3a52af166cdb483e25e0
--- /dev/null
+++ b/src/config/settings.js
@@ -0,0 +1,3 @@
+export default {
+  foo: 'bar',
+};
diff --git a/src/index.js b/src/index.js
new file mode 100644
index 0000000000000000000000000000000000000000..406e2a89e6e94cb1a348350daa7a2b69bc41f4ab
--- /dev/null
+++ b/src/index.js
@@ -0,0 +1,8 @@
+import init from './init';
+
+const exports = {
+  viewer: init,
+  plugins: {},
+};
+
+export default exports;
diff --git a/src/init.js b/src/init.js
new file mode 100644
index 0000000000000000000000000000000000000000..307b11a14a6953c72b223f66f0f3501f1a1b6c13
--- /dev/null
+++ b/src/init.js
@@ -0,0 +1,29 @@
+import React from 'react';
+import ReactDOM from 'react-dom';
+import { Provider } from 'react-redux';
+import deepmerge from 'deepmerge';
+import App from './components/App';
+import { actions, store } from './store';
+import settings from './config/settings';
+import './styles/index.scss';
+
+/**
+ * Default Mirador instantiation
+ */
+export default function (config) {
+  const viewer = {
+    actions,
+    store,
+  };
+  const action = actions.setConfig(deepmerge(settings, config));
+  store.dispatch(action);
+
+  ReactDOM.render(
+    <Provider store={store}>
+      <App config={config} />
+    </Provider>,
+    document.getElementById(config.id),
+  );
+
+  return viewer;
+}
diff --git a/src/lib/componentPlugins.js b/src/lib/componentPlugins.js
new file mode 100644
index 0000000000000000000000000000000000000000..a97841f2a59387f62efa3d7a61dbf0e1256e9e4f
--- /dev/null
+++ b/src/lib/componentPlugins.js
@@ -0,0 +1,14 @@
+/**
+ * componentPlugins - gets window plugins based on a component parent
+ */
+export default function componentPlugins(componentName, plugins = []) {
+  // TODO: Figure out how to handle when not running under window. Probably not
+  // a pressing priority, but relevant for tests
+  return plugins.map((pluginName) => {
+    if (window.Mirador.plugins[pluginName]
+        && window.Mirador.plugins[pluginName].parent === componentName) {
+      return window.Mirador.plugins[pluginName];
+    }
+    return null;
+  }).filter(plugin => (plugin !== (undefined || null)));
+}
diff --git a/src/lib/miradorWithPlugins.js b/src/lib/miradorWithPlugins.js
new file mode 100644
index 0000000000000000000000000000000000000000..06efc98cb8473c1104df4b9734971cf215b4d672
--- /dev/null
+++ b/src/lib/miradorWithPlugins.js
@@ -0,0 +1,60 @@
+import React, { Component, Fragment } from 'react';
+import PropTypes from 'prop-types';
+import { connect } from 'react-redux';
+import componentPlugins from './componentPlugins';
+/**
+ * miradorWithPlugins - renders and returns a component with provided plugins
+ *
+ * @param  {type} WrappedComponent
+ */
+export default function miradorWithPlugins(WrappedComponent) {
+  /**
+   */
+  class ConnectedComponent extends Component {
+    /**
+     * constructor -
+     */
+    constructor(props) {
+      super(props);
+
+      this.getPluginParent = this.getPluginParent.bind(this);
+    }
+
+    /**
+     * pluginParent - access the plugin's "parent"
+     */
+    getPluginParent() {
+      return this.pluginParent;
+    }
+
+    /**
+     * render - renders the wrapped component with the plugins.
+     */
+    render() {
+      const { config } = this.props;
+      const { plugins } = config;
+      return (
+        <Fragment>
+          <WrappedComponent {...this.props} ref={(parent) => { this.pluginParent = parent; }} />
+          { /* TODO: Refactor .name here in some way so we dont need to rely on it */}
+          {componentPlugins(WrappedComponent.name, plugins)
+            .map(component => React.createElement(
+              connect(component.mapStateToProps, component.mapDispatchToProps)(component.component),
+              { key: component.name, ...this.props, pluginParent: this.getPluginParent },
+            ))
+          }
+        </Fragment>
+      );
+    }
+  }
+
+  ConnectedComponent.propTypes = {
+    config: PropTypes.instanceOf(Object).isRequired,
+  };
+
+  /**
+   */
+  const mapStateToProps = state => ({ config: state.config });
+
+  return connect(mapStateToProps, null)(ConnectedComponent);
+}
diff --git a/src/reducers/config.js b/src/reducers/config.js
new file mode 100644
index 0000000000000000000000000000000000000000..92df252e757a53160f7c3fe1305aa35cbb286a82
--- /dev/null
+++ b/src/reducers/config.js
@@ -0,0 +1,18 @@
+import deepmerge from 'deepmerge';
+import ActionTypes from '../action-types';
+
+/**
+ * configReducer - does a deep merge of the config
+ */
+const configReducer = (state = {}, action) => {
+  switch (action.type) {
+    case ActionTypes.UPDATE_CONFIG:
+      return deepmerge(state, action.config);
+    case ActionTypes.SET_CONFIG:
+      return action.config;
+    default:
+      return state;
+  }
+};
+
+export { configReducer as default };
diff --git a/src/reducers/index.js b/src/reducers/index.js
new file mode 100644
index 0000000000000000000000000000000000000000..a52f1c82bd3259b2a501689a603aad622bbe5c2d
--- /dev/null
+++ b/src/reducers/index.js
@@ -0,0 +1,21 @@
+import { combineReducers } from 'redux';
+import workspaceReducer from './workspace';
+import windowsReducer from './windows';
+import manifestsReducer from './manifests';
+import configReducer from './config';
+import infoResponsesReducer from './infoResponses';
+
+/**
+ * Action Creators for Mirador
+ * @namespace RootReducer
+ */
+
+const rootReducer = combineReducers({
+  workspace: workspaceReducer,
+  windows: windowsReducer,
+  manifests: manifestsReducer,
+  config: configReducer,
+  infoResponses: infoResponsesReducer,
+});
+
+export default rootReducer;
diff --git a/src/reducers/infoResponses.js b/src/reducers/infoResponses.js
new file mode 100644
index 0000000000000000000000000000000000000000..9954dcb9b4c7ad1480fe09062a0c1ef98cd0591f
--- /dev/null
+++ b/src/reducers/infoResponses.js
@@ -0,0 +1,39 @@
+import ActionTypes from '../action-types';
+
+/**
+ * infoResponsesReducer
+ */
+const infoResponsesReducer = (state = {}, action) => {
+  switch (action.type) {
+    case ActionTypes.REQUEST_INFO_RESPONSE:
+      return Object.assign({}, state, {
+        [action.infoId]: {
+          isFetching: true,
+        },
+      });
+    case ActionTypes.RECEIVE_INFO_RESPONSE:
+      return Object.assign({}, state, {
+        [action.infoId]: {
+          json: action.infoJson,
+          isFetching: false,
+        },
+      });
+    case ActionTypes.RECEIVE_INFO_RESPONSE_FAILURE:
+      return Object.assign({}, state, {
+        [action.infoId]: {
+          error: action.error,
+          isFetching: false,
+        },
+      });
+    case ActionTypes.REMOVE_INFO_RESPONSE:
+      return Object.keys(state).reduce((object, key) => {
+        if (key !== action.infoId) {
+          object[key] = state[key]; // eslint-disable-line no-param-reassign
+        }
+        return object;
+      }, {});
+    default: return state;
+  }
+};
+
+export default infoResponsesReducer;
diff --git a/src/reducers/manifests.js b/src/reducers/manifests.js
new file mode 100644
index 0000000000000000000000000000000000000000..48041ac3a01a23b87e7666d4888e3158224fc6b7
--- /dev/null
+++ b/src/reducers/manifests.js
@@ -0,0 +1,40 @@
+import manifesto from 'manifesto.js';
+import ActionTypes from '../action-types';
+
+/**
+ * manifestsReducer
+ */
+const manifestsReducer = (state = {}, action) => {
+  switch (action.type) {
+    case ActionTypes.REQUEST_MANIFEST:
+      return Object.assign({}, state, {
+        [action.manifestId]: {
+          isFetching: true,
+        },
+      });
+    case ActionTypes.RECEIVE_MANIFEST:
+      return Object.assign({}, state, {
+        [action.manifestId]: {
+          manifestation: manifesto.create(action.manifestJson),
+          isFetching: false,
+        },
+      });
+    case ActionTypes.RECEIVE_MANIFEST_FAILURE:
+      return Object.assign({}, state, {
+        [action.manifestId]: {
+          error: action.error,
+          isFetching: false,
+        },
+      });
+    case ActionTypes.REMOVE_MANIFEST:
+      return Object.keys(state).reduce((object, key) => {
+        if (key !== action.manifestId) {
+          object[key] = state[key]; // eslint-disable-line no-param-reassign
+        }
+        return object;
+      }, {});
+    default: return state;
+  }
+};
+
+export default manifestsReducer;
diff --git a/src/reducers/windows.js b/src/reducers/windows.js
new file mode 100644
index 0000000000000000000000000000000000000000..508200fbdb3be9822833a37c10aa5d21d236e64a
--- /dev/null
+++ b/src/reducers/windows.js
@@ -0,0 +1,31 @@
+import ActionTypes from '../action-types';
+
+/**
+ * windowsReducer
+ */
+const windowsReducer = (state = [], action) => {
+  switch (action.type) {
+    case ActionTypes.ADD_WINDOW:
+      return state.concat(Object.assign({}, action.payload));
+    case ActionTypes.REMOVE_WINDOW:
+      return state.filter(window => window.id !== action.windowId);
+    case ActionTypes.NEXT_CANVAS:
+      return state.map((window) => {
+        if (window.id === action.windowId) {
+          return Object.assign({}, window, { canvasIndex: window.canvasIndex + 1 });
+        }
+        return window;
+      });
+    case ActionTypes.PREVIOUS_CANVAS:
+      return state.map((window) => {
+        if (window.id === action.windowId) {
+          return Object.assign({}, window, { canvasIndex: window.canvasIndex - 1 });
+        }
+        return window;
+      });
+    default:
+      return state;
+  }
+};
+
+export default windowsReducer;
diff --git a/src/reducers/workspace.js b/src/reducers/workspace.js
new file mode 100644
index 0000000000000000000000000000000000000000..2219a7bc3fdbb7bc373c9ea0237ae3d7e25f4942
--- /dev/null
+++ b/src/reducers/workspace.js
@@ -0,0 +1,15 @@
+import ActionTypes from '../action-types';
+
+/**
+ * workspaceReducer
+ */
+const workspaceReducer = (state = {}, action) => {
+  switch (action.type) {
+    case ActionTypes.FOCUS_WINDOW:
+      return Object.assign({}, state, { focusedWindowId: action.windowId });
+    default:
+      return state;
+  }
+};
+
+export { workspaceReducer as default };
diff --git a/src/store.js b/src/store.js
new file mode 100644
index 0000000000000000000000000000000000000000..bbb6c6e1cb30f7853ac971e599de3ba78bf1f321
--- /dev/null
+++ b/src/store.js
@@ -0,0 +1,20 @@
+// Topics for understanding
+// redux modules for nested stores
+// state normalisation
+// (normalizer library)
+
+import thunkMiddleware from 'redux-thunk';
+import { createStore, applyMiddleware } from 'redux';
+import { composeWithDevTools } from 'redux-devtools-extension';
+import rootReducer from './reducers/index';
+import * as ActionCreators from './actions';
+
+// Create a Redux store holding the state of your app.
+// Its API is { subscribe, dispatch, getState }.
+export const store = createStore(
+  rootReducer,
+  composeWithDevTools(applyMiddleware(thunkMiddleware)),
+);
+
+export const actions = ActionCreators;
+export default { actions, store };
diff --git a/src/styles/index.scss b/src/styles/index.scss
new file mode 100644
index 0000000000000000000000000000000000000000..f37eb5dc2f4a4d823401ae7b2cf93931f407bbd7
--- /dev/null
+++ b/src/styles/index.scss
@@ -0,0 +1,64 @@
+body {
+  background: white;
+  height: 100%;
+}
+
+.mirador {
+  &-control-panel {
+    position: absolute;
+    box-sizing: border-box;
+    padding: 15px;
+    margin:0px;
+    left:0;
+    top:0;
+    bottom:0;
+    width: 300px;
+    border-right: 2px solid black;
+  }
+
+  &-workspace {
+    position: absolute;
+    box-sizing: border-box;
+    margin: 0;
+    padding-left: 300px;
+    top:0;
+    right:0;
+    bottom:0;
+    left:0;
+    overflow: scroll;
+  }
+
+  &-window {
+    border: 1px solid black;
+    overflow: hidden;
+    position: relative;
+  }
+
+  &-window-top-bar {
+    background: linear-gradient(to bottom, rgba(0, 0, 0, .65) 0%, rgba(0, 0, 0, 0) 100%);
+    color: white;
+    position: absolute;
+    z-index: 10;
+
+    h3 {
+      margin: 0;
+    }
+  }
+
+  &-osd-container {
+    background: gray;
+    bottom: 0;
+    height: 100%;
+    position: absolute;
+    top: 0;
+    width: 100%;
+  }
+
+  &-osd-navigation {
+    bottom: 10px;
+    left: 0;
+    position: absolute;
+    right: 0;
+    text-align: center;
+  }
+}
diff --git a/webpack.config.js b/webpack.config.js
new file mode 100644
index 0000000000000000000000000000000000000000..b643a0c157d3c7d4335bd4c45415fab4b7ba516d
--- /dev/null
+++ b/webpack.config.js
@@ -0,0 +1,94 @@
+const path = require('path');
+const TerserPlugin = require('terser-webpack-plugin');
+const paths = require('./config/paths');
+
+const eslintLoaderConfig = {
+  test: /\.(js|mjs|jsx)$/,
+  enforce: 'pre',
+  use: [
+    {
+      options: {
+        formatter: require.resolve('react-dev-utils/eslintFormatter'),
+        eslintPath: require.resolve('eslint'),
+
+      },
+      loader: require.resolve('eslint-loader'),
+    },
+  ],
+  include: paths.appSrc,
+};
+
+const babelLoaderConfig = {
+  test: /\.(js|mjs|jsx)$/,
+  include: paths.appSrc, // CRL
+  loader: require.resolve('babel-loader'),
+  options: {
+    plugins: [
+      [
+        require.resolve('babel-plugin-named-asset-import'),
+        {
+          loaderMap: {
+            svg: {
+              ReactComponent: '@svgr/webpack?-prettier,-svgo![path]',
+            },
+          },
+        },
+      ],
+    ],
+    cacheDirectory: true,
+    // Save disk space when time isn't as important
+    cacheCompression: true,
+    compact: true,
+  },
+};
+
+module.exports = [
+  {
+    entry: './src/store.js',
+    output: {
+      path: path.join(__dirname, 'dist'),
+      filename: 'm3core.umd.js',
+      libraryTarget: 'umd',
+      library: 'm3core',
+    },
+    module: {
+      rules: [
+        eslintLoaderConfig,
+        babelLoaderConfig,
+      ],
+    },
+  },
+  {
+    entry: './src/index.js',
+    output: {
+      path: path.join(__dirname, 'dist'),
+      filename: 'mirador.min.js',
+      libraryTarget: 'umd',
+      library: 'Mirador',
+      libraryExport: 'default',
+    },
+    resolve: { extensions: ['.js'] },
+    module: {
+      rules: [
+        eslintLoaderConfig,
+        babelLoaderConfig,
+        {
+          test: /\.scss$/,
+          use: [
+            'style-loader', // creates style nodes from JS strings
+            'css-loader', // translates CSS into CommonJS
+            'sass-loader', // compiles Sass to CSS, using Node Sass by default
+          ],
+        }],
+    },
+    optimization: {
+      minimizer: [
+        new TerserPlugin({
+          terserOptions: {
+            mangle: false,
+          },
+        }),
+      ],
+    },
+  },
+];