| | | | | linguist.page@gmail.com

Why a PWA, and why this problem

A zikr counter looks trivial on the surface: tap a button, a number goes up. The interesting part is everything a tap-counter app needs that a normal web page doesn’t — the screen has to stay on during use, the interface should feel closer to a native counter than a webpage, and resetting a count needs to be deliberate enough that you don’t lose a session by accident. None of that requires a native app. It requires three browser APIs used correctly and a bit of restraint in the design.

This post walks through the core technical decisions: keeping the screen awake, going fullscreen, giving feedback at milestones, and building a reset gesture that’s hard to trigger by mistake.

Keeping the screen on: the Wake Lock API

The default behavior of any phone is to sleep the screen after a short idle period. For a counting app, that’s disqualifying — a user tapping every few seconds for dhikr shouldn’t have to keep unlocking their phone.

The Wake Lock API solves this directly:

let wakeLock = null;

async function requestWakeLock() {
  try {
    wakeLock = await navigator.wakeLock.request('screen');
    wakeLock.addEventListener('release', () => {
      console.log('Wake Lock released');
    });
  } catch (err) {
    console.error(`${err.name}, ${err.message}`);
  }
}

Two details matter here that are easy to miss in a first pass:

document.addEventListener('visibilitychange', async () => {
  if (wakeLock !== null && document.visibilityState === 'visible') {
    wakeLock = await navigator.wakeLock.request('screen');
  }
});

Browser support is solid on Chromium-based browsers and Safari 16.4+, but it’s worth feature-detecting ('wakeLock' in navigator) and failing silently rather than throwing — the counter should still work without it, just with a screen that can sleep.

Going fullscreen without fighting the browser chrome

The second piece is the Fullscreen API, which removes browser UI so the counter fills the screen:

function enterFullscreen() {
  const el = document.documentElement;
  if (el.requestFullscreen) {
    el.requestFullscreen();
  } else if (el.webkitRequestFullscreen) {
    el.webkitRequestFullscreen(); // Safari
  }
}

The friction point on iOS Safari is that Fullscreen API support is inconsistent for PWAs added to the home screen — but a PWA launched via display: standalone in the manifest already gets a fullscreen-like experience without needing the API at all. So the practical approach is layered: rely on display: standalone in the manifest as the baseline, and treat the Fullscreen API as a progressive enhancement for browser-tab usage rather than the primary mechanism.

{
  "display": "standalone",
  "orientation": "portrait"
}

Milestone feedback: haptics and audio together

Counting dhikr in sets of 33 or 100 benefits from feedback at milestones without requiring the user to look at the screen. This is where the Vibration API comes in, paired with a short audio cue:

function onMilestone(count, milestone) {
  if (count % milestone === 0) {
    if (navigator.vibrate) {
      navigator.vibrate([40, 30, 40]); // short-pause-short pattern
    }
    playMilestoneTone();
  }
}

The pattern-based vibration (rather than a single buzz) is deliberate — it’s distinguishable by feel from the vibration of an incoming notification, so it doesn’t get misread as something else. The audio tone is short (under 150ms) and generated with the Web Audio API rather than an audio file, which avoids a network request and keeps the app fully offline-capable:

function playMilestoneTone() {
  const ctx = new (window.AudioContext || window.webkitAudioContext)();
  const osc = ctx.createOscillator();
  const gain = ctx.createGain();
  osc.frequency.value = 880;
  gain.gain.setValueAtTime(0.15, ctx.currentTime);
  gain.gain.exponentialRampToValueAtTime(0.001, ctx.currentTime + 0.15);
  osc.connect(gain).connect(ctx.destination);
  osc.start();
  osc.stop(ctx.currentTime + 0.15);
}

The hold-to-reset gesture

A single tap to reset is dangerous — it’s the same gesture used to increment, so any misfire near the edge of the counter button wipes a session. The fix is a long-press threshold with visual feedback during the hold, so the reset only fires with clear intent:

let holdTimer = null;
const HOLD_DURATION = 1500; // ms

resetButton.addEventListener('touchstart', (e) => {
  e.preventDefault();
  resetButton.classList.add('holding');
  holdTimer = setTimeout(() => {
    count = 0;
    updateDisplay();
    navigator.vibrate?.(200); // confirmation buzz, distinct pattern
    resetButton.classList.remove('holding');
  }, HOLD_DURATION);
});

['touchend', 'touchcancel'].forEach(evt => {
  resetButton.addEventListener(evt, () => {
    clearTimeout(holdTimer);
    resetButton.classList.remove('holding');
  });
});

The .holding class drives a CSS radial fill or ring animation so the user can see the hold registering and let go if it was accidental — the confirmation is visual before it’s a vibration.

Aesthetic as functional design, not decoration

The Islamic geometric patterning isn’t just visual flourish layered on afterward — it’s built from repeating CSS conic-gradient and clip-path patterns that scale cleanly at any screen size without image assets, which matters for a PWA that needs to stay lightweight and installable offline. Arabic-Indic numerals (٠١٢٣٤٥٦٧٨٩) are rendered via a simple lookup rather than relying on Intl.NumberFormat locale quirks, which gives predictable control over exactly how the digits render across browsers.

What this adds up to

None of these four pieces — Wake Lock, Fullscreen, haptic/audio milestones, hold-to-reset — is complicated in isolation. The craft is in the edge cases: re-acquiring the wake lock on visibility change, treating fullscreen as an enhancement rather than a dependency, making vibration patterns distinguishable from system notifications, and making destructive actions require deliberate intent. Put together, they turn a basic counter into something that feels considered rather than assembled.