20 Commits a2c4169b48 ... e51266610b

Author SHA1 Message Date
  Brandon Wong e51266610b fix :infer-warning on isSymbolNode property access in parse-variables 2 weeks ago
  Brandon Wong 023884242b move tests from src/test/ to top-level test/ to match shadow-cljs convention 2 weeks ago
  Brandon Wong e483e06fbd add cljs.test unit and integration tests via shadow-cljs node-test target 2 weeks ago
  Brandon Wong 7509ba530a shift-enter in A1 does nothing instead of wrapping 3 weeks ago
  Brandon Wong 41b6afea64 add shift-enter to move up, mirroring enter moving down 3 weeks ago
  Brandon Wong f53b3bd03d make mobile Next key move down by using column-major tabindex on touch devices 3 weeks ago
  Brandon Wong c1e6b2bc1e remove completed todo items: control panel and About modal 3 weeks ago
  Brandon Wong a3d72d3e2e add sticky row and column headers to the spreadsheet table 3 weeks ago
  Brandon Wong a850c20a90 scope mobile scroll-containment to narrow screens via media query 3 weeks ago
  Brandon Wong eadfe5394d fix control bar position on mobile by containing scroll to the sheet area 3 weeks ago
  Brandon Wong 6bb445d10d replace placeholder control panel with fixed bottom bar and About modal 3 weeks ago
  Brandon Wong 8bcecab0e9 added visual styling suggestions to the todo.md file; referenced todo.md in CLAUDE.md 3 weeks ago
  Brandon Wong d9a15b79e6 added more to-do items from the remainder of the tech debt analysis 3 weeks ago
  Brandon Wong 50acb7d0f6 added to-do items from partial tech debt analysis 3 weeks ago
  Brandon Wong d745b11f3e added to-do list; added gitignore 3 weeks ago
  Brandon Wong 573b7c6153 Merge branch 'remove-lein' 3 weeks ago
  Brandon Wong bf206df037 changes before 2026-06-02 3 weeks ago
  Brandon Wong 1d63b2f925 build: replace Leiningen with shadow-cljs standalone 3 weeks ago
  Brandon Wong f0e1069fbb docs: add CLAUDE.md guide for LLM navigation 2 months ago
  Brandon Wong 187a8e46f4 fixed the bug where enter was moving back to the first cell prematurely 1 year ago

+ 1 - 0
.gitignore

@@ -0,0 +1 @@
+.cache

+ 76 - 0
CLAUDE.md

@@ -0,0 +1,76 @@
+# Microtables - LLM Navigation Guide
+
+## Project Overview
+A lightweight spreadsheet application designed for quick calculations — positioned between a calculator and a full office suite. The goal is fast loading with support for common spreadsheet operations.
+
+## Architecture
+
+**Frontend** (`/frontend/`)
+- **Language**: ClojureScript (compiled via shadow-cljs)
+- **Framework**: Reagent + re-frame (React wrapper)
+- **Build**: shadow-cljs (via npm scripts)
+- **Dev server**: `npm run dev` → localhost:8280
+
+**Server** (`/server/`)
+- **Language**: JavaScript (ES6+ with Babel)
+- **Framework**: Express
+- **Status**: Minimal implementation - currently the frontend is fully functional standalone
+- **Future**: Will handle persistence and collaboration
+
+## Key Concepts
+
+### Data Structure
+The spreadsheet data is a nested map: `{column-letter {row-number {:value ...}}}`
+
+Example:
+```clojure
+{"A" {1 {:value 78 :inbound [...]}
+      3 {:value "=B2 * 4" :refs [...]}}
+ "B" {2 {:value "=A1 + 3" :refs [...] :inbound [...]}}}
+```
+
+### Formula System
+- Formulae start with `=` and are evaluated using **math.js** library
+- Range syntax supported: `=sum(A1:A10)`
+- Cell references create a **doubly-linked tree structure**:
+  - `:refs` - forward references (cells this formula depends on)
+  - `:inbound` - back references (cells that depend on this cell)
+  - `:display` - computed/evaluated value shown to user
+  - `:value` - raw user input
+  - `:dirty` - flag indicating re-evaluation needed
+
+### Evaluation Flow
+1. When a cell changes, parse for references
+2. Update forward (`:refs`) and back (`:inbound`) reference trees
+3. Evaluate the cell
+4. Recursively evaluate all cells in `:inbound` queue
+5. Cycle detection prevents infinite loops
+
+## Important Files
+
+**Frontend Core Logic**
+- `frontend/src/cljs/microtables_frontend/core.cljs` - App initialization
+- `frontend/src/cljs/microtables_frontend/db.cljs` - Data model definition
+- `frontend/src/cljs/microtables_frontend/events.cljs` - State mutations (re-frame)
+- `frontend/src/cljs/microtables_frontend/subs.cljs` - Data queries (re-frame)
+- `frontend/src/cljs/microtables_frontend/evaluation.cljs` - Formula evaluation engine
+- `frontend/src/cljs/microtables_frontend/views/sheet.cljs` - Grid rendering
+
+**Project Configuration**
+- `frontend/shadow-cljs.edn` - ClojureScript build config (source paths, deps, build targets)
+- `frontend/package.json` - npm scripts and JS dependencies
+
+## Development Notes
+
+- there is a todo.md file which contains some desired changes
+- Almost everything is subject to change (architecture, structure, design)
+- The frontend currently operates completely standalone
+- Focus is on simplicity and performance for common use cases
+- Data structures prioritize clarity over micro-optimization (small dataset assumption)
+
+## Common Tasks
+
+**Run frontend**: `cd frontend && npm run dev`
+**Install npm dependency**: Add to `shadow-cljs.edn`, then restart
+**Understand formula eval**: Start in `evaluation.cljs`, trace through `evaluate-from-cell`
+**Modify grid UI**: Look in `views/sheet.cljs`

+ 1 - 1
README.md

@@ -7,7 +7,7 @@ A spreadsheet software with limited functionality, designed for quick calculatio
 
 ## Installation
 
-Clone the repo, go into `frontend`, and run `lein dev`. Then navigate to `localhost:8280` in a browser.
+Clone the repo, go into `frontend`, run `npm install`, then `npm run dev`. Navigate to `localhost:8280` in a browser.
 
 Currently, only the frontend works - but all the spreadsheet functionality works on the frontend side. The server will provide saving and collaboration features.
 

+ 83 - 0
codemcp.toml

@@ -0,0 +1,83 @@
+# Claude Desktop project's instructions (in "project knowledge"):
+# "Initialize the codemcp tool with /Users/yellowdig/personal/projects/microtables/."
+# (use absolute path for <home> because otherwise the LLM will hallucinate a home path)
+
+project_prompt = '''
+# Microtables
+
+## Project Overview
+A lightweight spreadsheet application designed for quick calculations — positioned between a calculator and a full office suite. The goal is fast loading with support for common spreadsheet operations.
+
+## Architecture
+
+**Frontend** (`/frontend/`)
+- **Language**: ClojureScript (compiled via shadow-cljs)
+- **Framework**: Reagent + re-frame (React wrapper)
+- **Build**: Leiningen + shadow-cljs
+- **Dev server**: `lein dev` → localhost:8280
+
+**Server** (`/server/`)
+- **Language**: JavaScript (ES6+ with Babel)
+- **Framework**: Express
+- **Status**: Minimal implementation - currently the frontend is fully functional standalone
+- **Future**: Will handle persistence and collaboration
+
+## Key Concepts
+
+### Data Structure
+The spreadsheet data is a nested map: `{column-letter {row-number {:value ...}}}`
+
+Example:
+```clojure
+{"A" {1 {:value 78 :inbound [...]}
+      3 {:value "=B2 * 4" :refs [...]}}
+ "B" {2 {:value "=A1 + 3" :refs [...] :inbound [...]}}}
+```
+
+### Formula System
+- Formulae start with `=` and are evaluated using **math.js** library
+- Range syntax supported: `=sum(A1:A10)`
+- Cell references create a **doubly-linked tree structure**:
+  - `:refs` - forward references (cells this formula depends on)
+  - `:inbound` - back references (cells that depend on this cell)
+  - `:display` - computed/evaluated value shown to user
+  - `:value` - raw user input
+  - `:dirty` - flag indicating re-evaluation needed
+
+### Evaluation Flow
+1. When a cell changes, parse for references
+2. Update forward (`:refs`) and back (`:inbound`) reference trees
+3. Evaluate the cell
+4. Recursively evaluate all cells in `:inbound` queue
+5. Cycle detection prevents infinite loops
+
+## Important Files
+
+**Frontend Core Logic**
+- `frontend/src/cljs/microtables_frontend/core.cljs` - App initialization
+- `frontend/src/cljs/microtables_frontend/db.cljs` - Data model definition
+- `frontend/src/cljs/microtables_frontend/events.cljs` - State mutations (re-frame)
+- `frontend/src/cljs/microtables_frontend/subs.cljs` - Data queries (re-frame)
+- `frontend/src/cljs/microtables_frontend/evaluation.cljs` - Formula evaluation engine
+- `frontend/src/cljs/microtables_frontend/views/sheet.cljs` - Grid rendering
+
+**Project Configuration**
+- `frontend/project.clj` - Leiningen config
+- `frontend/shadow-cljs.edn` - ClojureScript build config
+
+## Development Notes
+
+- Almost everything is subject to change (architecture, structure, design)
+- The frontend currently operates completely standalone
+- Focus is on simplicity and performance for common use cases
+- Data structures prioritize clarity over micro-optimization (small dataset assumption)
+
+## Common Tasks
+
+**Run frontend**: `cd frontend && lein dev`  
+**Install npm dependency**: Add to `shadow-cljs.edn`, then restart  
+**Understand formula eval**: Start in `evaluation.cljs`, trace through `evaluate-from-cell`  
+**Modify grid UI**: Look in `views/sheet.cljs`
+'''
+
+[commands]

+ 42 - 151
frontend/README.md

@@ -1,24 +1,14 @@
 # microtables-frontend
 
-A [re-frame](https://github.com/day8/re-frame) application designed to ... well, that part is up to
-you.
+A [re-frame](https://github.com/day8/re-frame) ClojureScript single-page application.
 
-## Getting Started
+## Project Overview
 
-### Project Overview
-
-* Architecture:
-[Single Page Application (SPA)](https://en.wikipedia.org/wiki/Single-page_application)
-* Languages
-  - Front end ([re-frame](https://github.com/day8/re-frame)): [ClojureScript](https://clojurescript.org/) (CLJS)
+* Architecture: [Single Page Application (SPA)](https://en.wikipedia.org/wiki/Single-page_application)
+* Language: [ClojureScript](https://clojurescript.org/) with [re-frame](https://github.com/day8/re-frame)
 * Dependencies
-  - UI framework: [re-frame](https://github.com/day8/re-frame)
-  ([docs](https://github.com/day8/re-frame/blob/master/docs/README.md),
-  [FAQs](https://github.com/day8/re-frame/blob/master/docs/FAQs/README.md)) ->
-  [Reagent](https://github.com/reagent-project/reagent) ->
-  [React](https://github.com/facebook/react)
+  - UI framework: [re-frame](https://github.com/day8/re-frame) → [Reagent](https://github.com/reagent-project/reagent) → [React](https://github.com/facebook/react)
 * Build tools
-  - Project task & dependency management: [Leiningen](https://github.com/technomancy/leiningen)
   - CLJS compilation, REPL, & hot reload: [`shadow-cljs`](https://github.com/thheller/shadow-cljs)
 * Development tools
   - Debugging: [CLJS DevTools](https://github.com/binaryage/cljs-devtools)
@@ -26,178 +16,79 @@ you.
 #### Directory structure
 
 * [`/`](/../../): project config files
-* [`dev/`](dev/): source files compiled only with the [dev](#running-the-app) profile
-  - [`cljs/user.cljs`](dev/cljs/user.cljs): symbols for use during development in the
-[ClojureScript REPL](#connecting-to-the-browser-repl-from-a-terminal)
-* [`resources/public/`](resources/public/): SPA root directory;
-[dev](#running-the-app) / [prod](#production) profile depends on the most recent build
+* [`dev/`](dev/): source files available only during development
+  - [`cljs/user.cljs`](dev/cljs/user.cljs): symbols for use during development in the ClojureScript REPL
+* [`resources/public/`](resources/public/): SPA root directory
   - [`index.html`](resources/public/index.html): SPA home page
-    - Dynamic SPA content rendered in the following `div`:
-        ```html
-        <div id="app"></div>
-        ```
-    - Customizable; add headers, footers, links to other scripts and styles, etc.
-  - Generated directories and files
-    - Created on build with either the [dev](#running-the-app) or [prod](#production) profile
-    - Deleted on `lein clean` (run by all `lein` aliases before building)
-    - `js/compiled/`: compiled CLJS (`shadow-cljs`)
-      - Not tracked in source control; see [`.gitignore`](.gitignore)
-* [`src/cljs/microtables_frontend/`](src/cljs/microtables_frontend/): SPA source files (ClojureScript,
-[re-frame](https://github.com/Day8/re-frame))
-  - [`core.cljs`](src/cljs/microtables_frontend/core.cljs): contains the SPA entry point, `init`
-
-### Editor/IDE
-
-Use your preferred editor or IDE that supports Clojure/ClojureScript development. See
-[Clojure tools](https://clojure.org/community/resources#_clojure_tools) for some popular options.
-
-### Environment Setup
-
-1. Install [JDK 8 or later](https://openjdk.java.net/install/) (Java Development Kit)
-2. Install [Leiningen](https://leiningen.org/#install) (Clojure/ClojureScript project task &
-dependency management)
-3. Install [Node.js](https://nodejs.org/) (JavaScript runtime environment)
-7. Clone this repo and open a terminal in the `microtables-frontend` project root directory
-8. Download project dependencies:
+  - `js/compiled/`: compiled CLJS output (not tracked in source control)
+* [`src/cljs/microtables_frontend/`](src/cljs/microtables_frontend/): SPA source files
+
+## Environment Setup
+
+1. Install [JDK 11 or later](https://openjdk.java.net/install/)
+2. Install [Node.js](https://nodejs.org/)
+3. Clone this repo and open a terminal in the `frontend` directory
+4. Install dependencies:
     ```sh
-    lein deps && npm install
+    npm install
     ```
 
 ### Browser Setup
 
-Browser caching should be disabled when developer tools are open to prevent interference with
-[`shadow-cljs`](https://github.com/thheller/shadow-cljs) hot reloading.
-
-Custom formatters must be enabled in the browser before
-[CLJS DevTools](https://github.com/binaryage/cljs-devtools) can display ClojureScript data in the
-console in a more readable way.
-
-#### Chrome/Chromium
-
-1. Open [DevTools](https://developers.google.com/web/tools/chrome-devtools/) (Linux/Windows: `F12`
-or `Ctrl-Shift-I`; macOS: `⌘-Option-I`)
-2. Open DevTools Settings (Linux/Windows: `?` or `F1`; macOS: `?` or `Fn+F1`)
-3. Select `Preferences` in the navigation menu on the left, if it is not already selected
-4. Under the `Network` heading, enable the `Disable cache (while DevTools is open)` option
-5. Under the `Console` heading, enable the `Enable custom formatters` option
-
-#### Firefox
+Disable browser caching when developer tools are open to prevent interference with hot reloading.
 
-1. Open [Developer Tools](https://developer.mozilla.org/en-US/docs/Tools) (Linux/Windows: `F12` or
-`Ctrl-Shift-I`; macOS: `⌘-Option-I`)
-2. Open [Developer Tools Settings](https://developer.mozilla.org/en-US/docs/Tools/Settings)
-(Linux/macOS/Windows: `F1`)
-3. Under the `Advanced settings` heading, enable the `Disable HTTP Cache (when toolbox is open)`
-option
-
-Unfortunately, Firefox does not yet support custom formatters in their devtools. For updates, follow
-the enhancement request in their bug tracker:
-[1262914 - Add support for Custom Formatters in devtools](https://bugzilla.mozilla.org/show_bug.cgi?id=1262914).
+Enable custom formatters in Chrome/Chromium DevTools (`Settings → Preferences → Console → Enable custom formatters`) so that [CLJS DevTools](https://github.com/binaryage/cljs-devtools) can display ClojureScript data readably in the console.
 
 ## Development
 
 ### Running the App
 
-Start a temporary local web server, build the app with the `dev` profile, and serve the app with
-hot reload:
-
 ```sh
-lein dev
+npm run dev
 ```
 
-Please be patient; it may take over 20 seconds to see any output, and over 40 seconds to complete.
-
-When `[:app] Build completed` appears in the output, browse to
-[http://localhost:8280/](http://localhost:8280/).
-
-[`shadow-cljs`](https://github.com/thheller/shadow-cljs) will automatically push ClojureScript code
-changes to your browser on save. To prevent a few common issues, see
-[Hot Reload in ClojureScript: Things to avoid](https://code.thheller.com/blog/shadow-cljs/2019/08/25/hot-reload-in-clojurescript.html#things-to-avoid).
+When `[:app] Build completed` appears in the output, browse to [http://localhost:8280/](http://localhost:8280/).
 
-Opening the app in your browser starts a
-[ClojureScript browser REPL](https://clojurescript.org/reference/repl#using-the-browser-as-an-evaluation-environment),
-to which you may now connect.
+`shadow-cljs` will automatically push ClojureScript code changes to your browser on save. See [Hot Reload in ClojureScript: Things to avoid](https://code.thheller.com/blog/shadow-cljs/2019/08/25/hot-reload-in-clojurescript.html#things-to-avoid) for common pitfalls.
 
-#### Connecting to the browser REPL from your editor
+### Connecting to the browser REPL from your editor
 
-See
-[Shadow CLJS User's Guide: Editor Integration](https://shadow-cljs.github.io/docs/UsersGuide.html#_editor_integration).
-Note that `lein dev` runs `shadow-cljs watch` for you, and that this project's running build id is
-`app`, or the keyword `:app` in a Clojure context.
-
-Alternatively, search the web for info on connecting to a `shadow-cljs` ClojureScript browser REPL
-from your editor and configuration.
-
-For example, in Vim / Neovim with `fireplace.vim`
-1. Open a `.cljs` file in the project to activate `fireplace.vim`
-2. In normal mode, execute the `Piggieback` command with this project's running build id, `:app`:
-    ```vim
-    :Piggieback :app
-    ```
+See [Shadow CLJS User's Guide: Editor Integration](https://shadow-cljs.github.io/docs/UsersGuide.html#_editor_integration). The running build id is `app` (`:app` in a Clojure context).
 
-#### Connecting to the browser REPL from a terminal
+### Connecting to the browser REPL from a terminal
 
-1. Connect to the `shadow-cljs` nREPL:
+1. Connect to the nREPL (port 8777, configured in `shadow-cljs.edn`):
     ```sh
-    lein repl :connect localhost:8777
+    npx shadow-cljs cljs-repl app
     ```
-    The REPL prompt, `shadow.user=>`, indicates that is a Clojure REPL, not ClojureScript.
-
-2. In the REPL, switch the session to this project's running build id, `:app`:
+    Or connect with your editor's nREPL client to `localhost:8777`, then:
     ```clj
     (shadow.cljs.devtools.api/nrepl-select :app)
     ```
-    The REPL prompt changes to `cljs.user=>`, indicating that this is now a ClojureScript REPL.
-3. See [`user.cljs`](dev/cljs/user.cljs) for symbols that are immediately accessible in the REPL
-without needing to `require`.
 
-### Running `shadow-cljs` Actions
+### Debug Logging
 
-See a list of [`shadow-cljs CLI`](https://shadow-cljs.github.io/docs/UsersGuide.html#_command_line)
-actions:
-```sh
-lein run -m shadow.cljs.devtools.cli --help
-```
+The `debug?` variable in [`config.cljs`](src/cljs/microtables_frontend/config.cljs) defaults to `true` in dev builds and `false` in prod builds.
 
-Please be patient; it may take over 10 seconds to see any output. Also note that some actions shown
-may not actually be supported, outputting "Unknown action." when run.
+## Testing
+
+Unit and integration tests live in `test/microtables_frontend/` and use [`cljs.test`](https://clojurescript.org/tools/testing) compiled to Node.js via a shadow-cljs `:node-test` target.
 
-Run a shadow-cljs action on this project's build id (without the colon, just `app`):
 ```sh
-lein run -m shadow.cljs.devtools.cli <action> app
+npm test
 ```
-### Debug Logging
-
-The `debug?` variable in [`config.cljs`](src/cljs/microtables_frontend/config.cljs) defaults to `true` in
-[`dev`](#running-the-app) builds, and `false` in [`prod`](#production) builds.
 
-Use `debug?` for logging or other tasks that should run only on `dev` builds:
+This compiles the `:test` build and runs it with Node.js. No browser is required.
 
-```clj
-(ns microtables-frontend.example
-  (:require [microtables-frontend.config :as config])
-
-(when config/debug?
-  (println "This message will appear in the browser console only on dev builds."))
-```
+| File | What it covers |
+|------|----------------|
+| `evaluation_test.cljs` | Formula evaluation engine — arithmetic, cell/range references, cycle detection, error propagation |
+| `events_test.cljs` | State-transition pipelines — downstream re-evaluation, dirty marking, reference graph updates |
 
 ## Production
 
-Build the app with the `prod` profile:
-
 ```sh
-lein prod
+npm run prod
 ```
 
-Please be patient; it may take over 15 seconds to see any output, and over 30 seconds to complete.
-
-The `resources/public/js/compiled` directory is created, containing the compiled `app.js` and
-`manifest.edn` files.
-
-The [`resources/public`](resources/public/) directory contains the complete, production web front
-end of your app.
-
-Always inspect the `resources/public/js/compiled` directory prior to deploying the app. Running any
-`lein` alias in this project after `lein dev` will, at the very least, run `lein clean`, which
-deletes this generated directory. Further, running `lein dev` will generate many, much larger
-development versions of the files in this directory.
+The `resources/public/js/compiled` directory will contain the compiled `app.js` and `manifest.edn` files. The `resources/public/` directory is the complete production web front end.

File diff suppressed because it is too large
+ 98 - 837
frontend/package-lock.json


+ 7 - 1
frontend/package.json

@@ -1,6 +1,12 @@
 {
+  "scripts": {
+    "dev": "shadow-cljs watch app",
+    "prod": "shadow-cljs release app",
+    "build-report": "shadow-cljs run shadow.cljs.build-report app target/build-report.html",
+    "test": "shadow-cljs compile test && node target/test/tests.js"
+  },
   "devDependencies": {
-    "shadow-cljs": "2.8.83"
+    "shadow-cljs": "3.4.11"
   },
   "dependencies": {
     "mathjs": "7.0.1",

+ 0 - 43
frontend/project.clj

@@ -1,43 +0,0 @@
-(defproject microtables-frontend "0.1.0-SNAPSHOT"
-  :dependencies [[org.clojure/clojure "1.10.1"]
-                 [org.clojure/clojurescript "1.10.597"
-                  :exclusions [com.google.javascript/closure-compiler-unshaded
-                               org.clojure/google-closure-library
-                               org.clojure/google-closure-library-third-party]]
-                 [thheller/shadow-cljs "2.8.83"]
-                 [reagent "0.9.1"]
-                 [re-frame "0.11.0"]]
-
-  :plugins [
-            [lein-shell "0.5.0"]]
-
-  :min-lein-version "2.5.3"
-
-  :source-paths ["src/clj" "src/cljs"]
-
-  :clean-targets ^{:protect false} ["resources/public/js/compiled" "target"]
-
-
-  :shell {:commands {"open" {:windows ["cmd" "/c" "start"]
-                             :macosx  "open"
-                             :linux   "xdg-open"}}}
-
-  :aliases {"dev"          ["with-profile" "dev" "do"
-                            ["run" "-m" "shadow.cljs.devtools.cli" "watch" "app"]]
-            "prod"         ["with-profile" "prod" "do"
-                            ["run" "-m" "shadow.cljs.devtools.cli" "release" "app"]]
-            "build-report" ["with-profile" "prod" "do"
-                            ["run" "-m" "shadow.cljs.devtools.cli" "run" "shadow.cljs.build-report" "app" "target/build-report.html"]
-                            ["shell" "open" "target/build-report.html"]]
-            "karma"        ["with-profile" "prod" "do"
-                            ["run" "-m" "shadow.cljs.devtools.cli" "compile" "karma-test"]
-                            ["shell" "karma" "start" "--single-run" "--reporters" "junit,dots"]]}
-
-  :profiles
-  {:dev
-   {:dependencies [[binaryage/devtools "1.0.0"]]
-    :source-paths ["dev"]}
-
-   :prod { }}
-
-  :prep-tasks [])

+ 117 - 82
frontend/resources/public/site.css

@@ -2,12 +2,40 @@ body {
   font-family: 'Helvetica Neue', Verdana, Helvetica, Arial, sans-serif;
   max-width: 600px;
   margin: 0;
+  padding-bottom: 44px;
   -webkit-font-smoothing: antialiased;
   font-size: 1.125em;
   color: #333;
   line-height: 1.5em;
 }
 
+/* On narrow screens, contain scroll to the sheet area so position:fixed
+   stays anchored to the viewport rather than the document bottom. */
+@media (max-width: 767px) {
+  html, body {
+    height: 100%;
+    overflow: hidden;
+    padding-bottom: 0;
+  }
+  #app {
+    height: 100%;
+    display: flex;
+    flex-direction: column;
+  }
+  #main-layout {
+    flex: 1;
+    display: flex;
+    flex-direction: column;
+    min-height: 0;
+  }
+  .sheet-container {
+    flex: 1;
+    overflow: auto;
+    min-height: 0;
+    padding-bottom: 44px;
+  }
+}
+
 h1, h2, h3 {
   color: #000;
 }
@@ -39,13 +67,37 @@ a:hover {
 }
 
 table {
-    border-collapse: collapse;
+    border-collapse: separate;
+    border-spacing: 0;
+    border-top: 1px solid black;
+    border-left: 1px solid black;
 }
 
 td, th {
-    border: 1px solid black;
+    border-right: 1px solid black;
+    border-bottom: 1px solid black;
     padding: 0;
 }
+
+/* Sticky column headers (top row) */
+#main-table tr:first-child th {
+    position: sticky;
+    top: 0;
+    z-index: 1;
+    background: white;
+}
+/* Sticky row headers (left column) */
+#main-table tr:not(:first-child) th {
+    position: sticky;
+    left: 0;
+    z-index: 1;
+    background: white;
+}
+/* Corner cell: sticky in both directions */
+#main-table tr:first-child th:first-child {
+    left: 0;
+    z-index: 2;
+}
 th {
     text-align: center;
     min-width: 40px;
@@ -75,96 +127,79 @@ td input:not(:focus) {
 }
 
 
-#controls {
-    --controls-width: 75px;
-    --controls-padding: 5px;
-    --controls-opener-width: 15px;
-}
-#controls > div, #controls > button {
+/* ── Control bar ── */
+#control-bar {
     position: fixed;
-    overflow: hidden;
-}
-/* TODO: design square logo for microtables */
-#main-logo {
-    background-color: lightblue;
-    z-index: 999;
-    left: 0;
     bottom: 0;
-    padding: var(--controls-padding);
-    height: var(--controls-width);
-    width: var(--controls-width);
-    font-size: xxx-large;
-    font-weight: bold;
-    text-align: center;
-    border: 1px solid black;
-}
-#main-logo > img {
-    width: 100%;
-    height:100%;
-}
-#left-controls-button, #bottom-controls-button {
-    border: 1px solid black;
-    border-radius: 2px;
-    text-align: center;
-    font-weight: bold;
-    font-size: xx-large;
-    background-color: white;
-    cursor: pointer;
-    user-select: none;
-}
-#left-controls-button {
     left: 0;
-    width: calc(var(--controls-width) + 2 * var(--controls-padding) - 1px);
-    height: var(--controls-opener-width);
-    z-index: 999;
-    bottom: calc(var(--controls-width) + 2 * var(--controls-padding));
-    line-height: 3px;
-    text-align: right;
-}
-#bottom-controls-button {
-    bottom: 0;
-    height: calc(var(--controls-width) + 2 * var(--controls-padding) - 1px);
-    width: var(--controls-opener-width);
+    right: 0;
+    height: 44px;
+    display: flex;
+    align-items: center;
+    background: #f5f5f5;
+    border-top: 1px solid #ccc;
     z-index: 999;
-    left: calc(var(--controls-width) + 2 * var(--controls-padding));
-}
-#controls-left {
-    background-color: pink;
-    z-index: 998;
-    top: 0;
-    left: calc(-1 * var(--controls-width) - 2 * var(--controls-padding));
-    padding: 5px;
-    height: calc(100vh - var(--controls-padding));
-    width: var(--controls-width);
-    border-right: 1px solid black;
 }
-/*TODO: link left controls with position of the whole table*/
-#controls-left.open {
-    left: 0;
+
+#bar-logo {
+    height: 44px;
+    width: 44px;
+    flex-shrink: 0;
+    display: flex;
+    align-items: center;
+    justify-content: center;
+    border-right: 1px solid #ccc;
+}
+#bar-logo img {
+    width: 100%;
+    height: 100%;
 }
-#controls-bottom {
-    background-color: lightblue;
-    z-index: 998;
-    left: 0;
-    bottom: calc(-1 * var(--controls-width) - 2 * var(--controls-padding));
-    height: var(--controls-width);
-    padding: var(--controls-padding);
-    padding-left: calc(var(--controls-width) + 3 * var(--controls-padding) + var(--controls-opener-width));
-    width: calc(100vw - var(--controls-width) - 4 * var(--controls-padding) - var(--controls-opener-width));
-    border-top: 1px solid black;
+
+.bar-btn {
+    height: 100%;
+    padding: 0 16px;
+    border: none;
+    border-right: 1px solid #ccc;
+    background: none;
+    cursor: pointer;
+    font-size: 0.9em;
+    color: #333;
 }
-#controls-bottom.open {
-    bottom: 0;
+.bar-btn:hover {
+    background: #e8e8e8;
 }
 
-
-.control-group {
-    float: left;
-    margin: 5px;
-    background-color: lightgreen;
+/* ── About modal ── */
+#about-modal-overlay {
+    position: fixed;
+    inset: 0;
+    background: rgba(0, 0, 0, 0.45);
+    z-index: 1000;
+    display: flex;
+    align-items: center;
+    justify-content: center;
+    padding: 16px;
+}
+#about-modal {
+    background: white;
+    border-radius: 8px;
+    padding: 24px;
+    max-width: 480px;
+    width: 100%;
+    max-height: 80vh;
+    overflow-y: auto;
+    box-shadow: 0 4px 24px rgba(0,0,0,0.2);
+}
+#about-modal button {
+    margin-top: 12px;
+    padding: 8px 20px;
+    border: 1px solid #ccc;
+    border-radius: 4px;
+    background: #f5f5f5;
+    cursor: pointer;
 }
-.control-label {
-    font-size: x-small;
+#about-modal button:hover {
+    background: #e0e0e0;
 }
 
 

+ 9 - 3
frontend/shadow-cljs.edn

@@ -1,4 +1,8 @@
-{:lein   true
+{:source-paths ["src/cljs" "test" "dev"]
+
+ :dependencies [[reagent "0.9.1"]
+                [re-frame "0.11.0"]
+                [binaryage/devtools "1.0.0"]]
 
  :nrepl {:port 8777}
 
@@ -8,5 +12,7 @@
                 :modules         {:app {:init-fn microtables-frontend.core/init
                                         :preloads [devtools.preload]}}
                 :devtools        {:http-root    "resources/public"
-                                  :http-port    8280
-                                  }}}}
+                                  :http-port    8280}}
+          :test {:target    :node-test
+                 :output-to "target/test/tests.js"
+                 :ns-regexp "-test$"}}}

+ 6 - 0
frontend/src/cljs/microtables_frontend/db.cljs

@@ -1,5 +1,11 @@
 (ns microtables-frontend.db)
 
+(def min-max-row
+  20)
+
+(def min-max-col
+  "G")
+
 (def default-db
   {:controls nil
    ;TODO: add "start" and "end" corners as selection

+ 17 - 2
frontend/src/cljs/microtables_frontend/events.cljs

@@ -45,11 +45,17 @@
 
 ; handle pressing enter (move to the next cell down)
 ; tab is taken care of natively, and is good enough
+;TODO: keyboard movement should determine the size of the table, not the other way around
+;      - highest-row/col should include the position of the in-focus cell
+;      - this function should merely increment the position
+;      - add a new function for tab handling (native isn't good enough anymore)
+;TODO: handle movement within a range selection
+;TODO: handle movement with arrow keys
 (re-frame/reg-event-fx
  ::press-enter-in-cell
  (fn [{:keys [db]} [_ c r]]
-   (let [max-row? (= (coords/highest-row (:table-data db)) r)
-         max-col? (= (coords/highest-col (:table-data db)) c)
+   (let [max-row? (= (coords/highest-row db) r)
+         max-col? (= (coords/highest-col db) c)
          new-col (if max-row?
                    (if max-col?
                      "A"
@@ -61,6 +67,15 @@
      (println "::press-enter-in-cell" c r)
      {:focus-on-cell [new-col new-row]})))
 
+(re-frame/reg-event-fx
+ ::press-shift-enter-in-cell
+ (fn [{:keys [db]} [_ c r]]
+   (when-not (and (= r 1) (= c "A"))
+     (let [min-row? (= r 1)
+           new-col (if min-row? (coords/prev-letter c) c)
+           new-row (if min-row? (coords/highest-row db) (dec r))]
+       {:focus-on-cell [new-col new-row]}))))
+
 (re-frame/reg-fx
  :focus-on-cell
  (fn [[c r]]

+ 16 - 7
frontend/src/cljs/microtables_frontend/utils/coordinates.cljs

@@ -1,24 +1,30 @@
-(ns microtables-frontend.utils.coordinates)
+(ns microtables-frontend.utils.coordinates
+  (:require
+   [microtables-frontend.db :as db]))
 
 (defn highest-col
   "Return the highest column (letter) for which there is a non-empty cell"
-  [data]
+  [db]
   ; choose the "max" (alphabetical order) value among the longest keys
-  (->> data
+  (->> db
+       :table-data
        keys
        (group-by #(.-length %))
        (apply max-key key)
        val
+       (concat [db/min-max-col])
        (apply max)))
 
 (defn highest-row
   "Return the highest row (number) for which there is a non-empty cell"
-  [data]
+  [db]
   ; get all the row keys from all the column objects (and flatten), then pick the max
-  (->> data
+  (->> db
+       :table-data
        vals
        (map keys)
        flatten
+       (concat [db/min-max-row])
        (apply max)))
 
 (defn- increment-letter-code [s]
@@ -38,6 +44,9 @@
 (def col-letters
   (iterate next-letter "A"))
 
+(defn prev-letter [lc]
+  (last (take-while #(not= % lc) col-letters)))
+
 (defn- order-two-cols
   "Accepts two column names (letters) and returns them in order."
   [col1 col2]
@@ -64,6 +73,6 @@
   [range-string]
   (let [col1 (second (re-find #"\(\s*([A-Z]+)" range-string))
         col2 (second (re-find #":\s*([A-Z]+)" range-string))
-        row1 (.parseInt js/window (second (re-find #"([0-9]+)\s*:" range-string)))
-        row2 (.parseInt js/window (second (re-find #"([0-9]+)\s*\)" range-string)))]
+        row1 (js/parseInt (second (re-find #"([0-9]+)\s*:" range-string)))
+        row2 (js/parseInt (second (re-find #"([0-9]+)\s*\)" range-string)))]
     (range->list col1 row1 col2 row2)))

+ 2 - 2
frontend/src/cljs/microtables_frontend/utils/data.cljs

@@ -27,14 +27,14 @@
 
 (def parse-variables (memoize (fn [expression]
                                 (as-> (js->clj (.parse mathjs (replace-ranges-in-expression expression))) $
-                                  (.filter $ #(true? (.-isSymbolNode %)))
+                                  (.filter $ #(true? (.-isSymbolNode ^js %)))
                                   (map #(.-name %) $)
                                   (map #(.toUpperCase %) $)
                                   (filter #(re-matches #"[A-Z]+[0-9]+" %) $)))))
 
 (def str->rc (memoize (fn [s]
                         (let [c (re-find #"^[A-Z]+" s)
-                              r (.parseInt js/window (re-find #"[0-9]+$" s))]
+                              r (js/parseInt (re-find #"[0-9]+$" s))]
                           {:row r :col c}))))
 
 ; leave in the :inbound references, since they probably have not have changed

+ 2 - 2
frontend/src/cljs/microtables_frontend/views.cljs

@@ -8,8 +8,8 @@
 (defn main-panel []
   (let [data (re-frame/subscribe [::subs/table-data])
         controls-state (re-frame/subscribe [::subs/controls-state])]
-    [:div
-     [sheet @data]
+    [:div#main-layout
+     [:div.sheet-container [sheet @data]]
      [control-panel @controls-state]]))
 
 

+ 16 - 57
frontend/src/cljs/microtables_frontend/views/control_panel.cljs

@@ -3,62 +3,21 @@
    [microtables-frontend.events :as events]
    [re-frame.core :as re-frame]))
 
-(defn- extend-range
-  []
-  [:div {:class "control-group"}
-   [:div {:class "control-label"}
-    "Extend Range"]
-   [:button "-cols"]
-   [:input]
-   [:button]])
-
-(defn- controls []
-  [:div
-   ;[extend-range]
-   ;extend range (number field, then shrink/left/up and expand/right/down buttons on either side)
-   ;fill range (single tap, single column/row fill? number field and button?)
-   ;move range (designate new "top-left corner" cell for range, figure out overwriting destination cells)
-   ;empty range (delete contents, re-evaluate)
-   ;delete range (figure out how to move cells over)
-   ;copy range (figure out clipboard)
-   [:div
-    {:class "control-group"}
-    #_[:a ;TODO: consider making the "About" info an overlay rather than a link
-       {:href "/about.html"
-        :target "_blank"}
-       "About"]
-    "This is a demo version only, and still in development. Nothing gets saved for the moment."
-    [:br]
-    "Try adding values (plain numbers) or formulae (ex: \"=B2 + sum(A1:A6)\") into the cells."
-    [:br]
-    "Created by "
-    [:a
-     {:href "https://betweentwocommits.com/about"
-      :target "_blank"}
-     "Brandon Wong"]]])
+(defn- about-modal []
+  [:div#about-modal-overlay
+   {:on-click #(re-frame/dispatch [::events/set-controls-state nil])}
+   [:div#about-modal
+    {:on-click #(.stopPropagation %)}
+    [:p "This is a demo version only, and still in development. Nothing gets saved for the moment."]
+    [:p "Try adding values (plain numbers) or formulae (ex: \"=B2 + sum(A1:A6)\") into the cells."]
+    [:p "Created by "
+     [:a {:href "https://betweentwocommits.com/about" :target "_blank"} "Brandon Wong"]]
+    [:button {:on-click #(re-frame/dispatch [::events/set-controls-state nil])} "Close"]]])
 
 (defn control-panel [state]
-  [:div
-   {:id "controls"}
-   [:div
-    ;TODO: link left controls with position of the whole table
-    {:id "controls-left"
-     :class (if (= state :left) "open" "")}
-    [controls]]
-   [:div
-    {:id "controls-bottom"
-     :class (if (= state :bottom) "open" "")}
-    [controls]]
-   ;←↑→↓
-   [:div
-    {:id "left-controls-button"
-     :on-click #(re-frame/dispatch [::events/set-controls-state (if (= state :left) nil :left)])}
-    (if (= state :left) "←" "→")]
-   [:div
-    {:id "bottom-controls-button"
-     :on-click #(re-frame/dispatch [::events/set-controls-state (if (= state :bottom) nil :bottom)])}
-    (if (= state :bottom) "↓" "↑")]
-   [:div
-    {:id "main-logo"
-     :title "Microtables"}
-    [:img {:src "logo.svg"}]]])
+  [:div#control-bar
+   [:div#bar-logo [:img {:src "logo.svg" :alt "Microtables"}]]
+   [:button.bar-btn
+    {:on-click #(re-frame/dispatch [::events/set-controls-state :about])}
+    "About"]
+   (when (= state :about) [about-modal])])

+ 35 - 17
frontend/src/cljs/microtables_frontend/views/sheet.cljs

@@ -4,25 +4,43 @@
    [microtables-frontend.utils.coordinates :as coords]
    [re-frame.core :as re-frame]))
 
-(defn cell [c r data]
-  (let [datum (get-in data [c r])]
+;TODO: expand selection/highlight mode
+;      - directional controls
+;        - arrow keys, enter, tab, shift-enter, shift-tab
+;        - enter key triggers editing (?)
+;      - special controls
+;        - expand selection
+;        - move selection
+;        - delete selection (neighbouring cells displaced in)
+;        - clear selection (cells become blank)
+;        - continue selection (intelligent sequence continuation)
+;      - common functions (sum, avg, etc)
+;        - display numeric answer in menu
+;        - offer to put the answer (in formula form) in a cell (to be chosen by clicking)
+(defn cell [c r col-idx maxrow data]
+  (let [datum (get-in data [c r])
+        touch-device? (pos? (.. js/navigator -maxTouchPoints))]
     ^{:key (str c r)} [:td
-                       [:input {:id (str c r)
-                                :class (if (= (:view datum) :highlighted) "highlighted" "")
-                                :value (if (= (get datum :view nil) :value)
-                                         (get datum :value "")
-                                         (get datum :error (get datum :display (get datum :value ""))));TODO: add "highlight" display mode (possibly just a css class)
-                                :on-change #(re-frame/dispatch [::events/edit-cell-value c r (.. % -target -value)])
-                                :on-focus #(re-frame/dispatch [::events/movement-enter-cell c r])
-                                :on-blur #(re-frame/dispatch [::events/movement-leave-cell c r])
-                                :on-keyPress #(when (= (.. % -which) 13)
-                                                (re-frame/dispatch [::events/press-enter-in-cell c r]))}]]))
+                       [:input (cond-> {:id (str c r)
+                                        :class (if (= (:view datum) :highlighted) "highlighted" "")
+                                        :value (if (= (get datum :view nil) :value)
+                                                 (get datum :value "")
+                                                 (get datum :error (get datum :display (get datum :value ""))));TODO: add "highlight" display mode (possibly just a css class)
+                                        :on-change #(re-frame/dispatch [::events/edit-cell-value c r (.. % -target -value)])
+                                        :on-focus #(re-frame/dispatch [::events/movement-enter-cell c r])
+                                        :on-blur #(re-frame/dispatch [::events/movement-leave-cell c r])
+                                        :on-keyPress #(when (= (.. % -which) 13)
+                                                        (if (.. % -shiftKey)
+                                                          (re-frame/dispatch [::events/press-shift-enter-in-cell c r])
+                                                          (re-frame/dispatch [::events/press-enter-in-cell c r])))}
+                                 ; column-major tabindex on touch devices so "Next" moves down instead of right
+                                 touch-device? (assoc :tab-index (+ (* col-idx maxrow) r)))]]))
 
-(defn row [r cols data]
+(defn row [r cols maxrow data]
   ^{:key (str "row-" r)} [:tr
                           (cons
                            ^{:key (str "row-head-" r)} [:th (str r)]
-                           (map #(cell % r data) cols))])
+                           (map-indexed #(cell %2 r %1 maxrow data) cols))])
 
 (defn header-row [cols]
   ^{:key "header"} [:tr
@@ -36,9 +54,9 @@
    [:tbody
     ;TODO: figure out appropriate starting values for maxrow and maxcol (maybe keep them intentionally small)
     ;TODO: figure out movement (maybe allow scroll overflow)
-    (let [maxrow 50;(coords/highest-row data)
-          maxcol "Z";(coords/highest-col data)
+    (let [maxrow 20;(coords/highest-row data)
+          maxcol "G";(coords/highest-col data)
           cols (take-while (partial not= (coords/next-letter maxcol)) coords/col-letters)]
       (cons
        (header-row cols)
-       (map #(row % cols data) (range 1 (inc maxrow)))))]])
+       (map #(row % cols maxrow data) (range 1 (inc maxrow)))))]])

+ 107 - 0
frontend/test/microtables_frontend/evaluation_test.cljs

@@ -0,0 +1,107 @@
+(ns microtables-frontend.evaluation-test
+  (:require
+   [cljs.test :refer-macros [deftest testing is]]
+   [microtables-frontend.evaluation :as evaluation]
+   [microtables-frontend.utils.data :as data-utils]))
+
+(defn prepare-and-evaluate
+  "Wire up all references and evaluate all formula cells in a raw table-data map."
+  [raw-data]
+  (-> raw-data
+      (data-utils/walk-modify-data
+       (fn [_c _r datum]
+         (if (data-utils/formula? (:value datum))
+           (assoc datum :dirty true)
+           datum)))
+      data-utils/create-all-references
+      evaluation/create-all-back-references
+      evaluation/evaluate-all))
+
+;; --- Literal values ---
+
+(deftest literal-value-is-preserved
+  (let [data (prepare-and-evaluate {"A" {1 {:value "42"}}})]
+    (is (= "42" (get-in data ["A" 1 :value])))))
+
+;; --- Basic arithmetic ---
+
+(deftest pure-arithmetic-formula
+  (let [data (prepare-and-evaluate {"A" {1 {:value "=1+2"}}})]
+    (is (= 3 (get-in data ["A" 1 :display])))))
+
+(deftest arithmetic-multiplication
+  (let [data (prepare-and-evaluate {"A" {1 {:value "=3*4"}}})]
+    (is (= 12 (get-in data ["A" 1 :display])))))
+
+;; --- Cell references ---
+
+(deftest single-cell-reference
+  (let [data (prepare-and-evaluate {"A" {1 {:value "5"}}
+                                    "B" {1 {:value "=A1+3"}}})]
+    (is (= 8 (get-in data ["B" 1 :display])))))
+
+(deftest chained-references
+  (let [data (prepare-and-evaluate {"A" {1 {:value "2"}}
+                                    "B" {1 {:value "=A1*3"}}
+                                    "C" {1 {:value "=B1+1"}}})]
+    (is (= 7 (get-in data ["C" 1 :display])))))
+
+(deftest multi-cell-reference-in-formula
+  (let [data (prepare-and-evaluate {"A" {1 {:value "10"}}
+                                    "B" {1 {:value "20"}}
+                                    "C" {1 {:value "=A1+B1"}}})]
+    (is (= 30 (get-in data ["C" 1 :display])))))
+
+;; --- Range functions ---
+
+(deftest sum-contiguous-range
+  (let [data (prepare-and-evaluate {"A" {1 {:value "1"}
+                                         2 {:value "2"}
+                                         3 {:value "3"}}
+                                    "B" {1 {:value "=sum(A1:A3)"}}})]
+    (is (= 6 (get-in data ["B" 1 :display])))))
+
+(deftest average-range
+  (let [data (prepare-and-evaluate {"A" {1 {:value "2"}
+                                         2 {:value "4"}
+                                         3 {:value "6"}}
+                                    "B" {1 {:value "=average(A1:A3)"}}})]
+    (is (= 4 (get-in data ["B" 1 :display])))))
+
+(deftest sum-with-empty-cell-in-range
+  ; A2 is absent — treated as 0 and excluded from the range by preprocess-expression,
+  ; so sum(A1,A3) = 5 + 10 = 15
+  (let [data (prepare-and-evaluate {"A" {1 {:value "5"}
+                                         3 {:value "10"}}
+                                    "B" {1 {:value "=sum(A1:A3)"}}})]
+    (is (= 15 (get-in data ["B" 1 :display])))))
+
+;; --- Cycle detection ---
+
+(deftest mutual-cycle-marked-as-error
+  (let [data (prepare-and-evaluate {"A" {1 {:value "=B1"}}
+                                    "B" {1 {:value "=A1"}}})]
+    (is (= :cycle-error (get-in data ["A" 1 :display])))
+    (is (= :cycle-error (get-in data ["B" 1 :display])))))
+
+(deftest self-reference-marked-as-cycle
+  (let [data (prepare-and-evaluate {"A" {1 {:value "=A1"}}})]
+    (is (= :cycle-error (get-in data ["A" 1 :display])))))
+
+(deftest three-cell-cycle
+  (let [data (prepare-and-evaluate {"A" {1 {:value "=C1"}}
+                                    "B" {1 {:value "=A1"}}
+                                    "C" {1 {:value "=B1"}}})]
+    (is (= :cycle-error (get-in data ["A" 1 :display])))))
+
+;; --- Error propagation ---
+
+(deftest calc-error-on-invalid-formula
+  (let [data (prepare-and-evaluate {"A" {1 {:value "=notafunction()"}}})]
+    (is (= :calc-error (get-in data ["A" 1 :display])))))
+
+;; --- Non-formula cell has no :display ---
+
+(deftest non-formula-has-no-display
+  (let [data (prepare-and-evaluate {"A" {1 {:value "hello"}}})]
+    (is (nil? (get-in data ["A" 1 :display])))))

+ 90 - 0
frontend/test/microtables_frontend/events_test.cljs

@@ -0,0 +1,90 @@
+(ns microtables-frontend.events-test
+  (:require
+   [cljs.test :refer-macros [deftest testing is]]
+   [microtables-frontend.evaluation :as evaluation]
+   [microtables-frontend.utils.data :as data-utils]))
+
+; Tests for the state-transition pipelines that event handlers compose.
+; We test the pure functions directly rather than going through re-frame dispatch.
+; The event handlers in events.cljs are thin wrappers around these functions.
+
+(defn initial-table
+  "Build a fully initialised table-data map (references wired, all formulas evaluated)."
+  [raw-data]
+  (-> raw-data
+      (data-utils/walk-modify-data
+       (fn [_c _r datum]
+         (if (data-utils/formula? (:value datum))
+           (assoc datum :dirty true)
+           datum)))
+      data-utils/create-all-references
+      evaluation/create-all-back-references
+      evaluation/evaluate-all))
+
+;; --- Mirrors ::edit-cell-value then ::movement-leave-cell ---
+
+(deftest editing-value-then-leaving-triggers-downstream-reevaluation
+  ; Start: A1=3, B1=A1*2 → B1 displays 6
+  ; Edit A1 to 10, then leave → B1 should display 20
+  (let [table   (initial-table {"A" {1 {:value "3"}}
+                                "B" {1 {:value "=A1*2"}}})
+        updated (-> table
+                    (data-utils/change-datum-value "A" 1 "10")
+                    (evaluation/reset-references "A" 1)
+                    (evaluation/evaluate-from-cell "A" 1))]
+    (is (= 20 (get-in updated ["B" 1 :display])))))
+
+(deftest edit-cell-value-marks-dirty-without-evaluating
+  ; change-datum-value (::edit-cell-value) only updates :value and marks :dirty;
+  ; it does not produce a :display value.
+  (let [table   (initial-table {"A" {1 {:value "3"}}})
+        updated (data-utils/change-datum-value table "A" 1 "=1+2")]
+    (is (= "=1+2" (get-in updated ["A" 1 :value])))
+    (is (= true (get-in updated ["A" 1 :dirty])))
+    (is (nil? (get-in updated ["A" 1 :display])))))
+
+(deftest downstream-cells-marked-dirty-on-edit
+  ; When A1 is edited, B1 (which depends on A1) should also be marked dirty.
+  (let [table   (initial-table {"A" {1 {:value "5"}}
+                                "B" {1 {:value "=A1+1"}}})
+        updated (data-utils/change-datum-value table "A" 1 "99")]
+    (is (= true (get-in updated ["B" 1 :dirty])))))
+
+;; --- Mirrors ::movement-leave-cell reference graph update ---
+
+(deftest formula-change-removes-old-back-reference
+  ; B1 starts referencing A1. When we change B1 to reference C1,
+  ; A1's :inbound should no longer include B1.
+  (let [table   (initial-table {"A" {1 {:value "5"}}
+                                "B" {1 {:value "=A1"}}})
+        updated (-> table
+                    (data-utils/change-datum-value "B" 1 "=C1+1")
+                    (evaluation/reset-references "B" 1)
+                    (evaluation/evaluate-from-cell "B" 1))]
+    (is (not (some #(= % {:col "B" :row 1})
+                   (get-in updated ["A" 1 :inbound]))))))
+
+(deftest formula-change-adds-new-back-reference
+  ; Same scenario: after B1 is changed to reference C1, C1's :inbound should include B1.
+  (let [table   (initial-table {"A" {1 {:value "5"}}
+                                "B" {1 {:value "=A1"}}})
+        updated (-> table
+                    (data-utils/change-datum-value "B" 1 "=C1+1")
+                    (evaluation/reset-references "B" 1)
+                    (evaluation/evaluate-from-cell "B" 1))]
+    (is (some #(= % {:col "B" :row 1})
+              (get-in updated ["C" 1 :inbound])))))
+
+;; --- Deeply nested dependency propagation ---
+
+(deftest deep-dependency-chain-reevaluated
+  ; A1 → B1 → C1 → D1; editing A1 should propagate all the way to D1.
+  (let [table   (initial-table {"A" {1 {:value "1"}}
+                                "B" {1 {:value "=A1+1"}}
+                                "C" {1 {:value "=B1+1"}}
+                                "D" {1 {:value "=C1+1"}}})
+        updated (-> table
+                    (data-utils/change-datum-value "A" 1 "10")
+                    (evaluation/reset-references "A" 1)
+                    (evaluation/evaluate-from-cell "A" 1))]
+    (is (= 13 (get-in updated ["D" 1 :display])))))

+ 367 - 0
todo.md

@@ -0,0 +1,367 @@
+# Microtables – TODO
+
+## 1. Keyboard Navigation & Table Bounds
+
+- [ ] **1.1 Decouple table size from keyboard movement**
+
+  Currently `press-enter-in-cell` uses `highest-row`/`highest-col` to decide where to move, coupling navigation to data extent. Intended design:
+  - `highest-row`/`highest-col` should include the cursor's current position
+  - `press-enter-in-cell` should only increment the position
+  - Add a dedicated event handler for Tab (native Tab is no longer sufficient)
+
+- [ ] **1.2 Add arrow-key navigation**
+
+  Implement `ArrowUp`, `ArrowDown`, `ArrowLeft`, `ArrowRight` handlers that move the cursor between cells, firing from `on-keyDown` (not `on-keyPress`). Navigation logic should live alongside Enter/Tab handling (1.1).
+
+- [ ] **1.3 Determine initial table size and scroll behaviour**
+
+  `maxrow = 20` and `maxcol = "G"` are hardcoded. Decide whether the grid grows as the cursor reaches the edge or stays fixed with a scroll container. A fixed viewport with overflow scroll is likely right for the quick-calculation use case.
+
+- [ ] **1.4 Movement within a range selection**
+
+  When a range is selected, Enter/Tab/arrow keys should cycle within the selection rather than the full table. Depends on the selection model (see section 5).
+
+---
+
+## 2. Formula Evaluation Engine
+
+- [ ] **2.1 Support lowercase cell references**
+
+  `parse-variables` already uppercases symbol names, but confirm end-to-end that typing `=a1 + b2` in a cell evaluates correctly.
+
+- [ ] **2.2 Clarify dirty-forward-reference error semantics**
+
+  `gather-variables-and-evaluate-cell` returns `:error` when forward references are dirty, but the intended semantics vs. `:insufficient-data` are not fully specified. Clarify and document the distinction.
+
+- [ ] **2.3 Verify `evaluate-all` handles cycles correctly**
+
+  `evaluate-all` calls `evaluate-from-cell` which runs `find-cycle`, so cycles should be caught at initialization. Confirm this is sufficient and clean up the open question.
+
+- [ ] **2.4 Handle multiple cells modified simultaneously**
+
+  The model processes one cell change at a time. A batch-update path will be needed for paste operations or future server sync.
+
+- [ ] **2.5 Consider incremental diff for back-reference updates**
+
+  `denotify-references` + `notify-references` wipe and re-add all back-references on every edit. Diffing old vs. new `:refs` and updating only the delta may help at scale.
+
+- [ ] **2.6 Consider memoizing `find-cycle`**
+
+  Evaluate whether a dynamic memoization strategy with cache invalidation on data change is worth the complexity.
+
+---
+
+## 3. Tests
+
+- [x] **3.1 Unit tests for formula evaluation**
+
+  Implemented in `frontend/test/microtables_frontend/evaluation_test.cljs` using `cljs.test` + shadow-cljs `:node-test` target. Covers:
+  - Basic arithmetic formulas
+  - Cell references and chained references
+  - Range functions (`sum`, `average`, etc.)
+  - Cycle detection (mutual cycles, self-reference, three-cell cycles)
+  - Error propagation (`:calc-error`, `:cycle-error`)
+  - Edge cases: empty cells in ranges, non-formula cells
+  - Run with `npm test` in `frontend/`
+
+- [x] **3.2 Integration tests for state transitions**
+
+  Implemented in `frontend/test/microtables_frontend/events_test.cljs`. Tests the pure function pipelines that event handlers compose (not through re-frame dispatch). Covers:
+  - Editing a cell and verifying downstream re-evaluation
+  - `change-datum-value` marks dirty without evaluating
+  - Reference graph updates when a formula changes (old refs removed, new refs added)
+  - Deeply nested dependency chain propagation
+
+- [ ] **3.3 End-to-end tests**
+
+  Browser-level tests covering full user flows:
+  - Data entry and formula results visible in cells
+  - Keyboard navigation
+  - Range selection operations (once implemented)
+  - Touch interactions (once implemented)
+
+---
+
+## 4. Formula Autocomplete
+
+- [ ] **4.1 Show available math functions after `=` is typed**
+
+  When a cell value begins with `=`, display a dropdown of available math.js functions. Filter the list as the user continues typing. Selecting an item inserts the function name with a placeholder for arguments.
+
+- [ ] **4.2 Cell and range reference autocomplete**
+
+  As the user types a column letter that could start a cell reference (e.g. `A`), suggest cell addresses that contain data. For ranges, trigger suggestions when `:` follows a cell reference.
+
+- [ ] **4.3 Argument hint tooltip**
+
+  Inside a function call, show a tooltip indicating expected arguments and their types (e.g. `sum(value1, value2, ...)`).
+
+---
+
+## 5. Selection & Highlight Mode
+
+- [ ] **5.1 Implement the selection data model**
+
+  The `:selection` key in `:position` is always `nil`. Implement `{:start {:col "A" :row 1} :end {:col "C" :row 3}}` as the selection shape. Set on shift+arrow or click-drag; clear on unmodified navigation.
+
+- [ ] **5.2 Wire up highlight rendering for selected cells**
+
+  `highlight-cells` in `subs.cljs` already sets `:view :highlighted` on cells within a selection range, but the `::table-data` subscription never calls it when `:selection` is non-nil. Connect the two so selected cells receive the `highlighted` CSS class.
+
+- [ ] **5.3 Aggregate display for a selection**
+
+  When a numeric range is selected, display the sum and average in the UI (e.g. a status bar or floating chip). Offer to insert the result as a formula into a user-chosen cell.
+
+---
+
+## 6. Cell & Range Control Buttons
+
+When a cell or range is selected, show a compact set of floating action buttons positioned around the selection. Buttons must be large enough to tap comfortably on a phone screen. Implement in this order:
+
+- [ ] **6.1 Expand / contract selection** — drag handles or +/− buttons to resize the selection boundary
+- [ ] **6.2 Calculate** — show sum/average inline for numeric selections (ties into 5.3)
+- [ ] **6.3 Clear** — delete cell contents and re-evaluate dependents
+- [ ] **6.4 Copy** — copy selection to the clipboard
+- [ ] **6.5 Move** — designate a new top-left cell; handle destination conflicts
+- [ ] **6.6 Delete** — remove cells and displace neighbours inward
+- [ ] **6.7 Intelligent fill** — trigger pattern inference (see section 10)
+
+---
+
+## 7. Column & Row Aggregates
+
+*Under consideration.*
+
+- [ ] **7.1 Always-visible column totals row**
+
+  A pinned row below the table showing the sum (and possibly average) of each column, updating reactively. Show only for columns that contain numbers.
+
+- [ ] **7.2 Always-visible row totals column**
+
+  Equivalent pinned column to the right of the table for row aggregates.
+
+---
+
+## 8. UI & Control Panel
+
+- [ ] **8.2 Add a "Help" modal**
+
+  A small modal (triggered from the control panel) listing and explaining:
+  - Supported formula syntax
+  - Available math functions
+  - Keyboard shortcuts
+  - How cell references and ranges work
+
+- [ ] **8.4 Replace the logo**
+
+  The current `logo.svg` is a placeholder. Design and replace with a better mark.
+
+---
+
+## 13. Visual Design & Styling
+
+The goal is intentional minimalism — not bare HTML. The current stylesheet has good structural foundations but is visibly unfinished: placeholder colours throughout, a content-template base, and unstyled interactive elements. Items are ordered so that each one builds on the previous.
+
+### Foundational
+
+- [ ] **13.1 Establish a CSS design token set**
+
+  Define all colours, spacing, and radii as CSS custom properties on `:root` before touching anything else. Suggested palette:
+  - `--color-bg: #f9fafb` (page background)
+  - `--color-surface: #ffffff` (cell / panel background)
+  - `--color-border: #e5e7eb` (grid lines, panel edges)
+  - `--color-header-bg: #f3f4f6` (row/column headers)
+  - `--color-text: #111827` (primary text)
+  - `--color-text-muted: #6b7280` (header labels, hints)
+  - `--color-accent: #3b82f6` (focus ring, selection border)
+  - `--color-accent-subtle: #eff6ff` (selection fill, hover tint)
+
+- [ ] **13.2 Strip out content-template CSS**
+
+  The stylesheet was adapted from a blog template. Remove or replace:
+  - `max-width: 600px` on `body` — clips the table on most screens; a spreadsheet should use available width
+  - `h1`, `h2`, `h3` type scale — not used in the app
+  - `line-height: 1.5em` on `body` — intended for prose, not a grid
+  - `a` tag styles (`text-decoration`, `color: #09f`) — not relevant to the app chrome
+
+- [ ] **13.3 Replace all placeholder colours**
+
+  Several elements have placeholder colours that were clearly never updated:
+  - `#controls-left`: `background-color: pink` → `var(--color-surface)`
+  - `#controls-bottom`: `background-color: lightblue` → `var(--color-surface)`
+  - `#main-logo`: `background-color: lightblue` → transparent (let the SVG stand alone)
+  - `.control-group`: `background-color: lightgreen` → remove; use spacing to separate groups
+
+- [ ] **13.4 Change grid lines from black to the border token**
+
+  Replace `border: 1px solid black` on `td, th` with `border: 1px solid var(--color-border)`. This is the single highest-impact change to the table's appearance.
+
+### Typography
+
+- [ ] **13.5 Switch to a system font stack**
+
+  Replace the current `'Helvetica Neue', Verdana, Helvetica, Arial, sans-serif` with `system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif`. This makes the app feel native on each platform rather than web-circa-2010.
+
+- [ ] **13.6 Use monospace for cell values**
+
+  Numbers in a proportional font look accidental; numbers in a monospace font look deliberate. Apply `font-family: 'SF Mono', ui-monospace, 'Fira Code', monospace` to `td input` and use a slightly smaller size (around `0.875rem`) relative to the header labels.
+
+### Grid & Cells
+
+- [ ] **13.7 Make inputs fill their cells**
+
+  `td input` currently has a fixed `width: 80px`, leaving dead space in the `<td>` that does not respond to clicks. Set `width: 100%` and ensure `box-sizing: border-box` so the input expands to fill the full cell width.
+
+- [ ] **13.8 Refine hover and focus states**
+
+  Current states (`#ccc` hover, `#ccf` focus background) are heavy and generic. Suggested approach:
+  - Hover: `var(--color-accent-subtle)` — a very light tint
+  - Focus: remove the background change entirely; rely on the `smartborder` ring (13.9) as the focus indicator, which avoids the "form field in a table" feeling
+
+- [ ] **13.9 Style the `smartborder` selection ring**
+
+  The `smartborder` concept (an absolutely-positioned overlay extending ~2px beyond the cell boundary) is the right approach — it mirrors how professional spreadsheets handle selection. Replace the current `3px solid blue` with `2px solid var(--color-accent)` and ensure `pointer-events: none` so it does not interfere with clicking adjacent cells.
+
+- [ ] **13.10 Style the `smartborder` action buttons**
+
+  The range-action buttons (extend, fill, move, empty, delete, copy) are currently unstyled blue circles. Redesign as small, clean pill buttons: white background, `var(--color-border)` border, `var(--color-text)` icon or label, with a shadow on hover. Size them to be comfortably tappable (minimum 32×32px touch target).
+
+- [ ] **13.11 Differentiate row and column headers**
+
+  Apply `background: var(--color-header-bg)` and `color: var(--color-text-muted)` to `th`. Remove bold weight — a medium-weight muted label reads as a header without competing with cell content.
+
+### Control Panel & Chrome
+
+- [ ] **13.12 Redesign the panel toggle buttons**
+
+  `#left-controls-button` and `#bottom-controls-button` currently render as `xx-large` bold unicode arrows with a 2px border radius — too raw. Replace with small, refined pill or tab buttons: consistent padding, `var(--color-border)` border, `border-radius: 6px`, normal font weight, subtle hover state.
+
+---
+
+## 9. Touch Support
+
+- [ ] **9.1 Make cells tap-friendly**
+
+  Ensure cells are large enough to tap accurately on mobile. Tap to select, double-tap or long-press to enter edit mode. The current `on-focus`/`on-blur` model may need adjustment for touch events.
+
+- [ ] **9.2 Touch-friendly range selection**
+
+  Implement tap-and-drag or tap-then-drag-handle selection. The floating control buttons (section 6) should be comfortably tappable.
+
+- [ ] **9.3 Responsive layout**
+
+  Adapt the layout to small screens: control panel sidebars should collapse by default on mobile; consider a bottom sheet instead of a side panel.
+
+- [ ] **9.4 Software keyboard handling**
+
+  On mobile, the software keyboard resizes the viewport and may conflict with cell navigation. Ensure formula entry and cell-to-cell movement work correctly with a software keyboard open.
+
+---
+
+## 10. Intelligent Fill
+
+*Longer-term.*
+
+- [ ] **10.1 Pattern inference from a selection**
+
+  When a user selects a range and triggers fill (via the control button in 6.7), infer the pattern (arithmetic sequence, geometric, repeated value, date sequence, etc.) and propose a continuation into adjacent empty cells.
+
+- [ ] **10.2 Fill preview and confirmation**
+
+  Show a preview of the proposed fill before committing, allowing the user to accept, adjust the inferred pattern, or cancel.
+
+---
+
+## 11. Data Persistence
+
+*Long-term consideration — not yet committed.*
+
+- [ ] **11.1 Local storage persistence**
+
+  Save the table to the browser's `localStorage` on change and restore on page load. Provides durable-but-local storage with no backend required.
+
+- [ ] **11.2 Server-side persistence**
+
+  Extend the Express server to store table data, with considerations for:
+  - Authentication / account model
+  - Paid vs. free tier
+  - Expiry / TTL on stored tables
+  - Conflict resolution for future collaboration
+
+- [ ] **11.3 Table sharing**
+
+  Allow a user to share a table with others via a link. Requires server-side persistence (11.2). Considerations:
+  - Read-only vs. editable share links
+  - Expiry / revocation of share links
+  - Real-time collaboration (multiple users editing simultaneously)
+
+---
+
+## 12. Code Health & Refactoring
+
+
+### Bugs & Correctness
+
+- [ ] **12.1 Fix duplicate `:inbound` entries**
+
+  `notify-references` uses `conj` on a list with no deduplication. A formula like `=A1 + A1` adds A1's back-reference twice. `denotify-references` removes only the first match via `filter`, leaving one stale copy. Use a set for `:inbound`, or deduplicate on insert.
+
+- [ ] **12.2 Replace lazy seq in `:inbound` with a concrete collection**
+
+  `denotify-references` stores the result of `(partial filter ...)` — a lazy sequence — into the app db. Lazy seqs in re-frame state can cause subtle equality and inspection issues. Replace with `(into [] (filter ...))`.
+
+- [ ] **12.3 Add a termination guard to `evaluate-all`**
+
+  The retry loop — `(recur data (concat (rest queue) (list cur)))` — re-queues cells with unsatisfied dependencies indefinitely. Cycle detection in `evaluate-from-cell` should prevent an infinite loop in practice, but there is no explicit guard. Add a visited set or iteration counter as a safety net.
+
+- [ ] **12.10 Fix string comparison in `highest-col` and `order-two-cols`**
+
+  Both functions call `(apply max ...)` on sequences of column-letter strings. ClojureScript's `max` is defined for numbers only — it does not perform lexicographic comparison on strings. This likely produces incorrect results for any column comparison and will break entirely once multi-letter columns (AA, AB, …) are introduced. Replace with an explicit comparator (e.g. `(apply max-key #(vector (count %) %) ...)` or a sort-based approach).
+
+- [ ] **12.11 Make `parse-range` fail loudly on malformed input**
+
+  `parse-range` runs four independent `re-find` calls on the same string. If any one fails to match, it silently passes `nil` into `range->list`, which cascades into bad data with no error surfaced to the user. Either validate the full string with a single comprehensive regex up front, or add an assertion/guard that all four captures succeeded.
+
+### Deprecations
+
+- [ ] **12.4 Replace deprecated `on-keyPress` with `on-key-down`**
+
+  The cell's enter-key handler in `views/sheet.cljs` uses `:on-keyPress`, which is deprecated in both React and the underlying browser API. Move to `:on-key-down`.
+
+- [ ] **12.12 Replace deprecated `reagent/render` with `reagent.dom/render`**
+
+  `core.cljs` calls `reagent/render`, which was deprecated in Reagent 1.x in favour of `reagent.dom/render`. Verify the installed Reagent version and update accordingly.
+
+### Performance
+
+- [ ] **12.5 Address unbounded `evaluate-expression` memoization cache**
+
+  `evaluate-expression` is memoized on `(expression, variables-map)`. Every unique combination ever computed is cached for the lifetime of the page. In a long editing session this becomes a gradual memory leak. Consider a bounded LRU cache or explicit cache invalidation.
+
+### Cleanup
+
+- [ ] **12.6 Remove `println` debug statements**
+
+  Every event handler and subscription (`::table-data` in particular) fires `println` on every call. This floods the console and makes debugging new code harder. Remove or gate behind a dev-mode flag.
+
+- [ ] **12.7 Consolidate hardcoded table dimensions**
+
+  `maxrow = 20` and `maxcol = "G"` are hardcoded in `views/sheet.cljs` while `min-max-row` and `min-max-col` defined in `db.cljs` go unused. The view should read from the db values, leaving a single source of truth. (Related: 1.3.)
+
+### Refactoring
+
+- [ ] **12.8 Break up `gather-variables-and-evaluate-cell`**
+
+  At ~35 lines with six boolean flags and a multi-branch `cond`, the function is near its readability limit. Adding another error type or evaluation mode will tip it over. Consider extracting the disqualification checks and the variable-collection step into named helpers.
+
+- [ ] **12.9 Rewrite `create-all-references` to use `walk-modify-data`**
+
+  Both functions perform a `reduce-kv` double-walk over the data map. Eliminate the duplication by having `create-all-references` call `walk-modify-data`.
+
+- [ ] **12.13 Convert `main-panel` to a form-2 Reagent component**
+
+  `main-panel` in `views.cljs` is a form-1 component that calls `re-frame/subscribe` at the top level. Re-frame's subscription cache makes this work in practice, but the canonical pattern for components holding subscriptions is form-2 — subscriptions are set up in the outer `defn`, and only the inner `fn` renders. Form-1 subscription calls can fail to clean up correctly across hot reloads.
+
+- [ ] **12.14 Call `dev-setup` before `mount-root` in `core.cljs`**
+
+  `init` currently calls `mount-root` before `dev-setup`. If `dev-setup` ever does real setup work (beyond a `println`), it will run after the first render. Swap the order so the dev environment is fully configured before any rendering occurs.