feat: resizable character panel and full-height sprite

- Change sprite to use full height instead of full width for better scaling
- Add draggable divider between character panel and terminal
- Persist panel width preference in config (min: 200px, max: 600px)

Closes #10
This commit is contained in:
2026-01-23 18:05:55 -08:00
committed by Naomi Carrigan
parent ad9c914fb1
commit 13c96a973a
6 changed files with 75 additions and 14 deletions
+6
View File
@@ -64,6 +64,9 @@ pub struct HikariConfig {
#[serde(default = "default_update_checks_enabled")] #[serde(default = "default_update_checks_enabled")]
pub update_checks_enabled: bool, pub update_checks_enabled: bool,
#[serde(default)]
pub character_panel_width: Option<u32>,
} }
impl Default for HikariConfig { impl Default for HikariConfig {
@@ -81,6 +84,7 @@ impl Default for HikariConfig {
notification_volume: 0.7, notification_volume: 0.7,
always_on_top: false, always_on_top: false,
update_checks_enabled: true, update_checks_enabled: true,
character_panel_width: None,
} }
} }
} }
@@ -126,6 +130,7 @@ mod tests {
assert!(config.greeting_custom_prompt.is_none()); assert!(config.greeting_custom_prompt.is_none());
assert!(!config.always_on_top); assert!(!config.always_on_top);
assert!(config.update_checks_enabled); assert!(config.update_checks_enabled);
assert!(config.character_panel_width.is_none());
} }
#[test] #[test]
@@ -143,6 +148,7 @@ mod tests {
notification_volume: 0.7, notification_volume: 0.7,
always_on_top: true, always_on_top: true,
update_checks_enabled: true, update_checks_enabled: true,
character_panel_width: Some(400),
}; };
let json = serde_json::to_string(&config).unwrap(); let json = serde_json::to_string(&config).unwrap();
+15 -11
View File
@@ -57,30 +57,34 @@
} }
</script> </script>
<div class="anime-girl-container flex flex-col items-center justify-end h-full p-4"> <div
<div class="character-frame relative {getBackgroundGlow()} w-full max-w-md"> class="anime-girl-container flex flex-col items-center justify-between h-full p-4 overflow-hidden"
<div class="sprite-container {getAnimationClass()}"> >
<div
class="character-frame relative {getBackgroundGlow()} flex-1 flex items-center justify-center min-h-0"
>
<div class="sprite-container {getAnimationClass()} h-full flex items-center justify-center">
<img <img
src="/sprites/{info.spriteFile}" src="/sprites/{info.spriteFile}"
alt="Hikari - {info.label}" alt="Hikari - {info.label}"
class="character-sprite w-full h-auto object-contain" class="character-sprite h-full w-auto max-w-full object-contain"
onerror={(e) => { onerror={(e) => {
const target = e.currentTarget as HTMLImageElement; const target = e.currentTarget as HTMLImageElement;
target.src = "/sprites/placeholder.svg"; target.src = "/sprites/placeholder.svg";
}} }}
/> />
</div> </div>
</div>
<div class="state-indicator absolute -bottom-2 left-1/2 transform -translate-x-1/2"> <div class="state-indicator mt-2">
<div <div
class="px-3 py-1 rounded-full text-xs font-medium bg-[var(--bg-secondary)] border border-[var(--border-color)] text-[var(--accent-primary)]" class="px-3 py-1 rounded-full text-xs font-medium bg-[var(--bg-secondary)] border border-[var(--border-color)] text-[var(--accent-primary)]"
> >
{info.label} {info.label}
</div>
</div> </div>
</div> </div>
<div class="speech-bubble mt-4 max-w-xs"> <div class="speech-bubble mt-2 max-w-xs flex-shrink-0">
<div <div
class="relative bg-[var(--bg-secondary)] rounded-lg px-4 py-2 border border-[var(--border-color)]" class="relative bg-[var(--bg-secondary)] rounded-lg px-4 py-2 border border-[var(--border-color)]"
> >
+1
View File
@@ -16,6 +16,7 @@
notification_volume: 0.7, notification_volume: 0.7,
always_on_top: false, always_on_top: false,
update_checks_enabled: true, update_checks_enabled: true,
character_panel_width: null,
}); });
let isOpen = $state(false); let isOpen = $state(false);
+1
View File
@@ -47,6 +47,7 @@
notification_volume: 0.5, notification_volume: 0.5,
always_on_top: false, always_on_top: false,
update_checks_enabled: true, update_checks_enabled: true,
character_panel_width: null,
}); });
onMount(async () => { onMount(async () => {
+2
View File
@@ -16,6 +16,7 @@ export interface HikariConfig {
notification_volume: number; notification_volume: number;
always_on_top: boolean; always_on_top: boolean;
update_checks_enabled: boolean; update_checks_enabled: boolean;
character_panel_width: number | null;
} }
const defaultConfig: HikariConfig = { const defaultConfig: HikariConfig = {
@@ -31,6 +32,7 @@ const defaultConfig: HikariConfig = {
notification_volume: 0.7, notification_volume: 0.7,
always_on_top: false, always_on_top: false,
update_checks_enabled: true, update_checks_enabled: true,
character_panel_width: null,
}; };
function createConfigStore() { function createConfigStore() {
+50 -3
View File
@@ -24,6 +24,35 @@
let updateNotification: UpdateNotification; let updateNotification: UpdateNotification;
let achievementPanelOpen = $state(false); let achievementPanelOpen = $state(false);
// Resizable panel state
let panelWidth = $state(320); // Default width in pixels
let isResizing = $state(false);
const MIN_PANEL_WIDTH = 200;
const MAX_PANEL_WIDTH = 600;
function startResize(event: MouseEvent) {
isResizing = true;
event.preventDefault();
document.addEventListener("mousemove", handleResize);
document.addEventListener("mouseup", stopResize);
}
function handleResize(event: MouseEvent) {
if (!isResizing) return;
const newWidth = event.clientX;
panelWidth = Math.max(MIN_PANEL_WIDTH, Math.min(MAX_PANEL_WIDTH, newWidth));
}
function stopResize() {
if (isResizing) {
isResizing = false;
document.removeEventListener("mousemove", handleResize);
document.removeEventListener("mouseup", stopResize);
// Save the panel width to config
configStore.updateConfig({ character_panel_width: panelWidth });
}
}
// Global keyboard shortcuts // Global keyboard shortcuts
function handleGlobalKeydown(event: KeyboardEvent) { function handleGlobalKeydown(event: KeyboardEvent) {
// Don't trigger shortcuts when typing in inputs (except for specific ones) // Don't trigger shortcuts when typing in inputs (except for specific ones)
@@ -105,6 +134,11 @@
await window.setAlwaysOnTop(true); await window.setAlwaysOnTop(true);
} }
// Load saved panel width
if (config.character_panel_width) {
panelWidth = config.character_panel_width;
}
// Initialize notification settings sync // Initialize notification settings sync
initNotificationSync(); initNotificationSync();
@@ -134,13 +168,22 @@
<main class="flex-1 flex overflow-hidden"> <main class="flex-1 flex overflow-hidden">
<!-- Left panel: Character display --> <!-- Left panel: Character display -->
<div <div
class="character-panel w-1/3 flex flex-col items-center justify-center border-r border-[var(--border-color)] bg-[var(--bg-secondary)]/50" class="character-panel flex flex-col items-center justify-center bg-[var(--bg-secondary)]/50"
style="width: {panelWidth}px; min-width: {MIN_PANEL_WIDTH}px; max-width: {MAX_PANEL_WIDTH}px;"
> >
<AnimeGirl /> <AnimeGirl />
</div> </div>
<!-- Resize handle -->
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
class="resize-handle w-1 cursor-col-resize bg-[var(--border-color)] hover:bg-[var(--accent-primary)] transition-colors flex-shrink-0"
class:bg-[var(--accent-primary)]={isResizing}
onmousedown={startResize}
></div>
<!-- Right panel: Terminal and input --> <!-- Right panel: Terminal and input -->
<div class="terminal-panel flex-1 flex flex-col"> <div class="terminal-panel flex-1 flex flex-col min-w-0">
<Terminal /> <Terminal />
<InputBar /> <InputBar />
</div> </div>
@@ -169,7 +212,11 @@
} }
.character-panel { .character-panel {
min-width: 320px;
background: linear-gradient(180deg, var(--bg-secondary) 0%, var(--bg-primary) 100%); background: linear-gradient(180deg, var(--bg-secondary) 0%, var(--bg-primary) 100%);
} }
.resize-handle:hover,
.resize-handle:active {
width: 4px;
}
</style> </style>