Files
trail-into-darkness/docs/plans/2026-04-06-popup-system-implementation.md
Sebastian Bularca cbf9f384d9 popup changes
2026-04-06 10:44:16 +02:00

31 KiB

Popup System Implementation Plan

For Claude: REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.

Goal: Build com.jovian.popup-system, a low-allocation popup/tooltip package with category-based isolation, fluent content builder, grow-only element cache, screen edge clamping, and extensible animations.

Architecture: Per-game-state IPopupSystem instances with registered categories. Each category lazily creates one PopupReference MonoBehaviour that reuses cached content elements. PopupTrigger MonoBehaviour handles hover detection. PopupContentBuilder struct operates directly on cached elements. Float-based timers in Tick() — no coroutines.

Tech Stack: Unity 6 / C# 9, TextMeshPro, Unity Input System, Newtonsoft.Json


Task 1: Package scaffold

Files:

  • Create: Packages/com.jovian.popup-system/package.json
  • Create: Packages/com.jovian.popup-system/Runtime/Jovian.PopupSystem.asmdef
  • Create: Packages/com.jovian.popup-system/Editor/Jovian.PopupSystem.Editor.asmdef
  • Create: Packages/com.jovian.popup-system/Samples~/README.md

Step 1: Create package.json

{
    "name": "com.jovian.popup-system",
    "version": "0.1.0",
    "displayName": "Jovian Popup System",
    "description": "A lightweight, low-allocation popup and tooltip system with category-based isolation, fluent content builder, and extensible animations.",
    "unity": "2022.3",
    "dependencies": {
        "com.unity.textmeshpro": "3.0.6",
        "com.unity.inputsystem": "1.18.0",
        "com.unity.nuget.newtonsoft-json": "3.2.1"
    },
    "keywords": [
        "popup",
        "tooltip",
        "ui"
    ],
    "author": {
        "name": "Jovian"
    }
}

Step 2: Create Runtime asmdef

{
    "name": "Jovian.PopupSystem",
    "rootNamespace": "Jovian.PopupSystem",
    "references": [
        "Unity.TextMeshPro",
        "Unity.InputSystem"
    ],
    "includePlatforms": [],
    "excludePlatforms": [],
    "allowUnsafeCode": false,
    "overrideReferences": true,
    "precompiledReferences": [
        "Newtonsoft.Json.dll"
    ],
    "autoReferenced": true,
    "defineConstraints": [],
    "versionDefines": [],
    "noEngineReferences": false
}

Step 3: Create Editor asmdef

{
    "name": "Jovian.PopupSystem.Editor",
    "rootNamespace": "Jovian.PopupSystem.Editor",
    "references": [
        "Jovian.PopupSystem"
    ],
    "includePlatforms": ["Editor"],
    "excludePlatforms": [],
    "allowUnsafeCode": false,
    "overrideReferences": false,
    "precompiledReferences": [],
    "autoReferenced": true,
    "defineConstraints": [],
    "versionDefines": [],
    "noEngineReferences": false
}

Step 4: Create Samples~/README.md

# Samples

This folder is reserved for sample scenes and scripts demonstrating the Popup System.

Step 5: Commit

git add Packages/com.jovian.popup-system/
git commit -m "feat: scaffold com.jovian.popup-system package"

Task 2: PopupCategory + JSON converter + enums

Files:

  • Create: Packages/com.jovian.popup-system/Runtime/PopupCategory.cs
  • Create: Packages/com.jovian.popup-system/Runtime/PopupCategoryJsonConverter.cs
  • Create: Packages/com.jovian.popup-system/Runtime/PopupEnums.cs

Step 1: Implement PopupCategory (same pattern as LogChannel)

using System;
using UnityEngine;

namespace Jovian.PopupSystem {
    [Serializable]
    public readonly struct PopupCategory : IEquatable<PopupCategory> {
        [SerializeField] readonly string id;

        public string Id => id;

        public PopupCategory(string id) {
            this.id = id;
        }

        public static readonly PopupCategory Character = new("Character");
        public static readonly PopupCategory Item = new("Item");
        public static readonly PopupCategory Skill = new("Skill");
        public static readonly PopupCategory General = new("General");

        public bool Equals(PopupCategory other) {
            return string.Equals(id, other.id, StringComparison.Ordinal);
        }

        public override bool Equals(object obj) {
            return obj is PopupCategory other && Equals(other);
        }

        public override int GetHashCode() {
            return id != null ? id.GetHashCode() : 0;
        }

        public override string ToString() {
            return id ?? string.Empty;
        }

        public static bool operator ==(PopupCategory left, PopupCategory right) {
            return left.Equals(right);
        }

        public static bool operator !=(PopupCategory left, PopupCategory right) {
            return !left.Equals(right);
        }
    }
}

Step 2: Implement JSON converter

using System;
using Newtonsoft.Json;

namespace Jovian.PopupSystem {
    public sealed class PopupCategoryJsonConverter : JsonConverter<PopupCategory> {
        public override void WriteJson(JsonWriter writer, PopupCategory value, JsonSerializer serializer) {
            writer.WriteValue(value.Id);
        }

        public override PopupCategory ReadJson(JsonReader reader, Type objectType, PopupCategory existingValue, bool hasExistingValue, JsonSerializer serializer) {
            var id = reader.Value as string;
            return new PopupCategory(id);
        }
    }
}

Step 3: Implement enums

namespace Jovian.PopupSystem {
    public enum AnchorSide {
        Below,
        Above,
        Left,
        Right
    }

    public enum PopupPositionMode {
        AnchorToElement,
        FollowMouse
    }
}

Step 4: Commit

git add Packages/com.jovian.popup-system/Runtime/
git commit -m "feat: add PopupCategory, enums, and JSON converter"

Task 3: PopupSettings ScriptableObject

Files:

  • Create: Packages/com.jovian.popup-system/Runtime/PopupSettings.cs

Step 1: Implement settings

using System;
using System.Collections.Generic;
using UnityEngine;

namespace Jovian.PopupSystem {
    [CreateAssetMenu(fileName = "PopupSettings", menuName = "Jovian/Popup System/Popup Settings")]
    public class PopupSettings : ScriptableObject {
        [Header("General")]
        public float popupDelay = 0.4f;
        public float fadeDuration = 0.2f;
        public AnchorSide defaultAnchorSide = AnchorSide.Below;
        public float screenEdgePadding = 10f;
        public float maxPopupWidth = 400f;
        public int sortingOrder = 100;

        [Header("Follow Mouse")]
        public Vector2 followMouseOffset = new(15f, -15f);

        [Header("Input")]
        public float touchHoldDuration = 0.6f;
        public bool gamepadFocusTrigger = true;

        [Header("Priority")]
        public List<CategoryPriority> categoryPriorities = new();

        [Header("Per-Category Overrides")]
        public List<CategoryDelay> categoryDelayOverrides = new();

        public int GetPriority(PopupCategory category) {
            foreach(var cp in categoryPriorities) {
                if(cp.category == category) {
                    return cp.priority;
                }
            }
            return 0;
        }

        public float GetDelay(PopupCategory category) {
            foreach(var cd in categoryDelayOverrides) {
                if(cd.category == category) {
                    return cd.delay;
                }
            }
            return popupDelay;
        }
    }

    [Serializable]
    public sealed class CategoryPriority {
        public PopupCategory category;
        public int priority;
    }

    [Serializable]
    public sealed class CategoryDelay {
        public PopupCategory category;
        public float delay;
    }
}

Step 2: Commit

git add Packages/com.jovian.popup-system/Runtime/PopupSettings.cs
git commit -m "feat: add PopupSettings ScriptableObject with full configuration"

Task 4: IPopupAnimator + FadePopupAnimator

Files:

  • Create: Packages/com.jovian.popup-system/Runtime/IPopupAnimator.cs
  • Create: Packages/com.jovian.popup-system/Runtime/FadePopupAnimator.cs

Step 1: Implement interface

using System;
using UnityEngine;

namespace Jovian.PopupSystem {
    public interface IPopupAnimator {
        void Show(CanvasGroup canvasGroup, float duration, Action onComplete);
        void Hide(CanvasGroup canvasGroup, float duration, Action onComplete);
        void Tick(float deltaTime);
        bool IsAnimating { get; }
    }
}

Step 2: Implement FadePopupAnimator

using System;
using UnityEngine;

namespace Jovian.PopupSystem {
    public sealed class FadePopupAnimator : IPopupAnimator {
        private CanvasGroup target;
        private float duration;
        private float elapsed;
        private float startAlpha;
        private float endAlpha;
        private Action onComplete;

        public bool IsAnimating => target != null;

        public void Show(CanvasGroup canvasGroup, float duration, Action onComplete) {
            target = canvasGroup;
            this.duration = Mathf.Max(duration, 0.001f);
            elapsed = 0f;
            startAlpha = 0f;
            endAlpha = 1f;
            this.onComplete = onComplete;
            canvasGroup.alpha = 0f;
        }

        public void Hide(CanvasGroup canvasGroup, float duration, Action onComplete) {
            target = canvasGroup;
            this.duration = Mathf.Max(duration, 0.001f);
            elapsed = 0f;
            startAlpha = canvasGroup.alpha;
            endAlpha = 0f;
            this.onComplete = onComplete;
        }

        public void Tick(float deltaTime) {
            if(target == null) {
                return;
            }

            elapsed += deltaTime;
            var t = Mathf.Clamp01(elapsed / duration);
            target.alpha = Mathf.Lerp(startAlpha, endAlpha, t);

            if(t >= 1f) {
                var callback = onComplete;
                target = null;
                onComplete = null;
                callback?.Invoke();
            }
        }
    }
}

Step 3: Commit

git add Packages/com.jovian.popup-system/Runtime/IPopupAnimator.cs Packages/com.jovian.popup-system/Runtime/FadePopupAnimator.cs
git commit -m "feat: add IPopupAnimator interface and FadePopupAnimator"

Task 5: PopupReference MonoBehaviour with element cache

Files:

  • Create: Packages/com.jovian.popup-system/Runtime/UI/PopupReference.cs

Step 1: Implement PopupReference with grow-only element cache, positioning, and screen clamping

using System.Collections.Generic;
using TMPro;
using UnityEngine;
using UnityEngine.InputSystem;
using UnityEngine.UI;

namespace Jovian.PopupSystem.UI {
    public class PopupReference : MonoBehaviour {
        [SerializeField] RectTransform content;
        [SerializeField] CanvasGroup canvasGroup;
        [SerializeField] RectTransform background;

        [Header("Element Prefabs")]
        [SerializeField] TMP_Text headerPrefab;
        [SerializeField] TMP_Text textPrefab;
        [SerializeField] RectTransform statPrefab; // contains label + value TMP_Text
        [SerializeField] Image imagePrefab;
        [SerializeField] Image separatorPrefab;

        // Element caches (grow-only)
        readonly List<TMP_Text> headerCache = new();
        readonly List<TMP_Text> textCache = new();
        readonly List<RectTransform> statCache = new();
        readonly List<Image> imageCache = new();
        readonly List<Image> separatorCache = new();

        int headerIndex;
        int textIndex;
        int statIndex;
        int imageIndex;
        int separatorIndex;

        // State
        PopupPositionMode positionMode;
        RectTransform anchorTarget;
        AnchorSide anchorSide;
        Vector2 followOffset;
        float screenEdgePadding;
        bool isVisible;

        public CanvasGroup CanvasGroup => canvasGroup;
        public RectTransform Content => content;
        public bool IsVisible => isVisible;

        public void SetVisible(bool visible) {
            isVisible = visible;
            gameObject.SetActive(visible);
            if(!visible) {
                canvasGroup.alpha = 0f;
            }
        }

        public void ClearContent() {
            for(int i = 0; i < headerIndex; i++) {
                headerCache[i].gameObject.SetActive(false);
            }
            for(int i = 0; i < textIndex; i++) {
                textCache[i].gameObject.SetActive(false);
            }
            for(int i = 0; i < statIndex; i++) {
                statCache[i].gameObject.SetActive(false);
            }
            for(int i = 0; i < imageIndex; i++) {
                imageCache[i].gameObject.SetActive(false);
            }
            for(int i = 0; i < separatorIndex; i++) {
                separatorCache[i].gameObject.SetActive(false);
            }
            headerIndex = 0;
            textIndex = 0;
            statIndex = 0;
            imageIndex = 0;
            separatorIndex = 0;
        }

        // --- Element access (grow-only) ---

        public TMP_Text GetHeader() {
            return GetOrCreate(headerCache, headerPrefab, ref headerIndex);
        }

        public TMP_Text GetText() {
            return GetOrCreate(textCache, textPrefab, ref textIndex);
        }

        public (TMP_Text label, TMP_Text value) GetStat() {
            var rt = GetOrCreate(statCache, statPrefab, ref statIndex);
            var children = rt.GetComponentsInChildren<TMP_Text>(true);
            return (children[0], children[1]);
        }

        public Image GetImage() {
            return GetOrCreate(imageCache, imagePrefab, ref imageIndex);
        }

        public Image GetSeparator() {
            return GetOrCreate(separatorCache, separatorPrefab, ref separatorIndex);
        }

        private T GetOrCreate<T>(List<T> cache, T prefab, ref int index) where T : Component {
            if(index < cache.Count) {
                var existing = cache[index];
                existing.gameObject.SetActive(true);
                existing.transform.SetAsLastSibling();
                index++;
                return existing;
            }

            var created = Instantiate(prefab, content);
            created.gameObject.SetActive(true);
            created.transform.SetAsLastSibling();
            cache.Add(created);
            index++;
            return created;
        }

        // --- Positioning ---

        public void SetAnchorMode(RectTransform target, AnchorSide side, float edgePadding) {
            positionMode = PopupPositionMode.AnchorToElement;
            anchorTarget = target;
            anchorSide = side;
            screenEdgePadding = edgePadding;
        }

        public void SetFollowMouseMode(Vector2 offset, float edgePadding) {
            positionMode = PopupPositionMode.FollowMouse;
            followOffset = offset;
            screenEdgePadding = edgePadding;
        }

        public void UpdatePosition() {
            if(positionMode == PopupPositionMode.FollowMouse) {
                PositionAtScreenPoint(Mouse.current.position.ReadValue() + followOffset);
            }
            else if(anchorTarget != null) {
                PositionAnchoredTo(anchorTarget, anchorSide);
            }
        }

        private void PositionAnchoredTo(RectTransform target, AnchorSide side) {
            var targetRect = GetScreenRect(target);
            var popupRect = GetScreenRect((RectTransform)transform);
            var popupSize = popupRect.size;

            var pos = side switch {
                AnchorSide.Below => new Vector2(targetRect.center.x - popupSize.x * 0.5f, targetRect.yMin - popupSize.y),
                AnchorSide.Above => new Vector2(targetRect.center.x - popupSize.x * 0.5f, targetRect.yMax),
                AnchorSide.Left => new Vector2(targetRect.xMin - popupSize.x, targetRect.center.y - popupSize.y * 0.5f),
                AnchorSide.Right => new Vector2(targetRect.xMax, targetRect.center.y - popupSize.y * 0.5f),
                _ => targetRect.center
            };

            PositionAtScreenPoint(pos);
        }

        private void PositionAtScreenPoint(Vector2 screenPos) {
            var rt = (RectTransform)transform;
            LayoutRebuilder.ForceRebuildLayoutImmediate(content);
            var popupSize = rt.rect.size;

            // Clamp to screen
            screenPos.x = Mathf.Clamp(screenPos.x, screenEdgePadding, Screen.width - popupSize.x - screenEdgePadding);
            screenPos.y = Mathf.Clamp(screenPos.y, screenEdgePadding, Screen.height - popupSize.y - screenEdgePadding);

            rt.position = screenPos;
        }

        private static Rect GetScreenRect(RectTransform rt) {
            var corners = new Vector3[4];
            rt.GetWorldCorners(corners);
            var min = new Vector2(corners[0].x, corners[0].y);
            var max = new Vector2(corners[2].x, corners[2].y);
            return new Rect(min, max - min);
        }
    }
}

Step 2: Commit

git add Packages/com.jovian.popup-system/Runtime/UI/PopupReference.cs
git commit -m "feat: add PopupReference with grow-only element cache and positioning"

Task 6: PopupContentBuilder struct

Files:

  • Create: Packages/com.jovian.popup-system/Runtime/PopupContentBuilder.cs

Step 1: Implement fluent builder

using Jovian.PopupSystem.UI;
using UnityEngine;

namespace Jovian.PopupSystem {
    public struct PopupContentBuilder {
        readonly PopupReference view;

        public PopupContentBuilder(PopupReference view) {
            this.view = view;
        }

        public PopupContentBuilder AddHeader(string text) {
            var header = view.GetHeader();
            header.text = text;
            return this;
        }

        public PopupContentBuilder AddText(string text) {
            var label = view.GetText();
            label.text = text;
            return this;
        }

        public PopupContentBuilder AddText(string text, string hexColor) {
            var label = view.GetText();
            var prefix = text.Length > 0 && hexColor[0] == '#' ? "" : "#";
            label.text = $"<color={prefix}{hexColor}>{text}</color>";
            return this;
        }

        public PopupContentBuilder AddStat(string label, int value) {
            var (labelText, valueText) = view.GetStat();
            labelText.text = label;
            valueText.text = value.ToString();
            return this;
        }

        public PopupContentBuilder AddStat(string label, string value) {
            var (labelText, valueText) = view.GetStat();
            labelText.text = label;
            valueText.text = value;
            return this;
        }

        public PopupContentBuilder AddImage(Sprite sprite, float height = 64f) {
            var image = view.GetImage();
            image.sprite = sprite;
            var rt = (RectTransform)image.transform;
            rt.SetSizeWithCurrentAnchors(RectTransform.Axis.Vertical, height);
            return this;
        }

        public PopupContentBuilder AddSeparator() {
            view.GetSeparator();
            return this;
        }
    }
}

Step 2: Commit

git add Packages/com.jovian.popup-system/Runtime/PopupContentBuilder.cs
git commit -m "feat: add PopupContentBuilder fluent API"

Task 7: IPopupSystem + PopupSystem implementation

Files:

  • Create: Packages/com.jovian.popup-system/Runtime/IPopupSystem.cs
  • Create: Packages/com.jovian.popup-system/Runtime/PopupSystem.cs

Step 1: Implement interface

using System;
using UnityEngine;

namespace Jovian.PopupSystem {
    public interface IPopupSystem {
        void RegisterCategory(PopupCategory category, int priority = 0);
        void Show(PopupCategory category, Action<PopupContentBuilder> buildContent,
                  RectTransform anchor = null, AnchorSide? anchorSide = null);
        void ShowAtPosition(PopupCategory category, Action<PopupContentBuilder> buildContent,
                            Vector2 screenPosition);
        void Hide(PopupCategory category);
        void HideAll();
        void Tick(float deltaTime);
        void Dispose();
    }
}

Step 2: Implement PopupSystem

using System;
using System.Collections.Generic;
using Jovian.PopupSystem.UI;
using UnityEngine;
using Object = UnityEngine.Object;

namespace Jovian.PopupSystem {
    public sealed class PopupSystem : IPopupSystem {
        readonly PopupSettings settings;
        readonly PopupReference viewPrefab;
        readonly IPopupAnimator animator;
        readonly Dictionary<PopupCategory, ViewState> categories = new();

        public PopupSystem(PopupSettings settings, PopupReference viewPrefab, IPopupAnimator animator = null) {
            this.settings = settings;
            this.viewPrefab = viewPrefab;
            this.animator = animator ?? new FadePopupAnimator();
        }

        public void RegisterCategory(PopupCategory category, int priority = 0) {
            if(categories.ContainsKey(category)) {
                return;
            }
            var effectivePriority = settings.GetPriority(category);
            if(effectivePriority == 0) {
                effectivePriority = priority;
            }
            categories[category] = new ViewState {
                priority = effectivePriority,
                delay = settings.GetDelay(category)
            };
        }

        public void Show(PopupCategory category, Action<PopupContentBuilder> buildContent,
                         RectTransform anchor = null, AnchorSide? anchorSide = null) {
            if(!categories.TryGetValue(category, out var state)) {
                return;
            }

            // Dismiss lower-priority visible popups
            DismissLowerPriority(state.priority);

            state.pendingBuild = buildContent;
            state.pendingAnchor = anchor;
            state.pendingAnchorSide = anchorSide ?? settings.defaultAnchorSide;
            state.pendingScreenPos = null;
            state.delayTimer = state.delay;
            state.isPending = true;
        }

        public void ShowAtPosition(PopupCategory category, Action<PopupContentBuilder> buildContent,
                                   Vector2 screenPosition) {
            if(!categories.TryGetValue(category, out var state)) {
                return;
            }

            DismissLowerPriority(state.priority);

            state.pendingBuild = buildContent;
            state.pendingAnchor = null;
            state.pendingScreenPos = screenPosition;
            state.delayTimer = state.delay;
            state.isPending = true;
        }

        public void Hide(PopupCategory category) {
            if(!categories.TryGetValue(category, out var state)) {
                return;
            }

            state.isPending = false;
            if(state.view != null && state.view.IsVisible) {
                animator.Hide(state.view.CanvasGroup, settings.fadeDuration, () => {
                    state.view.SetVisible(false);
                });
            }
        }

        public void HideAll() {
            foreach(var kvp in categories) {
                Hide(kvp.Key);
            }
        }

        public void Tick(float deltaTime) {
            animator.Tick(deltaTime);

            foreach(var kvp in categories) {
                var state = kvp.Value;
                if(!state.isPending) {
                    continue;
                }

                state.delayTimer -= deltaTime;
                if(state.delayTimer > 0f) {
                    continue;
                }

                state.isPending = false;
                ShowImmediate(state);
            }

            // Update follow-mouse positions
            foreach(var kvp in categories) {
                var state = kvp.Value;
                if(state.view != null && state.view.IsVisible && state.isFollowMouse) {
                    state.view.UpdatePosition();
                }
            }
        }

        public void Dispose() {
            foreach(var kvp in categories) {
                if(kvp.Value.view != null) {
                    Object.Destroy(kvp.Value.view.gameObject);
                }
            }
            categories.Clear();
        }

        private void ShowImmediate(ViewState state) {
            EnsureView(state);
            state.view.ClearContent();

            var builder = new PopupContentBuilder(state.view);
            state.pendingBuild?.Invoke(builder);

            if(state.pendingScreenPos.HasValue) {
                state.view.SetFollowMouseMode(settings.followMouseOffset, settings.screenEdgePadding);
                state.isFollowMouse = false; // positioned at fixed screen point
            }
            else if(state.pendingAnchor != null) {
                state.view.SetAnchorMode(state.pendingAnchor, state.pendingAnchorSide, settings.screenEdgePadding);
                state.isFollowMouse = false;
            }
            else {
                state.view.SetFollowMouseMode(settings.followMouseOffset, settings.screenEdgePadding);
                state.isFollowMouse = true;
            }

            state.view.SetVisible(true);
            state.view.UpdatePosition();
            animator.Show(state.view.CanvasGroup, settings.fadeDuration, null);
        }

        private void EnsureView(ViewState state) {
            if(state.view != null) {
                return;
            }
            state.view = Object.Instantiate(viewPrefab);
            state.view.SetVisible(false);

            var canvas = state.view.GetComponent<Canvas>();
            if(canvas != null) {
                canvas.sortingOrder = settings.sortingOrder;
            }
        }

        private void DismissLowerPriority(int showingPriority) {
            foreach(var kvp in categories) {
                var state = kvp.Value;
                if(state.priority < showingPriority && state.view != null && state.view.IsVisible) {
                    state.isPending = false;
                    animator.Hide(state.view.CanvasGroup, settings.fadeDuration, () => {
                        state.view.SetVisible(false);
                    });
                }
            }
        }

        private sealed class ViewState {
            public PopupReference view;
            public int priority;
            public float delay;
            public float delayTimer;
            public bool isPending;
            public bool isFollowMouse;
            public Action<PopupContentBuilder> pendingBuild;
            public RectTransform pendingAnchor;
            public AnchorSide pendingAnchorSide;
            public Vector2? pendingScreenPos;
        }
    }
}

Step 3: Commit

git add Packages/com.jovian.popup-system/Runtime/IPopupSystem.cs Packages/com.jovian.popup-system/Runtime/PopupSystem.cs
git commit -m "feat: add IPopupSystem and PopupSystem with priority and delay"

Task 8: PopupTrigger MonoBehaviour

Files:

  • Create: Packages/com.jovian.popup-system/Runtime/UI/PopupTrigger.cs

Step 1: Implement trigger

using System;
using UnityEngine;
using UnityEngine.EventSystems;

namespace Jovian.PopupSystem.UI {
    public class PopupTrigger : MonoBehaviour, IPointerEnterHandler, IPointerExitHandler {
        [SerializeField] PopupCategory category;
        [SerializeField] AnchorSide anchorSide = AnchorSide.Below;
        [SerializeField] PopupPositionMode positionMode = PopupPositionMode.AnchorToElement;

        IPopupSystem popupSystem;
        Action<PopupContentBuilder> contentCallback;
        bool initialized;

        public PopupCategory Category => category;

        public void Initialize(IPopupSystem popupSystem, Action<PopupContentBuilder> contentCallback) {
            this.popupSystem = popupSystem;
            this.contentCallback = contentCallback;
            initialized = true;
        }

        public void Initialize(IPopupSystem popupSystem, PopupCategory category, Action<PopupContentBuilder> contentCallback) {
            this.popupSystem = popupSystem;
            this.category = category;
            this.contentCallback = contentCallback;
            initialized = true;
        }

        public void UpdateContent(Action<PopupContentBuilder> contentCallback) {
            this.contentCallback = contentCallback;
        }

        public void OnPointerEnter(PointerEventData eventData) {
            if(!initialized || popupSystem == null || contentCallback == null) {
                return;
            }

            if(positionMode == PopupPositionMode.AnchorToElement) {
                popupSystem.Show(category, contentCallback, (RectTransform)transform, anchorSide);
            }
            else {
                popupSystem.Show(category, contentCallback);
            }
        }

        public void OnPointerExit(PointerEventData eventData) {
            if(!initialized || popupSystem == null) {
                return;
            }
            popupSystem.Hide(category);
        }
    }
}

Step 2: Commit

git add Packages/com.jovian.popup-system/Runtime/UI/PopupTrigger.cs
git commit -m "feat: add PopupTrigger MonoBehaviour for hover detection"

Task 9: README.md

Files:

  • Create: Packages/com.jovian.popup-system/README.md

Step 1: Write README covering: quick start, PopupCategory, PopupSettings, content builder API, PopupTrigger setup, code-only triggers, prefab setup, IPopupAnimator extensibility.

Step 2: Commit

git add Packages/com.jovian.popup-system/README.md
git commit -m "feat: add README for popup system package"

Task 10: Editor — PopupSettingsProvider

Files:

  • Create: Packages/com.jovian.popup-system/Editor/PopupSettingsProvider.cs

Step 1: Implement settings provider for Project Settings > Jovian > Popup System (same pattern as SaveSystemSettingsProvider).

Step 2: Commit

git add Packages/com.jovian.popup-system/Editor/PopupSettingsProvider.cs
git commit -m "feat: add PopupSettingsProvider for Project Settings"

File Summary

File Type Purpose
Packages/com.jovian.popup-system/package.json Create Package manifest
Packages/com.jovian.popup-system/Runtime/Jovian.PopupSystem.asmdef Create Runtime assembly
Packages/com.jovian.popup-system/Editor/Jovian.PopupSystem.Editor.asmdef Create Editor assembly
Packages/com.jovian.popup-system/Samples~/README.md Create Samples placeholder
Packages/com.jovian.popup-system/Runtime/PopupCategory.cs Create Category readonly struct
Packages/com.jovian.popup-system/Runtime/PopupCategoryJsonConverter.cs Create Newtonsoft converter
Packages/com.jovian.popup-system/Runtime/PopupEnums.cs Create AnchorSide, PopupPositionMode
Packages/com.jovian.popup-system/Runtime/PopupSettings.cs Create ScriptableObject config
Packages/com.jovian.popup-system/Runtime/IPopupAnimator.cs Create Animation interface
Packages/com.jovian.popup-system/Runtime/FadePopupAnimator.cs Create Default fade animation
Packages/com.jovian.popup-system/Runtime/PopupContentBuilder.cs Create Fluent builder struct
Packages/com.jovian.popup-system/Runtime/IPopupSystem.cs Create System interface
Packages/com.jovian.popup-system/Runtime/PopupSystem.cs Create System implementation
Packages/com.jovian.popup-system/Runtime/UI/PopupReference.cs Create MonoBehaviour with element cache
Packages/com.jovian.popup-system/Runtime/UI/PopupTrigger.cs Create Hover trigger MonoBehaviour
Packages/com.jovian.popup-system/README.md Create Package documentation
Packages/com.jovian.popup-system/Editor/PopupSettingsProvider.cs Create Project Settings UI