diff --git a/src/lib/components/units/CharacterUnit.svelte b/src/lib/components/units/CharacterUnit.svelte
index 58a46c35..e898e0a9 100644
--- a/src/lib/components/units/CharacterUnit.svelte
+++ b/src/lib/components/units/CharacterUnit.svelte
@@ -11,6 +11,7 @@
import { openDetailsSidebar } from '$lib/features/details/openDetailsSidebar.svelte'
import { getJobPortraitUrl, Gender } from '$lib/utils/jobUtils'
import { sidebar } from '$lib/stores/sidebar.svelte'
+ import { GridType } from '$lib/types/enums'
import perpetuityFilled from '$src/assets/icons/perpetuity/filled.svg'
import perpetuityEmpty from '$src/assets/icons/perpetuity/empty.svg'
import * as m from '$lib/paraglide/messages'
@@ -30,6 +31,8 @@
updateParty: (p: Party) => void
canEdit: () => boolean
getEditKey: () => string | null
+ getSelectedSlot?: () => number
+ getActiveTab?: () => GridType
services: { gridService: any; partyService: any }
openPicker?: (opts: {
type: 'character' | 'weapon' | 'summon'
@@ -53,6 +56,11 @@
return getJobPortraitUrl(job, Gender.Gran) // TODO: Get gender from user preferences
}
+ // For protagonist slot without a job, show nothing (relief.png background shows through)
+ if (position === 0 && !job) {
+ return ''
+ }
+
// If no item or no character with granblueId, return placeholder
if (!item || !item.character?.granblueId) {
return getCharacterImageWithPose(null, 'main', 0, 0)
@@ -74,6 +82,14 @@
// Check if this item is currently active in the sidebar
let isActive = $derived(item?.id && sidebar.activeItemId === String(item.id))
+ // Check if this empty slot is currently selected for adding an item
+ let isEmptySelected = $derived(
+ !item &&
+ !isProtagonist &&
+ ctx?.getSelectedSlot?.() === position &&
+ ctx?.getActiveTab?.() === GridType.Character
+ )
+
// Determine element class for focus ring
let elementClass = $derived.by(() => {
const element = item?.character?.element || partyElement
@@ -180,6 +196,7 @@
class="frame character cell {elementClass}"
class:protagonist={position === 0}
class:editable={ctx?.canEdit()}
+ class:is-active={isActive}
onclick={() => viewDetails()}
>
{#if position !== 0}
@@ -210,6 +227,7 @@
/>
{/if}
{/if}
+ {#if imageUrl}
+ {/if}
{/key}
@@ -254,20 +273,28 @@
class="frame character cell"
class:editable={ctx?.canEdit() && !isProtagonist}
class:protagonist={isProtagonist}
+ class:empty-protagonist={isProtagonist && !job}
+ class:is-selected={isEmptySelected}
onclick={() =>
!isProtagonist &&
ctx?.canEdit() &&
ctx?.openPicker &&
ctx.openPicker({ type: 'character', position, item })}
>
-
- {#if ctx?.canEdit()}
+ {#if !isProtagonist}
+
+ {:else if job && imageUrl}
+
+ {/if}
+ {#if ctx?.canEdit() && !isProtagonist}
@@ -381,6 +408,22 @@
opacity: 0.95;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
}
+
+ // Slot selection - subtle dark pulsing glow (works for both empty and filled)
+ &.is-selected,
+ &.is-active {
+ animation: pulse-slot-shadow 2s ease-in-out infinite;
+ }
+ }
+
+ @keyframes pulse-slot-shadow {
+ 0%,
+ 100% {
+ box-shadow: 0 0 0 1px rgba(0, 0, 0, 0.12), 0 0 4px 2px rgba(0, 0, 0, 0.06);
+ }
+ 50% {
+ box-shadow: 0 0 0 1px rgba(0, 0, 0, 0.24), 0 0 8px 4px rgba(0, 0, 0, 0.12);
+ }
}
.frame.character.cell {
@@ -391,6 +434,10 @@
background-size: cover;
background-position: center -20px;
background-repeat: no-repeat;
+
+ &.empty-protagonist {
+ background-position: center 0;
+ }
}
}
diff --git a/src/lib/utils/gridHelpers.ts b/src/lib/utils/gridHelpers.ts
index 570e2bf9..3898b465 100644
--- a/src/lib/utils/gridHelpers.ts
+++ b/src/lib/utils/gridHelpers.ts
@@ -19,7 +19,7 @@ export interface SlotRange {
const GRID_CONFIGS: Record = {
[GridType.Weapon]: { start: 0, end: 8, specialSlots: [-1] }, // mainhand + 9 grid slots
[GridType.Summon]: { start: 0, end: 5, specialSlots: [-1, 6] }, // main + 6 grid + friend
- [GridType.Character]: { start: 0, end: 4, specialSlots: [] } // 5 slots (0-4)
+ [GridType.Character]: { start: 1, end: 4, specialSlots: [] } // 4 slots (1-4), position 0 is protagonist (not user-selectable)
}
/**