ember-mug 0.1.3 → 0.1.4

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 (40) hide show
  1. package/dist/cli.js +37 -7
  2. package/dist/components/App.d.ts +1 -1
  3. package/dist/components/App.js +64 -53
  4. package/dist/components/BatteryDisplay.d.ts +8 -3
  5. package/dist/components/BatteryDisplay.js +7 -18
  6. package/dist/components/ConnectionStatus.d.ts +7 -2
  7. package/dist/components/ConnectionStatus.js +5 -4
  8. package/dist/components/Header.d.ts +7 -2
  9. package/dist/components/Header.js +11 -3
  10. package/dist/components/HelpDisplay.d.ts +5 -2
  11. package/dist/components/HelpDisplay.js +8 -3
  12. package/dist/components/HorizontalRule.d.ts +2 -0
  13. package/dist/components/HorizontalRule.js +7 -0
  14. package/dist/components/Panel.d.ts +18 -0
  15. package/dist/components/Panel.js +57 -0
  16. package/dist/components/Presets.d.ts +8 -3
  17. package/dist/components/Presets.js +13 -8
  18. package/dist/components/SettingsView.d.ts +9 -3
  19. package/dist/components/SettingsView.js +82 -16
  20. package/dist/components/TemperatureControl.d.ts +8 -3
  21. package/dist/components/TemperatureControl.js +13 -18
  22. package/dist/components/TemperatureDisplay.d.ts +8 -3
  23. package/dist/components/TemperatureDisplay.js +8 -35
  24. package/dist/hooks/useMug.d.ts +1 -1
  25. package/dist/hooks/useMug.js +22 -22
  26. package/dist/lib/bluetooth.d.ts +2 -0
  27. package/dist/lib/bluetooth.js +8 -0
  28. package/dist/lib/mock-bluetooth.d.ts +65 -0
  29. package/dist/lib/mock-bluetooth.js +214 -0
  30. package/dist/lib/settings.d.ts +1 -1
  31. package/dist/lib/settings.js +20 -20
  32. package/dist/lib/theme.d.ts +135 -0
  33. package/dist/lib/theme.js +112 -0
  34. package/dist/lib/types.d.ts +0 -1
  35. package/dist/lib/types.js +12 -12
  36. package/dist/lib/utils.d.ts +1 -2
  37. package/dist/lib/utils.js +19 -35
  38. package/package.json +2 -1
  39. package/dist/components/ColorControl.d.ts +0 -9
  40. package/dist/components/ColorControl.js +0 -71
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 { 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
18
27
  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());
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 }), _jsx(Box, { marginTop: 1, children: _jsx(BatteryDisplay, { batteryLevel: mugState.batteryLevel, isCharging: mugState.isCharging, liquidState: mugState.liquidState, width: panelWidth, theme: theme }) }), _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 }), _jsx(BatteryDisplay, { batteryLevel: mugState.batteryLevel, isCharging: mugState.isCharging, liquidState: mugState.liquidState, width: panelWidth, height: 8, theme: theme })] }), _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,14 @@
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;
7
12
  }
8
- export declare function BatteryDisplay({ batteryLevel, isCharging, liquidState, }: BatteryDisplayProps): React.ReactElement;
13
+ export declare function BatteryDisplay({ batteryLevel, isCharging, liquidState, width, height, theme, }: BatteryDisplayProps): React.ReactElement;
9
14
  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);
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, }) {
7
6
  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 }) })] }));
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 {};
@@ -1,7 +1,8 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
- import { Box, Text, useInput } from 'ink';
3
- import { formatTemperature } from '../lib/utils.js';
4
- export function Presets({ presets, selectedIndex, temperatureUnit, onSelect, isActive, }) {
2
+ import { Box, Text, useInput } from "ink";
3
+ import { formatTemperature } from "../lib/utils.js";
4
+ import { Panel } from "./Panel.js";
5
+ export function Presets({ presets, selectedIndex, temperatureUnit, onSelect, isActive, width, height, theme, }) {
5
6
  useInput((input, key) => {
6
7
  if (!isActive)
7
8
  return;
@@ -10,10 +11,14 @@ export function Presets({ presets, selectedIndex, temperatureUnit, onSelect, isA
10
11
  onSelect(presets[numKey - 1]);
11
12
  }
12
13
  }, { isActive });
13
- return (_jsxs(Box, { flexDirection: "column", marginY: 1, children: [_jsx(Box, { justifyContent: "center", children: _jsx(Text, { bold: true, children: "Presets" }) }), _jsx(Box, { justifyContent: "center", marginY: 1, gap: 2, children: presets.map((preset, index) => (_jsx(PresetButton, { preset: preset, index: index, isSelected: selectedIndex === index, temperatureUnit: temperatureUnit }, preset.id))) }), _jsx(Box, { justifyContent: "center", children: _jsxs(Text, { dimColor: true, children: ["Press ", _jsxs(Text, { color: "cyan", children: ["1-", presets.length] }), " to select a preset"] }) })] }));
14
+ // Fixed width for each preset button (12 chars inner content)
15
+ const boxWidth = 12;
16
+ return (_jsx(Panel, { title: "Temperature Presets", titleColor: theme.primary, borderColor: theme.border, width: width, height: height, children: _jsx(Box, { justifyContent: "center", gap: 1, children: presets.map((preset, index) => (_jsx(PresetButton, { preset: preset, index: index, isSelected: selectedIndex === index, temperatureUnit: temperatureUnit, theme: theme, boxWidth: boxWidth }, preset.id))) }) }));
14
17
  }
15
- function PresetButton({ preset, index, isSelected, temperatureUnit, }) {
16
- const borderColor = isSelected ? 'cyan' : 'gray';
17
- const textColor = isSelected ? 'cyan' : 'white';
18
- return (_jsxs(Box, { flexDirection: "column", alignItems: "center", children: [_jsx(Text, { color: borderColor, children: isSelected ? '┌───────────┐' : '┌───────────┐' }), _jsxs(Text, { color: borderColor, children: ["\u2502 ", _jsxs(Text, { color: textColor, children: [preset.icon, " ", preset.name.padEnd(6)] }), " \u2502"] }), _jsxs(Text, { color: borderColor, children: ["\u2502 ", _jsx(Text, { color: textColor, children: formatTemperature(preset.temperature, temperatureUnit).padStart(7) }), " \u2502"] }), _jsx(Text, { color: borderColor, children: "\u2514\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2518" }), _jsxs(Text, { color: "cyan", children: ["[", index + 1, "]"] })] }));
18
+ function PresetButton({ preset, index, isSelected, temperatureUnit, theme, boxWidth, }) {
19
+ const borderColor = isSelected ? theme.primary : theme.dimText;
20
+ const textColor = isSelected ? theme.text : theme.dimText;
21
+ const nameDisplay = preset.name.substring(0, boxWidth).padEnd(boxWidth);
22
+ const tempDisplay = formatTemperature(preset.temperature, temperatureUnit).padStart(boxWidth);
23
+ return (_jsxs(Box, { flexDirection: "column", alignItems: "center", children: [_jsxs(Text, { color: borderColor, children: ["+", "-".repeat(boxWidth), "+"] }), _jsxs(Text, { color: borderColor, children: ["|", _jsx(Text, { color: textColor, bold: true, children: nameDisplay }), "|"] }), _jsxs(Text, { color: borderColor, children: ["|", _jsx(Text, { color: isSelected ? theme.primary : theme.text, children: tempDisplay }), "|"] }), _jsxs(Text, { color: borderColor, children: ["+", "-".repeat(boxWidth), "+"] }), _jsxs(Text, { color: theme.primary, bold: true, children: ["[", index + 1, "]"] })] }));
19
24
  }
@@ -1,11 +1,17 @@
1
- import React from 'react';
2
- import { TemperatureUnit, Preset } from '../lib/types.js';
1
+ import React from "react";
2
+ import { TemperatureUnit, Preset, RGBColor } 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 SettingsViewProps {
4
6
  presets: Preset[];
5
7
  temperatureUnit: TemperatureUnit;
8
+ ledColor: RGBColor;
6
9
  onTemperatureUnitChange: (unit: TemperatureUnit) => void;
10
+ onPresetUpdate: (preset: Preset) => void;
11
+ onColorChange: (color: RGBColor) => void;
7
12
  onClose: () => void;
8
13
  isActive: boolean;
14
+ theme: TerminalTheme;
9
15
  }
10
- export declare function SettingsView({ presets, temperatureUnit, onTemperatureUnitChange, onClose, isActive, }: SettingsViewProps): React.ReactElement;
16
+ export declare function SettingsView({ presets, temperatureUnit, ledColor, onTemperatureUnitChange, onPresetUpdate, onColorChange, onClose, isActive, theme, }: SettingsViewProps): React.ReactElement;
11
17
  export {};