"))
+ )
+ )
+ ),
+
+ # ---- About ----
+ tabItem(tabName = "about",
+ fluidRow(
+ box(
+ title = "About EntoScan", status = "primary", solidHeader = TRUE, width = 8,
+ uiOutput("about_content")
+ )
+ )
+ )
+ )
+ )
+)
+
+# ---- Server ----
+server <- function(input, output, session) {
+ # State
+ preview_url <- reactiveVal(NULL)
+ preview_ts <- reactiveVal("")
+ scanner_busy <- reactiveVal(TRUE)
+ prev_busy <- reactiveVal(NA)
+ modal_shown <- reactiveVal(FALSE)
+
+ shinyjs::disable("preview_btn")
+ shinyjs::disable("scan_btn")
+
+ # Poll busy & toggle UI
+ observe({
+ invalidateLater(1000, session)
+ busy <- fetch_busy()
+ if (is.na(busy)) return()
+
+ if (!identical(prev_busy(), busy)) {
+ cat(sprintf("[Poll] /is_busy = %s @ %s\n", busy, format(Sys.time(), "%H:%M:%S")))
+ shinyjs::runjs(sprintf("console.log('[Poll] /is_busy = %s')", ifelse(busy, "true", "false")))
+ }
+
+ was_busy <- prev_busy()
+ prev_busy(busy)
+ scanner_busy(busy)
+
+ shinyjs::toggleState("preview_btn", condition = !busy)
+ shinyjs::toggleState("scan_btn", condition = !busy)
+
+ if (busy && !isTRUE(modal_shown())) { show_busy_modal("Scanning in progress…", session); modal_shown(TRUE) }
+ if (!busy && isTRUE(modal_shown())) { hide_busy_modal(session); modal_shown(FALSE) }
+
+ # if device just became idle, refresh config silently
+ if (identical(was_busy, TRUE) && identical(busy, FALSE)) {
+ cfg <- load_config(show_errors = FALSE, output = output)
+ if (!is.null(cfg)) updateTextAreaInput(session, "yaml_editor", value = cfg %||% "")
+ }
+ })
+
+ # Initial config load once
+ observeEvent(TRUE, {
+ cat("[Init] Loading config...\n")
+ cfg <- load_config(show_errors = FALSE, output = output)
+ if (!is.null(cfg)) updateTextAreaInput(session, "yaml_editor", value = cfg %||% "")
+ }, once = TRUE)
+
+ # Tab-enter hooks
+ observeEvent(input$tabs, {
+ cat("[UI] Switched tab:", input$tabs, "\n")
+ if (identical(input$tabs, "config")) {
+ cfg <- load_config(show_errors = FALSE, output = output)
+ if (!is.null(cfg)) updateTextAreaInput(session, "yaml_editor", value = cfg %||% "")
+ } else if (identical(input$tabs, "files")) {
+ df <- tryCatch(load_rois_df(limit = 1000), error = function(e) { cat("[DB] Error:", e$message, "\n"); NULL })
+ output$files_table_dt <- DT::renderDT(render_rois_dt(df))
+ }
+ }, ignoreInit = TRUE)
+
+ # Files tab refresh
+ observeEvent(input$refresh_files, {
+ cat("[UI] Refresh files clicked\n")
+ df <- tryCatch(load_rois_df(limit = 1000), error = function(e) { cat("[DB] Error:", e$message, "\n"); NULL })
+ output$files_table_dt <- DT::renderDT(render_rois_dt(df))
+ })
+observeEvent(input$preview_btn, {
+ cat("[Preview] Starting preview...\n")
+ show_busy_modal("Applying config & generating preview…", session)
+ modal_shown(TRUE)
+ shinyjs::disable("preview_btn"); shinyjs::disable("scan_btn"); scanner_busy(TRUE)
+
+ yaml_txt <- input$yaml_editor %||% ""
+ if (!nzchar(yaml_txt)) {
+ hide_busy_modal(session)
+ output$error_msg <- renderUI(tags$div(style="color:#b30000;", "YAML is empty."))
+ return(invisible())
+ }
+
+ # Apply config
+ res_apply <- tryCatch(
+ request(paste0(api_base_internal, "/config")) |>
+ req_method("POST") |>
+ req_body_raw(charToRaw(yaml_txt)) |>
+ req_headers(`Content-Type` = "text/plain") |>
+ req_perform(),
+ error = function(e) e
+ )
+ if (inherits(res_apply, "error") || resp_status(res_apply) >= 300) {
+ hide_busy_modal(session)
+ msg <- if (inherits(res_apply, "error")) res_apply$message else tryCatch(resp_body_string(res_apply), error = function(e) "")
+ output$error_msg <- renderUI(tags$div(style="color:#b30000;", paste("Apply failed:", msg)))
+ cat("[Preview] Apply failed:", msg, "\n")
+ return(invisible())
+ }
+ normalized <- tryCatch(resp_body_string(res_apply), error = function(e) "")
+ updateTextAreaInput(session, "yaml_editor", value = normalized %||% yaml_txt)
+ output$error_msg <- renderUI(tags$div(style="color:#006400;", "Configuration applied."))
+ cat("[Preview] Config applied\n")
+
+ # Trigger preview
+ res_prev <- tryCatch(
+ request(paste0(api_base_internal, "/preview")) |> req_method("POST") |> req_perform(),
+ error = function(e) e
+ )
+ if (inherits(res_prev, "error") || resp_status(res_prev) >= 300) {
+ hide_busy_modal(session)
+ msg <- if (inherits(res_prev, "error")) res_prev$message else tryCatch(resp_body_string(res_prev), error = function(e) "")
+ output$error_msg <- renderUI(tags$div(style="color:#b30000;", paste("Preview failed:", msg)))
+ cat("[Preview] Preview failed:", msg, "\n")
+ return(invisible())
+ }
+
+ payload <- resp_body_json(res_prev)
+ if (is.null(payload$svg)) {
+ hide_busy_modal(session)
+ output$error_msg <- renderUI(tags$div(style="color:#b30000;", "Preview missing SVG path."))
+ return(invisible())
+ }
+
+ url <- paste0(api_base_public, payload$svg, "?t=", URLencode(payload$ts, reserved = TRUE))
+ preview_url(url); preview_ts(payload$ts)
+ cat("[Preview] Preview URL:", url, "\n")
+
+ output$preview_img <- renderUI({
+ tags$img(
+ id = "preview-img",
+ src = preview_url(),
+ style = "max-width:100%;height:auto;border:1px solid #ccc;border-radius:6px;"
+ )
+ })
+
+ # Defer the JS bind until after the DOM update is flushed
+ session$onFlushed(function() {
+ shinyjs::runjs("console.log('[Shiny] forcing tooltip bind'); if (window.__bindPreviewTooltip) window.__bindPreviewTooltip();")
+ }, once = FALSE)
+
+ output$preview_ts <- renderText(paste("Last preview timestamp:", payload$ts))
+
+ # Force bind after the DOM updates
+ shinyjs::runjs("
+ console.log('[Shiny] forcing tooltip bind (deferred)');
+ setTimeout(function(){
+ if (window.__bindPreviewTooltip) { window.__bindPreviewTooltip(); }
+ else { console.warn('[Shiny] __bindPreviewTooltip missing'); }
+ }, 0);
+ ")
+
+ # If you prefer the custom-message approach, use this instead of runjs:
+ # session$sendCustomMessage('bind-preview-tooltip', list())
+ # (and ensure tooltip.js registers a handler for 'bind-preview-tooltip')
+
+ # modal closes via /is_busy poller
+})
+
+ # Scan button: run scan and render tiles
+ output$scan_summary <- renderUI({ NULL })
+ output$scan_tiles <- renderUI({ NULL })
+ observeEvent(input$scan_btn, {
+ cat("[Scan] Starting scan...\n")
+ show_busy_modal("Full scan in progress…", session)
+ modal_shown(TRUE)
+ shinyjs::disable("preview_btn"); shinyjs::disable("scan_btn"); scanner_busy(TRUE)
+
+ res <- tryCatch(
+ request(paste0(api_base_internal, "/scan")) |> req_method("POST") |> req_perform(),
+ error = function(e) e
+ )
+ if (inherits(res, "error") || resp_status(res) >= 300) {
+ hide_busy_modal(session)
+ msg <- if (inherits(res, "error")) res$message else tryCatch(resp_body_string(res), error = function(e) "")
+ output$error_msg_scan <- renderUI(tags$div(style="color:#b30000;", paste("Scan failed:", msg)))
+ cat("[Scan] Scan failed:", msg, "\n")
+ return(invisible())
+ }
+
+ payload <- tryCatch(resp_body_json(res), error = function(e) NULL)
+ if (is.null(payload) || is.null(payload$rois) || length(payload$rois) == 0) {
+ output$error_msg_scan <- renderUI(tags$div(style="color:#b30000;", "No ROIs returned."))
+ cat("[Scan] No ROIs returned\n")
+ return(invisible())
+ }
+
+ first <- payload$rois[[1]]
+ dev <- first$device_id %||% "(unknown)"
+ tstr <- first$timestamp %||% ""
+ meta <- first$scan_metadata
+ meta_html <- if (!is.null(meta) && length(meta)) {
+ tags$ul(lapply(names(meta), function(k) tags$li(tags$code(k), ": ", as.character(meta[[k]]))))
+ } else "(none)"
+
+ output$scan_summary <- renderUI({
+ tags$div(class = "roi-summary",
+ tags$p(tags$b("Device:"), dev, " | ", tags$b("Timestamp:"), tstr),
+ tags$div(tags$b("Scan metadata: "), meta_html),
+ tags$hr()
+ )
+ })
+
+ tiles <- lapply(payload$rois, function(r) {
+ thumb_abs <- paste0(api_base_public, r$thumbnail_uri, "?t=", URLencode(r$md5 %||% "", reserved = TRUE))
+ full_abs <- paste0(api_base_public, r$uri, "?t=", URLencode(r$md5 %||% "", reserved = TRUE))
+ tags$div(class = "roi-tile",
+ tags$a(href = full_abs, target = "_blank",
+ tags$img(src = thumb_abs, alt = r$name %||% "ROI")
+ ),
+ tags$div(class = "roi-name", r$name %||% "(unnamed)")
+ )
+ })
+ output$scan_tiles <- renderUI(tags$div(class = "roi-grid", tiles))
+ output$error_msg_scan <- renderUI(tags$div(style="color:#006400;", "Scan completed. See tiles above."))
+ # modal closes via /is_busy poller
+ })
+
+ # Label Maker
+ output$label_preview <- renderUI({ NULL })
+ observeEvent(input$gen_label, {
+ txt <- input$label_text %||% ""
+ size <- as.integer(input$label_size %||% 256)
+ hint <- input$label_hint %||% ""
+ if (!nzchar(txt)) {
+ output$label_preview <- renderUI(tags$div(style = "color:#b30000;", "Please enter label content."))
+ return(invisible())
+ }
+ img_url <- paste0(api_base_public, "/label/datamatrix?text=", URLencode(txt, reserved = TRUE), "&size=", size)
+ output$label_preview <- renderUI({
+ tags$div(
+ tags$img(src = img_url, style = "max-width:100%;height:auto;border:1px solid #ddd;padding:8px;border-radius:8px;"),
+ if (nzchar(hint)) tags$div(style="margin-top:8px;font-weight:600;", hint)
+ )
+ })
+ })
+
+ # Fallbacks
+ output$preview_img <- renderUI({ tags$div("No preview yet. Click 'New preview' to generate one.") })
+ output$preview_ts <- renderText({ "" })
+
+ about_reader <- reactiveFileReader(
+ intervalMillis = 1000, session = session,
+ filePath = "www/about.md",
+ readFunc = readLines
+ )
+
+ output$about_content <- renderUI({
+ md <- paste(about_reader(), collapse = "\n")
+ html <- markdown::markdownToHTML(text = md, fragment.only = TRUE)
+ HTML(html)
+ })
+}
+
+shinyApp(ui, server)
diff --git a/services/webapp/app/www/about.md b/services/webapp/app/www/about.md
new file mode 100644
index 0000000..a597d82
--- /dev/null
+++ b/services/webapp/app/www/about.md
@@ -0,0 +1,232 @@
+
+
+
+
+
+**Table of Contents**
+
+- [Flatbed Scanners For Entomology Uses and Limitations](#flatbed-scanners-for-entomology-uses-and-limitations)
+- [What EntoScan Is](#what-entoscan-is)
+- [Hardware Setup and Focus Considerations](#hardware-setup-and-focus-considerations)
+ - [Modify the Epson Perfection V850 Pro](#modify-the-epson-perfection-v850-pro)
+ - [Operational tips](#operational-tips)
+- [Installing and Running with Docker](#installing-and-running-with-docker)
+- [Interface](#interface)
+ - [Configuration and preview](#configuration-and-preview)
+ - [Using the preview](#using-the-preview)
+- [Running Scans](#running-scans)
+- [Result Files and Database](#result-files-and-database)
+ - [Metadata content](#metadata-content)
+- [Printing Labels](#printing-labels)
+
+
+
+***EntoScan** is a free and open-source scientific tool developed in the [DARSA group](https://darsa.info/), at Aarhus University.
+Please cite our publication if you use **EntoScan**.*
+
+## Flatbed Scanners For Entomology Uses and Limitations
+Digital imaging is increasingly used in entomology to study insects, but capturing large numbers of specimens at scale remains challenging.
+Traditional imaging approaches rely on specialised and expensive camera systems—often using motorised stages to tile images — which makes it difficult to standardise lighting, scale, and image quality across experiments.
+
+For small specimens and other biomedical applications, flatbed scanners have proven to be a powerful and affordable alternative.
+However, a gap remains between proof-of-principle demonstrations and widespread adoption.
+Scanners suffer from a shallow depth of field, which can limit their effectiveness, but this issue can be mitigated through simple hardware modifications.
+
+Another limitation lies in the proprietary software provided by scanner manufacturers, which is poorly suited for high-throughput biomedical imaging.
+It is difficult to associate scanned images with external metadata, and users often need to devise their own visual labelling systems, such as two-dimensional barcodes. Moreover, images are not stored in a structured format that facilitates downstream analysis, and biomedical imaging frequently requires custom configurations—for instance, capturing multiple regions of interest within the same sample.
+
+## What EntoScan Is
+EntoScan addresses some of the above limitations with a hardware and software stack for high-throughput imaging of individual or bulk of insects.
+A modified flatbed scanner captures each specimen in a configurable layout.
+The custom control software runs as a portable webapp.
+2D barcodes are used to label scans and link images to relevant metadata.
+The platform stores full-resolution images, thumbnails, and metadata in a structured database,
+which ensures consistent naming and metadata handling, and can easily be queried to filter and display relevant images.
+
+This repository is organised around three services:
+- `services/scanner-api`: a FastAPI service that talks to the scanner, keeps the configuration, generates previews, performs barcode-aware scans, and writes structured metadata.
+- `services/webapp`: a Shiny dashboard that surfaces recent scans, previews, and metadata stored in the database.
+- `services/db`: a PostgreSQL instance seeded with the schema EntoScan uses to persist scan records.
+
+## Hardware Setup and Focus Considerations
+At the moment EntoScan is tested on **Epson Perfection V850 Pro**.
+For optimal images, it is preferable to place insects as close as possible to the focal plane of the scanner: the top of the glass bed.
+There are several ways to do that:
+1. Place samples directly on top of the bed (optionally seal the edge of the bed with grout)
+2. Replace the thick glass with an aluminium plate with a cut-out "frame" that can hold a container (e.g., petri dish or well plate) and offset it so that the floor of the container, where the specimens are, is on the focal plane.
+
+In some cases (e.g., sticky cards), placing the insects on the glass directly will be efficient and reliable.
+However, specimens kept in liquid it is much more advisable to use containers such as petri dishes or well plates and move containers rather than individual insects,
+Since this approach is less trivial, we describe how to modify the scanner:
+
+### Modify the Epson Perfection V850 Pro
+- Power off the scanner, unplug USB, and work in a dust-free space.
+- Remove the four screws that hold the factory glass platen, lift the glass with a thin spatula, and clean residual adhesive.
+- Replace the glass with a 250 mm × 378 mm × 3.3 mm aluminium plate that has a 135 mm × 95 mm cut-out matching the well-plate footprint. Secure it with thin double-sided tape.
+- Preserve the calibration reference: tape a white strip to the top edge of the aluminium so the scanner retains its exposure baseline.
+- The cut-out lowers the specimen plane so insects rest closer to the optics' focal point; keep the plate flat to avoid defocus and artefacts.
+
+[//]: # ()
+[//]: # (### Fixtures and accessories)
+
+[//]: # (- `printing_files/well_plate_holder.stl`: PLA frame sized for 145 mm × 105 mm well plates; epoxy a 134 mm × 94 mm × 1 mm glass sheet to the underside so specimens sit flush and flat.)
+
+[//]: # (- `printing_files/barcode_stamp.stl`: round PLA insert that carries a removable label with the plate barcode; it occupies the bottom-right well (C6) for orientation.)
+
+[//]: # (- `printing_files/custom_lid.stl`: white PLA lid that suppresses ambient light; print two or three copies to rotate during the workflow.)
+
+[//]: # (- `printing_files/drying_rack.stl`: optional ASA part that keeps plates flat while drying insect specimens.)
+
+[//]: # (- Consumables: 24-well plates, removable Ø10 mm labels (Avery L6019REV-25), tweezers, ethanol for specimen storage, and Type 2D Data Matrix barcodes encoded with plate metadata.)
+
+### Operational tips
+- Always power down before mechanical work, keep the glass surfaces spotless, and avoid bending the aluminium insert to maintain focus.
+- Ensure the well-plate holder, barcode stamp, and lid are clean and dry; dust quickly degrades scan quality.
+- If you replace the scanner model, measure the new platen carefully and replicate the cut-out dimensions so the ROI coordinates stay meaningful.
+
+
+## Installing and Running with Docker
+1. [Install Docker and the Compose](https://docs.docker.com/compose/install/) plugin, and make sure the host user can access the scanner's USB bus (the compose file shares `/dev/bus/usb` with the container).
+2. On your machine, create a directory for EntoScan.
+3. In this directory download `docker-compose.yaml` (from [our repository](https://github.com/darsa-group/EntoScan/)).
+3. Create file named `.env` with these two variables:
+ ```
+ # A to a writable directory on the host machine (your computer).
+ # This is where all the data and the database will live.
+ SCANNER_HOST_OUTPUT_DIR=/ABC/DEF
+
+ # Whether you want to use a mock scanner
+ # Usefull if you want to test or develop the API without a physical device
+ USE_MOCK_SCANNER=1
+ ```
+4. From your EntoScan directory, execute: `docker compose pull`
+5. **Plug the scanner in your computer and turn it on**
+6. Run `docker compose up` to launch the services. Check the output (it should show the services starting in order, with the web app last)
+7. The web interface should now be available at on `http://localhost:8080`.
+8. Use `docker compose down` when you need to stop the services. If you move the data directory, update `SCANNER_HOST_OUTPUT_DIR` so the container continues to write and read historical scans.
+
+
+## Interface
+Once the Docker services have started, the interface is served at http://localhost:8080.
+On your browser, it should look like this:
+
+
+
+### Configuration and preview
+
+The first tab in the dashboard is for configuration and preview.
+The left hand side is the content of the current configuration file that describes how each scan is handled.
+This is the most important aspect of the interface.
+
+The default template (YAML file) looks like:
+```yaml
+global:
+ enforce_barcode: false
+
+preview:
+ xyxy_mm: [0.0, 0.0, 210.0, 200.0]
+ file_format: "jpg"
+ dpi: 150
+
+barcode:
+ xyxy_mm: [0.0, 0.0, 100.0, 100.0]
+ file_format: "jpg"
+ dpi: 300
+
+rois:
+ - name: "ROI-1"
+ xyxy_mm: [0.0, 0.0, 90.0, 90.0]
+ file_format: "jpg"
+ dpi: 600
+ - name: "ROI-2"
+ xyxy_mm: [100.0, 100.0, 190.0, 190.0]
+ file_format: "jpg"
+ dpi: 600
+```
+Key fields:
+- `global`: Global variables defining the overall behaviour
+ - `enforce_barcode`: if `true`, the scanner fails if no barcode is detected
+- `preview`: Special Region Of Interest (ROI) used to generate a preview; `xyxy_mm` is `[x0, y0, x1, y1]` in millimetres measured from the scanner origin (top-left). Choose a low DPI for fast feedback.
+- `barcode`: Special ROI where a 2d barecode ([data matrix](https://en.wikipedia.org/wiki/Data_Matrix)) is expected; The decoded content (wither a just a string of characters or a JSON dictionary) is attached to every image under `scan_metadata`.
+- `rois`: list (one or more) of regions to acquire subimages on. Each ROI must have:
+ - `name`: a unique and arbitrary name
+ - `xyxy_mm`: the coordinates of the ROI
+ - `file_format`: `"jpg"` or `"tiff"`
+ - `dpi`: the resolution in dot per inch
+
+[//]: # ( - and optional keys consumed by the ROI scanner such as `mode` (Color, Gray) or `extra_scanimage_args` for backend-specific overrides.)
+
+### Using the preview
+After setting the configuration, you can press the preview button. The scaner then uploads the config and starts scanning.
+When done, the interface displays, on the right hand side, the preview image and where regions described above would be.
+
+
+
+
+Note that **you can display the coordinates in mm by hovering your mouse** on the image, which is useful to write or correct the `xyxy_mm` position fields.
+In addition, that the current configuration is saved. You may want to copy it to your own file in case you or another user wants a new one.
+## Running Scans
+After satisfied with the configuration for your project, you most likely want to start scanning.
+In the scanning page just press "scan", according to the number of ROIs and the resolution, it might take a moment.
+When done, all the resulting images are displayed on this page. In addition, if a barcode was found and used:
+
+
+
+
+the embedded metadata is also displayed, so you chan check it is correct. If so, you can continue scanning.
+Note that, each image is named with a timestamp, and the ROI name.
+In addition, each image is associated with a metadata file (JSON) that contains additional information such as the computer and scanner used, the driver, a checksum for the image, etc
+
+## Result Files and Database
+In the "Files" tab, you can display the resulting images and their metadata in a filterable table:
+
+
+
+
+All of these images are stored in the location defined in `SCANNER_OUTPUT_DIR` (in your `.env` file, see Installation).
+The file structure is as follow:
+```
+
+├── acabb787f1a24180912d8483cacbfdc9
+│ └── Epson Perfection V850
+│ └── 2025-11-06
+│ └── 2025-11-06_08-39-36
+│ ├── ROI-1
+│ │ ├── 2025-11-06_08-39-36.ROI-1.jpg
+│ │ ├── 2025-11-06_08-39-36.ROI-1.json
+│ │ └── 2025-11-06_08-39-36.ROI-1.thumb.jpg
+│ └── ROI-2
+│ ├── 2025-11-06_08-39-36.ROI-2.json
+│ ├── 2025-11-06_08-39-36.ROI-2.thumb.jpg
+│ └── 2025-11-06_08-39-36.ROI-2.tiff
+├── config.yaml
+└── temporary
+ ├── barcode.jpg
+ └── preview.svg
+```
+So, each image is stored in a directory with the following hierarchy:
+
+```
+/////
+```
+
+This structure is designed so that if users merge data from multiple scanners/machines in the same directory/file structure,
+collisions (no duplicated path/filenames) should not happen.
+
+In each image directory is names after the ROI and contains:
+* the image
+* a thumbnail of the image for fast display (`.thum.jpg`)
+* a JSON file with metadata (`.json`)
+
+### Metadata content
+Each ROI emits a JSON companion with:
+- `timestamp`, `dirname`, `filename`, `uri`, and `thumbnail`.
+- Optical settings: `name`, `xyxy_mm`, `dpi`, `file_format`, plus the probed `driver`, `device_id`, `sane_device`, and `host_id`.
+- Integrity: the image MD5 checksum.
+- `scan_metadata`: the decoded Data Matrix payload (typically the plate label and replication identifiers).
+
+## Printing Labels
+
+We provide a [standalone webapp](https://darsa.info/EntoScan-labels/) to seamlessly generate labels that can be printed on standard paper or label paper.
+
+
diff --git a/services/webapp/app/www/img/doc_files.png b/services/webapp/app/www/img/doc_files.png
new file mode 100644
index 0000000..d2ff027
Binary files /dev/null and b/services/webapp/app/www/img/doc_files.png differ
diff --git a/services/webapp/app/www/img/doc_overview.png b/services/webapp/app/www/img/doc_overview.png
new file mode 100644
index 0000000..720ec44
Binary files /dev/null and b/services/webapp/app/www/img/doc_overview.png differ
diff --git a/services/webapp/app/www/img/doc_preview.png b/services/webapp/app/www/img/doc_preview.png
new file mode 100644
index 0000000..e4d8fe5
Binary files /dev/null and b/services/webapp/app/www/img/doc_preview.png differ
diff --git a/services/webapp/app/www/img/doc_scan.png b/services/webapp/app/www/img/doc_scan.png
new file mode 100644
index 0000000..9eb51ef
Binary files /dev/null and b/services/webapp/app/www/img/doc_scan.png differ
diff --git a/services/webapp/app/www/img/icon-256.png b/services/webapp/app/www/img/icon-256.png
new file mode 100644
index 0000000..b939ba8
Binary files /dev/null and b/services/webapp/app/www/img/icon-256.png differ
diff --git a/services/webapp/app/www/img/icon-512.png b/services/webapp/app/www/img/icon-512.png
new file mode 100644
index 0000000..62069e4
Binary files /dev/null and b/services/webapp/app/www/img/icon-512.png differ
diff --git a/services/webapp/app/www/img/icon.png b/services/webapp/app/www/img/icon.png
new file mode 100644
index 0000000..fea4ed6
Binary files /dev/null and b/services/webapp/app/www/img/icon.png differ
diff --git a/services/webapp/app/www/img/icon.svg b/services/webapp/app/www/img/icon.svg
new file mode 100644
index 0000000..ac38965
--- /dev/null
+++ b/services/webapp/app/www/img/icon.svg
@@ -0,0 +1,65 @@
+
+
+
+
diff --git a/services/webapp/app/www/tooltip.js b/services/webapp/app/www/tooltip.js
new file mode 100644
index 0000000..28aa463
--- /dev/null
+++ b/services/webapp/app/www/tooltip.js
@@ -0,0 +1,160 @@
+// www/tooltip.js
+(function () {
+ // ---------- Config ----------
+ const DEBUG = false; // set true only if you need diagnostics
+ const IDLE_DELAY_MS = 120; // tooltip updates after pointer stops this long
+
+ // ---------- Tiny logger ----------
+ const log = (...a) => { if (DEBUG) console.log('[Tooltip]', ...a); };
+ const warn = (...a) => { if (DEBUG) console.warn('[Tooltip]', ...a); };
+
+ // ---------- Shared paper dimensions (in "SVG units"—mm in your previews) ----------
+ let mmWidth = 210.0;
+ let mmHeight = 200.0;
+
+ async function updateDimsFromSVG(imgEl) {
+ try {
+ if (!imgEl || !imgEl.src) return;
+ const url = imgEl.src;
+ const path = new URL(url, window.location.href).pathname;
+ if (!/\.svg$/i.test(path)) { log('Not an SVG; keep defaults'); return; }
+
+ const resp = await fetch(url, { credentials: 'omit' });
+ if (!resp.ok) return warn('SVG fetch failed:', resp.status);
+
+ const text = await resp.text();
+ const doc = new DOMParser().parseFromString(text, 'image/svg+xml');
+ const svg = doc.querySelector('svg');
+ if (!svg) return warn('No