3 Commits
Author SHA1 Message Date
codex-bot 8e660f403b Salah 0.3: larger AMOLED UI with swipe-scrollable prayers 2026-09-12 13:05:15 +01:00
codex-bot bb8be7012a README: app docs 2026-09-09 17:52:24 +01:00
codex-bot 9ec6511448 v0.2 preview + cleanup 2026-09-09 17:15:11 +01:00
8 changed files with 177 additions and 149 deletions
+70
View File
@@ -0,0 +1,70 @@
# SALAH — prayer times for Casablanca (Xiaomi Watch S4)
Offline prayer-time companion: shows today's six times (FAJR, SHURUK, DHUHR,
ASR, MAGHRIB, ISHA) with the next prayer highlighted and a countdown banner.
**Status: VERIFIED MATH (matches AlAdhan on 3 reference dates); on-watch render
pending user confirmation.**
## Method
Morocco (Ministry of Habous), matching AlAdhan `method=21`:
- Fajr 19°, Isha 17° (twilight angles)
- Standard Asr (shadow factor 1× — Maliki default in Morocco)
- Dhuhr = solar noon + 5 min; Maghrib = sunset + 5 min (AlAdhan's baked offsets)
- Location hardcoded: Casablanca 33.5731, −7.5898 · fixed TZ = UTC+1.
Ramadan clock changes are not handled by this version.
All computation happens **on the watch** — no GPS, no network. Pure solar math
(same Meeus approach as the sundial) lives in `src/common/prayer.js`.
## Screen (v0.3)
- Pure #000000 AMOLED background, including the list and prayer rows
- Larger 42px clock, 28px prayer names and 36px times
- Native vertically swipe-scrollable list: 80px rows in a 320×240px viewport
- Top/bottom spacers let the first and last prayers scroll to the center
- Fixed next-prayer countdown; lavender outline/text highlight the next prayer
- Normal system back button exits; no crown support assumed
- 15s refresh; no changes to prayer calculations
Build and automated tests pass; v0.3 scrolling/rendering needs an on-watch check.
`preview-0.3.png` is a static layout mock with illustrative times, not a watch screenshot.
## Project layout
```text
src/
├── app.ux, manifest.json # com.ilias.salah, watch, designWidth 466
├── common/
│ ├── prayer.js # times/format/nextPrayer/formatCountdown — pure, unit-tested
│ └── icon.png # 192×192 circular icon (user-supplied mosque art)
└── pages/index/index.ux # layout + update loop
tests/
├── prayer.cjs # 10 tests vs AlAdhan reference times (3 dates)
├── page.cjs # 11 wiring/layout tests (incl. chord-fit assertions)
├── make_icon.py # icon conversion pipeline (disc detect → round mask → 192²)
└── check-rpk.py # verifies icon/manifest inside the built .rpk
```
## Build & test
```bash
npm ci
npm run release
node tests/prayer.cjs
node tests/page.cjs
node tests/amoled.cjs
```
## Install
Side-load `dist/com.ilias.salah.release.*.rpk` via AstroBox.
Releases: https://gitea.oimcloud.myaddr.tools/codex-bot/vela-salah
## Accuracy note
Verified against AlAdhan API (method 21) for 2026-09-09, 2026-01-15, 2026-07-01 —
all six times within 2 minutes on every date. Tolerance covers the low-precision
solar vector (±0.01°) plus rounding.
-46
View File
@@ -1,46 +0,0 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<style>
body { margin: 0; padding: 20px; background: #16161e; font-family: sans-serif; display: flex; gap: 30px; align-items: center; }
.watch {
width: 466px; height: 466px; border-radius: 233px;
background: #0a0d18; position: relative; overflow: hidden; flex-shrink: 0;
box-shadow: 0 0 0 8px #222, 0 10px 40px rgba(0,0,0,.6);
}
.city { position: absolute; top: 18px; width: 100%; text-align: center; color: #8f95c7; font-size: 18px; letter-spacing: 2px; }
.clock { position: absolute; top: 44px; width: 100%; text-align: center; color: #fff; font-size: 44px; }
.banner {
position: absolute; top: 116px; left: 68px; width: 330px; height: 64px; border-radius: 32px;
background: #6d4fd4; display: flex; align-items: center; justify-content: center; gap: 8px;
}
.bannerLabel { color: #d9d2ff; font-size: 22px; }
.bannerTime { color: #fff; font-size: 26px; font-weight: bold; }
.row {
position: absolute; left: 53px; width: 360px; height: 44px; border-radius: 22px;
background: #161a2e; display: flex; justify-content: space-between; align-items: center;
padding: 0 22px; box-sizing: border-box;
}
.rowName { color: #a7acce; font-size: 21px; }
.rowTime { color: #fff; font-size: 21px; }
.rowNext { background: #2b2460; border: 1px solid #6d4fd4; }
.nameNext { color: #b9a8ff; font-weight: bold; }
.caption { color: #8888aa; font-size: 14px; max-width: 220px; }
</style>
</head>
<body>
<div class="watch">
<div class="city">CASABLANCA</div>
<div class="clock">17:05</div>
<div class="banner"><span class="bannerLabel">ASR in</span><span class="bannerTime">2h 5m</span></div>
<div class="row" style="top:196px"><span class="rowName">FAJR</span><span class="rowTime">05:40</span></div>
<div class="row" style="top:245px"><span class="rowName">SHURUK</span><span class="rowTime">07:10</span></div>
<div class="row" style="top:294px"><span class="rowName">DHUHR</span><span class="rowTime">13:33</span></div>
<div class="row rowNext" style="top:343px"><span class="rowName nameNext">ASR</span><span class="rowTime">17:01</span></div>
<div class="row" style="top:392px"><span class="rowName">MAGHRIB</span><span class="rowTime">19:51</span></div>
<div class="row" style="top:441px"><span class="rowName">ISHA</span><span class="rowTime">21:05</span></div>
</div>
<div class="caption">SALAH 0.1 — round-screen layout preview (466px). Rows at top 196→441px; chord width at row 441 (y 441-485, dy=208 from center) is ~2·√(233²−208²)≈209px — the 360px row exceeds it. <b>This preview intentionally shows the clipping risk at the bottom row.</b></div>
</body>
</html>
+48
View File
@@ -0,0 +1,48 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<style>
body { margin: 0; padding: 20px; background: #16161e; font-family: sans-serif; display: flex; gap: 30px; align-items: center; }
.watch {
width: 466px; height: 466px; border-radius: 233px;
background: #0a0d18; position: relative; overflow: hidden; flex-shrink: 0;
box-shadow: 0 0 0 8px #222, 0 10px 40px rgba(0,0,0,.6);
}
.city { position: absolute; top: 8px; width: 100%; text-align: center; color: #8f95c7; font-size: 15px; letter-spacing: 2px; }
.clock { position: absolute; top: 30px; width: 100%; text-align: center; color: #fff; font-size: 34px; }
.banner {
position: absolute; top: 76px; left: 83px; width: 300px; height: 56px; border-radius: 28px;
background: #6d4fd4; display: flex; align-items: center; justify-content: center; gap: 8px;
}
.bannerLabel { color: #d9d2ff; font-size: 20px; }
.bannerTime { color: #fff; font-size: 24px; font-weight: bold; }
.row {
position: absolute; height: 40px; border-radius: 20px;
background: #161a2e; display: flex; justify-content: space-between; align-items: center;
padding: 0 20px; box-sizing: border-box;
}
.rowName { color: #a7acce; font-size: 20px; }
.rowTime { color: #fff; font-size: 20px; }
.rowNext { background: #2b2460; border: 1px solid #6d4fd4; }
.nameNext { color: #b9a8ff; font-weight: bold; }
.rowPassed { opacity: .45; }
/* chord-tapered: centered on 233 with widths 380/412/424/406/368/304, rows start y=130 */
.caption { color: #8888aa; font-size: 14px; max-width: 230px; }
</style>
</head>
<body>
<div class="watch">
<div class="city">CASABLANCA</div>
<div class="clock">17:15</div>
<div class="banner"><span class="bannerLabel">ASR in</span><span class="bannerTime">1h 55m</span></div>
<div class="row rowPassed" style="top:130px; left:43px; width:380px"><span class="rowName">FAJR</span><span class="rowTime">05:40</span></div>
<div class="row rowPassed" style="top:174px; left:27px; width:412px"><span class="rowName">SHURUK</span><span class="rowTime">07:10</span></div>
<div class="row rowPassed" style="top:218px; left:21px; width:424px"><span class="rowName">DHUHR</span><span class="rowTime">13:33</span></div>
<div class="row rowNext" style="top:262px; left:30px; width:406px"><span class="rowName nameNext">ASR</span><span class="rowTime">17:01</span></div>
<div class="row" style="top:306px; left:49px; width:368px"><span class="rowName">MAGHRIB</span><span class="rowTime">19:51</span></div>
<div class="row" style="top:350px; left:81px; width:304px"><span class="rowName">ISHA</span><span class="rowTime">21:05</span></div>
</div>
<div class="caption">SALAH 0.2 layout — every row width ≤ chord at its position (verified numerically). Next prayer highlighted; passed prayers dimmed.</div>
</body>
</html>
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

+3 -3
View File
@@ -2,8 +2,8 @@
"package": "com.ilias.salah", "package": "com.ilias.salah",
"name": "SALAH", "name": "SALAH",
"icon": "/common/icon.png", "icon": "/common/icon.png",
"versionName": "0.2", "versionName": "0.3",
"versionCode": 2, "versionCode": 3,
"minAPILevel": 1, "minAPILevel": 1,
"deviceTypeList": [ "deviceTypeList": [
"watch" "watch"
@@ -22,7 +22,7 @@
} }
}, },
"display": { "display": {
"backgroundColor": "#0a0d18" "backgroundColor": "#000000"
}, },
"features": [ "features": [
{ {
+27 -88
View File
@@ -1,19 +1,20 @@
<template> <template>
<div class="page"> <div class="page">
<!-- header: city + clock -->
<text class="city">CASABLANCA</text> <text class="city">CASABLANCA</text>
<text class="clock">{{ clock }}</text> <text class="clock">{{ clock }}</text>
<!-- next-prayer banner -->
<div class="banner"> <div class="banner">
<text class="bannerLabel">{{ nextName }} in</text> <text class="bannerLabel">{{ nextName }} in</text>
<text class="bannerTime">{{ countdown }}</text> <text class="bannerTime">{{ countdown }}</text>
</div> </div>
<!-- prayer rows: width tapers with the chord via per-row bound style (sundial markerStyle pattern) --> <list class="prayers" id="prayers">
<div class="row {{ row.key === nextKey ? 'rowNext' : '' }} {{ row.passed ? 'rowPassed' : '' }}" <list-item type="spacer" class="spacer"><div></div></list-item>
style="{{ row.rowStyle }}" for="{{ row in rows }}" tid="key"> <list-item type="prayer" for="{{ row in rows }}" tid="key" class="row {{ row.key === nextKey ? 'rowNext' : '' }}">
<text class="rowName {{ row.key === nextKey ? 'nameNext' : '' }}">{{ row.label }}</text> <text class="rowName {{ row.key === nextKey ? 'nameNext' : '' }}">{{ row.label }}</text>
<text class="rowTime {{ row.key === nextKey ? 'timeNext' : '' }}">{{ row.time }}</text> <text class="rowTime {{ row.key === nextKey ? 'timeNext' : '' }}">{{ row.time }}</text>
</div> </list-item>
<list-item type="spacer" class="spacer"><div></div></list-item>
</list>
<text class="hint">SWIPE TO SCROLL</text>
</div> </div>
</template> </template>
<script> <script>
@@ -22,7 +23,7 @@ import prayer from '../../common/prayer.js'
// Casablanca // Casablanca
const LAT = 33.5731 const LAT = 33.5731
const LON = -7.5898 const LON = -7.5898
const TZ = 1 // Africa/Casablanca is UTC+1 year-round (no DST since 2018) const TZ = 1 // Fixed UTC+1; Ramadan clock changes are not handled.
const LABELS = { const LABELS = {
fajr: 'FAJR', fajr: 'FAJR',
@@ -33,8 +34,8 @@ const LABELS = {
isha: 'ISHA' isha: 'ISHA'
} }
// chord-tapered row widths (v0.2 round-screen layout): safe = chord - 2*10px inset // Uniform rows fit the entire scroll viewport.
const ROW_WIDTHS = [380, 412, 424, 406, 368, 304] const ROW_WIDTHS = [320, 320, 320, 320, 320, 320]
export default { export default {
private: { private: {
@@ -80,81 +81,19 @@ export default {
} }
</script> </script>
<style> <style>
.page { .page { width: 466px; height: 466px; background-color: #000000; }
width: 466px; .city { position: absolute; top: 26px; left: 103px; width: 260px; height: 26px; color: #aaa5b5; font-size: 20px; text-align: center; }
height: 466px; .clock { position: absolute; top: 52px; left: 103px; width: 260px; height: 48px; color: #ffffff; font-size: 42px; text-align: center; }
background-color: #0a0d18; .banner { position: absolute; top: 104px; left: 53px; width: 360px; height: 36px; background-color: #000000; flex-direction: row; justify-content: center; align-items: center; }
flex-direction: column; .bannerLabel { color: #c5a3ff; font-size: 24px; margin-right: 8px; }
align-items: center; .bannerTime { color: #ffffff; font-size: 26px; font-weight: bold; }
} .prayers { position: absolute; top: 146px; left: 73px; width: 320px; height: 240px; flex-direction: column; background-color: #000000; }
.city { .spacer { width: 320px; height: 80px; background-color: #000000; }
height: 20px; .row { width: 320px; height: 80px; background-color: #000000; flex-direction: row; justify-content: space-between; align-items: center; padding-left: 10px; padding-right: 10px; }
color: #8f95c7; .rowNext { border-width: 1px; border-color: #a779ef; border-radius: 18px; }
font-size: 15px; .rowName { color: #d0cbd6; font-size: 28px; }
letter-spacing: 2px; .nameNext { color: #c5a3ff; font-weight: bold; }
text-align: center; .rowTime { color: #ffffff; font-size: 36px; }
margin-top: 8px; .timeNext { color: #c5a3ff; font-weight: bold; }
} .hint { position: absolute; top: 403px; left: 103px; width: 260px; height: 24px; color: #aaa5b5; font-size: 18px; text-align: center; }
.clock {
height: 40px;
color: #ffffff;
font-size: 34px;
text-align: center;
margin-top: 2px;
}
.banner {
width: 300px;
height: 56px;
border-radius: 28px;
background-color: #6d4fd4;
flex-direction: row;
justify-content: center;
align-items: center;
margin-top: 6px;
}
.bannerLabel {
color: #d9d2ff;
font-size: 20px;
margin-right: 8px;
}
.bannerTime {
color: #ffffff;
font-size: 24px;
font-weight: bold;
}
.row {
height: 40px;
border-radius: 20px;
background-color: #161a2e;
flex-direction: row;
justify-content: space-between;
align-items: center;
margin-top: 4px;
padding-left: 20px;
padding-right: 20px;
}
.rowNext {
background-color: #2b2460;
border-width: 1px;
border-color: #6d4fd4;
}
.rowPassed {
opacity: 0.45;
}
.rowName {
color: #a7acce;
font-size: 20px;
}
.nameNext {
color: #b9a8ff;
font-weight: bold;
}
.rowTime {
color: #ffffff;
font-size: 20px;
}
.timeNext {
color: #ffffff;
font-weight: bold;
}
</style> </style>
+25
View File
@@ -0,0 +1,25 @@
const assert = require('node:assert/strict')
const fs = require('node:fs')
const src = fs.readFileSync('src/pages/index/index.ux', 'utf8')
const manifest = JSON.parse(fs.readFileSync('src/manifest.json'))
function css(name, prop) {
const block = src.match(new RegExp('\\.' + name + '\\s*\\{([^}]+)'))[1]
return block.match(new RegExp(prop + ':\\s*([^;]+)'))[1].trim()
}
assert.equal(css('page', 'background-color'), '#000000', 'page must be AMOLED black')
assert.equal(manifest.display.backgroundColor, '#000000')
assert.match(src, /<list\s[^>]*class="prayers"/, 'native swipe-scrollable list')
assert.match(src, /<list-item[^>]*type="prayer"[^>]*for="\{\{ row in rows \}\}"/)
assert.ok(parseFloat(css('rowName', 'font-size')) >= 28)
assert.ok(parseFloat(css('rowTime', 'font-size')) >= 36)
const top = parseFloat(css('prayers', 'top'))
const height = parseFloat(css('prayers', 'height'))
const width = parseFloat(css('prayers', 'width'))
const dy = Math.max(Math.abs(top - 233), Math.abs(top + height - 233))
assert.ok(width <= 2 * Math.sqrt(233 ** 2 - dy ** 2) - 20, 'whole scroll viewport fits circle')
const rowH = parseFloat(css('row', 'height'))
const pad = parseFloat(css('spacer', 'height'))
assert.equal(pad, (height - rowH) / 2, 'first/last rows can reach viewport center')
assert.ok(6 * rowH + 2 * pad > height, 'content actually scrolls')
assert.ok(!src.includes('onBackPress'), 'system back still exits')
console.log('AMOLED, large text, scroll wiring and circular viewport checks passed')
+4 -12
View File
@@ -148,18 +148,10 @@ test('update() rows: six entries, key+label+time wired', () => {
} }
}) })
test('row widths taper with the chord and fit the 466 circle at every row position', () => { test('all rows fit the scroll viewport', () => {
// rows: 6 x 40px + 5 x 4px margins, block starts after header+banner (top=130) const widths = sandbox.ROW_WIDTHS
const ROW_WIDTHS = spec.ROW_WIDTHS || sandbox.ROW_WIDTHS assert.equal(widths.length, 6)
assert.equal(ROW_WIDTHS.length, 6) widths.forEach(width => assert.equal(width, 320))
const H = 40, MARGIN = 4, BLOCK_TOP = 130, R = 233, INSET = 10
for (let n = 0; n < 6; n++) {
const top = BLOCK_TOP + n * (H + MARGIN)
const dy = Math.max(Math.abs(233 - top), Math.abs(233 - (top + H)))
const chord = 2 * Math.sqrt(Math.max(0, R * R - dy * dy))
const safe = chord - 2 * INSET
assert.ok(ROW_WIDTHS[n] <= safe, `row ${n} width ${ROW_WIDTHS[n]} exceeds safe chord ${safe.toFixed(0)} at top ${top}`)
}
}) })
test('bound rowStyle width equals ROW_WIDTHS[i]', () => { test('bound rowStyle width equals ROW_WIDTHS[i]', () => {