Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 23 additions & 0 deletions DESIGN.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
# Don't Touch Design Notes

## Visual System

The app stays dark, quiet, and work-focused. Use neutral surfaces, restrained borders, and one calm blue accent for interactive affordances.

## Semantic Color

- Blue: interactive controls and selected setup surfaces.
- Green: healthy/complete/private-safe states.
- Amber: hand-near-face reminders and recoverable attention states.
- Red: destructive actions or unrecoverable failures only.

## Interaction

- Primary monitoring action stays immediate.
- Camera/model failures appear as persistent recovery panels near controls.
- Settings are organized as Setup, Detection, Alerts, Habit support, Privacy & data, and App.
- Detection presets offer Gentle, Balanced, and Strict before exposing advanced sliders.

## Motion

Motion is subtle and non-essential. Avoid shake, blink, scanner lines, heavy pulsing, and animated warning backgrounds. Progress fills may update directly without width transitions.
46 changes: 46 additions & 0 deletions PRODUCT.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
# Don't Touch Product Context

## Register

Product.

## Users

People who want help reducing face-touching habits, including hair pulling, skin picking, rubbing, or other repetitive hand-to-face patterns.

## Purpose

Detect hand proximity near the face locally and provide timely reminders that help the user interrupt the habit without shame, panic, or surveillance anxiety.

## Tone

Private coach.

Use firm, short, actionable language:

- Hand near face
- Move your hand away
- Clear to close
- Start monitoring

Avoid punitive or alarmist language:

- Warning
- Violation
- Face touch detected
- Remove your hand immediately

## Privacy Principles

- Camera access starts only after the user presses Start.
- Video processing stays local to the device.
- Recovery UI should explain camera/model issues without implying user fault.
- Error details belong behind disclosure controls unless required for action.

## Accessibility Principles

- Keyboard users must be able to reach every control.
- Active dialogs trap focus and close with Escape.
- State must not rely on color alone.
- Motion should respect reduced-motion preferences.
- Alerts can be immediate without shaking, blinking, or hostile visuals.
80 changes: 71 additions & 9 deletions electron/main/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,12 +13,28 @@ interface AppSettings {
autoStart: boolean
minimizeToTray: boolean
startMinimized: boolean
hidePreview: boolean
closeAction: 'ask' | 'quit' | 'tray'
alertSoundId: string
alertVolume: number
rightRailCollapsed: boolean
alertTimeoutDefaultMinutes: 5 | 10 | 15 | 30 | 45 | 60
cameraQuality: 'balanced' | 'high' | 'maximum'
detectionDebugHud: boolean
}

const DEFAULT_APP_SETTINGS: AppSettings = {
autoStart: false,
minimizeToTray: true,
startMinimized: false,
hidePreview: false,
closeAction: 'ask',
alertSoundId: 'tone-chime',
alertVolume: 0.5,
rightRailCollapsed: false,
alertTimeoutDefaultMinutes: 15,
cameraQuality: 'high',
detectionDebugHud: false,
}

const APP_SETTINGS_FILE = path.join(app.getPath('userData'), 'app-settings.json')
Expand All @@ -32,11 +48,7 @@ function loadAppSettings(): AppSettings {
// must not leave fields undefined, which would silently break the
// close/minimize-to-tray behavior that branches on these booleans.
if (parsed && typeof parsed === 'object') {
return {
autoStart: Boolean(parsed.autoStart ?? DEFAULT_APP_SETTINGS.autoStart),
minimizeToTray: Boolean(parsed.minimizeToTray ?? DEFAULT_APP_SETTINGS.minimizeToTray),
startMinimized: Boolean(parsed.startMinimized ?? DEFAULT_APP_SETTINGS.startMinimized),
}
return coerceAppSettings(parsed)
}
}
} catch (err) {
Expand All @@ -55,6 +67,47 @@ function saveAppSettings(settings: AppSettings): void {

let appSettings = loadAppSettings()

function isCloseAction(value: unknown): value is AppSettings['closeAction'] {
return value === 'ask' || value === 'quit' || value === 'tray'
}

function isAlertTimeoutDefaultMinutes(value: unknown): value is AppSettings['alertTimeoutDefaultMinutes'] {
return value === 5 || value === 10 || value === 15 || value === 30 || value === 45 || value === 60
}

function isCameraQuality(value: unknown): value is AppSettings['cameraQuality'] {
return value === 'balanced' || value === 'high' || value === 'maximum'
}

function coerceVolume(value: unknown): number {
return typeof value === 'number' && Number.isFinite(value)
? Math.max(0, Math.min(1, value))
: DEFAULT_APP_SETTINGS.alertVolume
}

function coerceAppSettings(value: unknown): AppSettings {
if (!value || typeof value !== 'object') return { ...DEFAULT_APP_SETTINGS }
const parsed = value as Partial<AppSettings>

return {
autoStart: Boolean(parsed.autoStart ?? DEFAULT_APP_SETTINGS.autoStart),
minimizeToTray: Boolean(parsed.minimizeToTray ?? DEFAULT_APP_SETTINGS.minimizeToTray),
startMinimized: Boolean(parsed.startMinimized ?? DEFAULT_APP_SETTINGS.startMinimized),
hidePreview: Boolean(parsed.hidePreview ?? DEFAULT_APP_SETTINGS.hidePreview),
closeAction: isCloseAction(parsed.closeAction) ? parsed.closeAction : DEFAULT_APP_SETTINGS.closeAction,
alertSoundId: typeof parsed.alertSoundId === 'string' ? parsed.alertSoundId : DEFAULT_APP_SETTINGS.alertSoundId,
alertVolume: coerceVolume(parsed.alertVolume),
rightRailCollapsed: Boolean(parsed.rightRailCollapsed ?? DEFAULT_APP_SETTINGS.rightRailCollapsed),
alertTimeoutDefaultMinutes: isAlertTimeoutDefaultMinutes(parsed.alertTimeoutDefaultMinutes)
? parsed.alertTimeoutDefaultMinutes
: DEFAULT_APP_SETTINGS.alertTimeoutDefaultMinutes,
cameraQuality: isCameraQuality(parsed.cameraQuality)
? parsed.cameraQuality
: DEFAULT_APP_SETTINGS.cameraQuality,
detectionDebugHud: Boolean(parsed.detectionDebugHud ?? DEFAULT_APP_SETTINGS.detectionDebugHud),
}
}

const __dirname = path.dirname(fileURLToPath(import.meta.url))

// The built directory structure
Expand Down Expand Up @@ -92,6 +145,7 @@ let win: BrowserWindow | null = null
let alertWindow: BrowserWindow | null = null
let tray: Tray | null = null
let isQuitting = false
let alertShowRequestId = 0

// URL validation for external links
function isValidExternalUrl(url: string): boolean {
Expand Down Expand Up @@ -288,11 +342,17 @@ async function createWindow() {

// IPC Handlers for fullscreen alert
ipcMain.handle('show-fullscreen-alert', (_, data) => {
const showRequestId = ++alertShowRequestId

if (!alertWindow) {
createAlertWindow()
}

const sendDataAndShow = () => {
if (showRequestId !== alertShowRequestId || !alertWindow) {
return
}

// Ensure window covers entire screen including taskbar
const primaryDisplay = screen.getPrimaryDisplay()
const { bounds } = primaryDisplay
Expand All @@ -314,6 +374,8 @@ ipcMain.handle('show-fullscreen-alert', (_, data) => {
})

ipcMain.handle('hide-fullscreen-alert', () => {
alertShowRequestId++

if (alertWindow) {
alertWindow.hide()
}
Expand Down Expand Up @@ -354,13 +416,13 @@ ipcMain.handle('get-app-settings', () => {
})

ipcMain.handle('set-app-settings', (_, settings: AppSettings) => {
appSettings = settings
saveAppSettings(settings)
appSettings = coerceAppSettings(settings)
saveAppSettings(appSettings)

// Update auto-start setting
app.setLoginItemSettings({
openAtLogin: settings.autoStart,
openAsHidden: settings.startMinimized,
openAtLogin: appSettings.autoStart,
openAsHidden: appSettings.startMinimized,
})

return true
Expand Down
13 changes: 9 additions & 4 deletions electron/preload/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -129,6 +129,7 @@ const safeDOM = {
*/
function useLoading() {
const className = `loaders-css__square-spin`
let loadingRemoved = false
const styleContent = `
@keyframes square-spin {
25% { transform: perspective(100px) rotateX(180deg) rotateY(0); }
Expand Down Expand Up @@ -166,10 +167,14 @@ function useLoading() {

return {
appendLoading() {
if (loadingRemoved) {
return
}
safeDOM.append(document.head, oStyle)
safeDOM.append(document.body, oDiv)
},
removeLoading() {
loadingRemoved = true
safeDOM.remove(document.head, oStyle)
safeDOM.remove(document.body, oDiv)
},
Expand All @@ -181,8 +186,8 @@ function useLoading() {
const { appendLoading, removeLoading } = useLoading()
domReady().then(appendLoading)

window.onmessage = (ev) => {
ev.data.payload === 'removeLoading' && removeLoading()
}
window.addEventListener('message', (ev) => {
ev.data?.payload === 'removeLoading' && removeLoading()
})

setTimeout(removeLoading, 4999)
setTimeout(removeLoading, 4999)
Loading
Loading