A browser-based, bilingual (TR / EN) vision screening app. A SPA built with Vite + Vue 3 (Composition API). Each test lives under its own route.
⚠️ This is not a medical diagnostic tool. Screen calibration, brightness and resolution all affect the results. If you have any concern or symptom, consult an eye doctor.
| Route | Test | What it measures |
|---|---|---|
/ishihara |
Color Blindness | Red–green color vision screening with Ishihara-style plates |
/snellen |
Visual Acuity | Snellen-based letter test (scaled to the measured distance) |
/amsler |
Amsler Grid | Macular / central vision distortion screening |
/contrast |
Contrast Sensitivity | Ability to read letters in faint gray tones |
Before it starts, each test sits behind a webcam-based positioning gate: the camera finds the face, estimates distance from the inter-pupillary distance (IPD), auto-starts the test once the correct distance/centering is reached, and pauses if you leave position during the test.
- MediaPipe FaceLandmarker is used for eye landmarks; it is lazy-loaded from a CDN only when the feature is enabled (dynamic
import), so the base bundle still contains onlyvue+vue-router. - Distance is computed with a pinhole model as
distance = focal_px × IPD / IPD_px. Because of two unknowns (the webcam's focal length and the person's IPD), the uncalibrated result is approximate (~15–25%). - One-time calibration: a capture is taken at a known distance and the focal ratio is written to
localStorage. By the math, the personal IPD and focal length cancel out; after that, the measurement is very close to reality on that webcam. Head rotation and iris jitter are the main remaining error sources. - All image processing happens in the browser; video never leaves the device. The camera requires
httpsorlocalhost(a secure context).
src/
main.js # entry point
App.vue # layout + <RouterView> + transition animation
router/index.js # hash history router, route -> view mapping
composables/
useI18n.js # reactive language state (synced to localStorage)
useCalibration.js # reactive px/mm screen calibration (synced to localStorage)
useFaceTracker.js # webcam + MediaPipe; distance from IPD + focal calibration
data/
messages.js # TR/EN translation dictionary
ishihara.js # plate generation (pure functions, canvas drawing)
snellen.js # optotype + visual angle math
components/
AppHeader.vue, LangToggle.vue, ResultScreen.vue
CameraPositioner.vue # live camera UI: distance readout, eye boxes, calibration
TestGate.vue # positioning gate + pause veil + PiP (wraps the tests)
views/
HomeView, IshiharaView, SnellenView, AmslerView, ContrastView, NotFoundView
styles/global.css # design tokens (CSS variables)
Design decisions
- Webcam positioning is mandatory: every test runs behind the camera gate (
TestGate), so there is no manual screen-distance setup on the home page. The camera is shown fullscreen while you position yourself, collapses to a small bottom-left picture-in-picture once you're in position, and returns to fullscreen if you leave position. - Calibration: letter size for Snellen is computed from visual angle (5 arc-minutes). The px/mm screen scale uses a sensible default (
useCalibration.js); the webcam supplies the viewing distance. - Hash history:
createWebHashHistoryis used so static hosting (Cloudflare Pages) needs no server-side routing. - Eager route import: the app is small, so a single bundle is faster than lazy chunks; it also avoids the async-component +
<Transition>race condition. - Minimal dependencies: the only runtime dependencies are
vueandvue-router. MediaPipe is not part of the bundle — it is lazy-loaded from a CDN. Styling is plain scoped CSS.
npm install
npm run dev # http://localhost:5173
npm run build # produces dist/
npm run preview # serve the build output locally
npm run lint # ESLint (eslint-plugin-vue)
npm run format # format with Prettier- ESLint (flat config,
eslint-plugin-vue) + Prettier (.prettierrc.json). .github/workflows/ci.yml: runsnpm run lint+npm run buildon every PR and push tomain.- Dependabot (
.github/dependabot.yml): scans npm and GitHub Actions dependencies weekly.dependabot-auto-merge.ymlauto-merges minor/patch updates once CI passes (majors are manual). For this to work, Settings → General → Allow auto-merge must be enabled in the repo settings.
Because vite.config.js sets base: './' and the router uses hash history, the build runs on any static host with no server-side routing.
This repo includes .github/workflows/deploy.yml: on push to main it builds and publishes to Cloudflare Pages (project eyeq) via wrangler pages deploy. Set up once:
- Create a Cloudflare API token (My Profile → API Tokens) with the Cloudflare Pages: Edit permission, and note your Account ID (Workers & Pages → Overview).
- In the GitHub repo, add them as secrets (Settings → Secrets and variables → Actions):
CLOUDFLARE_API_TOKENandCLOUDFLARE_ACCOUNT_ID. - Push to
main— the first deploy auto-creates theeyeqPages project and serves it athttps://eyeq.pages.dev.
MIT — see LICENSE.
The Ishihara plates are procedurally generated educational approximations; they do not replace the clinically validated original plate set.