diff --git a/.github/ISSUE_TEMPLATE/bug.yml b/.github/ISSUE_TEMPLATE/bug.yml
new file mode 100644
index 0000000..151ace4
--- /dev/null
+++ b/.github/ISSUE_TEMPLATE/bug.yml
@@ -0,0 +1,81 @@
+name: Bug report
+description: Something in the app behaved wrongly. For a bad crop use the detection template instead.
+title: "[bug] "
+labels: ["bug"]
+body:
+ - type: markdown
+ attributes:
+ value: |
+ ⚠️ **Never attach a card scan.** Paste text, or a screenshot with any
+ card artwork cropped out.
+
+ - type: textarea
+ id: what_happened
+ attributes:
+ label: What happened
+ description: What you did, what you expected, and what you got instead.
+ validations:
+ required: true
+
+ - type: textarea
+ id: repro
+ attributes:
+ label: Steps to reproduce
+ description: >
+ If you can reproduce it with `bash Scripts/bundle-app.sh --synthetic`,
+ say so — that means anyone can fix it without a scanner, which makes it
+ far more likely to get fixed quickly.
+ value: |
+ 1.
+ 2.
+ 3.
+ validations:
+ required: true
+
+ - type: dropdown
+ id: how_run
+ attributes:
+ label: How were you running ScanDeck?
+ options:
+ - "bash Scripts/bundle-app.sh --run (real scanner)"
+ - "bash Scripts/bundle-app.sh --synthetic (no scanner)"
+ - "swift run ScanDeck"
+ - "swift run scandeck-m0 (command line)"
+ validations:
+ required: true
+
+ - type: markdown
+ attributes:
+ value: |
+ If you chose `swift run ScanDeck` and the symptom is that **typing does
+ nothing**: that is a known trap rather than a bug. A bare SwiftPM
+ executable registers as background-only and receives no keyboard events.
+ Use `bash Scripts/bundle-app.sh --run` instead and check with
+ `lsappinfo list | grep -A4 ScanDeck` that you get `type="Foreground"`.
+
+ - type: input
+ id: versions
+ attributes:
+ label: macOS version, and scanner if one was attached
+ placeholder: "macOS 26.5.2, fi-8170"
+ validations:
+ required: true
+
+ - type: textarea
+ id: diagnostics
+ attributes:
+ label: Diagnostics report
+ description: >
+ Settings → Copy Diagnostics Report. Content-free by construction — no
+ path, file name or batch name can reach it.
+ render: shell
+ validations:
+ required: false
+
+ - type: checkboxes
+ id: safety
+ attributes:
+ label: Before you submit
+ options:
+ - label: I have not attached any card scan or personal data
+ required: true
diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml
new file mode 100644
index 0000000..d384785
--- /dev/null
+++ b/.github/ISSUE_TEMPLATE/config.yml
@@ -0,0 +1,8 @@
+blank_issues_enabled: true
+contact_links:
+ - name: How changes land, and what to expect back
+ url: https://github.com/eliseorobles/ScanDeck/blob/main/CONTRIBUTING.md
+ about: Branch, PR, review. Also which changes want an issue opened first, and whether you need a scanner (usually not).
+ - name: Will my scanner work?
+ url: https://github.com/eliseorobles/ScanDeck/blob/main/README.md#about-scanner-support
+ about: Only the fi-8170 today. If you have something else, open a hardware datapoint — that is how the list grows.
diff --git a/.github/ISSUE_TEMPLATE/detection-report.yml b/.github/ISSUE_TEMPLATE/detection-report.yml
new file mode 100644
index 0000000..3f7aec3
--- /dev/null
+++ b/.github/ISSUE_TEMPLATE/detection-report.yml
@@ -0,0 +1,100 @@
+name: ScanDeck measured a card wrong
+description: A crop clipped the card, kept too much, or the two sides disagreed when they should not have.
+title: "[detection] "
+labels: ["detection", "image-pipeline"]
+body:
+ - type: markdown
+ attributes:
+ value: |
+ This is the bug class the project cares most about, because it is the one
+ that can publish a wrong image that still looks plausible.
+
+ ⚠️ **Do not attach the scan.** Card images are your property. Everything
+ below is numbers and text, which is all we need to reproduce the
+ reasoning — and if it turns out we need pixels, we will talk about how to
+ share them privately rather than in a public issue.
+
+ - type: dropdown
+ id: symptom
+ attributes:
+ label: What went wrong?
+ options:
+ - The crop cut into the card
+ - The crop kept far too much background
+ - The two sides of one card measured very differently
+ - Complete Batch was blocked and I think it should not have been
+ - Complete Batch was allowed and I think it should not have been
+ - The card published visibly crooked
+ - Something else
+ validations:
+ required: true
+
+ - type: textarea
+ id: what_the_app_said
+ attributes:
+ label: What did ScanDeck say?
+ description: >
+ The margin badge on the row, the text of any banner, and the completion
+ blocker if there was one. Copy the wording exactly — the copy is part of
+ the product and getting it wrong is itself a bug.
+ validations:
+ required: true
+
+ - type: input
+ id: footprints
+ attributes:
+ label: The two measured footprints, if the card row showed them
+ placeholder: "front 2104 x 2942 px, back 2098 x 2951 px"
+ validations:
+ required: false
+
+ - type: dropdown
+ id: media
+ attributes:
+ label: What was in the feeder?
+ options:
+ - Raw (bare) card
+ - Card in a toploader
+ - Card in a semi-rigid (note - not a supported format)
+ - Card in a penny sleeve only (note - not a supported format)
+ - Something else
+ validations:
+ required: true
+
+ - type: input
+ id: scanner
+ attributes:
+ label: Scanner and macOS version
+ placeholder: "fi-8170, macOS 26.5.2"
+ validations:
+ required: true
+
+ - type: textarea
+ id: diagnostics
+ attributes:
+ label: Diagnostics report
+ description: >
+ Settings → Copy Diagnostics Report. It is content-free by construction —
+ it has no field a path, file name or batch name could go in, and there is
+ a test that proves it.
+ render: shell
+ validations:
+ required: false
+
+ - type: markdown
+ attributes:
+ value: |
+ **If you keep the masters**, you can turn this into a permanent
+ regression yourself without sending anyone an image:
+
+ ```sh
+ SCANDECK_CORPUS_DIR=
SCANDECK_CORPUS_REPORT=/tmp/r.csv \
+ swift test -c release --filter CardImageCropperCorpusDiagnostics
+ ```
+
+ That writes one row per side with both threshold reads, the dominant
+ component, the floor-supported bounds and the raw occupancy outside them
+ — which is the difference between "one side over-detected" and "the other
+ under-detected", opposite fixes on identical-looking evidence. Pasting
+ the CSV rows for the bad pair is the single most useful thing you can
+ attach.
diff --git a/.github/ISSUE_TEMPLATE/hardware-datapoint.yml b/.github/ISSUE_TEMPLATE/hardware-datapoint.yml
new file mode 100644
index 0000000..0dcecae
--- /dev/null
+++ b/.github/ISSUE_TEMPLATE/hardware-datapoint.yml
@@ -0,0 +1,95 @@
+name: Hardware datapoint
+description: Report what ScanDeck saw on a scanner other than the fi-8170. This is the most valuable report the project can get.
+title: "[hardware] "
+labels: ["hardware-datapoint", "scanner-support"]
+body:
+ - type: markdown
+ attributes:
+ value: |
+ Thank you — this is genuinely the highest-value contribution available.
+
+ ScanDeck supports exactly one scanner today, and widening that is capped
+ by nobody having capability data for anything else. Your report is the
+ seed of the supported-scanner matrix.
+
+ **You do not need the scan to have worked.** A device that fails is just
+ as useful as one that succeeds, as long as we can see *which* capability
+ was missing.
+
+ ⚠️ **Never attach a card scan.** Card images are your property and this
+ repository has never contained one. Paste text output only.
+
+ - type: input
+ id: model
+ attributes:
+ label: Scanner make and model
+ placeholder: "Ricoh fi-8040 / Brother ADS-4300N / Epson DS-C490"
+ validations:
+ required: true
+
+ - type: input
+ id: macos
+ attributes:
+ label: macOS version
+ placeholder: "26.5.2 (25F84)"
+ validations:
+ required: true
+
+ - type: dropdown
+ id: connection
+ attributes:
+ label: How is it connected?
+ options:
+ - USB
+ - Network (Wi-Fi or Ethernet)
+ - Both available, reporting USB
+ - Both available, reporting network
+ validations:
+ required: true
+
+ - type: textarea
+ id: list
+ attributes:
+ label: Output of `swift run scandeck-m0 --list`
+ description: This works on any scanner and is not gated.
+ render: shell
+ validations:
+ required: true
+
+ - type: textarea
+ id: inspect
+ attributes:
+ label: Output of `swift run scandeck-m0 --inspect`
+ description: >
+ Paste the whole thing, including any error. If it refuses your device,
+ that refusal IS the datapoint — paste it verbatim. Note that inspection
+ is currently gated to the fi-8170; if it refuses on those grounds, say
+ so and the `--list` output above is still useful on its own.
+ render: shell
+ validations:
+ required: false
+
+ - type: checkboxes
+ id: capabilities
+ attributes:
+ label: Which of these does the scanner have?
+ description: >
+ These four are what the pipeline actually depends on. Tick what you know;
+ leave the rest blank rather than guessing. The vendor's spec sheet is a
+ fine source.
+ options:
+ - label: Duplex ADF (images both sides in one pass)
+ - label: 600 dpi
+ - label: 24-bit colour
+ - label: An overscan or margin mode that captures background *around* the detected media
+ - label: Automatic media-size detection
+
+ - type: textarea
+ id: notes
+ attributes:
+ label: Anything else
+ description: >
+ Vendor driver name and version, whether Apple Image Capture sees it, or
+ anything that surprised you.
+ validations:
+ required: false
diff --git a/.github/ISSUE_TEMPLATE/spec-hole.yml b/.github/ISSUE_TEMPLATE/spec-hole.yml
new file mode 100644
index 0000000..19efa95
--- /dev/null
+++ b/.github/ISSUE_TEMPLATE/spec-hole.yml
@@ -0,0 +1,40 @@
+name: Spec hole or design disagreement
+description: SPEC.md says something wrong, missing, over-engineered, or that you think is a mistake.
+title: "[spec] "
+labels: ["spec"]
+body:
+ - type: markdown
+ attributes:
+ value: |
+ This is a real contribution and it is explicitly invited. SPEC.md is the
+ contract, and it has been changed before because someone argued it was
+ wrong — three of the seven invariants carry amendments for exactly that
+ reason.
+
+ You do not need code, a scanner, or a patch. An argument is enough.
+
+ - type: textarea
+ id: what
+ attributes:
+ label: What does the spec say, and where?
+ description: Quote it, with the section number.
+ validations:
+ required: true
+
+ - type: textarea
+ id: problem
+ attributes:
+ label: What is wrong with it?
+ description: >
+ Wrong, missing, over-engineered, internally inconsistent, or contradicted
+ by the code — say which, and why it matters to someone using the app.
+ validations:
+ required: true
+
+ - type: textarea
+ id: proposal
+ attributes:
+ label: What should it say instead?
+ description: Optional. "I think this is wrong but I don't know the fix" is a legitimate issue.
+ validations:
+ required: false
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
new file mode 100644
index 0000000..ca16008
--- /dev/null
+++ b/.github/workflows/ci.yml
@@ -0,0 +1,144 @@
+name: CI
+
+# ScanDeck compiles only against the macOS 26 SDK — `Package.swift` pins
+# `.macOS(.v26)` and swift-tools-version 6.2 — so every job that touches Swift
+# has to run on a `macos-26` runner. That image went generally available on
+# 2026-02-26, and because this repository is public it costs nothing: standard
+# GitHub-hosted runners are free and unmetered for public repositories.
+#
+# Nothing here needs an fi-8170. The suites that do need real hardware, a real
+# corpus, or the private centering tool are all gated — on a `SCANDECK_*`
+# environment variable, or on a file `.gitignore` deliberately keeps out of
+# this repository — so they skip and the rest runs.
+
+on:
+ push:
+ branches: [main]
+ pull_request:
+
+# A second push to the same branch makes the first run's answer worthless, and
+# a stale green is worse than no answer.
+concurrency:
+ group: ${{ github.workflow }}-${{ github.ref }}
+ cancel-in-progress: true
+
+permissions:
+ contents: read
+
+jobs:
+ build-and-test:
+ name: Build and test (macOS 26)
+ runs-on: macos-26
+ timeout-minutes: 30
+
+ env:
+ # Pinned rather than left to the image default. The `macos-26` image
+ # currently defaults to Xcode 26.5, which ships Swift 6.3; this pin is
+ # Xcode 26.3 (build 17C529, Swift 6.2.4), the exact toolchain the 251-test
+ # suite has been verified green against. Strict concurrency is on across
+ # the whole package and a compiler bump is precisely where new
+ # diagnostics land, so moving this number is a deliberate change with its
+ # own review — not something a runner-image refresh gets to do overnight.
+ XCODE_APP: /Applications/Xcode_26.3.app
+
+ steps:
+ - name: Check out
+ uses: actions/checkout@v7
+
+ # Fail loudly and legibly if the image has retired this Xcode, rather
+ # than silently compiling against whatever the default happens to be.
+ - name: Select Xcode
+ run: |
+ if [ ! -d "$XCODE_APP" ]; then
+ echo "::error::$XCODE_APP is not installed on this runner image."
+ echo "Xcodes present on the runner:"
+ ls -d /Applications/Xcode*.app
+ exit 1
+ fi
+ sudo xcode-select -s "$XCODE_APP"
+
+ - name: Record the toolchain
+ run: |
+ sw_vers
+ xcodebuild -version
+ swift --version
+
+ # ScanDeck has zero external package dependencies, so this cache buys
+ # incremental compilation and nothing else — there are no checkouts to
+ # restore. Keep it keyed tightly enough that a restored `.build` is only
+ # ever a speed-up: the Xcode version is in the key because a toolchain
+ # change invalidates every module inside, and the source hash means the
+ # exact key only hits on a re-run of the same commit. Ordinary runs land
+ # on the prefix via restore-keys and rebuild what changed. It is roughly
+ # 335 MB, well inside the 10 GB repository cache budget.
+ - name: Cache SwiftPM build products
+ uses: actions/cache@v6
+ with:
+ path: .build
+ key: >-
+ ${{ runner.os }}-${{ runner.arch }}-spm-xcode26.3-${{
+ hashFiles('Package.swift', 'Sources/**/*.swift', 'Tests/**/*.swift')
+ }}
+ restore-keys: |
+ ${{ runner.os }}-${{ runner.arch }}-spm-xcode26.3-
+
+ - name: Build
+ run: swift build
+
+ # Release, not debug, and it is not a preference: `CardImageCropper`
+ # analyses full-resolution 600 dpi masters, which costs ~47 s per master
+ # in debug against ~0.25 s in release.
+ - name: Test
+ run: swift test -c release
+
+ repository-hygiene:
+ # Free, ~15 s, and needs no macOS. This is the remote half of guards that
+ # currently exist only on the operator's machine: `.gitignore` plus the
+ # `.githooks/pre-commit` hook, which a fresh clone does not have until
+ # someone remembers `git config core.hooksPath .githooks`.
+ name: Repository hygiene
+ runs-on: ubuntu-latest
+ timeout-minutes: 5
+
+ steps:
+ - name: Check out
+ uses: actions/checkout@v7
+
+ - name: Nothing private reached the public remote
+ run: |
+ status=0
+ fail() { echo "::error::$1"; status=1; }
+
+ # The centering tool is private. This repository has a live public
+ # remote, so a stray `git add -f` is one push from publishing it.
+ tracked_centering="$(git ls-files centering)"
+ if [ -n "$tracked_centering" ]; then
+ fail "The private centering tool is tracked: $(echo "$tracked_centering" | tr '\n' ' ')"
+ fi
+
+ # Card scans are the operator's images. The corpus lives in
+ # Application Support and must never be committed.
+ tracked_images="$(git ls-files -- '*.jpg' '*.jpeg' '*.png' '*.tiff' '*.tif' '*.heic')"
+ if [ -n "$tracked_images" ]; then
+ fail "Card images are tracked: $(echo "$tracked_images" | tr '\n' ' ')"
+ fi
+
+ # Internal working documents: the ticket queue carries real consignor
+ # and batch names, and the PaperStream research is a derivative of one
+ # copyrighted source.
+ tracked_private="$(git ls-files -- CLAUDE.md PENDINGTICKETS.md 'PaperStream_for_Mac_UI_Spec*' docs/research/paperstream-capture-inventory.md)"
+ if [ -n "$tracked_private" ]; then
+ fail "Private working documents are tracked: $(echo "$tracked_private" | tr '\n' ' ')"
+ fi
+
+ # "PaperStream" is PFU Limited's trademark. Nominative use in prose is
+ # fine and appears throughout the docs; it must never become part of a
+ # shipped product name, target name, or bundle identifier.
+ if grep -i -q 'paperstream' Package.swift Resources/ScanDeck-Info.plist; then
+ fail "PaperStream appears in Package.swift or the app's Info.plist."
+ fi
+
+ if [ "$status" -eq 0 ]; then
+ echo "Repository hygiene checks passed."
+ fi
+ exit "$status"
diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md
new file mode 100644
index 0000000..a035e69
--- /dev/null
+++ b/CODE_OF_CONDUCT.md
@@ -0,0 +1,64 @@
+# Code of Conduct
+
+## The short version
+
+Be kind, be concrete. Assume the person on the other end is trying to help.
+
+Disagreement about the code is welcome and expected — this project changes its
+mind when the evidence says to, and "I think this constant is wrong, here is a
+corpus run" is the most valuable thing you can bring. Disagreement about a
+person is not.
+
+## The longer version
+
+We pledge to make participation a harassment-free experience for everyone,
+regardless of age, body size, visible or invisible disability, ethnicity, sex
+characteristics, gender identity and expression, level of experience, education,
+socio-economic status, nationality, personal appearance, race, religion, or
+sexual identity and orientation.
+
+Examples of behaviour that contributes to a positive environment:
+
+- Being respectful of differing opinions, viewpoints, and experiences
+- Giving and gracefully accepting constructive feedback
+- Accepting responsibility, apologising to those affected by our mistakes, and
+ learning from the experience
+- Focusing on what is best for the project and the people using it
+
+Examples of unacceptable behaviour:
+
+- Sexualised language or imagery, and sexual attention or advances of any kind
+- Trolling, insulting or derogatory comments, and personal or political attacks
+- Public or private harassment
+- Publishing others' private information, such as a physical or email address,
+ without their explicit permission
+- Other conduct which could reasonably be considered inappropriate in a
+ professional setting
+
+## Scope
+
+This applies in all project spaces — issues, pull requests, discussions, commit
+messages — and when someone is representing the project in public.
+
+## Reporting
+
+Report unacceptable behaviour to **eliseo@eliseorobles.me**. Reports are
+reviewed and investigated promptly and fairly, and the reporter's privacy and
+security are respected.
+
+If the report concerns the maintainer, say so directly; you are also free to
+report to GitHub through their abuse-reporting flow, which is outside this
+project's control.
+
+## Enforcement
+
+The maintainer is responsible for clarifying and enforcing these standards and
+will take appropriate and fair corrective action in response to any behaviour
+judged inappropriate, threatening, offensive, or harmful — including editing or
+removing comments, commits, code, issues and other contributions, and where
+necessary blocking a person from the project.
+
+## Attribution
+
+Adapted from the [Contributor Covenant](https://www.contributor-covenant.org),
+version 2.1.
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index b496f5c..f9758ba 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -22,6 +22,66 @@ Say in the PR whether you ran against real hardware or not. "Tests pass, no
scanner" is a completely fine and useful thing to write; claiming a hardware
behaviour nobody observed is not.
+## What you can expect back
+
+**A human reply within three days**, even when the reply is "not yet" or "not
+this". If you have waited longer than that, the notification was missed rather
+than ignored — say so on the thread and it will be picked up.
+
+This is a small project maintained by one person, so the honest version is:
+review may be slower than you would like, but silence is never the answer and a
+stale PR is a failure on this end, not yours.
+
+## Open an issue first for anything load-bearing
+
+Not bureaucracy — this codebase has unusually strong invariants, and the
+expensive place to discover a scope mismatch is after you have written the code.
+
+**Ask first** if the change touches:
+
+- a `BatchSession` gate, or any safety rule
+- `CardImageCropper` detection constants — these were measured against real
+ scanner artefacts and a plausible-sounding rewrite is how a defect gets back in
+- an invariant in [SPEC.md](SPEC.md), or the v1 scope
+- the `ImageCaptureCore` boundary
+
+**Just send it** for docs, operator copy, tests, accessibility, hardware
+datapoints, or anything already described in an open issue.
+
+One concrete example of why: `AcceptedPairSizeMismatch` stores the two measured
+footprints rather than a `Bool`, and that looks like something to simplify. It
+is not — a `Bool` would carry a person's consent onto a re-measured pair they
+never saw. Several things here are shaped that way, and the reasoning usually
+lives in a doc comment right above the code.
+
+## Do you need an fi-8170?
+
+Almost certainly not. Every issue is labelled `needs-fi-8170` or
+`no-hardware-needed`, and the second group is much larger: the whole reducer,
+the SwiftUI shell, image encoding and DPI metadata, the crash-safety primitives,
+error copy, accessibility, docs and CI.
+
+`swift test` runs 251 tests with no scanner attached, and
+`bash Scripts/bundle-app.sh --synthetic` runs the real crop, margin verdicts,
+deskew and atomic publication against a simulated page source — so you can
+reproduce and fix most UI and pipeline behaviour without hardware.
+
+## Using AI assistance
+
+Allowed, and it does not need to be hidden — but note it in the PR so a reviewer
+knows what kind of scrutiny to apply, and hold it to the same bar as anything
+you wrote by hand. You are the author of what you submit.
+
+Two rules that bind harder here than in most projects, because this codebase's
+safety story rests on things a model cannot check:
+
+- **An LLM cannot observe hardware.** Never let generated text become a claim
+ about what a scanner did. If a pass was not physically run, say so.
+- **Detector constants need evidence, not rationale.** A change to
+ `CardImageCropper`'s thresholds must cite a corpus run. A confident-sounding
+ explanation for a new number is exactly the failure mode those constants were
+ measured to prevent.
+
## The most useful contributions right now
- **Exercise the suite.** Run `swift test` on Apple Silicon with Xcode 26 —
diff --git a/README.md b/README.md
index e0a3bd7..4934640 100644
--- a/README.md
+++ b/README.md
@@ -1,5 +1,10 @@
# ScanDeck — free, open-source Mac scanning software for trading cards
+[](https://github.com/eliseorobles/ScanDeck/actions/workflows/ci.yml)
+[](LICENSE)
+
+
+
**Batch-scan trading cards front and back on macOS, at 600 dpi, with a real
scanner-background border — and get numbered `_F` / `_B` pairs a listing tool
can read.** ScanDeck drives a duplex ADF document scanner, crops each side to
@@ -222,9 +227,67 @@ The app scans real batches end to end on a physical fi-8170 — including a
186-card production run. Milestones M0–M2 are met.
Still open: a job/profile editor (there is one built-in profile today), an app
-icon, accessibility passes, CI, and a supported-scanner matrix. There is no CI
-yet, so there is no build badge — `swift test` on your own machine is the honest
-signal.
+icon, accessibility passes, a downloadable release, and a supported-scanner
+matrix. Every one of those has an [issue](https://github.com/eliseorobles/ScanDeck/issues).
+
+CI runs `swift build` and `swift test -c release` on every pull request, plus a
+hygiene job that fails if a card scan or any private working file ever becomes
+tracked.
+
+## Help make this the best scanning app on the Mac
+
+That is the actual goal, and it is reachable — but not by one person with one
+scanner.
+
+ScanDeck already does one thing nothing else on the Mac does: it **measures the
+border it is about to publish and refuses to publish a batch it cannot vouch
+for.** Every published pixel is captured background. When a card's two sides
+disagree by more than 140 px the batch stays blocked until a person overrules
+that specific card, and the overrule is stored against the two measured
+footprints so a re-scan invalidates it. That is not a slogan — it is a reducer,
+and you can go read it.
+
+"Best open-source Mac scanning app" would mean that same guarantee holds **on a
+scanner you already own, in an app you can download, driven from a keyboard.**
+All three of those currently fail, and none of them fail for architectural
+reasons:
+
+| The gap | Why it is not hard | Where |
+|---|---|---|
+| **One supported scanner** | The hardware seam is a single protocol. The allowlist is a two-entry `Set` | `ScannerInventory.swift:23` |
+| **No downloadable app** | The bundle script already builds and signs it; it needs notarization and a release | `Scripts/bundle-app.sh` |
+| **Mouse required** | Two review actions are hover-only, with no keyboard route | `BatchWorkspaceView.swift:741` |
+
+**Widening scanner support is the highest-value thing anyone can do here**, and
+it caps everything else — more scanners means more users, and contributors come
+from users. The correctness guarantee is exactly what makes the project worth
+widening, so the work is to grow the device list *without* loosening it. The
+test harness for that already exists: 251 tests run with no scanner, and
+`--synthetic` exercises the real crop, verdict, deskew and publication path.
+
+### You do not need a scanner
+
+This is the most common reason people assume they can't help, and it is wrong.
+Every issue is labelled `needs-fi-8170` or `no-hardware-needed`, and almost
+everything is the second one. The reducer, the SwiftUI shell, the JPEG and DPI
+metadata, the crash-safety primitives, the error copy, accessibility, docs and
+CI are all reachable with nothing but a Mac.
+
+### Good places to start
+
+- **[Open issues](https://github.com/eliseorobles/ScanDeck/issues)** — every one
+ names the files to touch and the command that proves it worked.
+- **Have a different duplex ADF scanner?** Run `swift run scandeck-m0 --list`
+ and `--inspect` and open a hardware datapoint. That is the seed of the
+ supported-scanner matrix and it costs you five minutes.
+- **Found a card ScanDeck measured wrong?** That is the most valuable bug report
+ this project can get. Tell us what the two sides measured — but never attach
+ the scan.
+- **Read [SPEC.md](SPEC.md) and poke holes in it.** It is the contract, and
+ disagreeing with it in an issue is a real contribution.
+
+You will get a reply. See [CONTRIBUTING.md](CONTRIBUTING.md) for what to expect
+and how changes land.
## Contributing
diff --git a/SPEC.md b/SPEC.md
index 68f138e..7fadab2 100644
--- a/SPEC.md
+++ b/SPEC.md
@@ -138,11 +138,12 @@ Every milestone is validated on Apple Silicon against the physical fi-8170 with
## 8. Open questions & research
-Research is in `docs/research/` (all three tracks complete):
+Research is in `docs/research/`:
- `macos-scanning-apis.md` — the ImageCaptureCore facts §6 is built on (verified against the macOS 26 SDK and the installed PFU driver binary)
-- `paperstream-capture-inventory.md` — full PaperStream Capture 6.0 feature inventory (from the official 644-page manual); the post-v1 backlog source: batch state machine + Batch Manager, three counter types with persistence modes, naming-token builder with live preview, marks/filters, separators, ad-hoc override, hotkeys/hardware-button job mapping
- `competitor-gaps.md` — positioning: the real competitor is ExactScan Pro (~100€, own drivers), and the two structural gaps nothing on Mac has are the review-then-completion gate and first-class pair naming with transactional counters. VueScan's watermark-trial/renewal model is the goodwill killer to avoid.
+A third track — a feature inventory of PaperStream Capture 6.0, distilled from the vendor's 644-page manual — is the source of the post-v1 backlog below (batch state machine, three counter types with persistence modes, naming-token builder with live preview, marks/filters, separators, ad-hoc override, hardware-button job mapping). It is **not published**: it is a dense page-cited abstract of one copyrighted document, and nothing in ScanDeck needs it published to be useful. The backlog items it produced are listed here in their own right.
+
A separate, broader UX study of a general-purpose PaperStream Capture remake (presets, OCR, searchable PDF, page-list review) exists as local research and is deliberately not published. **It is not this product's roadmap.** Settled 2026-07-28: ScanDeck stays a trading-card app and that document serves as a UX quality bar and post-v1 backlog only. Where the two disagree, this file wins. Consequently the domain models F/B *pairs*, not a general page list; adding a document profile later would be a reducer rewrite, accepted deliberately.
Still open:
diff --git a/Tests/ScanDeckImagePipelineTests/CardImageCropperDeskewTests.swift b/Tests/ScanDeckImagePipelineTests/CardImageCropperDeskewTests.swift
index af50695..cb676b3 100644
--- a/Tests/ScanDeckImagePipelineTests/CardImageCropperDeskewTests.swift
+++ b/Tests/ScanDeckImagePipelineTests/CardImageCropperDeskewTests.swift
@@ -130,8 +130,23 @@ struct CardImageCropperDeskewTests {
== result.analysis.requestedCropBounds.height
)
- // And the pixels came through by copy, not by interpolation: the
- // output must equal the source region exactly.
+ // And the pixels came through by copy, not by interpolation.
+ //
+ // The comparison has a tolerance, and the reason is the file format
+ // rather than the pipeline: the fixture is a lossless PNG but `crop`
+ // publishes JPEG, and JPEG at quality 1.0 is still a DCT round-trip
+ // through YCbCr. It lands a flat region within a level or two, not on
+ // it. Asserting exact equality passed on one machine and failed in CI
+ // reading 17 where 18 went in — a difference produced by the encoder,
+ // not by anything ScanDeck did.
+ //
+ // The tolerance is small on purpose. Interpolating a 0.1° rotation
+ // moves an edge pixel by whole levels of contrast, so ±2 still catches
+ // a resample while admitting the codec. The load-bearing proof that
+ // nothing was resampled is above this loop and is exact: `tiltDegrees`
+ // is nil, so `square` is never called, and the output dimensions equal
+ // the requested crop bounds rather than a rotated bounding box.
+ let jpegRoundTripTolerance = 2
let sourceImage = try cropper.decode(source)
let outputImage = try cropper.decode(output)
let bounds = result.analysis.requestedCropBounds
@@ -141,9 +156,13 @@ struct CardImageCropperDeskewTests {
let out = (y * outputImage.width + x) * 4
let into = ((y + bounds.y) * sourceImage.width
+ (x + bounds.x)) * 4
+ let difference = abs(
+ Int(outputImage.pixels[out])
+ - Int(sourceImage.pixels[into])
+ )
#expect(
- outputImage.pixels[out] == sourceImage.pixels[into],
- "pixel (\(x), \(y)) was resampled"
+ difference <= jpegRoundTripTolerance,
+ "pixel (\(x), \(y)) differs by \(difference), more than the codec accounts for, so it was resampled"
)
sampled += 1
}