momolulu 1.0.18 → 1.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.en.md CHANGED
@@ -18,10 +18,12 @@ dsh plugin --profile web add momolulu
18
18
 
19
19
  - **Photo companion**: a scenic card in the lower right with gentle motion; collapse it to an avatar with a countdown.
20
20
  - **Focus timer**: 15/25/45/60 minutes, pausable. Optionally fullscreen; Esc leaves fullscreen without stopping the timer.
21
- - **Break reminders**: every 20 minutes, a brief nudge to drink water and stretch, with a shake and highlight.
21
+ - **Break reminders**: every 20 minutes, a brief nudge to drink water and stretch, with a shake and highlight; optional chime (off by default).
22
22
  - **Session radar**: running, just-finished, waiting-for-input and permission-requesting sessions; click one to jump to it.
23
23
  - **Weather**: Open-Meteo, Nanjing by default; location is requested only after you click "Use my location".
24
24
 
25
+ Typing `/focus` in the composer also starts or pauses the timer.
26
+
25
27
  Focus requests fullscreen as an optional touch; a denied request never affects the timer.
26
28
 
27
29
  ## Install
package/README.md CHANGED
@@ -18,10 +18,12 @@ dsh plugin --profile web add momolulu
18
18
 
19
19
  - **照片陪伴**:右下角一张风景照,轻微浮动;可缩成带倒计时的小头像。
20
20
  - **专注计时**:15/25/45/60 分钟,可暂停。开始时可全屏,Esc 退出全屏但不停表。
21
- - **休息提醒**:每 20 分钟提示喝水、起来活动,到点轻晃一下并高亮。
21
+ - **休息提醒**:每 20 分钟提示喝水、起来活动,到点轻晃一下并高亮;可选提示音(默认关)。
22
22
  - **会话雷达**:列出运行中、刚结束、等待输入、请求权限的会话,点一下就跳过去。
23
23
  - **天气**:Open-Meteo,默认南京;点「使用当前位置」才会请求定位。
24
24
 
25
+ 在输入框输入 `/focus` 也能开始或暂停计时。
26
+
25
27
  开始专注会请求全屏,这是可选的氛围;被拒绝也不影响计时。
26
28
 
27
29
  ## 安装
package/client.js CHANGED
@@ -13,7 +13,7 @@ window.__ModuleLoader__.load({
13
13
  const zh = {
14
14
  title: 'momolulu', subtitle: '慢慢来,我陪你一起。', meadow: '草甸', hike: '徒步', lake: '湖畔',
15
15
  radarWaiting: '等待处理', radarApproval: '请求权限', radarQuestion: '等待输入', radarSoftHint: '有其他会话可查看,无需确认或立即处理。', radar: '会话雷达', radarRunning: '运行中', radarUnread: '刚结束 · 待查看', radarEmpty: '暂无运行中或待查看的会话', radarHint: '结束不代表成功;待查看状态仅在当前页面保留。', openFailed: '无法打开此会话,请从侧栏重试。', showAll: '展开全部', showLess: '收起列表', reminderPaused: '提醒随专注暂停',
16
- testReminder: '测试提醒(8 秒)', minimize: '缩成小头像', restore: '恢复照片', details: '详情',
16
+ testReminder: '测试提醒(8 秒)', sound: '提示音', soundOn: '提示音 · 开', soundOff: '提示音 · 关', focusCommand: '专注', focusCommandHint: '开始或暂停 momolulu 的专注计时', minimize: '缩成小头像', restore: '恢复照片', details: '详情',
17
17
  close: '收起', focus: '专注倒计时', start: '开始专注', pause: '暂停', resume: '继续', reset: '重置',
18
18
  ready: '准备好,开始一小段专注', running: '专注中', paused: '已暂停', done: '这一轮完成啦',
19
19
  minutes: '分钟', elapsed: '已专注', reminders: '休息提醒', on: '开启', off: '关闭', next: '距离下次休息',
@@ -29,7 +29,7 @@ window.__ModuleLoader__.load({
29
29
  const en = {
30
30
  title: 'momolulu', subtitle: 'One step at a time, together.', meadow: 'Meadow', hike: 'Hike', lake: 'Lake',
31
31
  radarWaiting: 'Needs input', radarApproval: 'Permission requested', radarQuestion: 'Waiting for input', radarSoftHint: 'Other sessions have updates. No acknowledgement or immediate action required.', radar: 'Session radar', radarRunning: 'Running', radarUnread: 'Ended · unread', radarEmpty: 'No running or unread sessions', radarHint: 'Ended does not mean successful. Unread status is local to this page.', openFailed: 'Unable to open this session. Try the sidebar.', showAll: 'Show all', showLess: 'Show less', reminderPaused: 'Reminders paused with focus',
32
- testReminder: 'Test reminder (8 sec)', minimize: 'Minimize to avatar', restore: 'Restore photo', details: 'Details',
32
+ testReminder: 'Test reminder (8 sec)', sound: 'Chime', soundOn: 'Chime · On', soundOff: 'Chime · Off', focusCommand: 'Focus', focusCommandHint: 'Start or pause the momolulu focus timer', minimize: 'Minimize to avatar', restore: 'Restore photo', details: 'Details',
33
33
  close: 'Close', focus: 'Focus countdown', start: 'Start focus', pause: 'Pause', resume: 'Resume', reset: 'Reset',
34
34
  ready: 'Ready for a little focus time', running: 'Focusing', paused: 'Paused', done: 'Session complete',
35
35
  minutes: 'min', elapsed: 'Focused', reminders: 'Break reminders', on: 'On', off: 'Off', next: 'Next break in',
@@ -93,6 +93,7 @@ window.__ModuleLoader__.load({
93
93
  minutes, status: valid ? saved.status : 'ready', remaining: valid ? saved.remaining : total,
94
94
  deadline: valid ? saved.deadline : 0, now: Date.now(),
95
95
  reminderRemaining: Number.isFinite(saved?.reminderRemaining) ? Math.max(0, saved.reminderRemaining) : 20 * 60000,
96
+ sound: saved?.sound === true,
96
97
  reminders: saved?.reminders !== false, nextReminder: Number.isFinite(saved?.nextReminder) ? saved.nextReminder : 0,
97
98
  pausedDate: typeof saved?.pausedDate === 'string' ? saved.pausedDate : '', notice: '', noticeAt: 0,
98
99
  scene: [0, 1, 2].includes(saved?.scene) ? saved.scene : 0,
@@ -112,15 +113,49 @@ window.__ModuleLoader__.load({
112
113
  if (persist) storage.save(state);
113
114
  for (const fn of listeners) fn();
114
115
  }
116
+ // Optional chime. The AudioContext is created only after a user gesture, so
117
+ // browsers that block autoplay simply stay silent instead of erroring.
118
+ let audioContext;
119
+ function ensureAudio() {
120
+ try {
121
+ const Ctor = window.AudioContext || window.webkitAudioContext;
122
+ if (!Ctor) return undefined;
123
+ audioContext ??= new Ctor();
124
+ if (audioContext.state === 'suspended') void audioContext.resume().catch(() => {});
125
+ } catch { audioContext = undefined; }
126
+ return audioContext;
127
+ }
128
+ function playChime() {
129
+ const audio = ensureAudio();
130
+ if (!audio || audio.state !== 'running') return;
131
+ const start = audio.currentTime;
132
+ [660, 880].forEach((frequency, index) => {
133
+ const oscillator = audio.createOscillator();
134
+ const gain = audio.createGain();
135
+ const at = start + index * 0.18;
136
+ oscillator.type = 'sine';
137
+ oscillator.frequency.value = frequency;
138
+ gain.gain.setValueAtTime(0, at);
139
+ gain.gain.linearRampToValueAtTime(0.12, at + 0.02);
140
+ gain.gain.exponentialRampToValueAtTime(0.0001, at + 0.35);
141
+ oscillator.connect(gain).connect(audio.destination);
142
+ oscillator.start(at);
143
+ oscillator.stop(at + 0.4);
144
+ });
145
+ }
115
146
  function tick(now = Date.now()) {
116
147
  const patch = { now };
117
148
  let persist = false;
118
149
  if (state.status === 'running' && remainingMs(state, now) === 0) {
119
150
  Object.assign(patch, { status: 'done', remaining: 0 });
120
- if (state.reminders && state.pausedDate !== new Date(now).toDateString()) Object.assign(patch, { notice: 'finish', noticeAt: now });
151
+ if (state.reminders && state.pausedDate !== new Date(now).toDateString()) {
152
+ Object.assign(patch, { notice: 'finish', noticeAt: now });
153
+ if (state.sound) playChime();
154
+ }
121
155
  persist = true;
122
156
  } else if (state.status === 'running' && state.reminders && state.pausedDate !== new Date(now).toDateString() && state.nextReminder && now >= state.nextReminder) {
123
157
  Object.assign(patch, { notice: 'break', noticeAt: now, nextReminder: now + 20 * 60000 });
158
+ if (state.sound) playChime();
124
159
  persist = true;
125
160
  }
126
161
  update(patch, persist);
@@ -323,7 +358,7 @@ window.__ModuleLoader__.load({
323
358
  rows.slice(0, showAll ? rows.length : 3).map((row) => button(
324
359
  h(React.Fragment, null, h('span', { className: 'llm-radar-row-title' }, row.displayTitle || row.id, row.attentionKind && h('small', { className: 'llm-radar-kind' }, t(row.attentionKind))), h('span', { 'aria-hidden': true }, '↗')),
325
360
  () => navigate(row), { key: row.id, className: 'llm-radar-row', title: `${row.displayTitle || row.id}${row.cwd ? '\n' + row.cwd : ''}` })));
326
- return h('div', { className: `llm-root ${panelMode ? 'llm-panel' : 'llm-floating'}` },
361
+ return h('div', { className: `llm-root ${panelMode ? 'llm-panel' : 'llm-floating'}`, onPointerDownCapture: () => { if (model.sound) ensureAudio(); } },
327
362
  h('style', null, css),
328
363
  model.bgOn && h('div', { className: 'llm-bg' + (model.readingProtection && !panelMode ? ' llm-bg-protected' : ''), style: { backgroundImage: `url("${asset(currentScene.name)}")`, opacity: model.intensity / 100, filter: `brightness(${model.brightness / 100}) saturate(1.12)` } }),
329
364
  !panelMode && h('button', { type: 'button', ref: radarTrigger, className: 'llm-radar-trigger' + (radarHighlight ? ' llm-radar-attention' : '') + (radarShake ? ' llm-alert-shake' : '') + (model.minimized && !expanded ? ' llm-radar-badge' : ''), onClick: () => { setRadarOpen(!radarOpen); setRadarAttentionAt(0); }, 'aria-expanded': radarOpen, 'aria-controls': radarId, 'aria-label': `${t('radar')} · ${radarSummary}`, title: radarSummary + (radarHighlight ? ' · ' + t('radarSoftHint') : '') }, model.minimized && !expanded ? (radarCount > 99 ? '99+' : radarCount || '☷') : `☷ ${radarSummary}`),
@@ -366,6 +401,7 @@ window.__ModuleLoader__.load({
366
401
  h('div', { className: 'llm-sub', style: { marginTop: 10 } }, `${t('elapsed')} · ${formatTime(total - remaining)}`)),
367
402
  h('details', { className: 'llm-disclosure' }, h('summary', null, `${t('reminderSettings')} · ${t(model.reminders && !todayPaused ? 'on' : 'off')}`),
368
403
  h('div', { className: 'llm-settings' }, button(t(model.reminders && !todayPaused ? 'on' : 'off'), () => setReminders(!model.reminders || todayPaused), { role: 'switch', 'aria-checked': model.reminders && !todayPaused })),
404
+ h('div', { className: 'llm-settings' }, button(t(model.sound ? 'soundOn' : 'soundOff'), () => { update({ sound: !model.sound }); if (!model.sound) ensureAudio(); }, { role: 'switch', 'aria-checked': model.sound })),
369
405
  h('div', { className: 'llm-summary' }, t('reminderEvery')),
370
406
  button(t('testReminder'), () => { const now = Date.now(); update({ now, previewAt: now, previewUntil: now + 8000 }, false); setOpen(false); if (panelMode) layout.selectPanel(null); }),
371
407
  h('div', { className: 'llm-summary' }, reminderLabel(model))),
@@ -419,6 +455,17 @@ window.__ModuleLoader__.load({
419
455
  document.addEventListener('fullscreenchange', onChange);
420
456
  return () => { document.removeEventListener('fullscreenchange', onChange); control.dispose(); if (fullscreenControl === control) fullscreenControl = undefined; };
421
457
  }, 'lumululu-mei fullscreen lifecycle');
458
+ ctx.inject(['commandUi'], (scope) => {
459
+ const commandUi = scope.get('commandUi');
460
+ if (commandUi === undefined) return;
461
+ scope.effect(() => commandUi.register({
462
+ name: 'focus',
463
+ label: () => t('focusCommand'),
464
+ description: () => t('focusCommandHint'),
465
+ available: () => true,
466
+ ui: { kind: 'action', run: () => { if (state.status === 'running') pause(); else startFocused(); } },
467
+ }), 'momolulu /focus command');
468
+ });
422
469
  ctx.slots.inject('sidebar.panellist', () => ctx.slots.register({ name: 'sidebar.panellist', id: 'lumululu-mei', order: 18, label: () => t('title') }, Icon));
423
470
  ctx.slots.inject('main', () => ctx.slots.register({ name: 'main', key: 'lumululu-mei' }, (props) => h(Companion, { ...props, panelMode: true })));
424
471
  ctx.slots.inject('shell.overlay', () => ctx.slots.register({ name: 'shell.overlay', id: 'lumululu-mei-overlay', order: 100, label: () => t('title') }, Companion));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "momolulu",
3
- "version": "1.0.18",
3
+ "version": "1.1.0",
4
4
  "type": "module",
5
5
  "description": "momolulu, a gentle coding companion with scenic backgrounds, live session activity, focus timing and wellbeing nudges.",
6
6
  "exports": {