diff --git a/client/README.md b/client/README.md new file mode 100644 index 00000000..634d83f4 --- /dev/null +++ b/client/README.md @@ -0,0 +1,25 @@ +Rewrite of TryPureScript using latest features of PS ecosystem, such as: +* Halogen Hooks +* Tailwind CSS + +Lots of HTML and JS code was eliminated. + +Also enables gist saving and tracking state in URL rather than local storage. + +### Local Development +``` +npm i +npm config set tps:configpath "config/dev/*.purs" +npm run gen-css # Create initial tailwind css files +npm run start # Launch local dev server with automatic reload/refresh. + +# Optional: +npm run build # To manually rebuild if IDE does not do this automatically. +npm run lock-css # To speed up rebuilds if you're not adding new css classes. +``` + +### Building for production +``` +npm config set tps:configpath "config/prod/*.purs" +npm run prod # Create minified production build +``` diff --git a/client/config/dev/Try.Config.purs b/client/config/dev/Try.Config.purs index 5c6218f1..e18b22ee 100644 --- a/client/config/dev/Try.Config.purs +++ b/client/config/dev/Try.Config.purs @@ -1,10 +1,23 @@ module Try.Config where +appDomain :: String +appDomain = "http://localhost:1234" + +tokenServerUrl :: String +--tokenServerUrl = "http://localhost:7071/api/localtrigger" +tokenServerUrl = "https://localtpsfunction.azurewebsites.net/api/localtps?code=Il1fqBKydiLWqoognUIzgppwi10qfmXjkhAa75yRg5S4S10LNfsiTw==" + +-- GitHub OAuth app for saving gists. +-- This is tied to a specific app domain. +-- I believe it's fine for client ID to be public information +clientID :: String +clientID = "6f4e10fd8cef6995ac09" + loaderUrl :: String -loaderUrl = "js/output" +--loaderUrl = "js/output" +--loaderUrl = "http://localhost:8080" +loaderUrl = "https://compile.purescript.org/output" compileUrl :: String -compileUrl = "http://localhost:8081" - -mainGist :: String -mainGist = "7ad2b2eef11ac7dcfd14aa1585dd8f69" +--compileUrl = "http://localhost:8081" +compileUrl = "https://compile.purescript.org" diff --git a/client/config/dev/index.js b/client/config/dev/index.js new file mode 100644 index 00000000..db8b552a --- /dev/null +++ b/client/config/dev/index.js @@ -0,0 +1 @@ +require("../output/Main/index.js").main(); diff --git a/client/config/prod/Try.Config.purs b/client/config/prod/Try.Config.purs index e2232d10..b2010f5a 100644 --- a/client/config/prod/Try.Config.purs +++ b/client/config/prod/Try.Config.purs @@ -1,10 +1,19 @@ module Try.Config where +appDomain :: String +appDomain = "https://try.ps.ai" + +tokenServerUrl :: String +tokenServerUrl = "https://tpsfunction.azurewebsites.net/api/tps?code=JmxFIJvNG9E4qFtrwyD2v40YIWAtKUt1HDxLQ9rjmP4bRafnxWjNZg==" + +-- GitHub OAuth app for saving gists. +-- This is tied to a specific app domain. +-- I believe it's fine for client ID to be public information +clientID :: String +clientID = "3634da383bb531261af5" + loaderUrl :: String loaderUrl = "https://compile.purescript.org/output" compileUrl :: String compileUrl = "https://compile.purescript.org" - -mainGist :: String -mainGist = "7ad2b2eef11ac7dcfd14aa1585dd8f69" diff --git a/client/css/css2purs.py b/client/css/css2purs.py new file mode 100755 index 00000000..3f7a75e4 --- /dev/null +++ b/client/css/css2purs.py @@ -0,0 +1,82 @@ +#!/usr/bin/python3 +import re +import fileinput + +# Usage: +# cat tailwind.css | ./css2purs.py > Tailwind.purs + +# vim Regex +# /^\s*\.[^ ]* + +# Using list rather than set to preserve sorted order +# Assuming that duplicates are always adjacent +cssNames = [] + +def process(line): + # Example input: + # line = ' .-sm\:-space-y-0-w-1\/2:hover {' + regName = re.compile('^\s*\.([^ ]*?[^\\\\])(:.*)? .*$') + + m = regName.match(line) + + if m: + escaped = m.group(1) + # Just escaped class name + # -sm\:-space-y-0-w-1\/2 + + cssStr = escaped.replace('\\', '') + # Remove escaped symbols - this is the CSS string + # -sm:-space-y-0-w-1/2 + + # don't add duplicates + # assuming always adjacent + if len(cssNames) and cssNames[-1] == cssStr: + return + + cssNames.append(cssStr) + +def cssToPs(cssStr): + # Conversion to PureScript-compatible name + # Must remove symbols + + def negRepl(m): + return m.group(1) + 'neg' + m.group(3).upper() + negSub = re.sub(r'(^|:)(-)(.)', negRepl, cssStr) + # Replace leading dashes (used to represent negatives) with 'neg' + # Camel-case for next word + # negSm:negSpace-y-0-w-1/2 + + colonDivSub = negSub.replace(':', '-').replace('/', 'd') + # replace colon separator with dash + # replace division sign for fractions with 'd' + # negSm-negSpace-y-0-w-1d2 + + def dashRepl(m): + return m.group(1).upper() + dashSub = re.sub(r'-(.)', dashRepl, colonDivSub) + # Convert dash-separator to camelCase + # negSmNegSpaceY0W1d2 + + # Debug prints + # print('cssStr', cssStr) + # print(escaped) + # print(negSub) + # print(colonDivSub) + # print(dashSub) + + psName = dashSub + print() + print('-- | ' + cssStr) + print(psName + ' :: ClassName') + print(psName + ' = ClassName "' + cssStr + '"') + +for line in fileinput.input(): + process(line) + +print('-- | Autogenerated from tailwind.css') +print('module Tailwind where') +print() +print('import Halogen.HTML.Core (ClassName(..))') + +for cssName in cssNames: + cssToPs(cssName) diff --git a/client/css/tailwind_inputs.css b/client/css/tailwind_inputs.css new file mode 100644 index 00000000..bd6213e1 --- /dev/null +++ b/client/css/tailwind_inputs.css @@ -0,0 +1,3 @@ +@tailwind base; +@tailwind components; +@tailwind utilities; \ No newline at end of file diff --git a/client/package.json b/client/package.json index 176e15c5..ee200f1d 100644 --- a/client/package.json +++ b/client/package.json @@ -1,17 +1,56 @@ { - "name": "trypurescript-client", + "name": "tps", "private": true, "config": { "configpath": "config/dev/*.purs" }, "scripts": { - "clean": "rimraf output", - "build": "spago bundle-app --path $npm_package_config_configpath --purs-args '--censor-lib --strict' --to public/js/index.js" + "c-user-facing": "# --------- user facing scripts -------", + + "c-gen-css": "# -- Generate tailwind css AND purs css wrapper", + "gen-css": "npm run build-css-only && npm run css2purs", + + "c1-lock-css": "# -- Strip away unused css from autogenerated purs files.", + "c2-lock-css": "# -- This improves rebuild times, but won't allow adding new css.", + "c3-lock-css": "# -- So re-run gen-css before making additional css changes.", + "lock-css": "npm run bundle && npm run css-purge && npm run css2purs", + + "c-build": "# -- Build purs code in project", + "build": "spago build --path $npm_package_config_configpath", + + "c-start": "# -- Launch development server in web browser", + "start": "npm run build && cp config/dev/index.js public && webpack-dev-server --port 1234 --open --config webpack.dev.js", + + "c-prod": "# -- Create minified production build", + "prod": "npm run bundle && npm run css-purge && webpack --config webpack.prod.js", + + "c-wp-dev-build": "# -- Create unminified dev build. This is useful for troubleshooting the production build.", + "wp-dev-build": "cp config/dev/index.js public && webpack --config webpack.dev.js", + + "c-serve-static": "# -- serves static files locally. For checking if everything was bundled correctly.", + "serve-static": "http-server dist/ -c-1 -o tps --port 1234 -P http://localhost:1234\\?", + + "c-internal": "# -------- internal helper scripts -------", + + "bundle": "spago bundle-app --path $npm_package_config_configpath --to public/index.js", + "build-css-only": "tailwindcss build css/tailwind_inputs.css -o public/tailwind.css", + "css2purs": "cat public/tailwind.css | ./css/css2purs.py > src/Tailwind.purs", + "css-purge": "NODE_ENV=production npm run build-css-only" }, "devDependencies": { - "purescript": "^0.13.6", - "purescript-psa": "^0.7.3", - "rimraf": "^2.5.4", - "spago": "^0.14.0" + "clean-webpack-plugin": "^3.0.0", + "copy-webpack-plugin": "^6.0.3", + "css-loader": "^3.6.0", + "cssnano": "^4.1.10", + "exports-loader": "^1.1.0", + "file-loader": "^6.0.0", + "html-webpack-plugin": "^4.3.0", + "style-loader": "^1.2.1", + "tailwindcss": "^1.4.6", + "webpack": "^4.43.0", + "webpack-cli": "^3.3.12", + "webpack-dev-server": "^3.11.0", + "webpack-merge": "^5.0.9", + "xhr2": "^0.2.0" } } diff --git a/client/packages.dhall b/client/packages.dhall index d293079b..c9fe001f 100644 --- a/client/packages.dhall +++ b/client/packages.dhall @@ -119,9 +119,12 @@ let additions = let upstream = - https://github.com/purescript/package-sets/releases/download/psc-0.13.6-20200404/packages.dhall sha256:f239f2e215d0cbd5c203307701748581938f74c4c78f4aeffa32c11c131ef7b6 + https://github.com/purescript/package-sets/releases/download/psc-0.13.8-20200724/packages.dhall sha256:bb941d30820a49345a0e88937094d2b9983d939c9fd3a46969b85ce44953d7d9 -let overrides = {=} +let overrides = + { halogen-hooks-extra = + upstream.halogen-hooks-extra // { version = "v0.7.1" } + } let additions = {=} diff --git a/client/public/CNAME b/client/public/CNAME new file mode 100644 index 00000000..d5796847 --- /dev/null +++ b/client/public/CNAME @@ -0,0 +1 @@ +try.ps.ai diff --git a/client/public/ace.css b/client/public/ace.css new file mode 100644 index 00000000..be55d39b --- /dev/null +++ b/client/public/ace.css @@ -0,0 +1,18 @@ +.error { + position: absolute; + z-index: 20; + border-bottom: 2px dotted red; +} + +/* Currently unsued, but can re-enable. Assuming it's spammy */ +.warning { + position: absolute; + z-index: 20; + border-bottom: 2px dotted #c4953a; +} + +/* Can re-enable if there's an issue without this option +.ace_gutter-tooltip { + white-space: pre-wrap; +} +*/ diff --git a/client/public/css/flare.css b/client/public/css/flare.css deleted file mode 100644 index c512eaff..00000000 --- a/client/public/css/flare.css +++ /dev/null @@ -1,207 +0,0 @@ -/*! normalize.css v3.0.3 | MIT License | github.com/necolas/normalize.css */html{font-family:sans-serif;-ms-text-size-adjust:100%;-webkit-text-size-adjust:100%}body{margin:0}article,aside,details,figcaption,figure,footer,header,hgroup,main,menu,nav,section,summary{display:block}audio,canvas,progress,video{display:inline-block;vertical-align:baseline}audio:not([controls]){display:none;height:0}[hidden],template{display:none}a{background-color:transparent}a:active,a:hover{outline:0}abbr[title]{border-bottom:1px dotted}b,strong{font-weight:bold}dfn{font-style:italic}h1{font-size:2em;margin:.67em 0}mark{background:#ff0;color:#000}small{font-size:80%}sub,sup{font-size:75%;line-height:0;position:relative;vertical-align:baseline}sup{top:-0.5em}sub{bottom:-0.25em}img{border:0}svg:not(:root){overflow:hidden}figure{margin:1em 40px}hr{box-sizing:content-box;height:0}pre{overflow:auto}code,kbd,pre,samp{font-family:monospace,monospace;font-size:1em}button,input,optgroup,select,textarea{color:inherit;font:inherit;margin:0}button{overflow:visible}button,select{text-transform:none}button,html input[type="button"],input[type="reset"],input[type="submit"]{-webkit-appearance:button;cursor:pointer}button[disabled],html input[disabled]{cursor:default}button::-moz-focus-inner,input::-moz-focus-inner{border:0;padding:0}input{line-height:normal}input[type="checkbox"],input[type="radio"]{box-sizing:border-box;padding:0}input[type="number"]::-webkit-inner-spin-button,input[type="number"]::-webkit-outer-spin-button{height:auto}input[type="search"]{-webkit-appearance:textfield;box-sizing:content-box}input[type="search"]::-webkit-search-cancel-button,input[type="search"]::-webkit-search-decoration{-webkit-appearance:none}fieldset{border:1px solid silver;margin:0 2px;padding:.35em .625em .75em}legend{border:0;padding:0}textarea{overflow:auto}optgroup{font-weight:bold}table{border-collapse:collapse;border-spacing:0}td,th{padding:0} - -@import url(https://fonts.googleapis.com/css?family=Source+Code+Pro:400,700|Roboto:400,700); - -body { - margin: 20px; -} - -body, p, input, label, legend, h1, h2, h3, h4 { - font-family: 'Roboto', sans-serif; -} - -a, a:visited, a:active, a:hover { - color: #cc0000; -} - -.flare-input > label { - display: inline-block; - width: 150px; -} - -.flare-input { - margin-bottom: 10px; -} - -.flare-input-number { - width: 85px; -} - -input[type="range"] { - width: 150px; - border: 1px solid transparent; -} - -input:invalid { - background-color: #DEAFB4; -} - -label { - font-weight: bold; -} - -fieldset.flare-input-radioGroup { - margin: 0px; - margin-top: 15px; - border: none; - padding: 0px; -} - -.flare-input-radioGroup legend { - padding: 0px; - width: 150px; - float: left; - font-weight: bold; -} - -.flare-input-radioGroup label { - margin-left: 3px; - margin-right: 15px; - cursor: pointer; - font-weight: normal; -} - -code { - background-color: #f0f0f0; - padding-left: 3px; - padding-right: 3px; -} - -.sparkle-test pre, -.sparkle-test code, -.sparkle-test input, -.sparkle-test label, -.sparkle-test legend, -.sparkle-test select, -.sparkle-test textarea { - font-family: 'Source Code Pro', monospace; -} - -fieldset.sparkle-test { - margin-top: 15px; - margin-bottom: 40px; - max-width: 800px; - border: 1px solid #666; - border-radius: 3px; - padding: .35em .625em .75em; -} - -fieldset.sparkle-test > legend { - background-color: #f0f0f0; - border: 1px solid #666; - border-radius: 3px; - float: initial; - width: initial; -} - -.sparkle-test pre { - background-color: #f0f0f0; - padding: 10px; - max-width: 780px; - - /* http://stackoverflow.com/a/248013/704831 */ - white-space: pre-wrap; /* CSS 3 */ - white-space: -moz-pre-wrap; /* Mozilla, since 1999 */ - white-space: -pre-wrap; /* Opera 4-6 */ - white-space: -o-pre-wrap; /* Opera 7 */ - word-wrap: break-word; /* Internet Explorer 5.5+ */ -} - -.sparkle-test .flare-input { - margin-bottom: 5px; -} - -.sparkle-test input:not([type="range"]) { - padding: 3px; - border-radius: 3px; - border: 1px solid #888; -} - -.sparkle-test input:invalid { - background-color: #DEAFB4; -} - -.sparkle-test .flare-input-number, -.sparkle-test .flare-input-int-number { - width: 80px; -} - -.sparkle-test fieldset { - border-radius: 3px; - margin-top: 5px; - margin-bottom: 20px; -} - -.sparkle-test label { - width: 160px; - display: inline-block; -} - -.sparkle-test legend { - padding: 2px 6px; - font-weight: 700; -} - -.sparkle-test .flare-input-list button { - margin-left: 5px; - margin-right: 5px; -} - -.sparkle-test .sparkle-okay { - background-color: #B4DEAF; -} - -.sparkle-test .sparkle-warn { - background-color: #DEAFB4; -} - -.sparkle-string { - color: #E91E63; -} - -.sparkle-number { - color: #1339E8; -} - -.sparkle-boolean { - font-weight: bold; -} - -.sparkle-constructor { - font-weight: bold; -} - -.sparkle-record-field { - font-style: italic; -} - -.sparkle-color { - width: 1em; - height: 1em; - margin-right: 0.5em; - display: inline-block; -} - -.sparkle-tooltip { - cursor: default; -} - -.sparkle-tooltip:hover { - background-color: #f2e7a6; -} - -.sparkle-tooltip > .sparkle-tooltip:hover { - background-color: #e9d563; -} - -.sparkle-tooltip > .sparkle-tooltip > .sparkle-tooltip:hover { - background-color: #dfc220; -} - -.sparkle-tooltip > .sparkle-tooltip > .sparkle-tooltip > .sparkle-tooltip:hover { - background-color: #938015; -} - -.sparkle-tooltip > .sparkle-tooltip > .sparkle-tooltip > .sparkle-tooltip > .sparkle-tooltip:hover { - background-color: #62560e; -} diff --git a/client/public/css/index.css b/client/public/css/index.css deleted file mode 100644 index 1c2b202c..00000000 --- a/client/public/css/index.css +++ /dev/null @@ -1,269 +0,0 @@ -/* Page layout */ - -html, body { - height: 100%; -} - -body { - font-family: 'Roboto', sans-serif; - margin: 0; - color: rgb(29, 34, 45); -} - -#wrapper { - display: flex; - flex-direction: column; - height: 100vh; -} - -#body { - display: flex; - flex-direction: column; - flex: 1; -} - -#menu { - background-color: #1d222d; - color: white; - vertical-align: middle; - margin: 0; - padding: 0; -} - - #menu input { - vertical-align: middle; - margin-bottom: 5px; - cursor: pointer; - } - -#home_link { - padding: 0px 10px; - text-decoration: none; - text-align: center; - font-weight: bold; -} - - #home_link > img { - vertical-align: middle; - } - -.menu-item { - list-style: none; - display: inline-block; - border-left: 1px solid #454648; - min-width: 90px; -} - - .menu-item a { - color: white; - text-decoration: none; - } - - .menu-item > label, .menu-item > a > label { - text-align: center; - line-height: 40px; - padding: 0px 10px; - display: block; - } - - .menu-item label { - cursor: pointer; - } - - .menu-item:hover { - background-color: black; - } - - .menu-item input[type="checkbox"] { - display: none; - } - - .menu-item input[type="checkbox"]:checked + label { - background-color: #8490a9; - } - - .menu-item input[type="checkbox"]:not(:checked) + label { - text-decoration: line-through; - color: #ababab; - } - -.menu-dropdown { - position: relative; -} - - .menu-dropdown > label { - display: block; - } - - .menu-dropdown > label:after { - content: ' ▾'; - } - - .menu-dropdown > ul { - position:absolute; - top: 40px; - left: 0px; - min-width: 100%; - display: none; - margin: 0px; - padding: 0px; - background-color: #1d222d; - list-style: none; - z-index: 100000; - } - - .menu-dropdown > ul > li { - margin: 0; - padding: 0; - } - - .menu-dropdown:hover > ul { - display: inherit; - } - - .menu-dropdown li label { - display: block; - padding: 10px 8px; - margin: 0; - } - - .menu-dropdown label:hover { - background-color: black; - } - - .menu-dropdown input[type="radio"] { - display:none; - } - - .menu-dropdown input[type="radio"]:checked + label { - color: #c4953a; - } - - .menu-dropdown a { - display: block; - color: white; - } - - .menu-dropdown a:visited { - color: white; - } - -#editor_view { - display: flex; - flex-direction: row; - flex: 1; - margin-top: 0px; - position: relative; -} - - #editor_view[data-view-mode="output"] #column1 { - display: none; - } - - #editor_view[data-view-mode="code"] #column2_wrapper { - display: none; - } - -#column1, #column2_wrapper { - position: relative; - flex: 1; -} - -#column2_wrapper { - position: relative; - -webkit-overflow-scrolling: touch; - overflow: auto; -} - -#code { - position: absolute; - top: 0; - left: 0; - right: 0; - bottom: 0; -} - -.separator { - flex-basis: 5px; - flex-grow: 0; - flex-shrink: 0; - background-color: rgb(250, 250, 250); -} - -.error { - position: absolute; - z-index: 20; - border-bottom: 2px dotted red; -} - -.warning { - position: absolute; - z-index: 20; - border-bottom: 2px dotted #c4953a; -} - -.ace_gutter-tooltip { - white-space: pre-wrap; -} - -.error-banner { - font-family: 'Roboto Slab', serif; - padding: 10px 20px; -} - -pre { - padding: 20px; - font-family: 'Monaco', monospace; -} - -pre code { - background: none; - border: 0; - margin: 0; -} - -iframe { - position: absolute; - left: 0; - right: 0; - width: 100%; - height: 100%; - border: 0; -} - -#loading { - position: absolute; - top: 0; - left: 0; - width: 100%; - height: 100%; - background: white url(../img/loading.gif); - background-position: center center; - background-repeat: no-repeat; - opacity: 0.85; - z-index: 10000; -} - -#code { - overflow: visible; -} - -.mobile-banner { - background: #dabf8b; - padding: 5px; - border-bottom: 1px solid #1d222d; - font-size: 14px; - margin-bottom: 10px; -} - -@media all and (max-width: 720px) { - .no-mobile { - display: none; - } -} - -@media all and (min-width: 720px) { - .mobile-only { - display: none; - } -} diff --git a/client/public/css/mathbox.css b/client/public/css/mathbox.css deleted file mode 100644 index dd93e59e..00000000 --- a/client/public/css/mathbox.css +++ /dev/null @@ -1,461 +0,0 @@ -.shadergraph-graph { - font: 12px sans-serif; - line-height: 25px; - position: relative; -} -.shadergraph-graph:after { - content: ' '; - display: block; - height: 0; - font-size: 0; - clear: both; -} -.shadergraph-graph svg { - pointer-events: none; -} -.shadergraph-clear { - clear: both; -} -.shadergraph-graph svg { - position: absolute; - left: 0; - right: 0; - top: 0; - bottom: 0; - width: auto; - height: auto; -} -.shadergraph-column { - float: left; -} -.shadergraph-node .shadergraph-graph { - float: left; - clear: both; - overflow: visible; -} -.shadergraph-node .shadergraph-graph .shadergraph-node { - margin: 5px 15px 15px; -} -.shadergraph-node { - margin: 5px 15px 25px; - background: rgba(0, 0, 0, .1); - border-radius: 5px; - box-shadow: 0 1px 2px rgba(0, 0, 0, .2), - 0 1px 10px rgba(0, 0, 0, .2); - min-height: 35px; - float: left; - clear: left; - position: relative; -} -.shadergraph-type { - font-weight: bold; -} -.shadergraph-header { - font-weight: bold; - text-align: center; - height: 25px; - background: rgba(0, 0, 0, .3); - text-shadow: 0 1px 2px rgba(0, 0, 0, .25); - color: #fff; - border-top-left-radius: 5px; - border-top-right-radius: 5px; - margin-bottom: 5px; - padding: 0 10px; -} -.shadergraph-outlet div { -} -.shadergraph-outlet-in .shadergraph-name { - margin-right: 7px; -} -.shadergraph-outlet-out .shadergraph-name { - margin-left: 7px; -} - -.shadergraph-name { - margin: 0 4px; -} -.shadergraph-point { - margin: 6px; - width: 11px; - height: 11px; - border-radius: 7.5px; - background: rgba(255, 255, 255, 1); -} -.shadergraph-outlet-in { - float: left; - clear: left; -} -.shadergraph-outlet-in div { - float: left; -} -.shadergraph-outlet-out { - float: right; - clear: right; -} -.shadergraph-outlet-out div { - float: right; -} - -.shadergraph-node-callback { - background: rgba(205, 209, 221, .5); - box-shadow: 0 1px 2px rgba(0, 10, 40, .2), - 0 1px 10px rgba(0, 10, 40, .2); -} -.shadergraph-node-callback > .shadergraph-header { - background: rgba(0, 20, 80, .3); -} -.shadergraph-graph .shadergraph-graph .shadergraph-node-callback { - background: rgba(0, 20, 80, .1); -} - -.shadergraph-node-call { - background: rgba(209, 221, 205, .5); - box-shadow: 0 1px 2px rgba(10, 40, 0, .2), - 0 1px 10px rgba(10, 40, 0, .2); -} -.shadergraph-node-call > .shadergraph-header { - background: rgba(20, 80, 0, .3); -} -.shadergraph-graph .shadergraph-graph .shadergraph-node-call { - background: rgba(20, 80, 0, .1); -} - -.shadergraph-node-isolate { - background: rgba(221, 205, 209, .5); - box-shadow: 0 1px 2px rgba(40, 0, 10, .2), - 0 1px 10px rgba(40, 0, 10, .2); -} -.shadergraph-node-isolate > .shadergraph-header { - background: rgba(80, 0, 20, .3); -} -.shadergraph-graph .shadergraph-graph .shadergraph-node-isolate { - background: rgba(80, 0, 20, .1); -} - -.shadergraph-node.shadergraph-has-code { - cursor: pointer; -} -.shadergraph-node.shadergraph-has-code::before { - position: absolute; - content: ' '; - top: 0; - left: 0; - right: 0; - bottom: 0; - display: none; - border: 2px solid rgba(0, 0, 0, .25); - border-radius: 5px; -} -.shadergraph-node.shadergraph-has-code:hover::before { - display: block; -} -.shadergraph-code { - z-index: 10000; - display: none; - position: absolute; - background: #fff; - color: #000; - white-space: pre; - padding: 10px; - border-radius: 5px; - box-shadow: 0 1px 2px rgba(0, 0, 0, .2), - 0 1px 10px rgba(0, 0, 0, .2); - font-family: monospace; - font-size: 10px; - line-height: 12px; -} - -.shadergraph-overlay { - position: fixed; - top: 50%; - left: 0; - right: 0; - bottom: 0; - background: #fff; - border-top: 1px solid #CCC; -} -.shadergraph-overlay .shadergraph-view { - position: absolute; - left: 0; - top: 0; - right: 0; - bottom: 0; - overflow: auto; -} -.shadergraph-overlay .shadergraph-inside { - width: 4000px; - min-height: 100%; - box-sizing: border-box; -} -.shadergraph-overlay .shadergraph-close { - position: absolute; - top: 5px; - right: 5px; - padding: 4px; - border-radius: 16px; - background: rgba(255,255,255,.3); - color: rgba(0, 0, 0, .3); - cursor: pointer; - font-size: 24px; - line-height: 24px; - width: 24px; - text-align: center; - vertical-align: middle; -} -.shadergraph-overlay .shadergraph-close:hover { - background: rgba(255,255,255,1); - color: rgba(0, 0, 0, 1); -} -.shadergraph-overlay .shadergraph-graph { - padding-top: 10px; - overflow: visible; - min-height: 100%; -} -.shadergraph-overlay span { - display: block; - padding: 5px 15px; - margin: 0; - background: rgba(0, 0, 0, .1); - font-weight: bold; - font-family: sans-serif; -} -.mathbox-loader { - position: absolute; - top: 50%; - left: 50%; - -webkit-transform: translate(-50%, -50%); - transform: translate(-50%, -50%); - padding: 10px; - border-radius: 50%; - background: #fff; -} - -.mathbox-loader.mathbox-exit { - opacity: 0; - -webkit-transition: - opacity .15s ease-in-out; - transition: - opacity .15s ease-in-out; -} - -.mathbox-progress { - height: 10px; - border-radius: 5px; - width: 80px; - margin: 0 auto 20px; - box-shadow: - 1px 1px 1px rgba(255, 255, 255, .2), - 1px -1px 1px rgba(255, 255, 255, .2), - -1px 1px 1px rgba(255, 255, 255, .2), - -1px -1px 1px rgba(255, 255, 255, .2); - background: #ccc; - overflow: hidden; -} - -.mathbox-progress > div { - display: block; - width: 0px; - height: 10px; - background: #888; -} - -.mathbox-logo { - position: relative; - width: 140px; - height: 100px; - margin: 0 auto 10px; - -webkit-perspective: 200px; - perspective: 200px; -} - -.mathbox-logo > div { - position: absolute; - left: 0; - top: 0; - bottom: 0; - right: 0; - -webkit-transform-style: preserve-3d; - transform-style: preserve-3d; -} - -.mathbox-logo > :nth-child(1) { - -webkit-transform: rotateZ(22deg) rotateX(24deg) rotateY(30deg); - transform: rotateZ(22deg) rotateX(24deg) rotateY(30deg); -} - -.mathbox-logo > :nth-child(2) { - -webkit-transform: rotateZ(11deg) rotateX(12deg) rotateY(15deg) scale3d(.6, .6, .6); - transform: rotateZ(11deg) rotateX(12deg) rotateY(15deg) scale3d(.6, .6, .6); -} - -.mathbox-logo > div > div { - position: absolute; - top: 50%; - left: 50%; - margin-left: -100px; - margin-top: -100px; - width: 200px; - height: 200px; - box-sizing: border-box; - border-radius: 50%; -} - -.mathbox-logo > div > :nth-child(1) { - -webkit-transform: scale(0.5, 0.5); - transform: rotateX(30deg) scale(0.5, 0.5); -} - -.mathbox-logo > div > :nth-child(2) { - -webkit-transform: rotateX(90deg) scale(0.42, 0.42); - transform: rotateX(90deg) scale(0.42, 0.42); -} - -.mathbox-logo > div > :nth-child(3) { - -webkit-transform: rotateY(90deg) scale(0.35, 0.35); - transform: rotateY(90deg) scale(0.35, 0.35); -} - -.mathbox-logo > :nth-child(1) > :nth-child(1) { - border: 16px solid #808080; -} -.mathbox-logo > :nth-child(1) > :nth-child(2) { - border: 19px solid #A0A0A0; -} -.mathbox-logo > :nth-child(1) > :nth-child(3) { - border: 23px solid #C0C0C0; -} -.mathbox-logo > :nth-child(2) > :nth-child(1) { - border: 27px solid #808080; -} -.mathbox-logo > :nth-child(2) > :nth-child(2) { - border: 32px solid #A0A0A0; -} -.mathbox-logo > :nth-child(2) > :nth-child(3) { - border: 38px solid #C0C0C0; -} - -.mathbox-splash-blue .mathbox-progress { - background: #def; -} -.mathbox-splash-blue .mathbox-progress > div { - background: #1979e7; -} -.mathbox-splash-blue .mathbox-logo > :nth-child(1) > :nth-child(1) { - border-color: #1979e7; -} -.mathbox-splash-blue .mathbox-logo > :nth-child(1) > :nth-child(2) { - border-color: #33b0ff; -} -.mathbox-splash-blue .mathbox-logo > :nth-child(1) > :nth-child(3) { - border-color: #75eaff; -} -.mathbox-splash-blue .mathbox-logo > :nth-child(2) > :nth-child(1) { - border-color: #18487F; -} -.mathbox-splash-blue .mathbox-logo > :nth-child(2) > :nth-child(2) { - border-color: #33b0ff; -} -.mathbox-splash-blue .mathbox-logo > :nth-child(2) > :nth-child(3) { - border-color: #75eaff; -} - - - - -.mathbox-overlays { - position: absolute; - left: 0; - top: 0; - right: 0; - bottom: 0; - pointer-events: none; - transform-style: preserve-3d; - overflow: hidden; -} -.mathbox-overlays > div { - transform-style: preserve-3d; -} -.mathbox-overlay > div { - position: absolute; - will-change: transform, opacity; -} -.mathbox-label { - font-family: sans-serif; -} -.mathbox-outline-1 { - text-shadow: - -1px -1px 0px rgb(255, 255, 255), - 1px 1px 0px rgb(255, 255, 255), - -1px 1px 0px rgb(255, 255, 255), - 1px -1px 0px rgb(255, 255, 255), - 1px 0px 1px rgb(255, 255, 255), - -1px 0px 1px rgb(255, 255, 255), - 0px -1px 1px rgb(255, 255, 255), - 0px 1px 1px rgb(255, 255, 255); -} -.mathbox-outline-2 { - text-shadow: - 0px -2px 0px rgb(255, 255, 255), - 0px 2px 0px rgb(255, 255, 255), - -2px 0px 0px rgb(255, 255, 255), - 2px 0px 0px rgb(255, 255, 255), - -1px -2px 0px rgb(255, 255, 255), - -2px -1px 0px rgb(255, 255, 255), - -1px 2px 0px rgb(255, 255, 255), - -2px 1px 0px rgb(255, 255, 255), - 1px 2px 0px rgb(255, 255, 255), - 2px 1px 0px rgb(255, 255, 255), - 1px -2px 0px rgb(255, 255, 255), - 2px -1px 0px rgb(255, 255, 255); -} -.mathbox-outline-3 { - text-shadow: - 3px 0px 0px rgb(255, 255, 255), - -3px 0px 0px rgb(255, 255, 255), - 0px 3px 0px rgb(255, 255, 255), - 0px -3px 0px rgb(255, 255, 255), - - -2px -2px 0px rgb(255, 255, 255), - -2px 2px 0px rgb(255, 255, 255), - 2px 2px 0px rgb(255, 255, 255), - 2px -2px 0px rgb(255, 255, 255), - - -1px -2px 1px rgb(255, 255, 255), - -2px -1px 1px rgb(255, 255, 255), - -1px 2px 1px rgb(255, 255, 255), - -2px 1px 1px rgb(255, 255, 255), - 1px 2px 1px rgb(255, 255, 255), - 2px 1px 1px rgb(255, 255, 255), - 1px -2px 1px rgb(255, 255, 255), - 2px -1px 1px rgb(255, 255, 255); -} -.mathbox-outline-4 { - text-shadow: - 4px 0px 0px rgb(255, 255, 255), - -4px 0px 0px rgb(255, 255, 255), - 0px 4px 0px rgb(255, 255, 255), - 0px -4px 0px rgb(255, 255, 255), - - -3px -2px 0px rgb(255, 255, 255), - -3px 2px 0px rgb(255, 255, 255), - 3px 2px 0px rgb(255, 255, 255), - 3px -2px 0px rgb(255, 255, 255), - - -2px -3px 0px rgb(255, 255, 255), - -2px 3px 0px rgb(255, 255, 255), - 2px 3px 0px rgb(255, 255, 255), - 2px -3px 0px rgb(255, 255, 255), - - -1px -2px 1px rgb(255, 255, 255), - -2px -1px 1px rgb(255, 255, 255), - -1px 2px 1px rgb(255, 255, 255), - -2px 1px 1px rgb(255, 255, 255), - 1px 2px 1px rgb(255, 255, 255), - 2px 1px 1px rgb(255, 255, 255), - 1px -2px 1px rgb(255, 255, 255), - 2px -1px 1px rgb(255, 255, 255); - -} -.mathbox-outline-fill, .mathbox-outline-fill * { - color: #fff !important; -} diff --git a/client/public/css/slides.css b/client/public/css/slides.css deleted file mode 100644 index 4d10df95..00000000 --- a/client/public/css/slides.css +++ /dev/null @@ -1,99 +0,0 @@ - -body { - margin: 0 auto; - -webkit-font-smoothing: antialiased; - font-size: 2vw; - color: #000; - line-height: 1.5em; -} - -pre { - font-size: 1.5vw !important; - line-height: 1.2em; -} - -h1 { - font-size: 3.5vw; -} - -h2 { - font-size: 3vw; -} - -h3 { - font-size: 1.5em -} - -img { - max-width: 100%; -} - -.flexbox { - margin: 5px; - - display: -webkit-box; - display: -moz-box; - display: -ms-flexbox; - display: -webkit-flex; - display: flex; - - flex-flow: row wrap; -} - - -.slide { - width: 80%; - height: 80%; - margin: auto; - display: flex; - justify-content: space-around; - align-items: center; -} - -.title { - display: inline-block; - text-align: center; - margin: auto; -} - -.marwid { - display: inline-block; - margin: auto; -} - -.rowflex { - display: flex; - flex-flow: row wrap; -} - -.colflex { - display: flex; - flex-flow: column wrap; -} - -.block { - display: block; -} - -.padapp { - padding: 0.2vw; -} - -.counter { - margin: 10px; - font-size: initial; -} - -.center { - display: flex; - margin: auto; - justify-content: center; -} - - -.boldEl { - font-weight: bold !important; -} -.italicEl { - font-style: italic !important; -} diff --git a/client/public/css/style.css b/client/public/css/style.css deleted file mode 100644 index 9a9d846e..00000000 --- a/client/public/css/style.css +++ /dev/null @@ -1,22 +0,0 @@ -@import url('https://fonts.googleapis.com/css?family=Roboto|Roboto+Slab'); - -body -{ - font-family: 'Roboto', sans-serif; - color: #404040; -} - -h1, h2, h3, h4, h5, h6 { - font-family: 'Roboto Slab', sans-serif; - color: #1d222d; -} - -a, a:visited, a:active, a:hover { - color: #c4953a; -} - -pre { - background-color: rgb(240, 240, 240); - padding: 10px; - border-radius: 10px; -} diff --git a/client/public/frame.html b/client/public/frame-error.html similarity index 51% rename from client/public/frame.html rename to client/public/frame-error.html index ba347939..6bc3a282 100644 --- a/client/public/frame.html +++ b/client/public/frame-error.html @@ -1,13 +1,11 @@ - Try PureScript! + Try PureScript iFrame - - -
+

Your browser is missing srcdoc support

diff --git a/client/public/img/favicon-black.ico b/client/public/img/favicon-black.ico new file mode 100644 index 00000000..f4cbbbd3 Binary files /dev/null and b/client/public/img/favicon-black.ico differ diff --git a/client/public/img/favicon-black.svg b/client/public/img/favicon-black.svg new file mode 100644 index 00000000..c8f3f7ea --- /dev/null +++ b/client/public/img/favicon-black.svg @@ -0,0 +1,67 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/client/public/img/favicon_clear-16.png b/client/public/img/favicon_clear-16.png deleted file mode 100644 index 04dcd0cc..00000000 Binary files a/client/public/img/favicon_clear-16.png and /dev/null differ diff --git a/client/public/img/favicon_clear-256.png b/client/public/img/favicon_clear-256.png deleted file mode 100644 index 5abc0011..00000000 Binary files a/client/public/img/favicon_clear-256.png and /dev/null differ diff --git a/client/public/img/favicon_clear-32.png b/client/public/img/favicon_clear-32.png deleted file mode 100644 index 7d720bc6..00000000 Binary files a/client/public/img/favicon_clear-32.png and /dev/null differ diff --git a/client/public/index.html b/client/public/index.html index b546efb4..efa1505b 100644 --- a/client/public/index.html +++ b/client/public/index.html @@ -1,198 +1,11 @@ - - Try PureScript! - - - - - - - - - - - - - - - - - - - -
-
- - -
- Your screen size is too small. Code editing has been disabled. -
- -
-
- -
-
- -
- -
-
- -
-
-
-
-
-
-
- - - - + + Try PureScript! + + + + + + diff --git a/client/public/js/frame.js b/client/public/js/frame.js index 4530ec45..1d6ce5f6 100644 --- a/client/public/js/frame.js +++ b/client/public/js/frame.js @@ -1,51 +1,87 @@ -(function() { - function evalSources(sources) { - var modules = {}; - function dirname(str) { - var ix = str.lastIndexOf("/"); - return ix < 0 ? "" : str.slice(0, ix); +/* +This script executes the JS files returned by PS compilation. +*/ + +// Get directory name of path +function dirname(str) { + let ix = str.lastIndexOf("/"); + return ix < 0 ? "" : str.slice(0, ix); +}; + +// Concatenates paths together +function resolvePath(a, b) { + // `b` relative to current directory with `./` + if (b[0] === "." && b[1] === "/") { + return dirname(a) + b.slice(1); + } + // `b` relative to `a` parent directory with `../` + if (b[0] === "." && b[1] === "." && b[2] === "/") { + return dirname(dirname(a)) + b.slice(2); + } + // `b` is either shim or path from root + return b; +}; + +// Executes JS source and all dependencies. +// Maintains cache of previously-executed sources. +function evalSources(sources) { + // Cache all modules + var modules = {}; + // Executes module source, or returns cached exports. + return function load(name) { + // Check if module is already cached + if (modules[name]) { + return modules[name].exports; } - function resolvePath(a, b) { - if (b[0] === "." && b[1] === "/") { - return dirname(a) + b.slice(1); - } - if (b[0] === "." && b[1] === "." && b[2] === "/") { - return dirname(dirname(a)) + b.slice(2); - } - return b; + // Not cached, so execute contents. + // Provide custom `require`, `module`, and `exports`. + // Custom `require` which executes file contents, as well as any dependencies. + function require(path) { + return load(resolvePath(name, path)); } - return function load(name) { - if (modules[name]) { - return modules[name].exports; - } - function require(path) { - return load(resolvePath(name, path)); - } - var module = modules[name] = { exports: {} }; - new Function("module", "exports", "require", sources[name])(module, module.exports, require); - return module.exports; - }; + // Provide empty exports, which will be set, and then returned. + var module = modules[name] = { exports: {} }; + // Create a function from the module's file contents, + // and execute this function with our substitutions. + new Function("module", "exports", "require", sources[name])(module, module.exports, require); + return module.exports; + }; +}; + +function loadFrame(str) { + // Convert JSON string back to object. + // keys: file paths + // values: compressed JS source + obj = JSON.parse(str); + + // Decompress values back to JS source + Object.keys(obj).forEach(function (key) { + obj[key] = LZString.decompressFromEncodedURIComponent(obj[key]); + }); + + // Execute all sources, and save returned `exports` from ``. + // Expecting a `exports.main` entry point. + let file = evalSources(obj)(""); + + // Check if `main` can be launched + if (!file.main) { + console.log('Missing "main"'); + } else if (typeof file.main !== "function") { + console.log('"main" is not a function'); + } else { + // Launch entry point + file.main(); } +}; - var parent; - - document.addEventListener("DOMContentLoaded", function() { - window.addEventListener("message", function(event) { - parent = event.source; - parent.postMessage("trypurescript", "*"); - var file = evalSources(event.data)(""); - if (file.main && typeof file.main === "function") { - file.main(); - } - }, { once: true }); - }, { once: true }); - - document.addEventListener("click", function(event) { - if (parent && event.target.nodeName === "A" && event.target.hostname === "gist.github.com") { - event.preventDefault(); - parent.postMessage({ - gistId: event.target.pathname.split("/").slice(-1)[0] - }, "*"); - } - }, false); -})(); +// Call script tag contents when frame loads. +// Expects a call to loadFrame, passing JS sources. +window.onload = function() { + // https://stackoverflow.com/a/8677590 + //grab the last script tag in the DOM + //this will always be the one that is currently evaluating during load + let tags = document.getElementsByTagName('script'); + let tag = tags[tags.length -1]; + //force evaluation of the contents + eval( tag.innerHTML ); +}; diff --git a/client/spago.dhall b/client/spago.dhall index 8cfd517e..50932485 100644 --- a/client/spago.dhall +++ b/client/spago.dhall @@ -4,53 +4,18 @@ You can edit this file as you like. -} { name = "try-purescript" , dependencies = - [ "arrays" - , "bifunctors" + [ "ace" + , "affjax" + , "argonaut" + , "argonaut-generic" , "console" - , "const" - , "contravariant" - , "control" - , "distributive" , "effect" - , "either" - , "enums" - , "exceptions" - , "exists" - , "foldable-traversable" - , "foreign" - , "foreign-generic" - , "foreign-object" - , "free" - , "functions" - , "functors" - , "generics-rep" - , "globals" - , "identity" - , "integers" - , "jquery" - , "js-timers" - , "lazy" - , "math" - , "maybe" - , "ordered-collections" - , "parallel" - , "prelude" - , "profunctor" - , "proxy" + , "halogen" + , "halogen-css" + , "halogen-hooks" + , "halogen-hooks-extra" , "psci-support" - , "quickcheck" - , "random" - , "refs" - , "semirings" - , "st" - , "strings" - , "tailrec" - , "transformers" - , "tuples" - , "typelevel-prelude" - , "unfoldable" - , "validation" - , "web-html" + , "routing" ] , packages = ./packages.dhall , sources = [ "src/**/*.purs" ] diff --git a/client/src/JQuery/Extras.js b/client/src/JQuery/Extras.js deleted file mode 100644 index 2d42098e..00000000 --- a/client/src/JQuery/Extras.js +++ /dev/null @@ -1,47 +0,0 @@ -"use strict"; - -exports.click = function(jq) { - return function() { - jq.click(); - }; -}; - -exports.empty = function(jq) { - return function() { - jq.empty(); - }; -}; - -exports.fadeIn = function(jq) { - return function() { - jq.fadeIn(); - }; -}; - -exports.fadeOut = function(jq) { - return function() { - jq.fadeOut(); - }; -}; - -exports.filter = function(jq) { - return function(sel) { - return function() { - return jq.filter(sel); - }; - }; -}; - -exports.is = function(jq) { - return function(sel) { - return function() { - return jq.is(sel); - }; - }; -}; - -exports.getValue = function(jq) { - return function() { - return jq.val(); - }; -}; diff --git a/client/src/JQuery/Extras.purs b/client/src/JQuery/Extras.purs deleted file mode 100644 index 4602893f..00000000 --- a/client/src/JQuery/Extras.purs +++ /dev/null @@ -1,40 +0,0 @@ -module JQuery.Extras - ( click - , empty - , fadeIn - , fadeOut - , filter - , is - , getValueMaybe - ) where - -import Prelude - -import Data.Maybe (Maybe) -import Data.Nullable (Nullable, toMaybe) -import Effect (Effect) -import JQuery (JQuery, Selector) - --- | Simulate a click event on the specified element. -foreign import click :: JQuery -> Effect Unit - --- | Remove all elements from the specified container element. -foreign import empty :: JQuery -> Effect Unit - --- | Fade in an element. -foreign import fadeIn :: JQuery -> Effect Unit - --- | Fade out an element. -foreign import fadeOut :: JQuery -> Effect Unit - --- | Filter elements based on an additional selector. -foreign import filter :: JQuery -> Selector -> Effect JQuery - --- | Test whether elements match an additional selector. -foreign import is :: JQuery -> Selector -> Effect Boolean - --- | Get the value of the first element, if it exists. -foreign import getValue :: JQuery -> Effect (Nullable String) - -getValueMaybe :: JQuery -> Effect (Maybe String) -getValueMaybe = map toMaybe <<< getValue diff --git a/client/src/LzString.js b/client/src/LzString.js new file mode 100644 index 00000000..6c6f4272 --- /dev/null +++ b/client/src/LzString.js @@ -0,0 +1,10 @@ +"use strict"; + +exports.compressToEncodedURIComponent = function (input) { + return LZString.compressToEncodedURIComponent(input); +} + +exports.decompressFromEncodedURIComponent = function (input) { + let result = LZString.decompressFromEncodedURIComponent(input); + return result || "Failed to decompress URI"; +} \ No newline at end of file diff --git a/client/src/LzString.purs b/client/src/LzString.purs new file mode 100644 index 00000000..74ba7714 --- /dev/null +++ b/client/src/LzString.purs @@ -0,0 +1,5 @@ +module LzString where + +foreign import compressToEncodedURIComponent :: String -> String + +foreign import decompressFromEncodedURIComponent :: String -> String diff --git a/client/src/Main.js b/client/src/Main.js deleted file mode 100644 index 7c9d6860..00000000 --- a/client/src/Main.js +++ /dev/null @@ -1,8 +0,0 @@ -"use strict"; - -exports.setEditorContent = setEditorContent; -exports.onEditorChanged = onEditorChanged; -exports.cleanUpMarkers = cleanUpMarkers; -exports.addMarker = addMarker; -exports.setAnnotations = setAnnotations; -exports.setupIFrame = setupIFrame; diff --git a/client/src/Main.purs b/client/src/Main.purs index cc5d7b15..8281b046 100644 --- a/client/src/Main.purs +++ b/client/src/Main.purs @@ -1,312 +1,33 @@ module Main where import Prelude - -import Control.Monad.Cont.Trans (ContT(..), runContT) -import Control.Monad.Except.Trans (runExceptT) -import Data.Array (mapMaybe) -import Data.Array as Array -import Data.Either (Either(..)) -import Data.Foldable (elem, fold, for_, intercalate, traverse_) -import Data.FoldableWithIndex (forWithIndex_) -import Data.Maybe (Maybe(..), fromMaybe) import Effect (Effect) -import Effect.Console (error) -import Effect.Uncurried (EffectFn1, EffectFn2, EffectFn5, mkEffectFn1, runEffectFn1, runEffectFn2, runEffectFn5) -import Foreign (renderForeignError) -import Foreign.Object (Object) -import Foreign.Object as Object -import JQuery as JQuery -import JQuery.Extras as JQueryExtras -import Try.API (CompileError(..), CompileResult(..), CompileWarning(..), CompilerError(..), ErrorPosition(..), FailedResult(..), SuccessResult(..)) -import Try.API as API -import Try.Config as Config -import Try.Gist (getGistById, tryLoadFileFromGist, uploadGist) -import Try.Loader (Loader, makeLoader, runLoader) -import Try.QueryString (getQueryStringMaybe, setQueryStrings) -import Try.Session (createSessionIdIfNecessary, storeSession, tryRetrieveSession) -import Try.Types (JS(..)) -import Web.HTML (window) -import Web.HTML.Location (setHref) -import Web.HTML.Window (alert, confirm, location) - -displayLoadingMessage :: Effect Unit -displayLoadingMessage = JQuery.select "#loading" >>= JQueryExtras.fadeIn - -hideLoadingMessage :: Effect Unit -hideLoadingMessage = JQuery.select "#loading" >>= JQueryExtras.fadeOut - --- | Display a list of errors in the right hand column. -displayErrors :: Array CompilerError -> Effect Unit -displayErrors errs = do - column2 <- JQuery.select "#column2" - JQueryExtras.empty column2 - - forWithIndex_ errs \i (CompilerError{ message }) -> do - h1 <- JQuery.create "

" - JQuery.addClass "error-banner" h1 - JQuery.setText ("Error " <> show (i + 1) <> " of " <> show (Array.length errs)) h1 - - pre <- JQuery.create "
"
-    code_ <- JQuery.create ""
-    JQuery.append code_ pre
-    JQuery.setText message code_
-
-    JQuery.append h1 column2
-    JQuery.append pre column2
-
--- | Display plain text in the right hand column.
-displayPlainText
-  :: String
-  -> Effect Unit
-displayPlainText s = do
-  column2 <- JQuery.select "#column2"
-  JQueryExtras.empty column2
-  pre <- JQuery.create "
"
-  code_ <- JQuery.create ""
-  JQuery.append code_ pre
-  JQuery.setText s code_
-  JQuery.append pre column2
-
-isShowJsChecked :: Effect Boolean
-isShowJsChecked = JQuery.select "#showjs" >>= \jq -> JQueryExtras.is jq ":checked"
-
-isAutoCompileChecked :: Effect Boolean
-isAutoCompileChecked = JQuery.select "#auto_compile" >>= \jq -> JQueryExtras.is jq ":checked"
-
--- | Update the view mode based on the menu selection
-changeViewMode :: Maybe String -> Effect Unit
-changeViewMode viewMode =
-  for_ viewMode \viewMode_ ->
-    JQuery.select "#editor_view" >>= JQuery.setAttr "data-view-mode" viewMode_
-
-getTextAreaContent :: Effect String
-getTextAreaContent = fold <$> (JQuery.select "#code_textarea" >>= JQueryExtras.getValueMaybe)
-
-setTextAreaContent :: String -> Effect Unit
-setTextAreaContent value = JQuery.select "#code_textarea" >>= JQuery.setValue value
-
--- | Set the editor content to the specified string.
-foreign import setEditorContent :: EffectFn1 String Unit
-
--- | Register a callback for editor change events.
-foreign import onEditorChanged
-  :: EffectFn2 (EffectFn1 String Unit)
-               Int
-               Unit
-
--- | Clean up any global state associated with any visible error markers.
-foreign import cleanUpMarkers :: Effect Unit
-
--- | Add a visible marker at the specified location.
-foreign import addMarker :: EffectFn5 String Int Int Int Int Unit
-
-type Annotation =
-  { row :: Int
-  , column :: Int
-  , type :: String
-  , text :: String
-  }
-
--- | Set the gutter annotations
-foreign import setAnnotations :: EffectFn1 (Array Annotation) Unit
-
-clearAnnotations :: Effect Unit
-clearAnnotations = runEffectFn1 setAnnotations []
-
--- | Set up a fresh iframe in the specified container, and use it
--- | to execute the provided JavaScript code.
-foreign import setupIFrame
-  :: EffectFn2 JQuery.JQuery
-               (Object JS)
-               Unit
-
-loader :: Loader
-loader = makeLoader Config.loaderUrl
-
--- | Compile the current code and execute it.
-compile :: Effect Unit
-compile = do
-  code <- getTextAreaContent
-
-  displayLoadingMessage
-  clearAnnotations
-
-  runContT (runExceptT (API.compile Config.compileUrl code)) \res_ ->
-    case res_ of
-      Left err -> displayPlainText err
-      Right res -> do
-        cleanUpMarkers
-
-        case res of
-          Right (CompileSuccess (SuccessResult { js, warnings })) -> do
-            showJs <- isShowJsChecked
-            if showJs
-              then do hideLoadingMessage
-                      displayPlainText js
-              else runContT (runExceptT $ runLoader loader (JS js)) \sources -> do
-                     hideLoadingMessage
-                     for_ warnings \warnings_ -> do
-                       let toAnnotation (CompileWarning{ errorCode, position, message }) =
-                             position <#> \(ErrorPosition pos) ->
-                               { row: pos.startLine - 1
-                               , column: pos.startColumn - 1
-                               , type: "warning"
-                               , text: message
-                               }
-                       runEffectFn1 setAnnotations (mapMaybe toAnnotation warnings_)
-                     for_ sources (execute (JS js))
-          Right (CompileFailed (FailedResult { error })) -> do
-            hideLoadingMessage
-            case error of
-              CompilerErrors errs -> do
-                displayErrors errs
-
-                let toAnnotation (CompilerError{ position, message }) =
-                      position <#> \(ErrorPosition pos) ->
-                        { row: pos.startLine - 1
-                        , column: pos.startColumn - 1
-                        , type: "error"
-                        , text: message
-                        }
-                runEffectFn1 setAnnotations (mapMaybe toAnnotation errs)
-
-                for_ errs \(CompilerError{ position }) ->
-                  for_ position \(ErrorPosition pos) ->
-                    runEffectFn5 addMarker
-                      "error"
-                      pos.startLine
-                      pos.startColumn
-                      pos.endLine
-                      pos.endColumn
-              OtherError err -> displayPlainText err
-          Left errs -> do
-            hideLoadingMessage
-            displayPlainText "Unable to parse the response from the server"
-            traverse_ (error <<< renderForeignError) errs
-
--- | Execute the compiled code in a new iframe.
-execute :: JS -> Object JS -> Effect Unit
-execute js modules = do
-  let eventData = Object.insert "" js modules
-  column2 <- JQuery.select "#column2"
-  runEffectFn2 setupIFrame column2 eventData
-
--- | Setup the editor component and some event handlers.
-setupEditor :: forall r. { code :: String | r } -> Effect Unit
-setupEditor { code } = do
-  loadOptions
-
-  setTextAreaContent code
-  runEffectFn1 setEditorContent code
-
-  runEffectFn2 onEditorChanged (mkEffectFn1 \value -> do
-    setTextAreaContent value
-    cacheCurrentCode
-    autoCompile <- isAutoCompileChecked
-    when autoCompile do
-      compile) 750
-
-  JQuery.select "#showjs" >>= JQuery.on "change" \e _ ->
-    compile
-
-  JQuery.select "#compile_label" >>= JQuery.on "click" \e _ ->
-    compile
-
-  JQuery.select "#gist_save" >>= JQuery.on "click" \e _ ->
-    publishNewGist
-
-  compile
-  cacheCurrentCode
-
-loadFromGist
-  :: String
-  -> ({ code :: String } -> Effect Unit)
-  -> Effect Unit
-loadFromGist id_ k = do
-  runContT (runExceptT (getGistById id_ >>= \gi -> tryLoadFileFromGist gi "Main.purs")) $
-    case _ of
-      Left err -> do
-        window >>= alert err
-        k { code: "" }
-      Right code -> k { code }
-
-withSession
-  :: String
-  -> ({ code :: String } -> Effect Unit)
-  -> Effect Unit
-withSession sessionId k = do
-  state <- tryRetrieveSession sessionId
-  case state of
-    Just state' -> k state'
-    Nothing -> do
-      gist <- fromMaybe Config.mainGist <$> getQueryStringMaybe "gist"
-      loadFromGist gist k
-
--- | Cache the current code in the session state
-cacheCurrentCode :: Effect Unit
-cacheCurrentCode  = do
-  sessionId <- getQueryStringMaybe "session"
-  case sessionId of
-    Just sessionId_ -> do
-      code <- getTextAreaContent
-      storeSession sessionId_ { code }
-    Nothing -> error "No session ID"
-
--- | Create a new Gist using the current content
-publishNewGist :: Effect Unit
-publishNewGist = do
-  ok <- window >>= confirm (intercalate "\n"
-          [ "Do you really want to publish this code as an anonymous Gist?"
-          , ""
-          , "Note: this code will be available to anyone with a link to the Gist."
-          ])
-  when ok do
-    content <- getTextAreaContent
-    runContT (runExceptT (uploadGist content)) $
-      case _ of
-        Left err -> do
-          window >>= alert "Failed to create gist"
-          error ("Failed to create gist: " <> err)
-        Right gistId -> do
-          setQueryStrings (Object.singleton "gist" gistId)
-
--- | Navigate to the specified URL.
-navigateTo :: String -> Effect Unit
-navigateTo uri = void (window >>= location >>= setHref uri)
-
--- | Read query string options and update the state accordingly
-loadOptions :: Effect Unit
-loadOptions = do
-  viewMode <- getQueryStringMaybe "view"
-  case viewMode of
-    Just viewMode_
-      | viewMode_ `elem` ["sidebyside", "code", "output"]
-      -> changeViewMode viewMode
-    _ -> pure unit
-
-  showJs <- getQueryStringMaybe "js"
-  case showJs of
-    Just showJs_ ->
-      JQuery.select "input:checkbox[name=showjs]" >>= JQuery.setProp "checked" (showJs_ == "true")
-    _ -> pure unit
-
-  autoCompile <- getQueryStringMaybe "compile"
-  case autoCompile of
-    Just autoCompile_ ->
-      JQuery.select "input:checkbox[name=auto_compile]" >>= JQuery.setProp "checked" (autoCompile_ == "true")
-    _ -> pure unit
-
-  gist <- getQueryStringMaybe "gist"
-  case gist of
-    Just gist_ -> JQuery.select ".view_gist" >>= JQuery.attr { href: "https://gist.github.com/" <> gist_ }
-    Nothing -> JQuery.select ".view_gist_li" >>= JQuery.hide
+import Effect.Aff (launchAff_)
+import Effect.Class (liftEffect)
+import Effect.Class.Console (log)
+import Foreign (Foreign, unsafeToForeign)
+import Halogen as H
+import Halogen.Aff as HA
+import Halogen.VDom.Driver (runUI)
+import Routing.PushState (makeInterface, matches)
+import Try.Component (component, Query(..))
+import Try.Routing (route)
+import Web.HTML (HTMLElement)
 
 main :: Effect Unit
-main = JQuery.ready do
-  JQuery.select "input[name=view_mode]" >>= JQuery.on "change" \_ jq -> do
-    viewMode <- JQueryExtras.filter jq ":checked" >>= JQueryExtras.getValueMaybe
-    changeViewMode viewMode
-
-  runContT (do sessionId <- ContT createSessionIdIfNecessary
-               ContT (withSession sessionId)) setupEditor
+main =
+  HA.runHalogenAff do
+    (body :: HTMLElement) <- HA.awaitBody
+    (replaceState :: Foreign -> String -> Effect Unit) <-
+      liftEffect
+        $ do
+            nav <- makeInterface
+            pure nav.replaceState
+    halogenIO <- runUI component (replaceState $ unsafeToForeign {}) body
+    void
+      $ liftEffect do
+          nav <- makeInterface
+          nav
+            # matches route \oldRoute newRoute -> do
+                log $ show oldRoute <> " -> " <> show newRoute
+                launchAff_ $ halogenIO.query $ H.tell $ Nav newRoute
diff --git a/client/src/MyAce.js b/client/src/MyAce.js
new file mode 100644
index 00000000..28190265
--- /dev/null
+++ b/client/src/MyAce.js
@@ -0,0 +1,15 @@
+"use strict";
+
+// Upstream version missing inFront, which is
+// interpreted as `false` when omitted.
+exports.getMarkersImpl = function (inFront, session) {
+  return function () {
+    var markerObj = session.getMarkers(inFront);
+    var ks = Object.getOwnPropertyNames(markerObj);
+    var result = [];
+    for (var i = 0; i < ks.length; i++) {
+      result[i] = markerObj[ks[i]];
+    }
+    return result;
+  };
+};
\ No newline at end of file
diff --git a/client/src/MyAce.purs b/client/src/MyAce.purs
new file mode 100644
index 00000000..fb596a27
--- /dev/null
+++ b/client/src/MyAce.purs
@@ -0,0 +1,16 @@
+module MyAce where
+
+import Ace (EditSession, Marker)
+import Data.Function.Uncurried (Fn2, runFn2)
+import Effect (Effect)
+
+{-
+Fixes some issues in package.
+Todo - make PRs for these.
+-}
+-- Returns array of marker IDs
+-- Boolean to indicate front or back
+foreign import getMarkersImpl :: Fn2 Boolean EditSession (Effect (Array Marker))
+
+getMarkers :: Boolean -> EditSession -> Effect (Array Marker)
+getMarkers inFront session = runFn2 getMarkersImpl inFront session
diff --git a/client/src/Try/API.js b/client/src/Try/API.js
deleted file mode 100644
index a00c83f8..00000000
--- a/client/src/Try/API.js
+++ /dev/null
@@ -1,23 +0,0 @@
-"use strict";
-
-exports.get_ = function(uri, done, fail) {
-  $.get(uri).done(done).fail(function(err) {
-    fail(err.statusText);
-  });
-};
-
-exports.compile_ = function(endpoint, code, done, fail) {
-  $.ajax({
-    url: endpoint + '/compile',
-    dataType: 'json',
-    data: code,
-    method: 'POST',
-    contentType: 'text/plain',
-    success: function(res) {
-      done(res);
-    },
-    error: function(res) {
-      fail(res.responseText)
-    }
-  });
-}
diff --git a/client/src/Try/API.purs b/client/src/Try/API.purs
index ccb920eb..87bc33df 100644
--- a/client/src/Try/API.purs
+++ b/client/src/Try/API.purs
@@ -1,153 +1,182 @@
-module Try.API
-  ( ErrorPosition(..)
-  , CompilerError(..)
-  , CompileError(..)
-  , CompileWarning(..)
-  , Suggestion(..)
-  , SuccessResult(..)
-  , FailedResult(..)
-  , CompileResult(..)
-  , get
-  , compile
-  ) where
+module Try.API where
 
 import Prelude
-
-import Control.Alt ((<|>))
-import Control.Monad.Cont.Trans (ContT(ContT))
-import Control.Monad.Except (runExcept)
-import Control.Monad.Except.Trans (ExceptT(ExceptT))
+import Ace (Range)
+import Ace.Range as Range
+import Affjax as AX
+import Affjax.RequestBody as AXRB
+import Affjax.ResponseFormat as AXRF
+import Control.Alternative ((<|>))
+import Data.Argonaut (class DecodeJson, decodeJson)
+import Data.Argonaut.Core as J
+import Data.Argonaut.Decode.Generic.Rep (genericDecodeJsonWith)
+import Data.Argonaut.Types.Generic.Rep (defaultEncoding)
 import Data.Either (Either(..))
 import Data.Generic.Rep (class Generic)
-import Data.List.NonEmpty (NonEmptyList)
-import Data.Maybe (Maybe)
+import Data.Maybe (Maybe(..))
 import Effect (Effect)
-import Effect.Uncurried (EffectFn1, EffectFn3, EffectFn4, mkEffectFn1, runEffectFn3, runEffectFn4)
-import Foreign (Foreign, ForeignError)
-import Foreign.Class (class Decode, decode)
-import Foreign.Generic (defaultOptions, genericDecode)
-import Foreign.Generic.Class (Options, SumEncoding(..))
-
-decodingOptions :: Options
-decodingOptions = defaultOptions { unwrapSingleConstructors = true }
-
--- | The range of text associated with an error
-newtype ErrorPosition = ErrorPosition
-  { startLine :: Int
-  , endLine :: Int
-  , startColumn :: Int
-  , endColumn :: Int
-  }
-
-derive instance genericErrorPosition :: Generic ErrorPosition _
-
-instance decodeErrorPosition :: Decode ErrorPosition where
-  decode = genericDecode decodingOptions
-
-newtype CompilerError = CompilerError
-  { message :: String
-  , position :: Maybe ErrorPosition
-  }
-
-derive instance genericCompilerError :: Generic CompilerError _
-
-instance decodeCompilerError :: Decode CompilerError where
-  decode = genericDecode decodingOptions
-
--- | An error reported from the compile API.
-data CompileError
-  = CompilerErrors (Array CompilerError)
-  | OtherError String
-
-derive instance genericCompileError :: Generic CompileError _
-
-instance decodeCompileError :: Decode CompileError where
-  decode = genericDecode
-    (defaultOptions
-      { sumEncoding =
-          TaggedObject
-            { tagFieldName: "tag"
-            , contentsFieldName: "contents"
-            , constructorTagTransform: identity
-            }
-      })
-
-newtype Suggestion = Suggestion
-  { replacement :: String
-  , replaceRange :: Maybe ErrorPosition
-  }
+import Effect.Aff (Aff)
+import Try.Common (Content(..))
+import Try.Config (compileUrl)
 
-derive instance genericSuggestion :: Generic Suggestion _
-
-instance decodeSuggestion :: Decode Suggestion where
-  decode = genericDecode decodingOptions
-
-newtype CompileWarning = CompileWarning
-  { errorCode :: String
-  , message :: String
-  , position :: Maybe ErrorPosition
-  , suggestion :: Maybe Suggestion
-  }
-
-derive instance genericCompileWarning :: Generic CompileWarning _
-
-instance decodeCompileWarning :: Decode CompileWarning where
-  decode = genericDecode decodingOptions
-
-newtype SuccessResult = SuccessResult
-  { js :: String
-  , warnings :: Maybe (Array CompileWarning)
-  }
-
-derive instance genericSuccessResult :: Generic SuccessResult _
-
-instance decodeSuccessResult :: Decode SuccessResult where
-  decode = genericDecode decodingOptions
-
-newtype FailedResult = FailedResult
-  { error :: CompileError }
-
-derive instance genericFailedResult :: Generic FailedResult _
-
-instance decodeFailedResult :: Decode FailedResult where
-  decode = genericDecode decodingOptions
-
--- | The result of calling the compile API.
+------- Compile API types -------
+--
+-- The result of calling the compile API.
 data CompileResult
   = CompileSuccess SuccessResult
   | CompileFailed FailedResult
 
--- | Parse the result from the compile API and verify it
-instance decodeCompileResult :: Decode CompileResult where
-  decode f =
-    CompileSuccess <$> genericDecode decodingOptions f
-    <|> CompileFailed <$> genericDecode decodingOptions f
+-- A successful compilation result
+type SuccessResult
+  = { js :: String
+    , warnings :: Maybe (Array CompileWarning)
+    }
+
+-- A warning about the code found during compilation
+type CompileWarning
+  = { errorCode :: String
+    , message :: String
+    , position :: Maybe ErrorPosition
+    , suggestion :: Maybe Suggestion
+    }
+
+-- The range of text associated with an error or warning
+type ErrorPosition
+  = { startLine :: Int
+    , endLine :: Int
+    , startColumn :: Int
+    , endColumn :: Int
+    }
+
+-- A code suggestion
+type Suggestion
+  = { replacement :: String
+    , replaceRange :: Maybe ErrorPosition
+    }
+
+-- A failed compilation result
+type FailedResult
+  = { error :: CompileError }
+
+-- An error reported from the compile API
+data CompileError
+  = CompilerErrors (Array CompilerError)
+  -- Examples of `OtherError` include:
+  -- * Code is not "module Main"
+  -- * The code snippet is too large
+  | OtherError String
 
-foreign import get_
-  :: EffectFn3
-            String
-            (EffectFn1 String Unit)
-            (EffectFn1 String Unit)
-            Unit
+-- An error found with the code during compilation
+type CompilerError
+  = { message :: String
+    , position :: Maybe ErrorPosition
+    }
+
+------- Json Decoding -------
+--
+-- The Compile API returns an object representing the contents of either:
+-- * CompileSuccess
+-- * CompileFailed
+-- Decoding to CompileResult requires attempting to match each of these.
+instance decodeJsonCompileResult :: DecodeJson CompileResult where
+  decodeJson j =
+    CompileSuccess <$> decodeJson j
+      <|> CompileFailed
+      <$> decodeJson j
+
+derive instance genericCompileResult :: Generic CompileResult _
+
+-- The Compile API encodes the CompileError tagged union differently than
+-- argonaut's generic options, so we need to adjust the default encoding
+-- options to successfully decode.
+instance decodeJsonCompileError :: DecodeJson CompileError where
+  decodeJson =
+    genericDecodeJsonWith
+      $ defaultEncoding
+          { valuesKey = "contents"
+          , unwrapSingleArguments = true
+          }
 
--- | A wrapper for `get` which uses `ContT`.
-get :: String -> ExceptT String (ContT Unit Effect) String
-get uri = ExceptT (ContT \k -> runEffectFn3 get_ uri (mkEffectFn1 (k <<< Right)) (mkEffectFn1 (k <<< Left)))
+derive instance genericCompileError :: Generic CompileError _
 
 -- | POST the specified code to the Try PureScript API, and wait for
 -- | a response.
-foreign import compile_
-  :: EffectFn4
-            String
-            String
-            (EffectFn1 Foreign Unit)
-            (EffectFn1 String Unit)
-            Unit
-
--- | A wrapper for `compileApi` which uses `ContT`.
-compile
-  :: String
-  -> String
-  -> ExceptT String (ContT Unit Effect)
-       (Either (NonEmptyList ForeignError) CompileResult)
-compile endpoint code = ExceptT (ContT \k -> runEffectFn4 compile_ endpoint code (mkEffectFn1 (k <<< Right <<< runExcept <<< decode)) (mkEffectFn1 (k <<< Left)))
+compile :: Content -> Aff (Either String CompileResult)
+compile (Content ct) = do
+  result <- AX.post AXRF.json (compileUrl <> "/compile") $ Just $ AXRB.string ct
+  pure
+    $ case result of
+        Left err -> Left $ "POST compile response failed to decode: " <> AX.printError err
+        Right response -> do
+          let
+            respStr = "POST /api response: " <> J.stringify response.body
+          case decodeJson response.body of
+            Left err -> Left $ "Failed to decode json response: " <> respStr <> ", Error: " <> show err
+            Right (decoded :: CompileResult) -> Right decoded
+
+------  generate errors for editor --------------
+-- Todo - move this to another file
+type Annotation
+  = { row :: Int
+    , column :: Int
+    , type :: String
+    , text :: String
+    }
+
+-- | Set the gutter annotations
+--foreign import setAnnotations :: EffectFn1 (Array Annotation) Unit
+data AnnotationType
+  = AnnotateWarning
+  | AnnotateError
+
+instance showAnnotationType :: Show AnnotationType where
+  show AnnotateWarning = "warning"
+  show AnnotateError = "error"
+
+-- Common fields of CompileWarning and CompilerError
+-- Todo - should both of these have `er` ending?
+type WarningOrError r
+  = { message :: String
+    , position :: Maybe ErrorPosition
+    | r
+    }
+
+-- Creates an annotation from a warning or error,
+-- but only if there's a position.
+toAnnotation :: forall r. AnnotationType -> WarningOrError r -> Maybe Annotation
+toAnnotation _ { position: Nothing } = Nothing
+
+toAnnotation annType { position: Just pos, message } =
+  Just
+    { row: pos.startLine - 1
+    , column: pos.startColumn - 1
+    , type: show annType
+    , text: message
+    }
+
+-- Make sure position's range is at least one character wide.
+nonZeroRange :: ErrorPosition -> ErrorPosition
+nonZeroRange p =
+  if p.startLine == p.endLine && p.endColumn <= p.startColumn then
+    if p.startColumn > 0 then
+      p { startColumn = p.endColumn - 1 }
+    else
+      p { endColumn = p.startColumn + 1 }
+  else
+    p
+
+-- Creates a Range for making Markers from a warning or error,
+-- but only if there's a position.
+mkMarkerRange :: forall r. WarningOrError r -> Effect (Maybe Range)
+mkMarkerRange { position: Nothing } = pure Nothing
+
+mkMarkerRange { position: Just p0 } = do
+  let
+    p = nonZeroRange p0
+  rg <-
+    Range.create
+      (p.startLine - 1)
+      (p.startColumn - 1)
+      (p.endLine - 1)
+      (p.endColumn - 1)
+  pure $ Just rg
diff --git a/client/src/Try/Classes.purs b/client/src/Try/Classes.purs
new file mode 100644
index 00000000..55106f8c
--- /dev/null
+++ b/client/src/Try/Classes.purs
@@ -0,0 +1,58 @@
+module Try.Classes where
+
+import Prelude
+import Halogen (ClassName)
+import Tailwind as T
+
+{-
+Groups of Tailwind CSS classes
+-}
+--
+data Responsiveness
+  = NonMobile
+  | MobileOnly
+  | RenderAlways
+
+nonMobileBlockClasses :: Array ClassName
+nonMobileBlockClasses = [ T.hidden, T.smBlock ]
+
+nonMobileBlock :: Responsiveness -> Array ClassName
+nonMobileBlock NonMobile = nonMobileBlockClasses
+
+nonMobileBlock _ = []
+
+commonBgClasses :: Array ClassName
+commonBgClasses =
+  [ T.bgTpsBlack
+  , T.hoverBgBlack
+  ]
+
+commonMenuClasses :: Array ClassName
+commonMenuClasses =
+  [ T.px3
+  , T.block
+  ]
+    <> commonBgClasses
+
+commonTextClasses :: Array ClassName
+commonTextClasses =
+  [ T.textWhite
+  , T.leading10
+  ]
+
+menuTextClasses :: Array ClassName
+menuTextClasses =
+  [ T.borderL
+  , T.borderSolid
+  , T.borderGray700
+  ]
+    <> commonMenuClasses
+    <> commonTextClasses
+
+dropdownItemClasses :: Array ClassName
+dropdownItemClasses =
+  [ T.block
+  , T.wFull
+  ]
+    <> commonBgClasses
+    <> commonTextClasses
diff --git a/client/src/Try/Common.purs b/client/src/Try/Common.purs
new file mode 100644
index 00000000..fc7b8fbe
--- /dev/null
+++ b/client/src/Try/Common.purs
@@ -0,0 +1,51 @@
+module Try.Common where
+
+import Prelude
+
+{-
+Common values and newtype wrappers
+-}
+--
+-- Page to launch on startup and when clicking home button
+homeRoute :: String
+homeRoute = "/?gist=93b9116a309c97af2af5d3f06f6a4479"
+
+-- Query param for compressed code.
+pursQP :: String
+pursQP = "purs"
+
+-- Query param for gist.
+gistQP :: String
+gistQP = "gist"
+
+newtype AuthCode
+  = AuthCode String
+
+instance showAuthCode :: Show AuthCode where
+  show (AuthCode c) = c
+
+newtype Compressed
+  = Compressed String
+
+instance showCompressed :: Show Compressed where
+  show (Compressed c) = c
+
+newtype Content
+  = Content String
+
+instance showContent :: Show Content where
+  show (Content c) = c
+
+derive instance eqContent :: Eq Content
+
+newtype GistID
+  = GistID String
+
+instance showGistID :: Show GistID where
+  show (GistID g) = g
+
+newtype GhToken
+  = GhToken String
+
+instance showToken :: Show GhToken where
+  show (GhToken t) = t
diff --git a/client/src/Try/Component.purs b/client/src/Try/Component.purs
new file mode 100644
index 00000000..7302a2be
--- /dev/null
+++ b/client/src/Try/Component.purs
@@ -0,0 +1,511 @@
+module Try.Component where
+
+import Prelude
+import Ace (Document, EditSession, Range, ace, edit)
+import Ace.Document as Document
+import Ace.EditSession as Session
+import Ace.Editor as Editor
+import Ace.Marker as Marker
+import Data.Argonaut (encodeJson, stringify)
+import Data.Array (catMaybes, concat, mapMaybe, mapWithIndex)
+import Data.Array as Array
+import Data.Either (Either(..))
+import Data.Foldable (traverse_)
+import Data.Maybe (Maybe(..), fromMaybe)
+import Data.Newtype (unwrap)
+import Data.Time.Duration (Milliseconds(..))
+import Data.Traversable (traverse)
+import Data.Tuple.Nested ((/\))
+import Effect.Aff.Class (class MonadAff, liftAff)
+import Effect.Class.Console (log)
+import Halogen (liftEffect)
+import Halogen as H
+import Halogen.HTML as HH
+import Halogen.HTML.Events as HE
+import Halogen.HTML.Properties as HP
+import Halogen.Hooks (useState)
+import Halogen.Hooks as HK
+import Halogen.Hooks.Extra.Hooks (useDebouncer, useModifyState_, usePutState)
+import Halogen.Query.EventSource as ES
+import LzString (compressToEncodedURIComponent)
+import MyAce as MyAce
+import Tailwind as T
+import Try.API (AnnotationType(..), CompileError(..), CompileResult(..), CompilerError, WarningOrError, compile, mkMarkerRange, toAnnotation)
+import Try.Classes (Responsiveness(..), commonMenuClasses, dropdownItemClasses, menuTextClasses, nonMobileBlock, nonMobileBlockClasses)
+import Try.Common (Content(..), GhToken, GistID(..), gistQP, homeRoute, pursQP)
+import Try.Gist (ghAuthorize, ghCreateGist, ghGetGist, ghRequestToken)
+import Try.Loader (Loader, makeLoader, runLoader)
+import Try.Routing (Route(..))
+import Try.Types (JS(..))
+import Try.Utility (ContentSource(..), PushRoute, ViewMode(..), compress, decompress)
+import Web.HTML (window)
+import Web.HTML.Location (setHref)
+import Web.HTML.Window (location)
+
+{-
+The main component for this app
+-}
+--
+type Input
+  = PushRoute
+
+-- Query must be (Type -> Type)
+data Query a
+  = Nav Route a
+
+-- This is required for good caching performance
+loader :: Loader
+loader = makeLoader
+
+-- What can be shown in the output pane
+data Output
+  = Text String -- startup or simple error
+  | Loading -- loading gif
+  | App String JS -- json string and main JS
+  | Errors (Array CompilerError) -- compiler errors
+
+component :: forall o m. MonadAff m => H.Component HH.HTML Query Input o m
+component =
+  -- No tokens for child slots or output
+  HK.component \({ queryToken } :: HK.ComponentTokens Query _ o) input -> HK.do
+    -- Annotations for initial values not required, but helps with clarity
+    viewMode /\ putViewMode <- usePutState (SideBySide :: ViewMode)
+    autoCompile /\ autoCompileIdx <- useState true
+    showJS /\ modifyShowJS <- useModifyState_ false
+    dropdownOpen /\ putDropdownOpen <- usePutState false
+    content /\ contentIdx <- useState $ Content ""
+    document /\ putDocument <- usePutState (Nothing :: Maybe Document)
+    session /\ sessionIdx <- useState (Nothing :: Maybe EditSession)
+    route /\ putRoute <- usePutState (Nothing :: Maybe Route)
+    ghToken /\ putGhToken <- usePutState (Nothing :: Maybe GhToken)
+    pushRoute /\ putPushRoute <- usePutState (input :: PushRoute)
+    contentSource /\ putContentSource <- usePutState (NewContent :: ContentSource)
+    output /\ putOutput <- usePutState $ (Text "" :: Output)
+    --
+    -- Helper functions to reduce code duplication
+    let
+      -- GhToken is a parameter rather than picked-up from state
+      -- to ensure it is not Nothing
+      doSaveGist gh_token = do
+        -- Cannot just use `content` - will be stale
+        currentContent <- HK.get contentIdx
+        log $ "saving gist, content: " <> show currentContent
+        eitherId <- liftAff $ ghCreateGist gh_token $ currentContent
+        case eitherId of
+          Left err -> log err
+          Right id -> do
+            putContentSource $ HaveGist id
+            liftEffect $ pushRoute $ "/?" <> gistQP <> "=" <> (show id)
+
+      -- Create editor annotations and markers for errors or warnings
+      annotateAndMark :: forall r. AnnotationType -> Array (WarningOrError r) -> _
+      annotateAndMark type_ arr = do
+        ses <- HK.get sessionIdx
+        case ses of
+          Nothing -> do
+            log "Error: Session unset"
+          Just s ->
+            liftEffect do
+              -- annotations
+              let
+                annotations = mapMaybe (toAnnotation type_) arr
+              Session.setAnnotations annotations s
+              -- markers
+              maybeRanges <- traverse mkMarkerRange arr
+              let
+                (ranges :: Array Range) = catMaybes maybeRanges
+              -- The shown type of "error" or "warning" should match the css class
+              -- found in ace.css.
+              -- `true` to show in "front"
+              traverse_ (\r -> Session.addMarker r (show type_) "text" true s) ranges
+
+      -- Clear annotations and marks from editor
+      removeAnnotationsAndMarks = do
+        ses <- HK.get sessionIdx
+        case ses of
+          Nothing -> log "Error: Session unset"
+          Just s ->
+            liftEffect do
+              -- Remove annotations
+              Session.clearAnnotations s
+              -- A `removeMarkers` function would be nice...
+              -- Get all inFront(=true) markers
+              markers <- MyAce.getMarkers true s
+              (markerIds :: Array Int) <- traverse Marker.getId markers
+              -- Remove markers
+              traverse_ (\m -> Session.removeMarker m s) markerIds
+
+      doCompile = do
+        -- Needed now that this is called from debouncer.
+        -- Stale otherwise.
+        currentContent <- HK.get contentIdx
+        -- Clear annotations and marks from editor
+        removeAnnotationsAndMarks
+        putOutput Loading
+        (res :: Either String CompileResult) <- liftAff $ compile currentContent
+        case res of
+          Right (CompileSuccess { js, warnings }) -> do
+            -- warnings is maybe array. Why not just let this be an array?
+            -- Create annotations and markers for warnings
+            annotateAndMark AnnotateWarning $ fromMaybe [] warnings
+            -- Get JS source files for all included modules
+            obj <- liftAff $ runLoader loader $ JS js
+            -- Compress source file contents.
+            -- This avoids non-trivial HTML + JSON string escaping
+            let
+              objcomp = map (unwrap >>> compressToEncodedURIComponent) obj
+            -- Convert object to JSON string
+            let
+              jsonStr = stringify $ encodeJson objcomp
+            -- Pass JS files to execute in iframe
+            putOutput $ App jsonStr $ JS js
+          Right (CompileFailed { error }) -> do
+            case error of
+              CompilerErrors (errs :: Array CompilerError) -> do
+                putOutput $ Errors errs
+                -- Create annotations and markers for errors
+                annotateAndMark AnnotateError errs
+              OtherError err -> do
+                putOutput $ Text err
+          Left err -> do
+            let
+              str = "bad result. Likely communication issue with compiler: " <> err
+            putOutput $ Text str
+    --
+    -- Debouncer for auto-compile
+    debouncedRecompile <-
+      useDebouncer (Milliseconds 1000.0) \_ -> do
+        -- Recompile if setting is enabled
+        autoRecomp <- HK.get autoCompileIdx
+        if autoRecomp then do
+          doCompile
+        else
+          pure unit -- don't recompile
+    --
+    -- Must put writeContent after debouncedRecompile
+    let
+      -- update content in editor and state
+      writeContent (Content ct) = do
+        --log $ "writing content: " <> ct
+        HK.put contentIdx $ Content ct
+        liftEffect $ traverse_ (Document.setValue ct) document
+        -- Queue an automatic recompile (ignored if setting disabled)
+        debouncedRecompile unit
+    --
+    -- Initialize Ace editor and subscribe to text changes
+    HK.useLifecycleEffect do
+      doc /\ ses <-
+        liftEffect do
+          -- Create an editor
+          editor <- edit "editor" ace
+          -- disable vertical line on right side of editor that
+          -- estimates where words will be wrapped on paper.
+          Editor.setShowPrintMargin false editor
+          session_ <- Editor.getSession editor
+          -- Haskell syntax highlighting.
+          -- We could build more specialized highlighting for purs:
+          -- https://ace.c9.io/#higlighter=&nav=higlighter
+          Session.setMode "ace/mode/haskell" session_
+          Session.setTabSize 2 session_
+          document_ <- Session.getDocument session_
+          pure $ document_ /\ session_
+      --
+      -- Handle changes within editor.
+      -- Ignoring returned subscription ID.
+      _ <-
+        HK.subscribe do
+          ES.effectEventSource \emitter -> do
+            -- Ignoring DocumentEvent
+            Document.onChange doc \_ -> do
+              str <- Document.getValue doc
+              let
+                newContent = Content str
+              ES.emit emitter do
+                -- Compare content to prevent clearing gist status immediately upon gist load
+                oldContent <- HK.get contentIdx
+                if (newContent /= oldContent) then do
+                  -- New content clears existing contentSource
+                  putContentSource NewContent
+                  writeContent newContent
+                  liftEffect $ pushRoute $ "/?" <> pursQP <> "=" <> (show $ compress newContent)
+                else do
+                  -- Do nothing if content unchanged
+                  pure unit
+            -- No finalizer, so return mempty
+            pure mempty
+      putDocument $ Just doc
+      HK.put sessionIdx $ Just ses
+      pure Nothing
+    --
+    -- Handle routing queries
+    HK.useQuery queryToken \(Nav rt a) -> do
+      -- multiple state modifications, but not a performance issue now.
+      putRoute $ Just rt
+      case rt of
+        AuthorizeCallback authCode compressed -> do
+          log "in auth callback"
+          -- Immediately show new content.
+          -- This also requires setting saving flag again, since state
+          -- is reset upon page refresh from callback.
+          writeContent $ decompress compressed
+          putContentSource SavingGist
+          -- Make ghToken request to private app server
+          res <- liftAff $ ghRequestToken authCode
+          case res of
+            Left err -> log err
+            Right gh_token -> do
+              -- Save ghToken
+              putGhToken $ Just gh_token
+              -- Save gist
+              doSaveGist gh_token
+        LoadCompressed compressed -> do
+          let
+            ct = decompress compressed
+          --log $ "Got content from url: " <> show ct
+          writeContent ct
+        LoadGist gist_id -> do
+          eitherContent <- liftAff $ ghGetGist gist_id
+          case eitherContent of
+            Left err -> putOutput $ Text $ "Failed to load gist at: https://gist.github.com/" <> show gist_id <> "\n" <> err
+            Right c -> do
+              --log $ "Got content from gist: " <> show c
+              writeContent c
+              putContentSource $ HaveGist gist_id
+        --
+        -- Note that pushRoute just sets the URL, but won't reload the page:
+        -- liftEffect $ pushRoute homeRoute
+        Home -> liftEffect $ window >>= location >>= setHref homeRoute
+      -- Required response boilerplate for query
+      pure (Just a)
+    --
+    -- Helper functions for rendering
+    let
+      menu =
+        HH.div
+          [ HP.classes
+              [ T.flex
+              , T.bgTpsBlack
+              ]
+          ]
+          [ HH.a
+              [ HP.href homeRoute
+              , HP.title "Try PureScript!"
+              , HP.classes commonMenuClasses
+              ]
+              -- Could also define image width/height in css
+              [ HH.img
+                  [ HP.src $ "img/favicon-white.svg"
+                  , HP.width 40
+                  , HP.width 40
+                  ]
+              ]
+          , viewModeDropdown
+          , mkClickButton
+              "Compile"
+              "Compile Now"
+              NonMobile
+              doCompile
+          , mkToggleButton
+              "Auto-Compile"
+              "Compile on code changes"
+              autoCompile
+              NonMobile
+              ( do
+                  -- toggle setting
+                  HK.modify_ autoCompileIdx not
+                  -- Queue a recompile in the case of enabling.
+                  -- Will be ignored if setting is disabled.
+                  debouncedRecompile unit
+              )
+          , mkToggleButton
+              "Show JS"
+              "Show resulting JavaScript code instead of output"
+              showJS
+              RenderAlways
+              $ modifyShowJS not
+          , gistButtonOrLink
+          , newTabLink
+              "Help"
+              "Learn more about Try PureScript"
+              "https://github.com/purescript/trypurescript/blob/master/README.md"
+          ]
+
+      mkClickButton text title res action =
+        HH.button
+          [ HP.classes $ menuTextClasses <> nonMobileBlock res
+          , HP.title title
+          , HE.onClick \_ -> Just action
+          ]
+          [ HH.text text ]
+
+      mkToggleButton text title enabled res action =
+        HH.button
+          [ HP.classes $ menuTextClasses <> highlight <> nonMobileBlock res
+          , HP.title title
+          , HE.onClick \_ -> Just action
+          ]
+          [ HH.text text ]
+        where
+        highlight = if enabled then [ T.textTpsEnabled ] else [ T.textTpsDisabled, T.lineThrough ]
+
+      viewModeDropdown =
+        let
+          dropdownItems =
+            if dropdownOpen then
+              HH.div
+                [ HP.classes
+                    [ T.absolute
+                    , T.z10
+                    , T.wFull
+                    ]
+                ]
+                $ map
+                    mkDropdownItem
+                    [ SideBySide, Code, Output ]
+            else
+              HH.div_ []
+        in
+          HH.div
+            [ HP.classes [ T.relative ]
+            , HE.onMouseEnter \_ -> Just $ putDropdownOpen true
+            , HE.onMouseLeave \_ -> Just $ putDropdownOpen false
+            ]
+            [ HH.div
+                [ HP.classes $ menuTextClasses <> nonMobileBlockClasses
+                , HP.title "Select a view mode"
+                ]
+                [ HH.text "View Mode ▾" ]
+            , dropdownItems
+            ]
+
+      mkDropdownItem vm =
+        HH.button
+          [ HP.classes $ dropdownItemClasses <> highlight
+          , HE.onClick \_ -> Just $ putViewMode vm
+          ]
+          [ HH.text $ show vm ]
+        where
+        highlight = if vm == viewMode then [ T.textTpsEnabled ] else []
+
+      newTabLink text title href =
+        HH.a
+          [ HP.href href
+          , HP.target "_blank" -- Open in new tab
+          , HP.classes menuTextClasses
+          , HP.title title
+          ]
+          [ HH.text text ]
+
+      gistButtonOrLink = case contentSource of
+        NewContent ->
+          mkClickButton
+            "Save Gist"
+            "Save code to GitHub Gist (requires OAuth login)"
+            NonMobile
+            ( do
+                -- Immediately show "saving" status
+                putContentSource SavingGist
+                case ghToken of
+                  Nothing -> do
+                    log "need token - authorizing"
+                    liftEffect $ ghAuthorize content
+                  Just gh_token -> do
+                    log "have token"
+                    doSaveGist gh_token
+            )
+        SavingGist ->
+          HH.div
+            [ HP.classes menuTextClasses ]
+            [ HH.text "Saving..." ]
+        HaveGist (GistID id) ->
+          newTabLink
+            "View Gist"
+            "Open the original gist in a new window"
+            $ "https://gist.github.com/"
+            <> id
+
+      renderIframe jsonStr =
+        HH.iframe
+          [ sandbox "allow-scripts allow-top-navigation"
+          , HP.src "frame-error.html"
+          , srcDoc
+              $ ""
+              <> ""
+          , HP.classes [ T.flexGrow ]
+          ]
+
+      renderOutputInner = case output of
+        Text str -> [ HH.text str ]
+        Loading ->
+          [ HH.img
+              [ HP.src "img/loading.gif"
+              , HP.classes [ T.objectContain ]
+              ]
+          ]
+        App str (JS js) ->
+          if showJS then
+            [ HH.pre [ HP.classes [ T.textXs ] ] [ HH.code_ [ HH.text js ] ] ]
+          else
+            [ renderIframe str ]
+        Errors errs -> concat $ mapWithIndex mkError errs
+          where
+          len = show $ Array.length errs
+
+          mkError i { message } =
+            [ HH.code
+                [ HP.classes [ T.fontBold, T.text2xl, T.py2, T.px5 ] ]
+                [ HH.text $ "Error " <> show (i + 1) <> " of " <> len ]
+            , HH.pre_ [ HH.code_ [ HH.text message ] ]
+            ]
+
+      renderOutput =
+        HH.div
+          [ HP.classes $ [ T.flex, T.flexCol, T.flexGrow, T.w0 ] <> hidden ]
+          renderOutputInner
+        where
+        hidden = case viewMode of
+          Code -> [ T.hidden ]
+          _ -> [] -- don't hide for output or side-by-side
+
+      renderEditor =
+        HH.div
+          [ HP.classes $ [ T.hidden ] <> display
+          ]
+          [ HH.div
+              [ HP.classes [ T.flexGrow ]
+              , HP.id_ "editor"
+              ]
+              []
+          ]
+        where
+        display = case viewMode of
+          -- don't show in output-only view mode
+          Output -> []
+          -- display for code or side-by-side
+          _ -> [ T.smFlex, T.flexGrow, T.borderR4, T.borderGray100 ]
+
+      banner =
+        HH.div
+          [ HP.classes [ T.block, T.smHidden, T.bgTpsMobileBanner, T.textTpsBlack, T.textSm, T.borderB, T.borderTpsBlack, T.p1, T.mb2 ] ]
+          [ HH.text "Your screen size is too small. Code editing has been disabled." ]
+    --
+    -- Render
+    HK.pure do
+      HH.div [ HP.classes [ T.flex, T.flexCol, T.hScreen ] ]
+        [ menu
+        , banner
+        , HH.div
+            [ HP.classes [ T.flex, T.flexRow, T.flexGrow, T.spaceX1 ] ]
+            [ renderEditor, renderOutput ]
+        ]
+
+{-
+Todo create Halogen PRs for these missing props
+-}
+sandbox :: forall r i. String -> HH.IProp ( sandbox :: String | r ) i
+sandbox = HH.prop (HH.PropName "sandbox")
+
+srcDoc :: forall r i. String -> HH.IProp ( srcDoc :: String | r ) i
+srcDoc = HH.prop (HH.PropName "srcdoc")
diff --git a/client/src/Try/Gist.js b/client/src/Try/Gist.js
deleted file mode 100644
index 003adac1..00000000
--- a/client/src/Try/Gist.js
+++ /dev/null
@@ -1,48 +0,0 @@
-"use strict";
-
-exports.getGistById_ = function(id, done, fail) {
-  $.ajax({
-    url: 'https://api.github.com/gists/' + id,
-    dataType: 'json'
-  }).done(done).fail(function(err) {
-    fail("Unable to load Gist metadata");
-  });
-}
-
-exports.tryLoadFileFromGist_ = function(gistInfo, filename, done, fail) {
-  if (gistInfo.files && gistInfo.files.hasOwnProperty(filename)) {
-    var url = gistInfo.files[filename].raw_url;
-
-    return $.ajax({
-      url: url,
-      dataType: 'text'
-    }).done(done).fail(function(err) {
-      fail(err.statusText);
-    });
-  } else {
-    fail("Gist does not contain a file named " + filename);
-  }
-};
-
-exports.uploadGist_ = function(content, done, fail) {
-  var data = {
-    "description": "Published with try.purescript.org",
-    "public": false,
-    "files": {
-      "Main.purs": {
-        "content": content
-      }
-    }
-  };
-
-  $.ajax({
-    url: 'https://api.github.com/gists',
-    type: 'POST',
-    dataType: 'json',
-    data: JSON.stringify(data)
-  }).success(function(e) {
-    done(e.id);
-  }).error(function(e) {
-    fail(e);
-  });
-};
diff --git a/client/src/Try/Gist.purs b/client/src/Try/Gist.purs
index 6057e291..2e52e0df 100644
--- a/client/src/Try/Gist.purs
+++ b/client/src/Try/Gist.purs
@@ -1,49 +1,120 @@
-module Try.Gist
-  ( GistInfo
-  , uploadGist
-  , getGistById
-  , tryLoadFileFromGist
-  ) where
-
--- | An abstract data type representing the data we get back from the GitHub API.
-import Prelude
+module Try.Gist where
 
-import Control.Monad.Cont.Trans (ContT(..))
-import Control.Monad.Except.Trans (ExceptT(..))
+import Prelude
+import Affjax as AX
+import Affjax.RequestBody as AXRB
+import Affjax.RequestHeader as AXRH
+import Affjax.ResponseFormat as AXRF
+import Data.Argonaut (decodeJson, encodeJson, stringify)
 import Data.Either (Either(..))
+import Data.HTTP.Method (Method(..))
+import Data.Maybe (Maybe(..))
 import Effect (Effect)
-import Effect.Uncurried (EffectFn1, EffectFn3, EffectFn4, mkEffectFn1, runEffectFn3, runEffectFn4)
-
--- | An abstract data type representing the data we get back from the GitHub API.
-data GistInfo
-
-foreign import uploadGist_
-  :: EffectFn3 String
-               (EffectFn1 String Unit)
-               (EffectFn1 String Unit)
-               Unit
-
--- | A wrapper for `uploadGist` which uses `ContT`.
-uploadGist :: String -> ExceptT String (ContT Unit Effect) String
-uploadGist content = ExceptT (ContT \k -> runEffectFn3 uploadGist_ content (mkEffectFn1 (k <<< Right)) (mkEffectFn1 (k <<< Left)))
-
--- | Get a gist by its ID
-foreign import getGistById_
-  :: EffectFn3 String
-               (EffectFn1 GistInfo Unit)
-               (EffectFn1 String Unit)
-               Unit
-
--- | A wrapper for `getGistById` which uses `ContT`.
-getGistById :: String -> ExceptT String (ContT Unit Effect) GistInfo
-getGistById id_ = ExceptT (ContT \k -> runEffectFn3 getGistById_ id_ (mkEffectFn1 (k <<< Right)) (mkEffectFn1 (k <<< Left)))
-
-foreign import tryLoadFileFromGist_
-  :: EffectFn4 GistInfo
-               String
-               (EffectFn1 String Unit)
-               (EffectFn1 String Unit)
-               Unit
-
-tryLoadFileFromGist :: GistInfo -> String -> ExceptT String (ContT Unit Effect) String
-tryLoadFileFromGist gi filename = ExceptT (ContT \k -> runEffectFn4 tryLoadFileFromGist_ gi filename (mkEffectFn1 (k <<< Right)) (mkEffectFn1 (k <<< Left)))
+import Effect.Aff (Aff)
+import Try.Common (AuthCode(..), Content(..), GhToken(..), GistID(..), pursQP)
+import Try.Config (appDomain, clientID, tokenServerUrl)
+import Try.Utility (compress)
+import Web.HTML (window)
+import Web.HTML.Location (setHref)
+import Web.HTML.Window (location)
+
+{-
+Handles HTTP requests for fetching and saving github gists.
+-}
+--
+type TokenResp
+  = { access_token :: String }
+
+ghRequestToken :: AuthCode -> Aff (Either String GhToken)
+ghRequestToken (AuthCode code) = do
+  result <- AX.post AXRF.json tokenServerUrl $ Just $ AXRB.json $ encodeJson { code }
+  pure
+    $ case result of
+        Left err -> do
+          Left $ "POST /api response failed to decode: " <> AX.printError err
+        Right response -> do
+          let
+            respStr = "POST /api response: " <> stringify response.body
+          case decodeJson response.body of
+            Left err -> Left $ "Failed to decode json response: " <> respStr <> ", Error: " <> show err
+            Right (decoded :: TokenResp) -> Right $ GhToken decoded.access_token
+
+gistApiUrl :: String
+gistApiUrl = "https://api.github.com/gists"
+
+type GistJson
+  = { files :: { "Main.purs" :: { content :: String } }
+    }
+
+type GistJsonWithDescription
+  = { files ::
+        { "Main.purs" ::
+            { content :: String
+            , description :: String
+            }
+        }
+    }
+
+setGistContent :: Content -> GistJsonWithDescription
+setGistContent (Content content) =
+  { files:
+      { "Main.purs":
+          { content
+          , description: "Created by TryPurescript"
+          }
+      }
+  }
+
+getGistContent :: GistJson -> Content
+getGistContent obj = Content obj.files."Main.purs".content
+
+ghGetGist :: GistID -> Aff (Either String Content)
+ghGetGist (GistID gistID) = do
+  result <- AX.get AXRF.json $ gistApiUrl <> "/" <> gistID
+  pure
+    $ case result of
+        Left err -> Left $ "GET gist response failed to decode: " <> AX.printError err
+        Right response -> do
+          let
+            respStr = "POST /api response: " <> stringify response.body
+          case decodeJson response.body of
+            Left err -> Left $ "Failed to decode json response: " <> respStr <> ", Error: " <> show err
+            Right (decoded :: GistJson) -> Right $ getGistContent decoded
+
+ghCreateGist :: GhToken -> Content -> Aff (Either String GistID)
+ghCreateGist token content = do
+  result <-
+    AX.request
+      ( AX.defaultRequest
+          { url = gistApiUrl
+          , method = Left POST
+          , responseFormat = AXRF.json
+          , headers = [ AXRH.RequestHeader "Authorization" $ "token " <> show token ]
+          , content = Just $ AXRB.json $ encodeJson $ setGistContent content
+          }
+      )
+  pure
+    $ case result of
+        Left err -> do
+          Left $ "POST /api response failed to decode: " <> AX.printError err
+        Right response -> do
+          let
+            respStr = "POST /api response: " <> stringify response.body
+          case decodeJson response.body of
+            Left err -> Left $ "Failed to decode json response: " <> respStr <> ", Error: " <> show err
+            Right (decoded :: { id :: String }) -> Right $ GistID decoded.id
+
+ghAuthorize :: Content -> Effect Unit
+ghAuthorize content = window >>= location >>= setHref authUrl
+  where
+  authUrl =
+    "https://github.com/login/oauth/authorize?"
+      <> "client_id="
+      <> clientID
+      <> "&scope=gist"
+      <> "&redirect_uri="
+      <> appDomain
+      <> "/?"
+      <> pursQP
+      <> "="
+      <> (show $ compress content)
diff --git a/client/src/Try/Loader.purs b/client/src/Try/Loader.purs
index c26f1fd2..2214d369 100644
--- a/client/src/Try/Loader.purs
+++ b/client/src/Try/Loader.purs
@@ -1,17 +1,14 @@
-module Try.Loader
-  ( Loader
-  , makeLoader
-  , runLoader
-  ) where
+module Try.Loader where
 
 import Prelude
-
+import Affjax as AX
+import Affjax.ResponseFormat as AXRF
+import Try.Config (loaderUrl)
 import Control.Bind (bindFlipped)
-import Control.Monad.Cont (ContT)
-import Control.Monad.Except (ExceptT)
 import Control.Parallel (parTraverse)
 import Data.Array as Array
 import Data.Array.NonEmpty as NonEmpty
+import Data.Either (Either(..))
 import Data.Maybe (Maybe(..), fromMaybe)
 import Data.Newtype (unwrap)
 import Data.String (Pattern(..))
@@ -22,39 +19,45 @@ import Data.String.Regex.Flags (noFlags)
 import Data.String.Regex.Unsafe (unsafeRegex)
 import Data.Tuple (Tuple(..))
 import Effect (Effect)
+import Effect.Aff (Aff)
 import Effect.Class (liftEffect)
 import Effect.Ref (Ref)
 import Effect.Ref as Ref
 import Effect.Unsafe (unsafePerformEffect)
 import Foreign.Object (Object)
 import Foreign.Object as Object
-import Try.API as API
 import Try.Shim (shims)
 import Try.Types (JS(..))
 
-type Module =
-  { name :: String
-  , path :: Maybe String
-  , deps :: Array Dependency
-  , src :: JS
-  }
-
-type Dependency =
-  { name :: String
-  , path :: Maybe String
-  }
+{-
+Collects all JS modules required by compled code.
+-}
+--
+type Module
+  = { name :: String
+    , path :: Maybe String
+    , deps :: Array Dependency
+    , src :: JS
+    }
+
+type Dependency
+  = { name :: String
+    , path :: Maybe String
+    }
 
 requireRegex :: Regex
 requireRegex = unsafeRegex """^var\s+\S+\s*=\s*require\(["']([^"']*)["']\)""" noFlags
 
+-- Consider replacing these with node-path dirname and concat
 dirname :: String -> String
-dirname path = fromMaybe "" do
-  ix <- String.lastIndexOf (Pattern "/") path
-  pure $ String.take ix path
+dirname path =
+  fromMaybe "" do
+    ix <- String.lastIndexOf (Pattern "/") path
+    pure $ String.take ix path
 
 resolvePath :: String -> String -> Maybe String
 resolvePath a b
-  | String.take 2 b == "./"  = Just $ dirname a <> String.drop 1 b
+  | String.take 2 b == "./" = Just $ dirname a <> String.drop 1 b
   | String.take 3 b == "../" = Just $ dirname (dirname a) <> String.drop 2 b
   | otherwise = Nothing
 
@@ -65,23 +68,39 @@ parseDeps current = Array.mapMaybe go <<< String.split (Pattern "\n") <<< unwrap
   go line = do
     match <- Regex.match requireRegex line
     requirePath <- join $ NonEmpty.index match 1
-    pure $ case resolvePath current requirePath of
-      Just path ->
-        { name: path
-        , path: String.stripPrefix (Pattern "/") path
-        }
-      _ ->
-        { name: requirePath
-        , path: Nothing
-        }
-
-newtype Loader = Loader (JS -> ExceptT String (ContT Unit Effect) (Object JS))
-
-runLoader :: Loader -> JS -> ExceptT String (ContT Unit Effect) (Object JS)
-runLoader (Loader k) = k
-
-makeLoader :: String -> Loader
-makeLoader rootPath = Loader (go Object.empty <<< parseDeps "")
+    pure
+      $ case resolvePath current requirePath of
+          Just path ->
+            { name: path
+            , path: String.stripPrefix (Pattern "/") path
+            }
+          _ ->
+            { name: requirePath
+            , path: Nothing
+            }
+
+{-
+Notes
+
+Could change error handling, but kinda nice to
+just throw the errors from JS.
+
+Assuming makeLoader runLoader pattern is to save
+cache between calls to runLoader.
+
+-}
+newtype Loader
+  = Loader (JS -> Aff (Object JS))
+
+runLoader :: Loader -> JS -> Aff (Object JS)
+runLoader (Loader k) js = do
+  -- Run loader to collect all dependencies for compiled code
+  obj <- k js
+  -- Return dependencies along with compiled code
+  pure $ Object.insert "" js obj
+
+makeLoader :: Loader
+makeLoader = Loader (go Object.empty <<< parseDeps "")
   where
   moduleCache :: Ref (Object Module)
   moduleCache = unsafePerformEffect (Ref.new Object.empty)
@@ -92,32 +111,51 @@ makeLoader rootPath = Loader (go Object.empty <<< parseDeps "")
   getModule :: String -> Effect (Maybe Module)
   getModule a = Object.lookup a <$> Ref.read moduleCache
 
-  load :: Dependency -> ExceptT String (ContT Unit Effect) Module
+  load :: Dependency -> Aff Module
   load { name, path } = do
     cached <- liftEffect $ getModule name
     case cached of
       Just mod -> pure mod
       Nothing -> do
-        mod <-
-          case path of
-            Just path' -> do
-              srcStr <- API.get (rootPath <> "/" <> path')
-              let src = JS $ srcStr <> "\n//# sourceURL=" <> path'
-              pure { name, path, deps: parseDeps name src, src }
-            Nothing -> case Object.lookup name shims of
-              Just shim -> do
-                srcStr <- API.get shim.url
-                let
-                  src = JS $ srcStr <> "\n//# sourceURL=" <> shim.url
+        mod <- case path of
+          -- Path means dependency is another file
+          Just path' -> do
+            let
+              url = loaderUrl <> "/" <> path'
+            --log $ "get: " <> url
+            res <- AX.get AXRF.string url
+            case res of
+              Left err -> pure { name, path, deps: [], src }
+                where
+                src = throwJSError $ "Could not get file " <> url <> ", " <> AX.printError err
+              Right { body } -> do
+                --log $ "got body:\n" <> body
+                pure { name, path, deps: parseDeps name src, src }
+                where
+                src = JS $ body <> "\n//# sourceURL=" <> path'
+          -- No path means dependency is a shim
+          Nothing -> case Object.lookup name shims of
+            Just shim -> do
+              res <- AX.get AXRF.string shim.url
+              case res of
+                Left err -> pure { name, path, deps: [], src }
+                  where
+                  src = throwJSError $ "Could not get shim " <> name <> " at " <> shim.url <> ", " <> AX.printError err
+                Right { body } -> pure { name, path, deps, src }
+                  where
+                  src = JS $ body <> "\n//# sourceURL=" <> shim.url
+
                   deps = { name: _, path: Nothing } <$> shim.deps
-                pure { name, path, deps, src }
-              Nothing ->
-                pure { name, path, deps: [], src: ffiDep name }
+            Nothing -> pure { name, path, deps: [], src }
+              where
+              -- Todo - link to instructions for adding shims
+              src = throwJSError $ "FFI dependency not provided: " <> name
         liftEffect $ putModule name mod
         pure mod
 
-  go :: Object JS -> Array Dependency -> ExceptT String (ContT Unit Effect) (Object JS)
-  go ms []   = pure ms
+  go :: Object JS -> Array Dependency -> Aff (Object JS)
+  go ms [] = pure ms
+
   go ms deps = do
     modules <- parTraverse load deps
     let
@@ -131,5 +169,5 @@ makeLoader rootPath = Loader (go Object.empty <<< parseDeps "")
       # Array.nubBy (comparing _.name)
       # go ms'
 
-ffiDep :: String -> JS
-ffiDep name = JS $ "throw new Error('FFI dependency not provided: " <> name <> "');"
+throwJSError :: String -> JS
+throwJSError err = JS $ "throw new Error('" <> err <> "');"
diff --git a/client/src/Try/QueryString.js b/client/src/Try/QueryString.js
deleted file mode 100644
index 56c5e290..00000000
--- a/client/src/Try/QueryString.js
+++ /dev/null
@@ -1,13 +0,0 @@
-"use strict";
-
-exports.getQueryString = function() {
-  return window.location.search;
-};
-
-exports.setQueryParameters = function(params) {
-  var encodedParams = Object.keys(params).map(function(key) {
-    return key + '=' + encodeURIComponent(params[key].replace('/', ''));
-  }).join('&');
-
-  window.location.search = '?' + encodedParams;
-};
diff --git a/client/src/Try/QueryString.purs b/client/src/Try/QueryString.purs
deleted file mode 100644
index 8009936a..00000000
--- a/client/src/Try/QueryString.purs
+++ /dev/null
@@ -1,54 +0,0 @@
-module Try.QueryString
-  ( getQueryParams
-  , getQueryStringMaybe
-  , setQueryString
-  , setQueryStrings
-  ) where
-
-import Prelude
-
-import Data.Array as Array
-import Data.Maybe (Maybe(..))
-import Data.Newtype (wrap)
-import Data.String as String
-import Data.Tuple (Tuple(..))
-import Effect (Effect)
-import Effect.Uncurried (EffectFn1, runEffectFn1)
-import Foreign.Object as Object
-import Global.Unsafe (unsafeDecodeURIComponent)
-
-foreign import getQueryString :: Effect String
-
--- | Get all of the URL's query parameters.
-getQueryParams :: Effect (Object.Object String)
-getQueryParams = breakQueryString <$> getQueryString where
-  breakQueryString :: String -> Object.Object String
-  breakQueryString =
-    String.drop 1
-    >>> String.split (wrap "&")
-    >>> map (String.split (wrap "=") >>> parseQueryTerm)
-    >>> Array.catMaybes
-    >>> Object.fromFoldable
-
-  parseQueryTerm :: Array String -> Maybe (Tuple String String)
-  parseQueryTerm [k, v] = Just (Tuple k (unsafeDecodeURIComponent (spaces v)))
-  parseQueryTerm _ = Nothing
-
-  spaces :: String -> String
-  spaces = String.replaceAll (wrap "+") (wrap " ")
-
--- | Try to get a key from the URL's query parameters.
-getQueryStringMaybe :: String -> Effect (Maybe String)
-getQueryStringMaybe key = Object.lookup key <$> getQueryParams
-
--- | Set the value of a query string parameter
-foreign import setQueryParameters :: EffectFn1 (Object.Object String) Unit
-
--- | Update the specified key in the URL's query parameters.
-setQueryString :: String -> String -> Effect Unit
-setQueryString k v = setQueryStrings (Object.singleton k v)
-
-setQueryStrings :: Object.Object String -> Effect Unit
-setQueryStrings ss = do
-  params <- getQueryParams
-  runEffectFn1 setQueryParameters (Object.union ss params)
diff --git a/client/src/Try/Routing.purs b/client/src/Try/Routing.purs
new file mode 100644
index 00000000..71a332d1
--- /dev/null
+++ b/client/src/Try/Routing.purs
@@ -0,0 +1,33 @@
+module Try.Routing where
+
+import Prelude
+import Data.Foldable (oneOf)
+import Data.Generic.Rep (class Generic)
+import Data.Generic.Rep.Show (genericShow)
+import Routing.Match (Match, end, param, root)
+import Try.Common (AuthCode(..), Compressed(..), GistID(..), gistQP, pursQP)
+
+{-
+Handles navigation within the single-page-app.
+-}
+--
+data Route
+  = AuthorizeCallback AuthCode Compressed
+  | LoadCompressed Compressed
+  | LoadGist GistID
+  | Home
+
+derive instance genericRoute :: Generic Route _
+
+instance showRoute :: Show Route where
+  show = genericShow
+
+route :: Match Route
+route =
+  root
+    *> oneOf
+        [ AuthorizeCallback <$> (AuthCode <$> param "code") <*> (Compressed <$> param pursQP)
+        , LoadCompressed <$> Compressed <$> param pursQP
+        , LoadGist <$> GistID <$> param gistQP
+        , Home <$ end
+        ]
diff --git a/client/src/Try/Session.js b/client/src/Try/Session.js
deleted file mode 100644
index 42c9971a..00000000
--- a/client/src/Try/Session.js
+++ /dev/null
@@ -1,18 +0,0 @@
-"use strict";
-
-exports.storeSession_ = function(sessionId, state) {
-  if (window.localStorage) {
-    localStorage.setItem(sessionId, state.code);
-    localStorage.setItem(sessionId + 'backend', state.backend);
-  }
-};
-
-exports.tryRetrieveSession_ = function(sessionId) {
-  if (window.localStorage) {
-    var code = localStorage.getItem(sessionId);
-    var backend = localStorage.getItem(sessionId + 'backend');
-    if (code && backend) {
-      return { code: code, backend: backend };
-    }
-  }
-};
diff --git a/client/src/Try/Session.purs b/client/src/Try/Session.purs
deleted file mode 100644
index 2262bf26..00000000
--- a/client/src/Try/Session.purs
+++ /dev/null
@@ -1,59 +0,0 @@
-module Try.Session
-  ( storeSession
-  , tryRetrieveSession
-  , createSessionIdIfNecessary
-  ) where
-
-import Prelude
-
-import Data.Functor.App (App(..))
-import Data.Int (hexadecimal, toStringAs)
-import Data.Maybe (Maybe(..))
-import Data.Newtype (unwrap)
-import Data.Nullable (Nullable, toMaybe)
-import Data.String as String
-import Effect (Effect)
-import Effect.Random (randomInt)
-import Effect.Uncurried (EffectFn1, EffectFn2, runEffectFn1, runEffectFn2)
-import Try.QueryString (getQueryStringMaybe, setQueryString)
-
-randomGuid :: Effect String
-randomGuid =
-    unwrap (App s4 <> App s4 <> pure "-" <>
-            App s4 <> pure "-" <>
-            App s4 <> pure "-" <>
-            App s4 <> pure "-" <>
-            App s4 <> App s4 <> App s4)
-  where
-    s4 = padLeft <<< toStringAs hexadecimal <$> randomInt 0 (256 * 256)
-    padLeft s = String.drop (String.length s - 1) ("000" <> s)
-
-foreign import storeSession_
-  :: EffectFn2 String
-               { code :: String }
-               Unit
-
--- | Store the current session state in local storage
-storeSession
-  :: String
-  -> { code :: String }
-  -> Effect Unit
-storeSession sessionId values = runEffectFn2 storeSession_ sessionId values
-
-foreign import tryRetrieveSession_
-  :: EffectFn1 String
-               (Nullable { code :: String })
-
--- | Retrieve the session state from local storage
-tryRetrieveSession :: String -> Effect (Maybe { code :: String })
-tryRetrieveSession sessionId = toMaybe <$> runEffectFn1 tryRetrieveSession_ sessionId
-
--- | Look up the session by ID, or create a new session ID.
-createSessionIdIfNecessary
-  :: (String -> Effect Unit)
-  -> Effect Unit
-createSessionIdIfNecessary k = do
-  sessionId <- getQueryStringMaybe "session"
-  case sessionId of
-    Just sessionId_ -> k sessionId_
-    Nothing -> randomGuid >>= setQueryString "session"
diff --git a/client/src/Try/Shim.purs b/client/src/Try/Shim.purs
index b6608b78..cff6471a 100644
--- a/client/src/Try/Shim.purs
+++ b/client/src/Try/Shim.purs
@@ -4,36 +4,42 @@ import Data.Tuple (Tuple(..))
 import Foreign.Object (Object)
 import Foreign.Object as Object
 
-type Shim =
-  { url :: String
-  , deps :: Array String
-  }
+{-
+Allows loading additional dependencies, which are required by
+some libraries.
+Feel free to add additional entries to this file.
+-}
+--
+type Shim
+  = { url :: String
+    , deps :: Array String
+    }
 
 shims :: Object Shim
-shims = Object.fromFoldable
-  [ Tuple "react"
-      { url: "https://unpkg.com/react@16.13.1/umd/react.development.js"
-      , deps: []
-      }
-  , Tuple "react-dom"
-      { url: "https://unpkg.com/react-dom@16.13.1/umd/react-dom.development.js"
-      , deps: [ "react" ]
-      }
-  , Tuple "react-dom/server"
-      { url: "https://unpkg.com/react-dom@16.13.1/umd/react-dom-server.browser.development.js"
-      , deps: [ "react" ]
-      }
-  , Tuple "big-integer"
-      { url: "https://unpkg.com/big-integer@1.6.48/BigInteger.min.js"
-      , deps: []
-      }
-  , Tuple "decimal.js"
-      { url: "https://unpkg.com/decimal.js@10.2.0/decimal.min.js"
-      , deps: []
-      }
-  , Tuple "uuid"
-      { url: "https://cdnjs.cloudflare.com/ajax/libs/uuid/8.1.0/uuid.min.js"
-      , deps: []
-      }
-  ]
-
+shims =
+  Object.fromFoldable
+    [ Tuple "react"
+        { url: "https://unpkg.com/react@16.13.1/umd/react.development.js"
+        , deps: []
+        }
+    , Tuple "react-dom"
+        { url: "https://unpkg.com/react-dom@16.13.1/umd/react-dom.development.js"
+        , deps: [ "react" ]
+        }
+    , Tuple "react-dom/server"
+        { url: "https://unpkg.com/react-dom@16.13.1/umd/react-dom-server.browser.development.js"
+        , deps: [ "react" ]
+        }
+    , Tuple "big-integer"
+        { url: "https://unpkg.com/big-integer@1.6.48/BigInteger.min.js"
+        , deps: []
+        }
+    , Tuple "decimal.js"
+        { url: "https://unpkg.com/decimal.js@10.2.0/decimal.min.js"
+        , deps: []
+        }
+    , Tuple "uuid"
+        { url: "https://cdnjs.cloudflare.com/ajax/libs/uuid/8.1.0/uuid.min.js"
+        , deps: []
+        }
+    ]
diff --git a/client/src/Try/Types.purs b/client/src/Try/Types.purs
index a85d2e8b..c57fb212 100644
--- a/client/src/Try/Types.purs
+++ b/client/src/Try/Types.purs
@@ -1,12 +1,17 @@
-module Try.Types
-  ( JS(..)
-  ) where
+module Try.Types where
 
+import Data.Argonaut (class EncodeJson)
 import Data.Newtype (class Newtype)
-import Foreign.Class (class Encode)
 
-newtype JS = JS String
+{-
+Some common types.
+Just the `JS` type for now.
+-}
+--
+newtype JS
+  = JS String
 
+-- enable `unwrap`
 derive instance newtypeJS :: Newtype JS _
 
-derive newtype instance encodeJS :: Encode JS
+derive newtype instance encodeJsonJS :: EncodeJson JS
diff --git a/client/src/Try/Utility.purs b/client/src/Try/Utility.purs
new file mode 100644
index 00000000..3a273e9c
--- /dev/null
+++ b/client/src/Try/Utility.purs
@@ -0,0 +1,37 @@
+module Try.Utility where
+
+import Prelude
+import Try.Common (Compressed(..), Content(..), GistID)
+import Effect (Effect)
+import LzString (compressToEncodedURIComponent, decompressFromEncodedURIComponent)
+
+{-
+Helper functions that can exist outside of the main component.
+-}
+--
+data ViewMode
+  = SideBySide
+  | Code
+  | Output
+
+-- Could alternatively derive if displaying "SideBySide"(no hyphens) is okay.
+instance showViewMode :: Show ViewMode where
+  show SideBySide = "Side-by-side"
+  show Code = "Code"
+  show Output = "Output"
+
+derive instance eqViewMode :: Eq ViewMode
+
+type PushRoute
+  = String -> Effect Unit
+
+data ContentSource
+  = NewContent --NoGist
+  | SavingGist
+  | HaveGist GistID
+
+compress :: Content -> Compressed
+compress (Content c) = Compressed $ compressToEncodedURIComponent c
+
+decompress :: Compressed -> Content
+decompress (Compressed c) = Content $ decompressFromEncodedURIComponent c
diff --git a/client/tailwind.config.js b/client/tailwind.config.js
new file mode 100644
index 00000000..bd52ff78
--- /dev/null
+++ b/client/tailwind.config.js
@@ -0,0 +1,21 @@
+// tailwind.config.js
+module.exports = {
+  purge: [
+    './public/index.js',
+  ],
+  theme: {
+    extend: {
+      colors: {
+        'tps-black': '#1d222d',
+        //'tps-button-enabled-background': '#8490a9',
+        'tps-disabled': '#ababab',
+        'tps-enabled': '#c4953a',
+        'tps-mobile-banner': '#dabf8b',
+        // mobile border same as button background
+
+      }
+    }
+  },
+  variants: {},
+  plugins: [],
+}
diff --git a/client/webpack.common.js b/client/webpack.common.js
new file mode 100644
index 00000000..5bd4ac03
--- /dev/null
+++ b/client/webpack.common.js
@@ -0,0 +1,44 @@
+const path = require('path');
+const HtmlWebpackPlugin = require('html-webpack-plugin');
+const { CleanWebpackPlugin } = require('clean-webpack-plugin');
+const CopyPlugin = require('copy-webpack-plugin');
+const webpack = require('webpack');
+
+module.exports = {
+  entry: {
+    "index.js": [
+     "./public/tailwind.css",
+     "./public/ace.css",
+     "./public/index.js",
+    ],
+  },
+  plugins: [
+    new CleanWebpackPlugin(),
+    new HtmlWebpackPlugin({
+      template: './public/index.html',
+    }),
+    new CopyPlugin({
+      patterns: [
+        {from: 'public/js/frame.js'},
+        {from: 'public/img', to: 'img'},
+        {from: 'public/CNAME'},
+      ],
+    }),
+  ],
+  module: {
+    rules: [
+      {
+        test: /\.css$/,
+        use: [
+          'style-loader',
+          'css-loader',
+        ],
+      },
+    ],
+  },
+  output: {
+    filename: '[name]',
+    path: path.resolve(__dirname, 'dist'),
+    publicPath: '/',
+  },
+};
diff --git a/client/webpack.dev.js b/client/webpack.dev.js
new file mode 100644
index 00000000..1af5978f
--- /dev/null
+++ b/client/webpack.dev.js
@@ -0,0 +1,11 @@
+const { merge } = require('webpack-merge');
+const common = require('./webpack.common.js');
+
+module.exports = merge(common, {
+  mode: 'development',
+  devtool: 'inline-source-map',
+  devServer: {
+    contentBase: false,
+  },
+});
+
diff --git a/client/webpack.prod.js b/client/webpack.prod.js
new file mode 100644
index 00000000..ed0670ee
--- /dev/null
+++ b/client/webpack.prod.js
@@ -0,0 +1,6 @@
+const { merge } = require('webpack-merge');
+const common = require('./webpack.common.js');
+
+module.exports = merge(common, {
+  mode: 'production',
+});