From 8de3e8aea06481cf22303bc384e302b5b5caba84 Mon Sep 17 00:00:00 2001 From: SandObserver <260779319+SandObserver@users.noreply.github.com> Date: Sun, 4 Oct 2026 13:08:58 -0400 Subject: [PATCH 1/3] Docs: describe the Shahanshahi clock date in Persian Sync the clock widget copies and the shared date formatter from the app. --- public/js/clock-date.js | 28 +++++++++++++++++ public/widgets/clock/analog.html | 42 +++++++++++++++---------- public/widgets/clock/digital.html | 19 +++++------ src/content/docs/docs/widgets/clock.mdx | 2 +- 4 files changed, 61 insertions(+), 30 deletions(-) create mode 100644 public/js/clock-date.js diff --git a/public/js/clock-date.js b/public/js/clock-date.js new file mode 100644 index 0000000..f4674fe --- /dev/null +++ b/public/js/clock-date.js @@ -0,0 +1,28 @@ +// @ts-check +/* Persian dates use the Shahanshahi year: the Solar Hijri year plus 1180. */ + +const SHAHANSHAHI_OFFSET = 1180; + +/** @param {string} lang @param {'long'|'short'} width + @returns {(date: Date) => string} */ +export function clockDateFormatter(lang, width) { + /** @type {Intl.DateTimeFormatOptions} */ + const opts = { weekday: width, month: width, day: 'numeric' }; + if (String(lang).split('-')[0] !== 'fa') { + let fmt; + try { + fmt = new Intl.DateTimeFormat(lang, opts); + } catch { + fmt = new Intl.DateTimeFormat('en', opts); + } + return date => fmt.format(date); + } + const names = new Intl.DateTimeFormat(lang, { ...opts, calendar: 'persian' }); + const year = new Intl.DateTimeFormat('en-u-nu-latn', { calendar: 'persian', year: 'numeric' }); + const digits = new Intl.NumberFormat(lang, { useGrouping: false }); + return date => { + const part = Object.fromEntries(names.formatToParts(date).map(p => [p.type, p.value])); + const solar = Number.parseInt(year.formatToParts(date).find(p => p.type === 'year')?.value ?? '', 10); + return `${part.weekday} ${part.day} ${part.month} ${digits.format(solar + SHAHANSHAHI_OFFSET)}`; + }; +} diff --git a/public/widgets/clock/analog.html b/public/widgets/clock/analog.html index 60bb3e0..8b4e36b 100644 --- a/public/widgets/clock/analog.html +++ b/public/widgets/clock/analog.html @@ -23,7 +23,7 @@ .face { display: block; } .face text { font-family: -apple-system, BlinkMacSystemFont, system-ui, sans-serif; - font-weight: 600; letter-spacing: 0.09em; text-transform: uppercase; + font-weight: 600; text-transform: uppercase; } @media (prefers-reduced-motion: reduce){ *{transition:none !important;animation:none !important} } @@ -38,19 +38,11 @@ const API = '/api'; const NS = 'http://www.w3.org/2000/svg'; -/* Day and month names come from the browser, never from a catalog. */ const _lang = new URLSearchParams(location.search).get('lang') || 'en'; -const _dateFmt = (() => { - try { return new Intl.DateTimeFormat(_lang, { weekday: 'long', month: 'long', day: 'numeric' }); } - catch { return new Intl.DateTimeFormat('en', { weekday: 'long', month: 'long', day: 'numeric' }); } -})(); -const _dateShortFmt = (() => { - try { return new Intl.DateTimeFormat(_lang, { weekday: 'short', month: 'short', day: 'numeric' }); } - catch { return new Intl.DateTimeFormat('en', { weekday: 'short', month: 'short', day: 'numeric' }); } -})(); +let _dateLong = null, _dateShort = null; function _a11yLabel(now){ const hh=String(now.getHours()).padStart(2,'0'), mm=String(now.getMinutes()).padStart(2,'0'); - return `${hh}:${mm}, ${_dateFmt.format(now)}`; + return `${hh}:${mm}, ${_dateLong(now)}`; } const _reduceMotion = matchMedia('(prefers-reduced-motion: reduce)').matches; @@ -75,7 +67,7 @@ }; let _mode='dark', _timezone='', _showDate=true, _zoneBad=false; -let _groups=null, _dateTexts=[], _rafId=null, _running=false, _vb=0; +let _groups=null, _dateTexts=[], _dateShown='', _rafId=null, _running=false, _vb=0; function _now(){ if(!_timezone) return new Date(); @@ -96,7 +88,7 @@ async function showZoneError(){ const w=document.getElementById('widget'); w.className='widget mode-'+_mode; - const { errorState, loadStrings, wt } = await import('/js/widget-toolbox.js?v=f23ad458'); + const { errorState, loadStrings, wt } = await import('/js/widget-toolbox.js?v=5ecee136'); await loadStrings(); const line=wt('ui.unknownZone','Unknown timezone'); errorState({ root: w, place: 'center' }).empty(line); @@ -132,10 +124,11 @@ } _dateTexts=[]; + _dateShown=''; const dateOf=clip=>{ if(!_showDate) return; const n=el('text',{ x:cx, y:V-V*0.085, 'text-anchor':'middle', - 'font-size':V*0.072, fill:dial.date }); + fill:dial.date }); if(clip) n.setAttribute('clip-path', clip); _dateTexts.push(n); face.appendChild(n); @@ -218,11 +211,23 @@ document.getElementById('widget').setAttribute('aria-label', _a11yLabel(now)); if(_showDate && _dateTexts.length){ - const d=_dateShortFmt.format(now); - for(const n of _dateTexts) n.textContent=d; + const d=_dateShort(now); + if(d!==_dateShown) showDate(d); } } +/* Wider than the dial chord at the date's baseline, the date runs off the face. */ +const DATE_SIZE=0.072, DATE_MAX_WIDTH=0.46; +function showDate(d){ + const size=_vb*DATE_SIZE, max=_vb*DATE_MAX_WIDTH; + for(const n of _dateTexts){ n.textContent=d; n.setAttribute('font-size', size); } + const w=_dateTexts[0].getComputedTextLength(); + if(!w) return; + _dateShown=d; + if(w<=max) return; + for(const n of _dateTexts) n.setAttribute('font-size', size*max/w); +} + function _loop(){ if(!_running) return; render(true); @@ -266,7 +271,7 @@ let _rzId=null; addEventListener('resize', ()=>{ clearTimeout(_rzId); - _rzId=setTimeout(()=>{ if(_zoneBad) return; const run=_running; stop(); buildShell(); render(!_reduceMotion); if(run) start(); }, 150); + _rzId=setTimeout(()=>{ if(_zoneBad || !_dateShort) return; const run=_running; stop(); buildShell(); render(!_reduceMotion); if(run) start(); }, 150); }); function hostTheme(){ return document.documentElement.getAttribute('data-theme')==='light'?'light':'dark'; } @@ -280,6 +285,9 @@ } }catch{} if(!zoneOk(_timezone)){ _zoneBad=true; return showZoneError(); } + const { clockDateFormatter } = await import('/js/clock-date.js?v=22d2c48a'); + _dateLong = clockDateFormatter(_lang, 'long'); + _dateShort = clockDateFormatter(_lang, 'short'); buildShell(); render(!_reduceMotion); watchVisibility(); diff --git a/public/widgets/clock/digital.html b/public/widgets/clock/digital.html index 4352837..86195c2 100644 --- a/public/widgets/clock/digital.html +++ b/public/widgets/clock/digital.html @@ -63,19 +63,11 @@ const W = 22, H = 40, S = 3.4, G = 1.8, MID = H / 2, PAD = S; -/* Day and month names come from the browser, never from a catalog. */ const _lang = new URLSearchParams(location.search).get('lang') || 'en'; -const _dateFmt = (() => { - try { return new Intl.DateTimeFormat(_lang, { weekday: 'long', month: 'long', day: 'numeric' }); } - catch { return new Intl.DateTimeFormat('en', { weekday: 'long', month: 'long', day: 'numeric' }); } -})(); -const _dateShortFmt = (() => { - try { return new Intl.DateTimeFormat(_lang, { weekday: 'short', month: 'short', day: 'numeric' }); } - catch { return new Intl.DateTimeFormat('en', { weekday: 'short', month: 'short', day: 'numeric' }); } -})(); +let _dateLong = null, _dateShort = null; function _a11yLabel(now){ const hh=String(now.getHours()).padStart(2,'0'), mm=String(now.getMinutes()).padStart(2,'0'); - return `${hh}:${mm}, ${_dateFmt.format(now)}`; + return `${hh}:${mm}, ${_dateLong(now)}`; } @@ -181,7 +173,7 @@ async function showZoneError() { const w = document.getElementById('widget'); w.className = 'widget mode-' + _mode; - const { errorState, loadStrings, wt } = await import('/js/widget-toolbox.js?v=f23ad458'); + const { errorState, loadStrings, wt } = await import('/js/widget-toolbox.js?v=5ecee136'); await loadStrings(); const line = wt('ui.unknownZone', 'Unknown timezone'); errorState({ root: w, place: 'center' }).empty(line); @@ -199,7 +191,7 @@ hd.forEach((n,i) => { applyDigit(_hSVGs[i], n, _lastH[i]); _lastH[i] = n; }); md.forEach((n,i) => { applyDigit(_mSVGs[i], n, _lastM[i]); _lastM[i] = n; }); if (_showDate) { - document.getElementById('date-line').textContent = _dateShortFmt.format(now); + document.getElementById('date-line').textContent = _dateShort(now); } if (_tickId) clearTimeout(_tickId); _tickId = setTimeout(tick, (60 - now.getSeconds()) * 1000); @@ -216,6 +208,9 @@ } } catch {} if (!zoneOk(_timezone)) return showZoneError(); + const { clockDateFormatter } = await import('/js/clock-date.js?v=22d2c48a'); + _dateLong = clockDateFormatter(_lang, 'long'); + _dateShort = clockDateFormatter(_lang, 'short'); buildShell(); tick(); } diff --git a/src/content/docs/docs/widgets/clock.mdx b/src/content/docs/docs/widgets/clock.mdx index a06d87e..e2278cb 100644 --- a/src/content/docs/docs/widgets/clock.mdx +++ b/src/content/docs/docs/widgets/clock.mdx @@ -18,5 +18,5 @@ Small only. From a4bf495b01037b73f85a6dc51bcfe78e84451df4 Mon Sep 17 00:00:00 2001 From: SandObserver <260779319+SandObserver@users.noreply.github.com> Date: Sun, 4 Oct 2026 13:51:59 -0400 Subject: [PATCH 2/3] Sync the two-line analog clock date from the app --- public/js/clock-date.js | 37 ++++++++++++++++++++--------- public/widgets/clock/analog.html | 39 ++++++++++++++++++++----------- public/widgets/clock/digital.html | 2 +- 3 files changed, 53 insertions(+), 25 deletions(-) diff --git a/public/js/clock-date.js b/public/js/clock-date.js index f4674fe..2286780 100644 --- a/public/js/clock-date.js +++ b/public/js/clock-date.js @@ -3,19 +3,26 @@ const SHAHANSHAHI_OFFSET = 1180; -/** @param {string} lang @param {'long'|'short'} width - @returns {(date: Date) => string} */ -export function clockDateFormatter(lang, width) { +/** @param {string} lang @param {Intl.DateTimeFormatOptions} opts */ +function format(lang, opts) { + try { + return new Intl.DateTimeFormat(lang, opts); + } catch { + return new Intl.DateTimeFormat('en', opts); + } +} + +/** The whole date, and its weekday and day-month parts for a two-line layout. + @param {string} lang @param {'long'|'short'} width + @returns {(date: Date) => { full: string, weekday: string, date: string }} */ +export function clockDateParts(lang, width) { /** @type {Intl.DateTimeFormatOptions} */ const opts = { weekday: width, month: width, day: 'numeric' }; if (String(lang).split('-')[0] !== 'fa') { - let fmt; - try { - fmt = new Intl.DateTimeFormat(lang, opts); - } catch { - fmt = new Intl.DateTimeFormat('en', opts); - } - return date => fmt.format(date); + const full = format(lang, opts); + const weekday = format(lang, { weekday: width }); + const dayMonth = format(lang, { month: width, day: 'numeric' }); + return date => ({ full: full.format(date), weekday: weekday.format(date), date: dayMonth.format(date) }); } const names = new Intl.DateTimeFormat(lang, { ...opts, calendar: 'persian' }); const year = new Intl.DateTimeFormat('en-u-nu-latn', { calendar: 'persian', year: 'numeric' }); @@ -23,6 +30,14 @@ export function clockDateFormatter(lang, width) { return date => { const part = Object.fromEntries(names.formatToParts(date).map(p => [p.type, p.value])); const solar = Number.parseInt(year.formatToParts(date).find(p => p.type === 'year')?.value ?? '', 10); - return `${part.weekday} ${part.day} ${part.month} ${digits.format(solar + SHAHANSHAHI_OFFSET)}`; + const dayMonthYear = `${part.day} ${part.month} ${digits.format(solar + SHAHANSHAHI_OFFSET)}`; + return { full: `${part.weekday} ${dayMonthYear}`, weekday: part.weekday, date: dayMonthYear }; }; } + +/** @param {string} lang @param {'long'|'short'} width + @returns {(date: Date) => string} */ +export function clockDateFormatter(lang, width) { + const parts = clockDateParts(lang, width); + return date => parts(date).full; +} diff --git a/public/widgets/clock/analog.html b/public/widgets/clock/analog.html index 8b4e36b..bd5982a 100644 --- a/public/widgets/clock/analog.html +++ b/public/widgets/clock/analog.html @@ -125,13 +125,17 @@ _dateTexts=[]; _dateShown=''; + /* Two text elements, not two tspans. Safari reorders right-to-left text + across tspans and scrambles a Persian date. */ const dateOf=clip=>{ if(!_showDate) return; - const n=el('text',{ x:cx, y:V-V*0.085, 'text-anchor':'middle', - fill:dial.date }); - if(clip) n.setAttribute('clip-path', clip); - _dateTexts.push(n); - face.appendChild(n); + const y=V-V*0.085; + const line=dy=>{ + const n=el('text',{ x:cx, y:y-dy, 'text-anchor':'middle', fill:dial.date }); + if(clip) n.setAttribute('clip-path', clip); + return face.appendChild(n); + }; + _dateTexts.push({ date:line(0), weekday:line(V*DATE_SIZE*DATE_LINE_GAP) }); }; dateOf(null); @@ -212,20 +216,29 @@ document.getElementById('widget').setAttribute('aria-label', _a11yLabel(now)); if(_showDate && _dateTexts.length){ const d=_dateShort(now); - if(d!==_dateShown) showDate(d); + if(d.full!==_dateShown) showDate(d); } } /* Wider than the dial chord at the date's baseline, the date runs off the face. */ -const DATE_SIZE=0.072, DATE_MAX_WIDTH=0.46; +const DATE_SIZE=0.072, DATE_MAX_WIDTH=0.46, DATE_LINE_GAP=1.15; function showDate(d){ const size=_vb*DATE_SIZE, max=_vb*DATE_MAX_WIDTH; - for(const n of _dateTexts){ n.textContent=d; n.setAttribute('font-size', size); } - const w=_dateTexts[0].getComputedTextLength(); + const set=(key, text, px)=>{ + for(const t of _dateTexts){ t[key].textContent=text; t[key].setAttribute('font-size', px); } + }; + set('weekday', '', size); + set('date', d.full, size); + const w=_dateTexts[0].date.getComputedTextLength(); if(!w) return; - _dateShown=d; + _dateShown=d.full; if(w<=max) return; - for(const n of _dateTexts) n.setAttribute('font-size', size*max/w); + set('weekday', d.weekday, size); + set('date', d.date, size); + const widest=Math.max(_dateTexts[0].weekday.getComputedTextLength(), _dateTexts[0].date.getComputedTextLength()); + if(widest<=max) return; + set('weekday', d.weekday, size*max/widest); + set('date', d.date, size*max/widest); } function _loop(){ @@ -285,9 +298,9 @@ } }catch{} if(!zoneOk(_timezone)){ _zoneBad=true; return showZoneError(); } - const { clockDateFormatter } = await import('/js/clock-date.js?v=22d2c48a'); + const { clockDateFormatter, clockDateParts } = await import('/js/clock-date.js?v=978eaa4f'); _dateLong = clockDateFormatter(_lang, 'long'); - _dateShort = clockDateFormatter(_lang, 'short'); + _dateShort = clockDateParts(_lang, 'short'); buildShell(); render(!_reduceMotion); watchVisibility(); diff --git a/public/widgets/clock/digital.html b/public/widgets/clock/digital.html index 86195c2..90408a1 100644 --- a/public/widgets/clock/digital.html +++ b/public/widgets/clock/digital.html @@ -208,7 +208,7 @@ } } catch {} if (!zoneOk(_timezone)) return showZoneError(); - const { clockDateFormatter } = await import('/js/clock-date.js?v=22d2c48a'); + const { clockDateFormatter } = await import('/js/clock-date.js?v=978eaa4f'); _dateLong = clockDateFormatter(_lang, 'long'); _dateShort = clockDateFormatter(_lang, 'short'); buildShell(); From aa3f94932cc37c74a0315f1840496c0ef6d98144 Mon Sep 17 00:00:00 2001 From: SandObserver <260779319+SandObserver@users.noreply.github.com> Date: Sun, 4 Oct 2026 14:08:23 -0400 Subject: [PATCH 3/3] Sync the analog clock date weight from the app --- public/widgets/clock/analog.html | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/public/widgets/clock/analog.html b/public/widgets/clock/analog.html index bd5982a..7bdfcb0 100644 --- a/public/widgets/clock/analog.html +++ b/public/widgets/clock/analog.html @@ -23,7 +23,7 @@ .face { display: block; } .face text { font-family: -apple-system, BlinkMacSystemFont, system-ui, sans-serif; - font-weight: 600; text-transform: uppercase; + font-weight: 500; text-transform: uppercase; } @media (prefers-reduced-motion: reduce){ *{transition:none !important;animation:none !important} }