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

@@ -690,10 +690,66 @@ def do_travel(session_id: str):
return f'<div class="error">Travel failed: {e}</div>', 500 return f'<div class="error">Travel failed: {e}</div>', 500
@game_bp.route('/session/<session_id>/npc/<npc_id>')
@require_auth
def npc_chat_page(session_id: str, npc_id: str):
"""
Dedicated NPC chat page (mobile-friendly full page view).
Used on mobile devices for better UX.
"""
client = get_api_client()
try:
# Get session to find character
session_response = client.get(f'/api/v1/sessions/{session_id}')
session_data = session_response.get('result', {})
character_id = session_data.get('character_id')
# Get NPC details with relationship info
npc_response = client.get(f'/api/v1/npcs/{npc_id}?character_id={character_id}')
npc_data = npc_response.get('result', {})
npc = {
'npc_id': npc_data.get('npc_id'),
'name': npc_data.get('name'),
'role': npc_data.get('role'),
'appearance': npc_data.get('appearance', {}).get('brief', ''),
'tags': npc_data.get('tags', []),
'image_url': npc_data.get('image_url')
}
# Get relationship info
interaction_summary = npc_data.get('interaction_summary', {})
relationship_level = interaction_summary.get('relationship_level', 50)
interaction_count = interaction_summary.get('interaction_count', 0)
# Conversation history would come from character's npc_interactions
# For now, we'll leave it empty - the API returns it in dialogue responses
conversation_history = []
return render_template(
'game/npc_chat_page.html',
session_id=session_id,
npc=npc,
conversation_history=conversation_history,
relationship_level=relationship_level,
interaction_count=interaction_count
)
except APINotFoundError:
return render_template('errors/404.html', message="NPC not found"), 404
except APIError as e:
logger.error("failed_to_load_npc_chat_page", session_id=session_id, npc_id=npc_id, error=str(e))
return render_template('errors/500.html', message=f"Failed to load NPC: {e}"), 500
@game_bp.route('/session/<session_id>/npc/<npc_id>/chat') @game_bp.route('/session/<session_id>/npc/<npc_id>/chat')
@require_auth @require_auth
def npc_chat_modal(session_id: str, npc_id: str): def npc_chat_modal(session_id: str, npc_id: str):
"""Get NPC chat modal with conversation history.""" """
Get NPC chat modal with conversation history.
Used on desktop for modal overlay experience.
"""
client = get_api_client() client = get_api_client()
try: try:

View File

@@ -0,0 +1,380 @@
# Responsive Modal Pattern
## Overview
This pattern provides an optimal UX for modal-like content by adapting to screen size:
- **Desktop (>1024px)**: Displays content in modal overlays
- **Mobile (≤1024px)**: Navigates to dedicated full pages
This addresses common mobile modal UX issues: cramped space, nested scrolling, keyboard conflicts, and lack of native back button support.
---
## When to Use This Pattern
Use this pattern when:
- Content requires significant interaction (forms, chat, complex data)
- Mobile users need better scroll/keyboard handling
- The interaction benefits from full-screen on mobile
- Desktop users benefit from keeping context visible
**Don't use** for:
- Simple confirmations/alerts (use standard modals)
- Very brief interactions (dropdowns, tooltips)
- Content that must overlay the game state
---
## Implementation Steps
### 1. Create Shared Content Partial
Extract the actual content into a reusable partial template that both the modal and page can use.
**File**: `templates/game/partials/[feature]_content.html`
```html
{# Shared content partial #}
<div class="feature-container">
<!-- Your content here -->
<!-- This will be used by both modal and page -->
</div>
```
### 2. Create Two Routes
Create both a modal route and a page route in your view:
**File**: `app/views/game_views.py`
```python
@game_bp.route('/session/<session_id>/feature/<feature_id>')
@require_auth
def feature_page(session_id: str, feature_id: str):
"""
Dedicated page view (mobile-friendly).
Used on mobile devices for better UX.
"""
# Fetch data
data = get_feature_data(session_id, feature_id)
return render_template(
'game/feature_page.html',
session_id=session_id,
feature=data
)
@game_bp.route('/session/<session_id>/feature/<feature_id>/modal')
@require_auth
def feature_modal(session_id: str, feature_id: str):
"""
Modal view (desktop overlay).
Used on desktop for quick interactions.
"""
# Fetch same data
data = get_feature_data(session_id, feature_id)
return render_template(
'game/partials/feature_modal.html',
session_id=session_id,
feature=data
)
```
### 3. Create Page Template
Create a dedicated page template with header, back button, and shared content.
**File**: `templates/game/feature_page.html`
```html
{% extends "base.html" %}
{% block title %}{{ feature.name }} - Code of Conquest{% endblock %}
{% block extra_head %}
<link rel="stylesheet" href="{{ url_for('static', filename='css/play.css') }}">
{% endblock %}
{% block content %}
<div class="feature-page">
{# Page Header with Back Button #}
<div class="feature-header">
<a href="{{ url_for('game.play_session', session_id=session_id) }}"
class="feature-back-btn">
<svg><!-- Back arrow icon --></svg>
Back
</a>
<h1 class="feature-title">{{ feature.name }}</h1>
</div>
{# Include shared content #}
{% include 'game/partials/feature_content.html' %}
</div>
{% endblock %}
{% block scripts %}
<script>
// Add any feature-specific JavaScript here
</script>
{% endblock %}
```
### 4. Update Modal Template
Update your modal template to use the shared content partial.
**File**: `templates/game/partials/feature_modal.html`
```html
{# Modal wrapper #}
<div class="modal-overlay" onclick="if(event.target === this) closeModal()">
<div class="modal-content modal-content--xl">
{# Modal Header #}
<div class="modal-header">
<h3 class="modal-title">{{ feature.name }}</h3>
<button class="modal-close" onclick="closeModal()">&times;</button>
</div>
{# Modal Body - Uses shared content #}
<div class="modal-body">
{% include 'game/partials/feature_content.html' %}
</div>
{# Modal Footer (optional) #}
<div class="modal-footer">
<button class="btn btn--secondary" onclick="closeModal()">
Close
</button>
</div>
</div>
</div>
```
### 5. Add Responsive Navigation
Update the trigger element to use responsive navigation JavaScript.
**File**: `templates/game/partials/sidebar_features.html`
```html
<div class="feature-item"
data-feature-id="{{ feature.id }}"
onclick="navigateResponsive(
event,
'{{ url_for('game.feature_page', session_id=session_id, feature_id=feature.id) }}',
'{{ url_for('game.feature_modal', session_id=session_id, feature_id=feature.id) }}'
)"
style="cursor: pointer;">
<div class="feature-name">{{ feature.name }}</div>
</div>
```
### 6. Add Mobile-Friendly CSS
Add CSS for the dedicated page with mobile optimizations.
**File**: `static/css/play.css`
```css
/* Feature Page */
.feature-page {
min-height: 100vh;
display: flex;
flex-direction: column;
background: var(--bg-secondary);
}
/* Page Header with Back Button */
.feature-header {
display: flex;
align-items: center;
gap: 1rem;
padding: 1rem;
background: var(--bg-primary);
border-bottom: 2px solid var(--play-border);
position: sticky;
top: 0;
z-index: 100;
}
.feature-back-btn {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.5rem 1rem;
background: var(--bg-input);
color: var(--text-primary);
text-decoration: none;
border-radius: 4px;
border: 1px solid var(--play-border);
transition: all 0.2s ease;
}
.feature-back-btn:hover {
background: var(--bg-tertiary);
border-color: var(--accent-gold);
color: var(--accent-gold);
}
.feature-title {
flex: 1;
margin: 0;
font-family: var(--font-heading);
font-size: 1.5rem;
color: var(--accent-gold);
}
/* Responsive layout */
@media (min-width: 1025px) {
.feature-page .feature-container {
max-width: 1400px;
margin: 0 auto;
padding: 2rem;
}
}
@media (max-width: 1024px) {
.feature-page .feature-container {
padding: 1rem;
flex: 1;
}
}
```
### 7. Include JavaScript
Ensure the responsive modals JavaScript is included in your play page template.
**File**: `templates/game/play.html`
```html
{% block scripts %}
<!-- Existing scripts -->
<script>
// Your existing JavaScript
</script>
<!-- Responsive Modal Navigation -->
<script src="{{ url_for('static', filename='js/responsive-modals.js') }}"></script>
{% endblock %}
```
---
## JavaScript API
The responsive navigation is handled by `responsive-modals.js`:
### Functions
#### `navigateResponsive(event, pageUrl, modalUrl)`
Navigates responsively based on screen size.
**Parameters:**
- `event` (Event): Click event (will be prevented)
- `pageUrl` (string): Full page URL for mobile navigation
- `modalUrl` (string): Modal content URL for desktop HTMX loading
**Example:**
```javascript
navigateResponsive(
event,
'/game/session/123/npc/456', // Page route
'/game/session/123/npc/456/chat' // Modal route
)
```
#### `isMobile()`
Returns true if viewport is ≤1024px.
**Example:**
```javascript
if (isMobile()) {
// Mobile-specific behavior
}
```
---
## Breakpoint
The mobile/desktop breakpoint is **1024px** to match the CSS media query:
```javascript
const MOBILE_BREAKPOINT = 1024;
```
This ensures consistent behavior between JavaScript navigation and CSS responsive layouts.
---
## Example: NPC Chat
See the NPC chat implementation for a complete working example:
**Routes:**
- Page: `/game/session/<session_id>/npc/<npc_id>` (public_web/app/views/game_views.py:695)
- Modal: `/game/session/<session_id>/npc/<npc_id>/chat` (public_web/app/views/game_views.py:746)
**Templates:**
- Shared content: `templates/game/partials/npc_chat_content.html`
- Page: `templates/game/npc_chat_page.html`
- Modal: `templates/game/partials/npc_chat_modal.html`
**Trigger:**
- `templates/game/partials/sidebar_npcs.html` (line 11)
**CSS:**
- Page styles: `static/css/play.css` (lines 1809-1956)
---
## Testing Checklist
When implementing this pattern, test:
- [ ] Desktop (>1024px): Modal opens correctly
- [ ] Desktop: Modal closes with X button, overlay click, and Escape key
- [ ] Desktop: Game screen remains visible behind modal
- [ ] Mobile (≤1024px): Navigates to dedicated page
- [ ] Mobile: Back button returns to game
- [ ] Mobile: Content scrolls naturally
- [ ] Mobile: Keyboard doesn't break layout
- [ ] Both: Shared content renders identically
- [ ] Both: HTMX interactions work correctly
- [ ] Resize: Behavior adapts when crossing breakpoint
---
## Best Practices
1. **Always share content**: Use a partial template to avoid duplication
2. **Keep routes parallel**: Use consistent URL patterns (`/feature` and `/feature/modal`)
3. **Test both views**: Ensure feature parity between modal and page
4. **Mobile-first CSS**: Design for mobile, enhance for desktop
5. **Consistent breakpoint**: Always use 1024px to match the JavaScript
6. **Document examples**: Link to working implementations in this doc
---
## Future Improvements
Potential enhancements to this pattern:
- [ ] Add transition animations for modal open/close
- [ ] Support deep linking to modals on desktop
- [ ] Add browser back button handling for desktop modals
- [ ] Support customizable breakpoints per feature
- [ ] Add analytics tracking for modal vs page usage
---
## Related Documentation
- **HTMX_PATTERNS.md** - HTMX integration patterns
- **TEMPLATES.md** - Template structure and conventions
- **TESTING.md** - Manual testing procedures

View File

@@ -1699,6 +1699,26 @@
font-style: italic; font-style: italic;
} }
/* HTMX Loading Indicator */
.history-loading {
text-align: center;
color: var(--text-muted);
font-size: 0.875rem;
padding: 1rem 0;
font-style: italic;
display: none;
}
/* Show loading indicator when HTMX is making a request */
.htmx-indicator.htmx-request .history-loading {
display: block;
}
/* Hide other content while loading */
.htmx-indicator.htmx-request > *:not(.history-loading) {
opacity: 0.5;
}
/* Responsive NPC Modal */ /* Responsive NPC Modal */
@media (max-width: 700px) { @media (max-width: 700px) {
.npc-modal-body { .npc-modal-body {
@@ -1805,3 +1825,152 @@
.chat-history::-webkit-scrollbar-thumb:hover { .chat-history::-webkit-scrollbar-thumb:hover {
background: var(--text-muted); background: var(--text-muted);
} }
/* ===== NPC CHAT DEDICATED PAGE ===== */
/* Mobile-friendly full page view for NPC conversations */
.npc-chat-page {
min-height: 100vh;
display: flex;
flex-direction: column;
background: var(--bg-secondary);
}
/* Page Header with Back Button */
.npc-chat-header {
display: flex;
align-items: center;
gap: 1rem;
padding: 1rem;
background: var(--bg-primary);
border-bottom: 2px solid var(--play-border);
position: sticky;
top: 0;
z-index: 100;
}
.npc-chat-back-btn {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.5rem 1rem;
background: var(--bg-input);
color: var(--text-primary);
text-decoration: none;
border-radius: 4px;
border: 1px solid var(--play-border);
font-family: var(--font-heading);
font-size: 0.9rem;
transition: all 0.2s ease;
}
.npc-chat-back-btn:hover {
background: var(--bg-tertiary);
border-color: var(--accent-gold);
color: var(--accent-gold);
}
.npc-chat-back-btn svg {
width: 20px;
height: 20px;
}
.npc-chat-title {
flex: 1;
margin: 0;
font-family: var(--font-heading);
font-size: 1.5rem;
color: var(--accent-gold);
}
/* Chat Container - Full Height Layout */
.npc-chat-page .npc-chat-container {
flex: 1;
display: flex;
flex-direction: column;
padding: 1rem;
gap: 1rem;
overflow: hidden;
}
/* Responsive Layout for NPC Chat Content */
/* Desktop: 3-column grid */
@media (min-width: 1025px) {
.npc-chat-page .npc-chat-container {
display: grid;
grid-template-columns: 250px 1fr 300px;
gap: 1.5rem;
max-width: 1400px;
margin: 0 auto;
width: 100%;
}
}
/* Mobile: Stacked layout */
@media (max-width: 1024px) {
.npc-chat-page .npc-chat-container {
display: flex;
flex-direction: column;
gap: 1rem;
}
/* Compact profile on mobile */
.npc-chat-page .npc-profile {
flex-direction: row;
flex-wrap: wrap;
align-items: flex-start;
gap: 1rem;
}
.npc-chat-page .npc-portrait {
width: 80px;
height: 80px;
flex-shrink: 0;
}
.npc-chat-page .npc-profile-info {
flex: 1;
min-width: 150px;
}
.npc-chat-page .npc-relationship,
.npc-chat-page .npc-profile-tags,
.npc-chat-page .npc-interaction-stats {
width: 100%;
}
/* Conversation takes most of the vertical space */
.npc-chat-page .npc-conversation {
flex: 1;
display: flex;
flex-direction: column;
min-height: 400px;
}
/* History panel is collapsible on mobile */
.npc-chat-page .npc-history-panel {
max-height: 200px;
overflow-y: auto;
border-top: 1px solid var(--play-border);
padding-top: 1rem;
}
}
/* Ensure chat history fills available space on page */
.npc-chat-page .chat-history {
flex: 1;
overflow-y: auto;
min-height: 300px;
}
/* Fix chat input to bottom on mobile */
@media (max-width: 1024px) {
.npc-chat-page .chat-input-form {
position: sticky;
bottom: 0;
background: var(--bg-secondary);
padding: 0.75rem 0;
border-top: 1px solid var(--play-border);
margin-top: 0.5rem;
}
}

View File

@@ -0,0 +1,81 @@
/**
* Responsive Modal Navigation
*
* Provides smart navigation that uses modals on desktop (>1024px)
* and full page navigation on mobile (<=1024px) for better UX.
*
* Usage:
* Instead of using hx-get directly on elements, use:
* onclick="navigateResponsive(event, '/page/url', '/modal/url')"
*/
// Breakpoint for mobile vs desktop (matches CSS @media query)
const MOBILE_BREAKPOINT = 1024;
/**
* Check if current viewport is mobile size
*/
function isMobile() {
return window.innerWidth <= MOBILE_BREAKPOINT;
}
/**
* Navigate responsively based on screen size
*
* @param {Event} event - Click event (will be prevented)
* @param {string} pageUrl - Full page URL for mobile
* @param {string} modalUrl - Modal content URL for desktop (HTMX)
*/
function navigateResponsive(event, pageUrl, modalUrl) {
event.preventDefault();
event.stopPropagation();
if (isMobile()) {
// Mobile: Navigate to full page
window.location.href = pageUrl;
} else {
// Desktop: Load modal via HTMX
const target = event.currentTarget;
// Trigger HTMX request programmatically
htmx.ajax('GET', modalUrl, {
target: '#modal-container',
swap: 'innerHTML'
});
}
}
/**
* Setup responsive navigation for NPC items
* Call this after NPCs are loaded
*/
function setupNPCResponsiveNav(sessionId) {
document.querySelectorAll('.npc-item').forEach(item => {
const npcId = item.getAttribute('data-npc-id');
if (!npcId) return;
const pageUrl = `/game/session/${sessionId}/npc/${npcId}`;
const modalUrl = `/game/session/${sessionId}/npc/${npcId}/chat`;
// Remove HTMX attributes and add responsive navigation
item.removeAttribute('hx-get');
item.removeAttribute('hx-target');
item.removeAttribute('hx-swap');
item.style.cursor = 'pointer';
item.onclick = (e) => navigateResponsive(e, pageUrl, modalUrl);
});
}
/**
* Handle window resize to adapt navigation behavior
* Debounced to avoid excessive calls
*/
let resizeTimeout;
window.addEventListener('resize', () => {
clearTimeout(resizeTimeout);
resizeTimeout = setTimeout(() => {
// Re-setup navigation if needed
console.log('Viewport resized:', isMobile() ? 'Mobile' : 'Desktop');
}, 250);
});

View File

@@ -0,0 +1,44 @@
{% extends "base.html" %}
{% block title %}{{ npc.name }} - Code of Conquest{% endblock %}
{% block extra_head %}
<!-- Play screen styles for NPC chat -->
<link rel="stylesheet" href="{{ url_for('static', filename='css/play.css') }}">
{% endblock %}
{% block content %}
<div class="npc-chat-page">
{# Page Header with Back Button #}
<div class="npc-chat-header">
<a href="{{ url_for('game.play_session', session_id=session_id) }}" class="npc-chat-back-btn">
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M19 12H5M12 19l-7-7 7-7"/>
</svg>
Back
</a>
<h1 class="npc-chat-title">{{ npc.name }}</h1>
</div>
{# Include shared NPC chat content #}
{% include 'game/partials/npc_chat_content.html' %}
</div>
{% endblock %}
{% block scripts %}
<script>
// Clear chat input after submission
document.body.addEventListener('htmx:afterSwap', function(e) {
if (e.target.closest('.chat-history')) {
const form = document.querySelector('.chat-input-form');
if (form) {
const input = form.querySelector('.chat-input');
if (input) {
input.value = '';
input.focus();
}
}
}
});
</script>
{% endblock %}

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) NPC Chat Modal (Expanded)
Shows NPC profile with portrait, relationship meter, and conversation interface 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-overlay" onclick="if(event.target === this) closeModal()">
<div class="modal-content modal-content--xl"> <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> <button class="modal-close" onclick="closeModal()">&times;</button>
</div> </div>
{# Modal Body - Three Column Layout #} {# Modal Body - Uses shared content partial #}
<div class="modal-body npc-modal-body npc-modal-body--three-col"> <div class="modal-body npc-modal-body npc-modal-body--three-col">
{# Left Column: NPC Profile #} {% include 'game/partials/npc_chat_content.html' %}
<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>
</div> </div>
{# Modal Footer #} {# Modal Footer #}

View File

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

View File

@@ -149,4 +149,7 @@ document.addEventListener('keydown', function(e) {
} }
}); });
</script> </script>
<!-- Responsive Modal Navigation -->
<script src="{{ url_for('static', filename='js/responsive-modals.js') }}"></script>
{% endblock %} {% endblock %}