feat(web): implement responsive modal pattern for mobile-friendly NPC chat

- Add hybrid modal/page navigation based on screen size (1024px breakpoint)
- Desktop (>1024px): Uses modal overlays for quick interactions
- Mobile (≤1024px): Navigates to dedicated full pages for better UX
- Extract shared NPC chat content into reusable partial template
- Add responsive navigation JavaScript (responsive-modals.js)
- Create dedicated NPC chat page route with back button navigation
- Add mobile-optimized CSS with sticky header and chat input
- Fix HTMX indicator errors by using htmx-indicator class pattern
- Document responsive modal pattern for future features

Addresses mobile UX issues: cramped space, nested scrolling, keyboard conflicts,
and lack of native back button support in modals.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2025-11-25 21:30:51 -06:00
parent 196346165f
commit 2419dbeb34
9 changed files with 861 additions and 119 deletions

View File

@@ -0,0 +1,120 @@
{#
NPC Chat Content (Shared Partial)
Used by both modal and dedicated page views
Displays NPC profile, conversation interface, and message history
#}
<div class="npc-chat-container">
{# Left Column: NPC Profile #}
<div class="npc-profile">
{# NPC Portrait #}
<div class="npc-portrait">
{% if npc.image_url %}
<img src="{{ npc.image_url }}" alt="{{ npc.name }}">
{% else %}
<div class="npc-portrait-placeholder">
{{ npc.name[0] }}
</div>
{% endif %}
</div>
{# NPC Info #}
<div class="npc-profile-info">
<div class="npc-profile-role">{{ npc.role }}</div>
{% if npc.appearance %}
<div class="npc-profile-appearance">{{ npc.appearance }}</div>
{% endif %}
</div>
{# NPC Tags #}
{% if npc.tags %}
<div class="npc-profile-tags">
{% for tag in npc.tags %}
<span class="npc-profile-tag">{{ tag }}</span>
{% endfor %}
</div>
{% endif %}
{# Relationship Meter #}
<div class="npc-relationship">
<div class="relationship-header">
<span class="relationship-label">Relationship</span>
<span class="relationship-value">{{ relationship_level|default(50) }}/100</span>
</div>
<div class="relationship-bar">
<div class="relationship-fill" style="width: {{ relationship_level|default(50) }}%"></div>
</div>
<div class="relationship-status">
{% set level = relationship_level|default(50) %}
{% if level >= 80 %}
<span class="status-friendly">Trusted Ally</span>
{% elif level >= 60 %}
<span class="status-friendly">Friendly</span>
{% elif level >= 40 %}
<span class="status-neutral">Neutral</span>
{% elif level >= 20 %}
<span class="status-unfriendly">Wary</span>
{% else %}
<span class="status-unfriendly">Hostile</span>
{% endif %}
</div>
</div>
{# Interaction Stats #}
<div class="npc-interaction-stats">
<div class="interaction-stat">
<span class="stat-label">Conversations</span>
<span class="stat-value">{{ interaction_count|default(0) }}</span>
</div>
</div>
</div>
{# Center Column: Conversation #}
<div class="npc-conversation">
{# Conversation History #}
<div class="chat-history" id="chat-history-{{ npc.npc_id }}">
{% if conversation_history %}
{% for msg in conversation_history %}
<div class="chat-message chat-message--{{ msg.speaker }}">
<strong>{{ msg.speaker_name }}:</strong> {{ msg.text }}
</div>
{% endfor %}
{% else %}
<div class="chat-empty-state">
Start a conversation with {{ npc.name }}
</div>
{% endif %}
</div>
{# Chat Input #}
<form class="chat-input-form"
hx-post="{{ url_for('game.talk_to_npc', session_id=session_id, npc_id=npc.npc_id) }}"
hx-target="#chat-history-{{ npc.npc_id }}"
hx-swap="beforeend">
<input type="text"
name="player_response"
class="chat-input"
placeholder="Say something..."
autocomplete="off"
autofocus>
<button type="submit" class="chat-send-btn">Send</button>
<button type="button"
class="chat-greet-btn"
hx-post="{{ url_for('game.talk_to_npc', session_id=session_id, npc_id=npc.npc_id) }}"
hx-vals='{"topic": "greeting"}'
hx-target="#chat-history-{{ npc.npc_id }}"
hx-swap="beforeend">
Greet
</button>
</form>
</div>
{# Right Column: Message History Sidebar #}
<aside class="npc-history-panel htmx-indicator"
id="npc-history-{{ npc.npc_id }}"
hx-get="{{ url_for('game.npc_chat_history', session_id=session_id, npc_id=npc.npc_id) }}"
hx-trigger="load, newMessage from:body"
hx-swap="innerHTML">
{# History loaded via HTMX #}
<div class="history-loading">Loading history...</div>
</aside>
</div>

View File

@@ -1,6 +1,7 @@
{#
NPC Chat Modal (Expanded)
Shows NPC profile with portrait, relationship meter, and conversation interface
Uses shared content partial for consistency with dedicated page view
#}
<div class="modal-overlay" onclick="if(event.target === this) closeModal()">
<div class="modal-content modal-content--xl">
@@ -10,122 +11,9 @@ Shows NPC profile with portrait, relationship meter, and conversation interface
<button class="modal-close" onclick="closeModal()">&times;</button>
</div>
{# Modal Body - Three Column Layout #}
{# Modal Body - Uses shared content partial #}
<div class="modal-body npc-modal-body npc-modal-body--three-col">
{# Left Column: NPC Profile #}
<div class="npc-profile">
{# NPC Portrait #}
<div class="npc-portrait">
{% if npc.image_url %}
<img src="{{ npc.image_url }}" alt="{{ npc.name }}">
{% else %}
<div class="npc-portrait-placeholder">
{{ npc.name[0] }}
</div>
{% endif %}
</div>
{# NPC Info #}
<div class="npc-profile-info">
<div class="npc-profile-role">{{ npc.role }}</div>
{% if npc.appearance %}
<div class="npc-profile-appearance">{{ npc.appearance }}</div>
{% endif %}
</div>
{# NPC Tags #}
{% if npc.tags %}
<div class="npc-profile-tags">
{% for tag in npc.tags %}
<span class="npc-profile-tag">{{ tag }}</span>
{% endfor %}
</div>
{% endif %}
{# Relationship Meter #}
<div class="npc-relationship">
<div class="relationship-header">
<span class="relationship-label">Relationship</span>
<span class="relationship-value">{{ relationship_level|default(50) }}/100</span>
</div>
<div class="relationship-bar">
<div class="relationship-fill" style="width: {{ relationship_level|default(50) }}%"></div>
</div>
<div class="relationship-status">
{% set level = relationship_level|default(50) %}
{% if level >= 80 %}
<span class="status-friendly">Trusted Ally</span>
{% elif level >= 60 %}
<span class="status-friendly">Friendly</span>
{% elif level >= 40 %}
<span class="status-neutral">Neutral</span>
{% elif level >= 20 %}
<span class="status-unfriendly">Wary</span>
{% else %}
<span class="status-unfriendly">Hostile</span>
{% endif %}
</div>
</div>
{# Interaction Stats #}
<div class="npc-interaction-stats">
<div class="interaction-stat">
<span class="stat-label">Conversations</span>
<span class="stat-value">{{ interaction_count|default(0) }}</span>
</div>
</div>
</div>
{# Right Column: Conversation #}
<div class="npc-conversation">
{# Conversation History #}
<div class="chat-history" id="chat-history-{{ npc.npc_id }}">
{% if conversation_history %}
{% for msg in conversation_history %}
<div class="chat-message chat-message--{{ msg.speaker }}">
<strong>{{ msg.speaker_name }}:</strong> {{ msg.text }}
</div>
{% endfor %}
{% else %}
<div class="chat-empty-state">
Start a conversation with {{ npc.name }}
</div>
{% endif %}
</div>
{# Chat Input #}
<form class="chat-input-form"
hx-post="{{ url_for('game.talk_to_npc', session_id=session_id, npc_id=npc.npc_id) }}"
hx-target="#chat-history-{{ npc.npc_id }}"
hx-swap="beforeend">
<input type="text"
name="player_response"
class="chat-input"
placeholder="Say something..."
autocomplete="off"
autofocus>
<button type="submit" class="chat-send-btn">Send</button>
<button type="button"
class="chat-greet-btn"
hx-post="{{ url_for('game.talk_to_npc', session_id=session_id, npc_id=npc.npc_id) }}"
hx-vals='{"topic": "greeting"}'
hx-target="#chat-history-{{ npc.npc_id }}"
hx-swap="beforeend">
Greet
</button>
</form>
</div>
{# Right Column: Message History Sidebar #}
<aside class="npc-history-panel"
id="npc-history-{{ npc.npc_id }}"
hx-get="{{ url_for('game.npc_chat_history', session_id=session_id, npc_id=npc.npc_id) }}"
hx-trigger="load, newMessage from:body"
hx-swap="innerHTML"
hx-indicator=".history-loading">
{# History loaded via HTMX #}
<div class="loading-state-small history-loading">Loading history...</div>
</aside>
{% include 'game/partials/npc_chat_content.html' %}
</div>
{# Modal Footer #}

View File

@@ -1,14 +1,15 @@
{#
NPCs Accordion Content
Shows NPCs at current location with click to chat
Uses responsive navigation: modals on desktop, full pages on mobile
#}
{% if npcs %}
<div class="npc-list">
{% for npc in npcs %}
<div class="npc-item"
hx-get="{{ url_for('game.npc_chat_modal', session_id=session_id, npc_id=npc.npc_id) }}"
hx-target="#modal-container"
hx-swap="innerHTML">
data-npc-id="{{ npc.npc_id }}"
onclick="navigateResponsive(event, '{{ url_for('game.npc_chat_page', session_id=session_id, npc_id=npc.npc_id) }}', '{{ url_for('game.npc_chat_modal', session_id=session_id, npc_id=npc.npc_id) }}')"
style="cursor: pointer;">
<div class="npc-name">{{ npc.name }}</div>
<div class="npc-role">{{ npc.role }}</div>
<div class="npc-appearance">{{ npc.appearance }}</div>