Wednesday, October 8, 2025

Digital Signature Capture for Property Inspections

Paul (Founder)
Development
Developers testing touch-based signature capture functionality on mobile devices

Property inspections require signatures documenting tenant or landlord acknowledgment of inspection findings. Traditional paper-based inspections collect physical signatures, then staff manually attach signed documents to digital records—a workflow combining the worst of paper and digital systems: physical storage requirements with additional data entry work.

Digital signature capture transforms this workflow: staff collect signatures directly on mobile devices during inspections, signatures attach immediately to inspection records, and signed reports generate automatically without paper intermediaries. Week 41 implemented comprehensive signature functionality for LetAdmin using HTML5 Canvas for drawing, touch event handling for mobile devices, and image generation for server storage. This article explores building reliable signature capture that works across devices and browsers.

What Your Team Will Notice

The inspection form now includes a "Capture Signature" section after all observations and photos. Tapping this section opens a signature pad: a blank canvas with instructions "Sign above using your finger or stylus", a "Clear" button to start over if mistakes occur, and a "Save Signature" button completing the capture.

Drawing feels natural—finger movements translate smoothly into strokes without lag or jitter. The canvas supports both quick signatures (single continuous stroke) and detailed signatures (multiple strokes with pauses). Staff can rotate devices to landscape orientation for larger signature areas if needed, with the canvas adapting automatically.

Once captured, signatures appear as images in the inspection record. The signature section changes from "Capture Signature" to "Signature Captured" with a thumbnail preview and options to view full-size or re-capture if needed. Signed inspections display signature images in generated PDF reports, providing complete documentation for compliance and landlord communication.

On cellular networks, signatures upload reliably despite connectivity variations. The system converts signatures to compressed images before upload, reducing bandwidth requirements whilst maintaining sufficient quality for legal documentation. Failed uploads retry automatically, and offline inspections queue signatures locally for sync when connectivity returns.

Under the Bonnet: Canvas-Based Signature Pad

HTML5 Canvas provides the drawing surface for signature capture:

// app/javascript/controllers/signature_controller.js
import { Controller } from '@hotwired/stimulus';

export default class extends Controller {
  static targets = ['canvas', 'preview', 'input'];

  connect() {
    this.setupCanvas();
    this.attachEventListeners();
    this.isDrawing = false;
    this.strokes = [];
    this.currentStroke = [];
  }

  setupCanvas() {
    const canvas = this.canvasTarget;
    const container = canvas.parentElement;

    // Size canvas to container whilst maintaining drawing resolution
    const rect = container.getBoundingClientRect();
    canvas.width = rect.width * window.devicePixelRatio;
    canvas.height = 300 * window.devicePixelRatio;
    canvas.style.width = rect.width + 'px';
    canvas.style.height = '300px';

    this.ctx = canvas.getContext('2d');
    this.ctx.scale(window.devicePixelRatio, window.devicePixelRatio);

    // Configure drawing style
    this.ctx.strokeStyle = '#000000';
    this.ctx.lineWidth = 2;
    this.ctx.lineCap = 'round';
    this.ctx.lineJoin = 'round';
  }

  attachEventListeners() {
    const canvas = this.canvasTarget;

    // Mouse events (desktop)
    canvas.addEventListener('mousedown', (e) => this.startDrawing(e));
    canvas.addEventListener('mousemove', (e) => this.draw(e));
    canvas.addEventListener('mouseup', () => this.stopDrawing());
    canvas.addEventListener('mouseout', () => this.stopDrawing());

    // Touch events (mobile)
    canvas.addEventListener('touchstart', (e) => {
      e.preventDefault();
      this.startDrawing(e.touches[0]);
    });

    canvas.addEventListener('touchmove', (e) => {
      e.preventDefault();
      this.draw(e.touches[0]);
    });

    canvas.addEventListener('touchend', (e) => {
      e.preventDefault();
      this.stopDrawing();
    });
  }

  startDrawing(event) {
    this.isDrawing = true;
    this.currentStroke = [];

    const point = this.getDrawingPoint(event);
    this.currentStroke.push(point);

    this.ctx.beginPath();
    this.ctx.moveTo(point.x, point.y);
  }

  draw(event) {
    if (!this.isDrawing) return;

    const point = this.getDrawingPoint(event);
    this.currentStroke.push(point);

    this.ctx.lineTo(point.x, point.y);
    this.ctx.stroke();
  }

  stopDrawing() {
    if (!this.isDrawing) return;

    this.isDrawing = false;

    if (this.currentStroke.length > 0) {
      this.strokes.push([...this.currentStroke]);
      this.currentStroke = [];
    }
  }

  getDrawingPoint(event) {
    const canvas = this.canvasTarget;
    const rect = canvas.getBoundingClientRect();

    return {
      x: (event.clientX - rect.left) * (canvas.width / rect.width) / window.devicePixelRatio,
      y: (event.clientY - rect.top) * (canvas.height / rect.height) / window.devicePixelRatio
    };
  }

  clear() {
    const canvas = this.canvasTarget;
    this.ctx.clearRect(0, 0, canvas.width, canvas.height);
    this.strokes = [];
    this.currentStroke = [];
  }

  async save() {
    if (this.strokes.length === 0) {
      alert('Please provide a signature first');
      return;
    }

    // Convert canvas to blob
    const blob = await this.canvasToBlob();

    // Upload to server
    await this.uploadSignature(blob);

    // Show preview
    this.showPreview(blob);

    // Close signature pad
    this.close();
  }

  async canvasToBlob() {
    return new Promise((resolve) => {
      this.canvasTarget.toBlob((blob) => {
        resolve(blob);
      }, 'image/png', 0.95);
    });
  }

  async uploadSignature(blob) {
    const formData = new FormData();
    formData.append('signature[image]', blob, 'signature.png');
    formData.append('signature[inspection_id]', this.inspectionId);

    const response = await fetch('/inspections/add_signature', {
      method: 'POST',
      headers: {
        'X-CSRF-Token': document.querySelector('[name="csrf-token"]').content
      },
      body: formData
    });

    if (!response.ok) {
      throw new Error('Signature upload failed');
    }

    return await response.json();
  }

  showPreview(blob) {
    const url = URL.createObjectURL(blob);
    this.previewTarget.src = url;
    this.previewTarget.style.display = 'block';

    // Store data URL in hidden input for offline access
    const reader = new FileReader();
    reader.onloadend = () => {
      this.inputTarget.value = reader.result;
    };
    reader.readAsDataURL(blob);
  }

  close() {
    // Close modal or hide signature pad
    this.element.style.display = 'none';
  }

  get inspectionId() {
    return this.element.dataset.inspectionId;
  }
}

This implementation handles both mouse and touch events, scales properly for high-DPI displays, and converts signatures to images for server upload.

Touch Event Handling

Mobile touch events require careful handling to prevent unwanted browser behaviours:

canvas.addEventListener('touchstart', (e) => {
  // Prevent default to stop scrolling whilst drawing
  e.preventDefault();

  // Use first touch point (ignore multi-touch)
  this.startDrawing(e.touches[0]);
});

canvas.addEventListener('touchmove', (e) => {
  // Prevent scrolling during drawing
  e.preventDefault();

  this.draw(e.touches[0]);
});

canvas.addEventListener('touchend', (e) => {
  // Prevent ghost clicks
  e.preventDefault();

  this.stopDrawing();
});

The preventDefault() calls prevent the canvas from scrolling the page when users draw signatures, whilst touches[0] ignores multi-touch gestures that would create confusing drawing artefacts.

Server-Side Signature Storage

The server handles signature uploads through Active Storage:

class InspectionsController < ApplicationController
  def add_signature
    @inspection = Inspection.find(params[:inspection_id])

    signature = InspectionSignature.new(
      inspection: @inspection,
      agency: current_tenant
    )

    if params[:signature][:image].present?
      signature.image.attach(params[:signature][:image])
    end

    if signature.save
      render json: {
        id: signature.id,
        image_url: url_for(signature.image)
      }
    else
      render json: { errors: signature.errors.full_messages },
            status: :unprocessable_entity
    end
  end
end

The InspectionSignature model associates with inspections:

class InspectionSignature < ApplicationRecord
  belongs_to :inspection
  belongs_to :agency
  acts_as_tenant :agency

  has_one_attached :image

  validates :inspection, presence: true
  validates :image, presence: true

  # Signature metadata
  attribute :signed_by_name, :string
  attribute :signed_by_type, :string # 'tenant', 'landlord', 'agent'
  attribute :signed_at, :datetime, default: -> { Time.current }

  def image_url
    return nil unless image.attached?

    Rails.application.routes.url_helpers.rails_blob_url(image, only_path: true)
  end
end

This model captures who signed (tenant, landlord, or agent) and when, providing complete audit trails for compliance.

Responsive Signature Pad UI

The signature interface adapts to different screen sizes and orientations:

<!-- app/views/inspections/_signature_pad.html.erb -->
<div data-controller="signature"
     data-signature-inspection-id-value="<%= @inspection.id %>"
     class="signature-pad-modal">

  <div class="signature-pad-container">
    <h3>Capture Signature</h3>

    <div class="signature-instructions">
      Sign above using your finger or stylus
    </div>

    <div class="canvas-wrapper">
      <canvas data-signature-target="canvas"></canvas>
    </div>

    <div class="signature-actions">
      <button data-action="click->signature#clear"
              type="button"
              class="btn btn-secondary">
        Clear
      </button>

      <button data-action="click->signature#save"
              type="button"
              class="btn btn-primary">
        Save Signature
      </button>

      <button data-action="click->signature#close"
              type="button"
              class="btn btn-outline">
        Cancel
      </button>
    </div>
  </div>

  <!-- Hidden inputs for offline storage -->
  <input type="hidden"
         data-signature-target="input"
         name="inspection[signature_data]" />

  <!-- Preview (shown after capture) -->
  <img data-signature-target="preview"
       alt="Signature preview"
       class="signature-preview"
       style="display: none;" />
</div>

<style>
.canvas-wrapper {
  border: 2px solid #e5e7eb;
  border-radius: 8px;
  background: white;
  margin: 1rem 0;
  touch-action: none; /* Prevent browser touch gestures */
}

canvas {
  display: block;
  cursor: crosshair;
  touch-action: none;
}

@media (max-width: 768px) {
  .signature-pad-container {
    padding: 1rem;
  }

  .canvas-wrapper {
    /* Full width on mobile */
    width: 100%;
  }
}

@media (orientation: landscape) and (max-width: 768px) {
  .canvas-wrapper canvas {
    /* Larger canvas in landscape */
    height: 400px;
  }
}
</style>

The touch-action: none CSS prevents browser touch gestures (like pinch-to-zoom) from interfering with signature drawing.

Testing Signature Functionality

Testing canvas-based signatures requires simulating drawing events:

describe('SignatureController', () => {
  let controller;
  let canvas;

  beforeEach(() => {
    document.body.innerHTML = `
      <div data-controller="signature" data-signature-inspection-id-value="1">
        <canvas data-signature-target="canvas"></canvas>
      </div>
    `;

    controller = application.getControllerForElementAndIdentifier(
      document.querySelector('[data-controller="signature"]'),
      'signature'
    );

    canvas = controller.canvasTarget;
  });

  it('captures drawing strokes', () => {
    const startEvent = { clientX: 10, clientY: 10 };
    const moveEvent = { clientX: 50, clientY: 50 };

    controller.startDrawing(startEvent);
    controller.draw(moveEvent);
    controller.stopDrawing();

    expect(controller.strokes.length).toBe(1);
    expect(controller.strokes[0].length).toBeGreaterThan(0);
  });

  it('clears signatures', () => {
    controller.strokes = [[{ x: 10, y: 10 }, { x: 50, y: 50 }]];

    controller.clear();

    expect(controller.strokes.length).toBe(0);
  });

  it('converts canvas to blob', async () => {
    // Draw something on canvas
    controller.ctx.fillRect(10, 10, 50, 50);

    const blob = await controller.canvasToBlob();

    expect(blob).toBeInstanceOf(Blob);
    expect(blob.type).toBe('image/png');
  });
});

These tests verify drawing capture and image generation work correctly.

What's Next

The signature capture foundation enables sophisticated features: multi-party signatures allowing both tenant and landlord signatures on the same inspection, signature fields for specific items (individual room sign-offs, itemised damage acknowledgments), typed signatures offering alternatives to drawn signatures for accessibility, and biometric signatures integrating device authentication for enhanced security.

Future enhancements might include signature comparison detecting when signatures don't match previous records (fraud prevention), time-stamped signatures with cryptographic verification for legal compliance, and signature templates pre-populating signatory details based on inspection type.

By implementing comprehensive signature capture with canvas-based drawing and mobile touch handling, LetAdmin provides letting agencies with paperless inspection workflows that maintain legal compliance whilst improving efficiency and reducing administrative overhead.