ember-mug 0.1.3 → 0.1.5

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.
Files changed (41) hide show
  1. package/README.md +22 -15
  2. package/dist/cli.js +37 -7
  3. package/dist/components/App.d.ts +1 -1
  4. package/dist/components/App.js +65 -54
  5. package/dist/components/BatteryDisplay.d.ts +9 -3
  6. package/dist/components/BatteryDisplay.js +8 -19
  7. package/dist/components/ConnectionStatus.d.ts +7 -2
  8. package/dist/components/ConnectionStatus.js +5 -4
  9. package/dist/components/Header.d.ts +7 -2
  10. package/dist/components/Header.js +11 -3
  11. package/dist/components/HelpDisplay.d.ts +5 -2
  12. package/dist/components/HelpDisplay.js +8 -3
  13. package/dist/components/HorizontalRule.d.ts +2 -0
  14. package/dist/components/HorizontalRule.js +7 -0
  15. package/dist/components/Panel.d.ts +18 -0
  16. package/dist/components/Panel.js +57 -0
  17. package/dist/components/Presets.d.ts +8 -3
  18. package/dist/components/Presets.js +13 -8
  19. package/dist/components/SettingsView.d.ts +9 -3
  20. package/dist/components/SettingsView.js +90 -16
  21. package/dist/components/TemperatureControl.d.ts +8 -3
  22. package/dist/components/TemperatureControl.js +13 -18
  23. package/dist/components/TemperatureDisplay.d.ts +9 -3
  24. package/dist/components/TemperatureDisplay.js +9 -36
  25. package/dist/hooks/useMug.d.ts +3 -1
  26. package/dist/hooks/useMug.js +69 -22
  27. package/dist/lib/bluetooth.d.ts +2 -0
  28. package/dist/lib/bluetooth.js +8 -0
  29. package/dist/lib/mock-bluetooth.d.ts +65 -0
  30. package/dist/lib/mock-bluetooth.js +214 -0
  31. package/dist/lib/settings.d.ts +1 -1
  32. package/dist/lib/settings.js +20 -20
  33. package/dist/lib/theme.d.ts +135 -0
  34. package/dist/lib/theme.js +112 -0
  35. package/dist/lib/types.d.ts +0 -1
  36. package/dist/lib/types.js +12 -12
  37. package/dist/lib/utils.d.ts +7 -4
  38. package/dist/lib/utils.js +63 -40
  39. package/package.json +3 -1
  40. package/dist/components/ColorControl.d.ts +0 -9
  41. package/dist/components/ColorControl.js +0 -71
package/README.md CHANGED
@@ -39,35 +39,37 @@ npm run dev
39
39
  - **Temperature Control**: View current temperature and adjust target temperature
40
40
  - **Temperature Presets**: Quick-select from predefined temperature presets (Latte, Coffee, Tea)
41
41
  - **Battery Monitoring**: Real-time battery level with estimated battery life
42
- - **Time Estimates**:
43
- - Estimated time to reach target temperature
44
- - Estimated battery life based on current mug state
45
- - **LED Color Control**: Customize your mug's LED color with presets or custom RGB values
42
+ - **Dynamic Time Estimates**:
43
+ - Estimated time to reach target temperature based on real-time heating/cooling rates
44
+ - Estimated battery life based on actual discharge/charge rates
45
+ - **LED Color Control**: Customize your mug's LED color via the settings panel
46
46
  - **Temperature Unit Toggle**: Switch between Celsius and Fahrenheit
47
- - **Persistent Settings**: Your preferences are saved between sessions
47
+ - **Persistent Settings**: Your preferences and custom presets are saved between sessions
48
+ - **Responsive Layout**: Adapts to narrow or wide terminals automatically
48
49
 
49
50
  ## Controls
50
51
 
51
52
  ### When Disconnected
53
+
52
54
  - `s` - Start scanning for Ember mug
53
55
  - `r` - Retry scanning (after error)
54
56
  - `q` - Quit
55
57
 
56
58
  ### When Connected
57
- - `t` - Enter temperature adjustment mode
58
- - `←/→` or `h/l` - Adjust by ±0.5°
59
- - `↑/↓` or `j/k` - Adjust by ±1°
60
- - `t` or `Enter` - Exit temperature mode
59
+
60
+ - `←/→` or `h/l` - Adjust temperature by ±0.5°
61
61
  - `1-3` - Select temperature preset
62
- - `c` - Toggle LED color control
63
- - `1-8` - Select preset color
64
- - `c` - Toggle custom color mode
65
- - `r/g/b` - Select color channel (in custom mode)
66
- - `←/→` - Adjust selected channel
67
62
  - `u` - Toggle temperature unit (°C/°F)
68
- - `o` - Open settings
63
+ - `o` - Open settings (LED color, unit, preset editing)
69
64
  - `q` - Quit
70
65
 
66
+ ### In Settings
67
+
68
+ - `↑/↓` or `j/k` - Navigate settings
69
+ - `Enter/Space` - Toggle unit
70
+ - `←/→` or `h/l` - Change LED color or edit preset temperature
71
+ - `Esc` or `q` - Close settings
72
+
71
73
  ## Requirements
72
74
 
73
75
  - Node.js 18+
@@ -77,20 +79,25 @@ npm run dev
77
79
  ### Platform-Specific Notes
78
80
 
79
81
  #### Linux
82
+
80
83
  You may need to grant Bluetooth permissions:
84
+
81
85
  ```bash
82
86
  sudo setcap cap_net_raw+eip $(eval readlink -f `which node`)
83
87
  ```
84
88
 
85
89
  #### macOS
90
+
86
91
  Grant Bluetooth permissions to your terminal application in System Preferences > Security & Privacy > Privacy > Bluetooth.
87
92
 
88
93
  #### Windows
94
+
89
95
  Requires Windows 10 build 15063 or later with Bluetooth 4.0+ adapter.
90
96
 
91
97
  ## Technical Details
92
98
 
93
99
  This application uses:
100
+
94
101
  - [@abandonware/noble](https://github.com/abandonware/noble) for Bluetooth LE communication
95
102
  - [Ink](https://github.com/vadimdemedes/ink) for the React-based CLI interface
96
103
  - [Conf](https://github.com/sindresorhus/conf) for persistent settings storage
package/dist/cli.js CHANGED
@@ -1,13 +1,43 @@
1
1
  #!/usr/bin/env node
2
2
  import { jsx as _jsx } from "react/jsx-runtime";
3
- import { render } from 'ink';
4
- import { App } from './components/App.js';
5
- // Handle graceful shutdown
6
- process.on('SIGINT', () => {
3
+ import { render } from "ink";
4
+ import { App } from "./components/App.js";
5
+ import { isMockMode, setBluetoothManager } from "./lib/bluetooth.js";
6
+ // ANSI escape codes for alternate screen buffer
7
+ const enterAltScreen = "\x1b[?1049h";
8
+ const exitAltScreen = "\x1b[?1049l";
9
+ const hideCursor = "\x1b[?25l";
10
+ const showCursor = "\x1b[?25h";
11
+ const clearScreen = "\x1b[2J\x1b[H";
12
+ // Enter alternate screen buffer and hide cursor
13
+ process.stdout.write(enterAltScreen + hideCursor + clearScreen);
14
+ // Handle graceful shutdown - restore terminal state
15
+ const cleanup = () => {
16
+ process.stdout.write(showCursor + exitAltScreen);
7
17
  process.exit(0);
18
+ };
19
+ process.on("SIGINT", cleanup);
20
+ process.on("SIGTERM", cleanup);
21
+ process.on("exit", () => {
22
+ process.stdout.write(showCursor + exitAltScreen);
8
23
  });
9
- process.on('SIGTERM', () => {
24
+ // Handle terminal resize - clear screen to prevent artifacts
25
+ process.stdout.on("resize", () => {
26
+ process.stdout.write(clearScreen);
27
+ });
28
+ async function main() {
29
+ // Initialize mock manager if in mock mode (set via EMBER_MOCK env var)
30
+ if (isMockMode()) {
31
+ const { getMockBluetoothManager } = await import("./lib/mock-bluetooth.js");
32
+ setBluetoothManager(getMockBluetoothManager());
33
+ }
34
+ // Render the app
35
+ const app = render(_jsx(App, {}));
36
+ await app.waitUntilExit();
10
37
  process.exit(0);
38
+ }
39
+ main().catch((error) => {
40
+ process.stdout.write(showCursor + exitAltScreen);
41
+ console.error("Failed to start:", error);
42
+ process.exit(1);
11
43
  });
12
- // Render the app
13
- render(_jsx(App, {}));
@@ -1,2 +1,2 @@
1
- import React from 'react';
1
+ import React from "react";
2
2
  export declare function App(): React.ReactElement;
@@ -1,24 +1,36 @@
1
- import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
- import { useState, useEffect, useCallback } from 'react';
3
- import { Box, useApp, useInput } from 'ink';
4
- import { useMug } from '../hooks/useMug.js';
5
- import { Header } from './Header.js';
6
- import { TemperatureDisplay } from './TemperatureDisplay.js';
7
- import { BatteryDisplay } from './BatteryDisplay.js';
8
- import { TemperatureControl } from './TemperatureControl.js';
9
- import { Presets } from './Presets.js';
10
- import { ColorControl } from './ColorControl.js';
11
- import { ConnectionStatus } from './ConnectionStatus.js';
12
- import { SettingsView } from './SettingsView.js';
13
- import { HelpDisplay } from './HelpDisplay.js';
14
- import { TemperatureUnit } from '../lib/types.js';
15
- import { getPresets, getTemperatureUnit, setTemperatureUnit as saveTemperatureUnit, setLastTargetTemp, } from '../lib/settings.js';
1
+ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
2
+ import { useState, useEffect, useCallback } from "react";
3
+ import { Box, useApp, useInput, useStdout } from "ink";
4
+ import { useMug } from "../hooks/useMug.js";
5
+ import { Header } from "./Header.js";
6
+ import { TemperatureDisplay } from "./TemperatureDisplay.js";
7
+ import { BatteryDisplay } from "./BatteryDisplay.js";
8
+ import { TemperatureControl } from "./TemperatureControl.js";
9
+ import { Presets } from "./Presets.js";
10
+ import { ConnectionStatus } from "./ConnectionStatus.js";
11
+ import { SettingsView } from "./SettingsView.js";
12
+ import { HelpDisplay } from "./HelpDisplay.js";
13
+ import { TemperatureUnit } from "../lib/types.js";
14
+ import { getPresets, getTemperatureUnit, setTemperatureUnit as saveTemperatureUnit, setLastTargetTemp, updatePreset, } from "../lib/settings.js";
15
+ import { getThemeForState, getTerminalTheme } from "../lib/theme.js";
16
16
  export function App() {
17
17
  const { exit } = useApp();
18
- const { state: mugState, isScanning, error, foundMugName, startScanning, setTargetTemp, setTemperatureUnit: setMugTempUnit, setLedColor, } = useMug();
19
- const [viewMode, setViewMode] = useState('main');
20
- const [activeControl, setActiveControl] = useState('none');
21
- const [presets] = useState(getPresets());
18
+ const { stdout } = useStdout();
19
+ const terminalWidth = stdout?.columns || 80;
20
+ // Responsive layout: stack vertically on narrow terminals, 2x2 grid on wide
21
+ const isNarrowTerminal = terminalWidth < 80;
22
+ // Calculate panel widths: 1/4 screen each in 2x2 grid (accounts for gaps)
23
+ // For a 2x2 grid with gap of 2 between panels, each panel is (width - 6) / 2
24
+ const panelWidth = isNarrowTerminal
25
+ ? terminalWidth - 4 // Full width minus margins for stacked layout
26
+ : Math.floor((terminalWidth - 6) / 2); // Half width minus gaps for 2x2 grid
27
+ const { state: mugState, isScanning, error, foundMugName, startScanning, setTargetTemp, setTemperatureUnit: setMugTempUnit, setLedColor, tempRate, batteryRate, } = useMug();
28
+ // Get theme based on mug state
29
+ const themeKey = getThemeForState(mugState.liquidState, mugState.connected);
30
+ const theme = getTerminalTheme(themeKey);
31
+ const [viewMode, setViewMode] = useState("main");
32
+ const [activeControl, setActiveControl] = useState("none");
33
+ const [presets, setPresets] = useState(getPresets());
22
34
  const [selectedPresetIndex, setSelectedPresetIndex] = useState(-1);
23
35
  const [localTempUnit, setLocalTempUnit] = useState(getTemperatureUnit());
24
36
  // Start scanning on mount
@@ -26,11 +38,17 @@ export function App() {
26
38
  startScanning();
27
39
  }, [startScanning]);
28
40
  // Sync temperature unit with mug when connected
41
+ // Priority: Local settings > Mug settings
29
42
  useEffect(() => {
30
- if (mugState.connected) {
31
- setLocalTempUnit(mugState.temperatureUnit);
43
+ if (mugState.connected && mugState.temperatureUnit !== localTempUnit) {
44
+ setMugTempUnit(localTempUnit);
32
45
  }
33
- }, [mugState.connected, mugState.temperatureUnit]);
46
+ }, [
47
+ mugState.connected,
48
+ mugState.temperatureUnit,
49
+ localTempUnit,
50
+ setMugTempUnit,
51
+ ]);
34
52
  const handleTempChange = useCallback(async (temp) => {
35
53
  await setTargetTemp(temp);
36
54
  setLastTargetTemp(temp);
@@ -52,46 +70,45 @@ export function App() {
52
70
  await setMugTempUnit(unit);
53
71
  }
54
72
  }, [mugState.connected, setMugTempUnit]);
73
+ const handlePresetUpdate = useCallback((preset) => {
74
+ // Update local state
75
+ setPresets((currentPresets) => currentPresets.map((p) => (p.id === preset.id ? preset : p)));
76
+ // Persist to settings
77
+ updatePreset(preset.id, preset);
78
+ }, []);
55
79
  useInput((input, key) => {
56
80
  // Global controls
57
- if (input === 'q' && viewMode === 'main' && activeControl === 'none') {
81
+ if (input === "q" && viewMode === "main" && activeControl === "none") {
58
82
  exit();
59
83
  return;
60
84
  }
61
85
  // Handle escape to go back
62
86
  if (key.escape) {
63
- if (activeControl !== 'none') {
64
- setActiveControl('none');
87
+ if (activeControl !== "none") {
88
+ setActiveControl("none");
65
89
  }
66
- else if (viewMode !== 'main') {
67
- setViewMode('main');
90
+ else if (viewMode !== "main") {
91
+ setViewMode("main");
68
92
  }
69
93
  return;
70
94
  }
71
95
  // Only handle these if we're in main view with no active control
72
- if (viewMode === 'main' && activeControl === 'none') {
73
- if (input === 's' && !mugState.connected) {
96
+ if (viewMode === "main" && activeControl === "none") {
97
+ if (input === "s" && !mugState.connected) {
74
98
  startScanning();
75
99
  return;
76
100
  }
77
- if (input === 'r' && !mugState.connected && error) {
101
+ if (input === "r" && !mugState.connected && error) {
78
102
  startScanning();
79
103
  return;
80
104
  }
81
105
  if (mugState.connected) {
82
- if (input === 't') {
83
- setActiveControl('temperature');
84
- return;
85
- }
86
- if (input === 'c') {
87
- setActiveControl('color');
88
- return;
89
- }
90
- if (input === 'o') {
91
- setViewMode('settings');
106
+ // 'c' hotkey removed
107
+ if (input === "o") {
108
+ setViewMode("settings");
92
109
  return;
93
110
  }
94
- if (input === 'u') {
111
+ if (input === "u") {
95
112
  const newUnit = localTempUnit === TemperatureUnit.Celsius
96
113
  ? TemperatureUnit.Fahrenheit
97
114
  : TemperatureUnit.Celsius;
@@ -100,21 +117,15 @@ export function App() {
100
117
  }
101
118
  }
102
119
  }
103
- // Exit temperature control mode
104
- if (activeControl === 'temperature' && (key.return || input === 't')) {
105
- setActiveControl('none');
106
- return;
107
- }
108
- // Exit color control mode
109
- if (activeControl === 'color' && input === 'c') {
110
- setActiveControl('none');
111
- return;
112
- }
113
120
  });
114
121
  // Settings view
115
- if (viewMode === 'settings') {
116
- return (_jsxs(Box, { flexDirection: "column", children: [_jsx(Header, { mugName: mugState.mugName, connected: mugState.connected }), _jsx(SettingsView, { presets: presets, temperatureUnit: localTempUnit, onTemperatureUnitChange: handleTemperatureUnitChange, onClose: () => setViewMode('main'), isActive: true })] }));
122
+ if (viewMode === "settings") {
123
+ return (_jsxs(Box, { flexDirection: "column", children: [_jsx(Header, { mugName: mugState.mugName, connected: mugState.connected, theme: theme, ledColor: mugState.color }), _jsx(SettingsView, { presets: presets, temperatureUnit: localTempUnit, ledColor: mugState.color, onTemperatureUnitChange: handleTemperatureUnitChange, onPresetUpdate: handlePresetUpdate, onColorChange: handleColorChange, onClose: () => setViewMode("main"), isActive: true, theme: theme })] }));
117
124
  }
118
125
  // Main view
119
- return (_jsxs(Box, { flexDirection: "column", children: [_jsx(Header, { mugName: mugState.mugName, connected: mugState.connected }), !mugState.connected ? (_jsx(ConnectionStatus, { isScanning: isScanning, isConnected: mugState.connected, foundMugName: foundMugName, error: error, onRetry: startScanning })) : (_jsxs(Box, { flexDirection: "column", children: [_jsxs(Box, { children: [_jsx(Box, { flexDirection: "column", flexGrow: 1, children: _jsx(TemperatureDisplay, { currentTemp: mugState.currentTemp, targetTemp: mugState.targetTemp, liquidState: mugState.liquidState, temperatureUnit: localTempUnit }) }), _jsx(Box, { flexDirection: "column", flexGrow: 1, children: _jsx(BatteryDisplay, { batteryLevel: mugState.batteryLevel, isCharging: mugState.isCharging, liquidState: mugState.liquidState }) })] }), _jsx(TemperatureControl, { targetTemp: mugState.targetTemp, temperatureUnit: localTempUnit, onTempChange: handleTempChange, isActive: activeControl === 'temperature' }), _jsx(Presets, { presets: presets, selectedIndex: selectedPresetIndex, temperatureUnit: localTempUnit, onSelect: handlePresetSelect, isActive: activeControl === 'none' }), activeControl === 'color' && (_jsx(ColorControl, { color: mugState.color, onColorChange: handleColorChange, isActive: true }))] })), _jsx(HelpDisplay, { isConnected: mugState.connected })] }));
126
+ return (_jsxs(Box, { flexDirection: "column", children: [_jsx(Header, { mugName: mugState.mugName, connected: mugState.connected, theme: theme, ledColor: mugState.color }), !mugState.connected ? (_jsx(ConnectionStatus, { isScanning: isScanning, isConnected: mugState.connected, foundMugName: foundMugName, error: error, onRetry: startScanning, width: panelWidth, theme: theme })) : (_jsx(Box, { flexDirection: "column", children: isNarrowTerminal ? (
127
+ /* Narrow terminal: stack all panels vertically */
128
+ _jsxs(_Fragment, { children: [_jsx(TemperatureDisplay, { currentTemp: mugState.currentTemp, targetTemp: mugState.targetTemp, liquidState: mugState.liquidState, temperatureUnit: localTempUnit, width: panelWidth, theme: theme, tempRate: tempRate }), _jsx(Box, { marginTop: 1, children: _jsx(BatteryDisplay, { batteryLevel: mugState.batteryLevel, isCharging: mugState.isCharging, liquidState: mugState.liquidState, width: panelWidth, theme: theme, batteryRate: batteryRate }) }), _jsx(Box, { marginTop: 1, children: _jsx(TemperatureControl, { targetTemp: mugState.targetTemp, temperatureUnit: localTempUnit, onTempChange: handleTempChange, isActive: true, width: panelWidth, theme: theme }) }), _jsx(Box, { marginTop: 1, children: _jsx(Presets, { presets: presets, selectedIndex: selectedPresetIndex, temperatureUnit: localTempUnit, onSelect: handlePresetSelect, isActive: activeControl === "none", width: panelWidth, theme: theme }) })] })) : (
129
+ /* Wide terminal: 2x2 grid layout */
130
+ _jsxs(_Fragment, { children: [_jsxs(Box, { justifyContent: "center", gap: 2, children: [_jsx(TemperatureDisplay, { currentTemp: mugState.currentTemp, targetTemp: mugState.targetTemp, liquidState: mugState.liquidState, temperatureUnit: localTempUnit, width: panelWidth, height: 8, theme: theme, tempRate: tempRate }), _jsx(BatteryDisplay, { batteryLevel: mugState.batteryLevel, isCharging: mugState.isCharging, liquidState: mugState.liquidState, width: panelWidth, height: 8, theme: theme, batteryRate: batteryRate })] }), _jsxs(Box, { justifyContent: "center", gap: 2, marginTop: 1, children: [_jsx(TemperatureControl, { targetTemp: mugState.targetTemp, temperatureUnit: localTempUnit, onTempChange: handleTempChange, isActive: true, width: panelWidth, height: 9, theme: theme }), _jsx(Presets, { presets: presets, selectedIndex: selectedPresetIndex, temperatureUnit: localTempUnit, onSelect: handlePresetSelect, isActive: activeControl === "none", width: panelWidth, height: 9, theme: theme })] })] })) })), _jsx(HelpDisplay, { isConnected: mugState.connected, theme: theme })] }));
120
131
  }
@@ -1,9 +1,15 @@
1
- import React from 'react';
2
- import { LiquidState } from '../lib/types.js';
1
+ import React from "react";
2
+ import { LiquidState } from "../lib/types.js";
3
+ import { TERMINAL_COLORS } from "../lib/theme.js";
4
+ type TerminalTheme = (typeof TERMINAL_COLORS)[keyof typeof TERMINAL_COLORS];
3
5
  interface BatteryDisplayProps {
4
6
  batteryLevel: number;
5
7
  isCharging: boolean;
6
8
  liquidState: LiquidState;
9
+ width?: number;
10
+ height?: number;
11
+ theme: TerminalTheme;
12
+ batteryRate?: number;
7
13
  }
8
- export declare function BatteryDisplay({ batteryLevel, isCharging, liquidState, }: BatteryDisplayProps): React.ReactElement;
14
+ export declare function BatteryDisplay({ batteryLevel, isCharging, liquidState, width, height, theme, batteryRate, }: BatteryDisplayProps): React.ReactElement;
9
15
  export {};
@@ -1,25 +1,14 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
2
- import { Box, Text } from 'ink';
3
- import { formatBatteryLevel, getBatteryIcon, estimateBatteryLife, formatDuration, } from '../lib/utils.js';
4
- export function BatteryDisplay({ batteryLevel, isCharging, liquidState, }) {
5
- const batteryColor = getBatteryColor(batteryLevel, isCharging);
6
- const batteryIcon = getBatteryIcon(batteryLevel, isCharging);
7
- const batteryTimeEstimate = estimateBatteryLife(batteryLevel, isCharging, liquidState);
8
- return (_jsxs(Box, { flexDirection: "column", marginY: 1, children: [_jsx(Box, { justifyContent: "center", children: _jsx(Text, { bold: true, children: "Battery" }) }), _jsx(Box, { justifyContent: "center", marginY: 1, children: _jsxs(Text, { color: batteryColor, children: [batteryIcon, " ", formatBatteryLevel(batteryLevel), isCharging && _jsx(Text, { color: "yellow", children: " (Charging)" })] }) }), batteryTimeEstimate !== null && (_jsx(Box, { justifyContent: "center", children: _jsx(Text, { dimColor: true, children: isCharging ? (_jsxs(_Fragment, { children: ['Time to full: ', _jsx(Text, { color: "green", children: formatDuration(batteryTimeEstimate) })] })) : (_jsxs(_Fragment, { children: ['Est. battery life: ', _jsx(Text, { color: batteryLevel < 20 ? 'red' : 'yellow', children: formatDuration(batteryTimeEstimate) })] })) }) })), _jsx(Box, { justifyContent: "center", marginTop: 1, children: _jsx(BatteryBar, { level: batteryLevel, isCharging: isCharging }) })] }));
2
+ import { Box, Text } from "ink";
3
+ import { formatBatteryLevel, estimateBatteryLife, formatDuration, } from "../lib/utils.js";
4
+ import { Panel } from "./Panel.js";
5
+ export function BatteryDisplay({ batteryLevel, isCharging, liquidState, width, height, theme, batteryRate, }) {
6
+ const batteryTimeEstimate = estimateBatteryLife(batteryLevel, isCharging, liquidState, batteryRate);
7
+ return (_jsxs(Panel, { title: "Battery", titleColor: theme.primary, borderColor: theme.border, width: width, height: height, children: [_jsxs(Box, { justifyContent: "center", marginY: 1, children: [_jsx(Text, { color: theme.primary, bold: true, children: formatBatteryLevel(batteryLevel) }), isCharging && (_jsxs(Text, { color: theme.primary, bold: true, children: [" ", "~"] }))] }), _jsx(Box, { justifyContent: "center", children: _jsx(BatteryBar, { level: batteryLevel, isCharging: isCharging, theme: theme }) }), batteryTimeEstimate !== null && (_jsx(Box, { justifyContent: "center", marginTop: 1, children: _jsx(Text, { color: theme.dimText, children: isCharging ? (_jsxs(_Fragment, { children: ["[~] ", _jsx(Text, { color: theme.primary, children: formatDuration(batteryTimeEstimate) }), _jsx(Text, { color: theme.dimText, children: " to full" })] })) : (_jsxs(_Fragment, { children: ["[~] ", _jsx(Text, { color: theme.primary, children: formatDuration(batteryTimeEstimate) }), _jsx(Text, { color: theme.dimText, children: " remaining" })] })) }) }))] }));
9
8
  }
10
- function BatteryBar({ level, isCharging }) {
9
+ function BatteryBar({ level, isCharging, theme, }) {
11
10
  const totalSegments = 20;
12
11
  const filledSegments = Math.round((level / 100) * totalSegments);
13
12
  const emptySegments = totalSegments - filledSegments;
14
- const color = getBatteryColor(level, isCharging);
15
- return (_jsxs(Text, { children: [_jsx(Text, { dimColor: true, children: "[" }), _jsx(Text, { color: color, children: '█'.repeat(filledSegments) }), _jsx(Text, { dimColor: true, children: '░'.repeat(emptySegments) }), _jsx(Text, { dimColor: true, children: "]" }), isCharging && _jsx(Text, { color: "yellow", children: " \u26A1" })] }));
16
- }
17
- function getBatteryColor(level, isCharging) {
18
- if (isCharging)
19
- return 'yellow';
20
- if (level >= 50)
21
- return 'green';
22
- if (level >= 25)
23
- return 'yellow';
24
- return 'red';
13
+ return (_jsxs(Text, { children: [_jsx(Text, { color: theme.dimText, children: "\u2590" }), _jsx(Text, { color: theme.primary, children: "█".repeat(filledSegments) }), _jsx(Text, { color: theme.dimText, children: "░".repeat(emptySegments) }), _jsx(Text, { color: theme.dimText, children: "\u258C" })] }));
25
14
  }
@@ -1,10 +1,15 @@
1
- import React from 'react';
1
+ import React from "react";
2
+ import { TERMINAL_COLORS } from "../lib/theme.js";
3
+ type TerminalTheme = (typeof TERMINAL_COLORS)[keyof typeof TERMINAL_COLORS];
2
4
  interface ConnectionStatusProps {
3
5
  isScanning: boolean;
4
6
  isConnected: boolean;
5
7
  foundMugName: string | null;
6
8
  error: string | null;
7
9
  onRetry: () => void;
10
+ width?: number;
11
+ height?: number;
12
+ theme: TerminalTheme;
8
13
  }
9
- export declare function ConnectionStatus({ isScanning, isConnected, foundMugName, error, }: ConnectionStatusProps): React.ReactElement;
14
+ export declare function ConnectionStatus({ isScanning, isConnected, foundMugName, error, width, height, theme, }: ConnectionStatusProps): React.ReactElement;
10
15
  export {};
@@ -1,9 +1,10 @@
1
1
  import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
- import { Box, Text } from 'ink';
3
- import Spinner from 'ink-spinner';
4
- export function ConnectionStatus({ isScanning, isConnected, foundMugName, error, }) {
2
+ import { Box, Text } from "ink";
3
+ import Spinner from "ink-spinner";
4
+ import { Panel } from "./Panel.js";
5
+ export function ConnectionStatus({ isScanning, isConnected, foundMugName, error, width, height = 10, theme, }) {
5
6
  if (isConnected) {
6
7
  return _jsx(_Fragment, {});
7
8
  }
8
- return (_jsx(Box, { flexDirection: "column", alignItems: "center", marginY: 2, children: error ? (_jsxs(Box, { flexDirection: "column", alignItems: "center", children: [_jsxs(Text, { color: "red", children: ["Error: ", error] }), _jsx(Box, { marginTop: 1, children: _jsxs(Text, { dimColor: true, children: ["Press ", _jsx(Text, { color: "cyan", children: "r" }), " to retry scanning"] }) })] })) : isScanning ? (_jsxs(Box, { flexDirection: "column", alignItems: "center", children: [_jsxs(Box, { children: [_jsx(Text, { color: "cyan", children: _jsx(Spinner, { type: "dots" }) }), _jsx(Text, { children: " Scanning for Ember mug..." })] }), foundMugName && (_jsxs(Box, { marginTop: 1, children: [_jsxs(Text, { color: "green", children: ["Found: ", foundMugName] }), _jsx(Text, { children: " - Connecting..." })] })), _jsx(Box, { marginTop: 1, children: _jsx(Text, { dimColor: true, children: "Make sure your Ember mug is powered on and in range" }) })] })) : (_jsxs(Box, { flexDirection: "column", alignItems: "center", children: [_jsx(Text, { children: "No Ember mug connected" }), _jsx(Box, { marginTop: 1, children: _jsxs(Text, { dimColor: true, children: ["Press ", _jsx(Text, { color: "cyan", children: "s" }), " to start scanning"] }) })] })) }));
9
+ return (_jsx(Box, { justifyContent: "center", marginY: 2, children: _jsx(Panel, { title: error ? "[!] Error" : isScanning ? "[~] Scanning" : "C[_] Welcome", titleColor: error ? "red" : theme.primary, borderColor: error ? "red" : theme.border, width: width, height: height, children: error ? (_jsxs(Box, { flexDirection: "column", alignItems: "center", marginY: 1, children: [_jsx(Text, { color: "red", bold: true, children: error }), _jsx(Box, { marginTop: 1, children: _jsxs(Text, { color: theme.dimText, children: ["Press", " ", _jsx(Text, { color: "yellow", bold: true, children: "[r]" }), " ", "to retry scanning"] }) })] })) : isScanning ? (_jsxs(Box, { flexDirection: "column", alignItems: "center", marginY: 1, children: [_jsxs(Box, { children: [_jsx(Text, { color: theme.primary, children: _jsx(Spinner, { type: "dots" }) }), _jsx(Text, { color: theme.text, children: " Searching for Ember mug..." })] }), foundMugName && (_jsx(Box, { marginTop: 1, children: _jsxs(Text, { color: "green", bold: true, children: ["* Found: ", foundMugName] }) })), foundMugName && (_jsxs(Box, { marginTop: 1, children: [_jsx(Text, { color: theme.primary, children: _jsx(Spinner, { type: "dots" }) }), _jsx(Text, { color: theme.text, children: " Connecting..." })] })), _jsx(Box, { marginTop: 1, children: _jsx(Text, { color: theme.dimText, children: "Ensure mug is powered on and nearby" }) })] })) : (_jsxs(Box, { flexDirection: "column", alignItems: "center", marginY: 1, children: [_jsx(Text, { color: theme.text, children: "No Ember mug connected" }), _jsx(Box, { marginTop: 1, children: _jsxs(Text, { color: theme.dimText, children: ["Press", " ", _jsx(Text, { color: "green", bold: true, children: "[s]" }), " ", "to start scanning"] }) })] })) }) }));
9
10
  }
@@ -1,7 +1,12 @@
1
- import React from 'react';
1
+ import React from "react";
2
+ import { TERMINAL_COLORS } from "../lib/theme.js";
3
+ import { RGBColor } from "../lib/types.js";
4
+ type TerminalTheme = (typeof TERMINAL_COLORS)[keyof typeof TERMINAL_COLORS];
2
5
  interface HeaderProps {
3
6
  mugName: string;
4
7
  connected: boolean;
8
+ theme: TerminalTheme;
9
+ ledColor?: RGBColor;
5
10
  }
6
- export declare function Header({ mugName, connected }: HeaderProps): React.ReactElement;
11
+ export declare function Header({ mugName, connected, theme, ledColor, }: HeaderProps): React.ReactElement;
7
12
  export {};
@@ -1,5 +1,13 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
- import { Box, Text } from 'ink';
3
- export function Header({ mugName, connected }) {
4
- return (_jsxs(Box, { flexDirection: "column", marginBottom: 1, children: [_jsxs(Box, { children: [_jsx(Text, { bold: true, color: "cyan", children: '☕ Ember Mug CLI ' }), connected ? (_jsxs(Text, { color: "green", children: ["[Connected: ", mugName, "]"] })) : (_jsx(Text, { color: "yellow", children: "[Disconnected]" }))] }), _jsx(Text, { dimColor: true, children: '─'.repeat(50) })] }));
2
+ import { Box, Text, useStdout } from "ink";
3
+ import { isMockMode } from "../lib/bluetooth.js";
4
+ import { rgbToHex } from "../lib/utils.js";
5
+ export function Header({ mugName, connected, theme, ledColor, }) {
6
+ const mockMode = isMockMode();
7
+ const { stdout } = useStdout();
8
+ const width = stdout?.columns || 80;
9
+ const statusText = connected ? mugName : "Disconnected";
10
+ return (_jsxs(Box, { flexDirection: "column", marginBottom: 1, children: [_jsx(Text, { color: theme.border, children: "═".repeat(width) }), _jsxs(Box, { justifyContent: "space-between", paddingX: 1, children: [_jsxs(Box, { children: [_jsx(Text, { color: theme.primary, bold: true, children: "C[_] EMBER MUG" }), mockMode && _jsx(Text, { color: "gray", children: " [MOCK]" })] }), _jsxs(Box, { children: [_jsxs(Text, { color: ledColor
11
+ ? rgbToHex(ledColor.r, ledColor.g, ledColor.b)
12
+ : theme.primary, children: ["\u25CF", " "] }), _jsx(Text, { color: theme.text, children: statusText })] })] }), _jsx(Text, { color: theme.border, children: "═".repeat(width) })] }));
5
13
  }
@@ -1,6 +1,9 @@
1
- import React from 'react';
1
+ import React from "react";
2
+ import { TERMINAL_COLORS } from "../lib/theme.js";
3
+ type TerminalTheme = (typeof TERMINAL_COLORS)[keyof typeof TERMINAL_COLORS];
2
4
  interface HelpDisplayProps {
3
5
  isConnected: boolean;
6
+ theme: TerminalTheme;
4
7
  }
5
- export declare function HelpDisplay({ isConnected }: HelpDisplayProps): React.ReactElement;
8
+ export declare function HelpDisplay({ isConnected, theme, }: HelpDisplayProps): React.ReactElement;
6
9
  export {};
@@ -1,5 +1,10 @@
1
1
  import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime";
2
- import { Box, Text } from 'ink';
3
- export function HelpDisplay({ isConnected }) {
4
- return (_jsxs(Box, { flexDirection: "column", marginTop: 1, children: [_jsx(Text, { dimColor: true, children: '─'.repeat(50) }), _jsx(Box, { marginY: 1, justifyContent: "center", children: _jsx(Text, { dimColor: true, children: isConnected ? (_jsxs(_Fragment, { children: [_jsx(Text, { color: "cyan", children: "t" }), ":temp", ' ', _jsx(Text, { color: "cyan", children: "1-3" }), ":presets", ' ', _jsx(Text, { color: "cyan", children: "c" }), ":color", ' ', _jsx(Text, { color: "cyan", children: "u" }), ":unit", ' ', _jsx(Text, { color: "cyan", children: "o" }), ":settings", ' ', _jsx(Text, { color: "cyan", children: "q" }), ":quit"] })) : (_jsxs(_Fragment, { children: [_jsx(Text, { color: "cyan", children: "s" }), ":scan", ' ', _jsx(Text, { color: "cyan", children: "r" }), ":retry", ' ', _jsx(Text, { color: "cyan", children: "q" }), ":quit"] })) }) })] }));
2
+ import { Box, Text, useStdout } from "ink";
3
+ export function HelpDisplay({ isConnected, theme, }) {
4
+ const { stdout } = useStdout();
5
+ const width = stdout?.columns || 80;
6
+ return (_jsxs(Box, { flexDirection: "column", marginTop: 1, children: [_jsx(Text, { color: theme.border, children: "─".repeat(width) }), _jsx(Box, { paddingY: 1, justifyContent: "center", gap: 2, children: isConnected ? (_jsxs(_Fragment, { children: [_jsx(HelpKey, { keyChar: "t", label: "temp", color: theme.primary }), _jsx(HelpKey, { keyChar: "1-3", label: "presets", color: theme.primary }), _jsx(HelpKey, { keyChar: "u", label: "unit", color: theme.primary }), _jsx(HelpKey, { keyChar: "o", label: "settings", color: theme.primary }), _jsx(HelpKey, { keyChar: "q", label: "quit", color: theme.primary })] })) : (_jsxs(_Fragment, { children: [_jsx(HelpKey, { keyChar: "s", label: "scan", color: theme.primary }), _jsx(HelpKey, { keyChar: "r", label: "retry", color: theme.primary }), _jsx(HelpKey, { keyChar: "q", label: "quit", color: theme.primary })] })) })] }));
7
+ }
8
+ function HelpKey({ keyChar, label, color }) {
9
+ return (_jsxs(Text, { children: [_jsxs(Text, { color: color, bold: true, children: ["[", keyChar, "]"] }), _jsxs(Text, { dimColor: true, children: [" ", label] })] }));
5
10
  }
@@ -0,0 +1,2 @@
1
+ import React from 'react';
2
+ export declare function HorizontalRule(): React.ReactElement;
@@ -0,0 +1,7 @@
1
+ import { jsx as _jsx } from "react/jsx-runtime";
2
+ import { Text, useStdout } from 'ink';
3
+ export function HorizontalRule() {
4
+ const { stdout } = useStdout();
5
+ const width = stdout?.columns || 80;
6
+ return _jsx(Text, { dimColor: true, children: '─'.repeat(width) });
7
+ }
@@ -0,0 +1,18 @@
1
+ import React from "react";
2
+ interface PanelProps {
3
+ title?: string;
4
+ titleColor?: string;
5
+ borderColor?: string;
6
+ children: React.ReactNode;
7
+ width?: number | string;
8
+ minWidth?: number;
9
+ padding?: number;
10
+ centerContent?: boolean;
11
+ height?: number;
12
+ }
13
+ export declare function Panel({ title, titleColor, borderColor, children, width, minWidth, padding, centerContent, height, }: PanelProps): React.ReactElement;
14
+ export declare function useTerminalSize(): {
15
+ width: number;
16
+ height: number;
17
+ };
18
+ export {};
@@ -0,0 +1,57 @@
1
+ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
2
+ import { Box, Text, useStdout } from "ink";
3
+ export function Panel({ title, titleColor = "cyan", borderColor = "gray", children, width, minWidth = 20, padding = 1, centerContent = true, height, }) {
4
+ const { stdout } = useStdout();
5
+ const terminalWidth = stdout?.columns || 80;
6
+ // Calculate actual width
7
+ let actualWidth;
8
+ if (typeof width === "number") {
9
+ actualWidth = width;
10
+ }
11
+ else if (width === "100%") {
12
+ actualWidth = terminalWidth;
13
+ }
14
+ else if (typeof width === "string" && width.endsWith("%")) {
15
+ const percent = parseInt(width, 10) / 100;
16
+ actualWidth = Math.floor(terminalWidth * percent);
17
+ }
18
+ else {
19
+ // Default to ~48% of terminal width (to fit 2 panels side by side with gap)
20
+ actualWidth = Math.floor((terminalWidth - 4) / 2);
21
+ }
22
+ // Ensure minimum width
23
+ actualWidth = Math.max(actualWidth, minWidth);
24
+ const innerWidth = actualWidth - 2; // Account for borders
25
+ const titleText = title ? ` ${title} ` : "";
26
+ const titleLen = title ? titleText.length : 0;
27
+ // Ensure title fits
28
+ const safeTitleLen = Math.min(titleLen, innerWidth - 4);
29
+ const safeTitle = titleLen > innerWidth - 4
30
+ ? titleText.substring(0, innerWidth - 4)
31
+ : titleText;
32
+ // Top border with title
33
+ const topLeftPadding = Math.max(0, Math.floor((innerWidth - safeTitleLen) / 2));
34
+ const topRightPadding = Math.max(0, innerWidth - safeTitleLen - topLeftPadding);
35
+ const bottomBorder = `+${"-".repeat(innerWidth)}+`;
36
+ // Calculate content height (total height minus top and bottom borders)
37
+ const contentHeight = height ? height - 2 : undefined;
38
+ // Build left border column - one | for each content row
39
+ const renderSideBorders = (content) => {
40
+ if (!contentHeight) {
41
+ // Dynamic height fallback
42
+ return (_jsxs(Box, { flexDirection: "row", flexGrow: 1, children: [_jsx(Text, { color: borderColor, children: "|" }), _jsx(Box, { flexDirection: "column", width: innerWidth, paddingX: padding, justifyContent: centerContent ? "center" : "flex-start", alignItems: centerContent ? "center" : "flex-start", flexGrow: 1, children: content }), _jsx(Text, { color: borderColor, children: "|" })] }));
43
+ }
44
+ // Fixed height: render border characters for each line
45
+ const borderLines = "|\n".repeat(contentHeight).trim();
46
+ return (_jsxs(Box, { flexDirection: "row", height: contentHeight, children: [_jsx(Text, { color: borderColor, children: borderLines }), _jsx(Box, { flexDirection: "column", width: innerWidth, paddingX: padding, justifyContent: centerContent ? "center" : "flex-start", alignItems: centerContent ? "center" : "flex-start", children: content }), _jsx(Text, { color: borderColor, children: borderLines })] }));
47
+ };
48
+ return (_jsxs(Box, { flexDirection: "column", width: actualWidth, height: height, children: [_jsx(Text, { color: borderColor, children: title ? (_jsxs(_Fragment, { children: ["+", "-".repeat(topLeftPadding), _jsx(Text, { color: titleColor, bold: true, children: safeTitle }), "-".repeat(topRightPadding), "+"] })) : (`+${"-".repeat(innerWidth)}+`) }), renderSideBorders(children), _jsx(Text, { color: borderColor, children: bottomBorder })] }));
49
+ }
50
+ // Hook to get terminal dimensions for responsive layouts
51
+ export function useTerminalSize() {
52
+ const { stdout } = useStdout();
53
+ return {
54
+ width: stdout?.columns || 80,
55
+ height: stdout?.rows || 24,
56
+ };
57
+ }
@@ -1,11 +1,16 @@
1
- import React from 'react';
2
- import { Preset, TemperatureUnit } from '../lib/types.js';
1
+ import React from "react";
2
+ import { Preset, TemperatureUnit } from "../lib/types.js";
3
+ import { TERMINAL_COLORS } from "../lib/theme.js";
4
+ type TerminalTheme = (typeof TERMINAL_COLORS)[keyof typeof TERMINAL_COLORS];
3
5
  interface PresetsProps {
4
6
  presets: Preset[];
5
7
  selectedIndex: number;
6
8
  temperatureUnit: TemperatureUnit;
7
9
  onSelect: (preset: Preset) => void;
8
10
  isActive: boolean;
11
+ width?: number;
12
+ height?: number;
13
+ theme: TerminalTheme;
9
14
  }
10
- export declare function Presets({ presets, selectedIndex, temperatureUnit, onSelect, isActive, }: PresetsProps): React.ReactElement;
15
+ export declare function Presets({ presets, selectedIndex, temperatureUnit, onSelect, isActive, width, height, theme, }: PresetsProps): React.ReactElement;
11
16
  export {};