Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1c20c2927c | ||
|
|
d92609e046 | ||
|
|
8e660f403b | ||
|
|
bb8be7012a | ||
|
|
9ec6511448 |
@@ -0,0 +1,78 @@
|
|||||||
|
# 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.5)
|
||||||
|
|
||||||
|
- Pure #000000 AMOLED background, large 50px clock, no swipe hint
|
||||||
|
- Prayer names above their times, in fixed 104px native list rows
|
||||||
|
- After scrolling stops, the nearest-center prayer gets a 60px time and 28px
|
||||||
|
label; other rows use 30px times and 20px labels
|
||||||
|
- No font resizing or row-array replacement during gesture/inertial scrolling
|
||||||
|
- Duplicate stop notifications do not repaint unchanged focus; periodic refresh
|
||||||
|
defers while scrolling and resumes normally after hiding/showing the app
|
||||||
|
- Scroll viewport 320×264 at x73/y101 (center y233); 80px end spacers
|
||||||
|
- Fixed countdown below the list; lavender next-prayer highlight
|
||||||
|
- Normal system back exits; calculation logic unchanged
|
||||||
|
|
||||||
|
User reported watch lag in v0.4. A 500-event VM replay measured 500 row-array
|
||||||
|
replacements in v0.4 versus zero during scrolling in v0.5. This verifies reduced
|
||||||
|
update work, NOT device frame rate. All automated tests and release build pass;
|
||||||
|
on-watch smoothness still needs confirmation. `preview-0.4.png` remains an
|
||||||
|
illustrative static layout mock; the settled appearance is unchanged.
|
||||||
|
|
||||||
|
## 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
|
||||||
|
node tests/focus.cjs
|
||||||
|
node tests/scroll-performance.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.
|
||||||
@@ -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>
|
|
||||||
@@ -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>
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 19 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 17 KiB |
+3
-3
@@ -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.5",
|
||||||
"versionCode": 2,
|
"versionCode": 5,
|
||||||
"minAPILevel": 1,
|
"minAPILevel": 1,
|
||||||
"deviceTypeList": [
|
"deviceTypeList": [
|
||||||
"watch"
|
"watch"
|
||||||
@@ -22,7 +22,7 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"display": {
|
"display": {
|
||||||
"backgroundColor": "#0a0d18"
|
"backgroundColor": "#000000"
|
||||||
},
|
},
|
||||||
"features": [
|
"features": [
|
||||||
{
|
{
|
||||||
|
|||||||
+55
-88
@@ -1,19 +1,19 @@
|
|||||||
<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" onscroll="onScroll" onscrollend="onScrollEnd">
|
||||||
<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 style="{{ row.nameStyle }}" class="rowName {{ row.key === nextKey ? 'nameNext' : '' }}">{{ row.label }}</text>
|
||||||
<text class="rowTime {{ row.key === nextKey ? 'timeNext' : '' }}">{{ row.time }}</text>
|
<text style="{{ row.timeStyle }}" class="rowTime {{ row.key === nextKey ? 'timeNext' : '' }}">{{ row.time }}</text>
|
||||||
</div>
|
</list-item>
|
||||||
|
<list-item type="spacer" class="spacer"><div></div></list-item>
|
||||||
|
</list>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
<script>
|
<script>
|
||||||
@@ -22,7 +22,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 +33,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: {
|
||||||
@@ -46,6 +46,7 @@ export default {
|
|||||||
},
|
},
|
||||||
onInit() {},
|
onInit() {},
|
||||||
onShow() {
|
onShow() {
|
||||||
|
this._scrolling = false
|
||||||
this.update()
|
this.update()
|
||||||
this.timer = setInterval(() => this.update(), 15000) // 15s cadence
|
this.timer = setInterval(() => this.update(), 15000) // 15s cadence
|
||||||
},
|
},
|
||||||
@@ -55,7 +56,35 @@ export default {
|
|||||||
onDestroy() {
|
onDestroy() {
|
||||||
clearInterval(this.timer)
|
clearInterval(this.timer)
|
||||||
},
|
},
|
||||||
|
onScroll(event) {
|
||||||
|
if (!event || typeof event.scrollY !== 'number' || !isFinite(event.scrollY)) return
|
||||||
|
// Non-template bookkeeping only while the native list is moving.
|
||||||
|
this._scrollY = Math.max(0, Math.min(520, event.scrollY))
|
||||||
|
this._scrolling = event.scrollState !== 0
|
||||||
|
if (event.scrollState === 0) this.onScrollEnd()
|
||||||
|
},
|
||||||
|
onScrollEnd() {
|
||||||
|
this._scrolling = false
|
||||||
|
this.applyFocus()
|
||||||
|
},
|
||||||
|
applyFocus() {
|
||||||
|
const focused = Math.round((this._scrollY || 0) / 104)
|
||||||
|
let changed = false
|
||||||
|
const rows = this.rows.map((row, index) => {
|
||||||
|
const timeSize = index === focused ? '60px' : '30px'
|
||||||
|
const nameSize = index === focused ? '28px' : '20px'
|
||||||
|
if (row.timeStyle && row.timeStyle.fontSize === timeSize &&
|
||||||
|
row.nameStyle && row.nameStyle.fontSize === nameSize) return row
|
||||||
|
changed = true
|
||||||
|
return Object.assign({}, row, {
|
||||||
|
timeStyle: { fontSize: timeSize },
|
||||||
|
nameStyle: { fontSize: nameSize }
|
||||||
|
})
|
||||||
|
})
|
||||||
|
if (changed) this.rows = rows
|
||||||
|
},
|
||||||
update() {
|
update() {
|
||||||
|
if (this._scrolling) return
|
||||||
const now = new Date()
|
const now = new Date()
|
||||||
const hh = String(now.getHours()).padStart(2, '0')
|
const hh = String(now.getHours()).padStart(2, '0')
|
||||||
const mm = String(now.getMinutes()).padStart(2, '0')
|
const mm = String(now.getMinutes()).padStart(2, '0')
|
||||||
@@ -76,85 +105,23 @@ export default {
|
|||||||
passed: t[key] < nowMin,
|
passed: t[key] < nowMin,
|
||||||
rowStyle: { width: ROW_WIDTHS[i] + 'px' }
|
rowStyle: { width: ROW_WIDTHS[i] + 'px' }
|
||||||
}))
|
}))
|
||||||
|
this.applyFocus()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
<style>
|
<style>
|
||||||
.page {
|
.page { width: 466px; height: 466px; background-color: #000000; }
|
||||||
width: 466px;
|
.city { position: absolute; top: 12px; left: 103px; width: 260px; height: 26px; color: #aaa5b5; font-size: 20px; text-align: center; }
|
||||||
height: 466px;
|
.clock { position: absolute; top: 35px; left: 103px; width: 260px; height: 56px; color: #ffffff; font-size: 50px; text-align: center; }
|
||||||
background-color: #0a0d18;
|
.banner { position: absolute; top: 383px; 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: 101px; left: 73px; width: 320px; height: 264px; flex-direction: column; background-color: #000000; }
|
||||||
.city {
|
.spacer { width: 320px; height: 80px; background-color: #000000; }
|
||||||
height: 20px;
|
.row { width: 320px; height: 104px; background-color: #000000; flex-direction: column; justify-content: center; 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 { width: 298px; height: 30px; text-align: center; color: #d0cbd6; font-size: 28px; }
|
||||||
letter-spacing: 2px;
|
.nameNext { color: #c5a3ff; font-weight: bold; }
|
||||||
text-align: center;
|
.rowTime { width: 298px; height: 70px; text-align: center; color: #ffffff; font-size: 60px; }
|
||||||
margin-top: 8px;
|
.timeNext { color: #c5a3ff; font-weight: bold; }
|
||||||
}
|
|
||||||
.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>
|
||||||
|
|||||||
@@ -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')
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
const assert = require('node:assert/strict')
|
||||||
|
const fs = require('node:fs')
|
||||||
|
const vm = require('node:vm')
|
||||||
|
const src = fs.readFileSync('src/pages/index/index.ux', 'utf8')
|
||||||
|
const body = src.match(/<script>([\s\S]*?)<\/script>/)[1].replace(/import\s+\w+\s+from\s+'[^']*';?/, '').replace('export default', 'globalThis.spec =')
|
||||||
|
const ctx = {prayer: require('../src/common/prayer.js'), setInterval: () => 1, clearInterval: () => {}}
|
||||||
|
vm.createContext(ctx); vm.runInContext(body, ctx)
|
||||||
|
const p = Object.assign({}, ctx.spec, JSON.parse(JSON.stringify(ctx.spec.private)))
|
||||||
|
p.update()
|
||||||
|
assert.equal(typeof p.onScroll, 'function', 'scroll handler drives center magnification')
|
||||||
|
const size = i => parseFloat(p.rows[i].timeStyle.fontSize)
|
||||||
|
p.onScroll({scrollY: 0, scrollState: 0})
|
||||||
|
assert.equal(size(0), 60, 'first centered prayer gets 60px time')
|
||||||
|
assert.ok(size(1) < size(0))
|
||||||
|
p.onScroll({scrollY: 104, scrollState: 0})
|
||||||
|
assert.equal(size(1), 60)
|
||||||
|
assert.equal(size(0), size(2), 'symmetric shrink around center')
|
||||||
|
p.onScroll({scrollY: 52, scrollState: 0})
|
||||||
|
assert.equal(size(1), 60, 'nearest row wins after stopping halfway')
|
||||||
|
assert.equal(size(0), 30)
|
||||||
|
p.onScroll({scrollY: 520, scrollState: 0})
|
||||||
|
assert.equal(size(5), 60, 'last prayer can be magnified')
|
||||||
|
p.update()
|
||||||
|
assert.equal(size(5), 60, 'clock refresh preserves scroll focus')
|
||||||
|
for (const offset of [-100, 9999]) {
|
||||||
|
p.onScroll({scrollY: offset, scrollState: 0})
|
||||||
|
p.rows.forEach(row => assert.ok(parseFloat(row.timeStyle.fontSize) >= 30 && parseFloat(row.timeStyle.fontSize) <= 60))
|
||||||
|
}
|
||||||
|
const before = JSON.stringify(p.rows)
|
||||||
|
for (const event of [undefined, {}, {scrollY: NaN}, {scrollY: Infinity}]) p.onScroll(event)
|
||||||
|
assert.equal(JSON.stringify(p.rows), before, 'invalid events do not corrupt styles')
|
||||||
|
assert.ok(!src.includes('SWIPE TO SCROLL'))
|
||||||
|
assert.match(src, /onscroll="onScroll"/)
|
||||||
|
assert.match(src, /style="\{\{ row.timeStyle \}\}"/)
|
||||||
|
assert.match(src, /style="\{\{ row.nameStyle \}\}"/)
|
||||||
|
function css(c,k) { return parseFloat(src.match(new RegExp('\\.'+c+'\\s*\\{([^}]+)'))[1].match(new RegExp(k+':\\s*([^;]+)'))[1]) }
|
||||||
|
assert.equal(css('prayers','top') + css('prayers','height') / 2, 233, 'magnification center is physical screen center')
|
||||||
|
assert.equal(css('row','height'), 104, 'fixed row geometry prevents scroll feedback')
|
||||||
|
assert.ok(css('clock','font-size') > 42, 'header clock enlarged too')
|
||||||
|
console.log('Center magnification, settled focus, end rows, refresh preservation, invalid events and wiring passed')
|
||||||
+6
-12
@@ -94,6 +94,8 @@ function page() {
|
|||||||
p.onHide = spec.onHide
|
p.onHide = spec.onHide
|
||||||
p.onDestroy = spec.onDestroy
|
p.onDestroy = spec.onDestroy
|
||||||
p.update = spec.update
|
p.update = spec.update
|
||||||
|
p.applyFocus = spec.applyFocus
|
||||||
|
p.onScroll = spec.onScroll
|
||||||
p.rows = []
|
p.rows = []
|
||||||
return p
|
return p
|
||||||
}
|
}
|
||||||
@@ -148,18 +150,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]', () => {
|
||||||
|
|||||||
@@ -0,0 +1,38 @@
|
|||||||
|
const assert = require('node:assert/strict')
|
||||||
|
const fs = require('node:fs')
|
||||||
|
const vm = require('node:vm')
|
||||||
|
const src = fs.readFileSync('src/pages/index/index.ux', 'utf8')
|
||||||
|
const body = src.match(/<script>([\s\S]*?)<\/script>/)[1].replace(/import\s+\w+\s+from\s+'[^']*';?/, '').replace('export default', 'globalThis.spec =')
|
||||||
|
const ctx = {prayer: require('../src/common/prayer.js'), setInterval: () => 1, clearInterval: () => {}}
|
||||||
|
vm.createContext(ctx); vm.runInContext(body, ctx)
|
||||||
|
const p = Object.assign({}, ctx.spec, JSON.parse(JSON.stringify(ctx.spec.private)))
|
||||||
|
p.update()
|
||||||
|
let rows = p.rows, writes = 0
|
||||||
|
Object.defineProperty(p, 'rows', {get: () => rows, set: value => {writes++; rows = value}})
|
||||||
|
const original = JSON.stringify(rows)
|
||||||
|
for (let i=0; i<500; i++) p.onScroll({scrollY: i, scrollState: i < 250 ? 1 : 2})
|
||||||
|
console.log('Row-array replacements during 500 gesture/inertia events:', writes)
|
||||||
|
assert.equal(writes, 0, 'native scrolling must not rebuild reactive rows')
|
||||||
|
assert.equal(JSON.stringify(rows), original, 'no font changes while scrolling')
|
||||||
|
p.update()
|
||||||
|
assert.equal(writes, 0, 'periodic refresh must defer during scrolling')
|
||||||
|
p.onScroll({scrollY: 520, scrollState: 0})
|
||||||
|
assert.equal(parseFloat(p.rows[5].timeStyle.fontSize), 60, 'last row grows after stop')
|
||||||
|
assert.ok(writes <= 1, 'one focus update at stop')
|
||||||
|
const settled = writes
|
||||||
|
p.onScrollEnd()
|
||||||
|
assert.equal(writes, settled, 'duplicate stop event is a no-op')
|
||||||
|
p.onScroll({scrollY: 208, scrollState: 1})
|
||||||
|
p.onScrollEnd()
|
||||||
|
assert.equal(parseFloat(p.rows[2].timeStyle.fontSize), 60, 'scrollend fallback applies nearest row')
|
||||||
|
assert.equal(parseFloat(p.rows[1].timeStyle.fontSize), 30)
|
||||||
|
const saved = JSON.stringify(p.rows)
|
||||||
|
for (const e of [undefined, {}, {scrollY: NaN}, {scrollY: Infinity}]) p.onScroll(e)
|
||||||
|
assert.equal(JSON.stringify(p.rows), saved)
|
||||||
|
p.onScroll({scrollY: 312, scrollState: 1})
|
||||||
|
p.onHide()
|
||||||
|
p.onShow()
|
||||||
|
assert.equal(p._scrolling, false, 'hiding during a swipe must not freeze refresh after resume')
|
||||||
|
assert.equal(parseFloat(p.rows[3].timeStyle.fontSize), 60)
|
||||||
|
assert.match(src, /onscrollend="onScrollEnd"/)
|
||||||
|
console.log('Scroll workload, deferred refresh, settled focus and duplicate-event checks passed')
|
||||||
Reference in New Issue
Block a user