1 Commits
Author SHA1 Message Date
codex-bot 1c20c2927c Salah 0.5: defer center resizing until scrolling stops 2026-09-12 13:15:30 +01:00
5 changed files with 85 additions and 32 deletions
+16 -13
View File
@@ -19,22 +19,24 @@ Morocco (Ministry of Habous), matching AlAdhan `method=21`:
All computation happens **on the watch** — no GPS, no network. Pure solar math 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`. (same Meeus approach as the sundial) lives in `src/common/prayer.js`.
## Screen (v0.4) ## Screen (v0.5)
- Pure #000000 AMOLED background - Pure #000000 AMOLED background, large 50px clock, no swipe hint
- Larger 50px clock; no swipe instruction label
- Prayer names above their times, in fixed 104px native list rows - Prayer names above their times, in fixed 104px native list rows
- Center magnification: prayer times grow continuously from 30px to 60px as - After scrolling stops, the nearest-center prayer gets a 60px time and 28px
their row approaches the physical screen center; names grow from 20px to 28px label; other rows use 30px times and 20px labels
- Scroll viewport 320×264 at x73/y101, centered at y233; 80px end spacers - No font resizing or row-array replacement during gesture/inertial scrolling
- Fixed next-prayer countdown below the list; lavender next-prayer highlight - Duplicate stop notifications do not repaint unchanged focus; periodic refresh
- Glyph sizes change without changing row heights, avoiding scroll-position drift defers while scrolling and resumes normally after hiding/showing the app
- 15s clock refresh preserves scroll focus; calculation logic unchanged - Scroll viewport 320×264 at x73/y101 (center y233); 80px end spacers
- Normal system back exits; crown support not assumed - Fixed countdown below the list; lavender next-prayer highlight
- Normal system back exits; calculation logic unchanged
Automated math/page/focus/layout tests and release build pass. The live scroll User reported watch lag in v0.4. A 500-event VM replay measured 500 row-array
magnification still needs an on-watch check. `preview-0.4.png` is a static layout replacements in v0.4 versus zero during scrolling in v0.5. This verifies reduced
mock with illustrative times, not a device screenshot. 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 ## Project layout
@@ -61,6 +63,7 @@ node tests/prayer.cjs
node tests/page.cjs node tests/page.cjs
node tests/amoled.cjs node tests/amoled.cjs
node tests/focus.cjs node tests/focus.cjs
node tests/scroll-performance.cjs
``` ```
## Install ## Install
+2 -2
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.4", "versionName": "0.5",
"versionCode": 4, "versionCode": 5,
"minAPILevel": 1, "minAPILevel": 1,
"deviceTypeList": [ "deviceTypeList": [
"watch" "watch"
+21 -9
View File
@@ -6,7 +6,7 @@
<text class="bannerLabel">{{ nextName }} in</text> <text class="bannerLabel">{{ nextName }} in</text>
<text class="bannerTime">{{ countdown }}</text> <text class="bannerTime">{{ countdown }}</text>
</div> </div>
<list class="prayers" id="prayers" onscroll="onScroll"> <list class="prayers" id="prayers" onscroll="onScroll" onscrollend="onScrollEnd">
<list-item type="spacer" class="spacer"><div></div></list-item> <list-item type="spacer" class="spacer"><div></div></list-item>
<list-item type="prayer" for="{{ row in rows }}" tid="key" class="row {{ row.key === nextKey ? 'rowNext' : '' }}"> <list-item type="prayer" for="{{ row in rows }}" tid="key" class="row {{ row.key === nextKey ? 'rowNext' : '' }}">
<text style="{{ row.nameStyle }}" class="rowName {{ row.key === nextKey ? 'nameNext' : '' }}">{{ row.label }}</text> <text style="{{ row.nameStyle }}" class="rowName {{ row.key === nextKey ? 'nameNext' : '' }}">{{ row.label }}</text>
@@ -42,11 +42,11 @@ export default {
countdown: '', countdown: '',
nextKey: 'fajr', nextKey: 'fajr',
nextName: 'FAJR', nextName: 'FAJR',
scrollY: 0,
rows: [] rows: []
}, },
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
}, },
@@ -58,21 +58,33 @@ export default {
}, },
onScroll(event) { onScroll(event) {
if (!event || typeof event.scrollY !== 'number' || !isFinite(event.scrollY)) return if (!event || typeof event.scrollY !== 'number' || !isFinite(event.scrollY)) return
this.scrollY = Math.max(0, Math.min(520, event.scrollY)) // 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() this.applyFocus()
}, },
applyFocus() { applyFocus() {
// Row heights never change: only glyph sizes change, avoiding scroll jumps. const focused = Math.round((this._scrollY || 0) / 104)
const offset = this.scrollY || 0 let changed = false
this.rows = this.rows.map((row, index) => { const rows = this.rows.map((row, index) => {
const focus = Math.max(0, 1 - Math.abs(index * 104 - offset) / 104) 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, { return Object.assign({}, row, {
timeStyle: { fontSize: Math.round(30 + 30 * focus) + 'px' }, timeStyle: { fontSize: timeSize },
nameStyle: { fontSize: Math.round(20 + 8 * focus) + 'px' } 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')
+8 -8
View File
@@ -9,21 +9,21 @@ const p = Object.assign({}, ctx.spec, JSON.parse(JSON.stringify(ctx.spec.private
p.update() p.update()
assert.equal(typeof p.onScroll, 'function', 'scroll handler drives center magnification') assert.equal(typeof p.onScroll, 'function', 'scroll handler drives center magnification')
const size = i => parseFloat(p.rows[i].timeStyle.fontSize) const size = i => parseFloat(p.rows[i].timeStyle.fontSize)
p.onScroll({scrollY: 0}) p.onScroll({scrollY: 0, scrollState: 0})
assert.equal(size(0), 60, 'first centered prayer gets 60px time') assert.equal(size(0), 60, 'first centered prayer gets 60px time')
assert.ok(size(1) < size(0)) assert.ok(size(1) < size(0))
p.onScroll({scrollY: 104}) p.onScroll({scrollY: 104, scrollState: 0})
assert.equal(size(1), 60) assert.equal(size(1), 60)
assert.equal(size(0), size(2), 'symmetric shrink around center') assert.equal(size(0), size(2), 'symmetric shrink around center')
p.onScroll({scrollY: 52}) p.onScroll({scrollY: 52, scrollState: 0})
assert.equal(size(0), size(1), 'continuous interpolation between rows') assert.equal(size(1), 60, 'nearest row wins after stopping halfway')
assert.ok(size(0) > 30 && size(0) < 60) assert.equal(size(0), 30)
p.onScroll({scrollY: 520}) p.onScroll({scrollY: 520, scrollState: 0})
assert.equal(size(5), 60, 'last prayer can be magnified') assert.equal(size(5), 60, 'last prayer can be magnified')
p.update() p.update()
assert.equal(size(5), 60, 'clock refresh preserves scroll focus') assert.equal(size(5), 60, 'clock refresh preserves scroll focus')
for (const offset of [-100, 9999]) { for (const offset of [-100, 9999]) {
p.onScroll({scrollY: offset}) p.onScroll({scrollY: offset, scrollState: 0})
p.rows.forEach(row => assert.ok(parseFloat(row.timeStyle.fontSize) >= 30 && parseFloat(row.timeStyle.fontSize) <= 60)) p.rows.forEach(row => assert.ok(parseFloat(row.timeStyle.fontSize) >= 30 && parseFloat(row.timeStyle.fontSize) <= 60))
} }
const before = JSON.stringify(p.rows) const before = JSON.stringify(p.rows)
@@ -37,4 +37,4 @@ function css(c,k) { return parseFloat(src.match(new RegExp('\\.'+c+'\\s*\\{([^}]
assert.equal(css('prayers','top') + css('prayers','height') / 2, 233, 'magnification center is physical screen center') 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.equal(css('row','height'), 104, 'fixed row geometry prevents scroll feedback')
assert.ok(css('clock','font-size') > 42, 'header clock enlarged too') assert.ok(css('clock','font-size') > 42, 'header clock enlarged too')
console.log('Center magnification, interpolation, end rows, refresh preservation, invalid events and wiring passed') console.log('Center magnification, settled focus, end rows, refresh preservation, invalid events and wiring passed')
+38
View File
@@ -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')