From 2419dbeb3438c0bad0861bd8c62d0448016a7100 Mon Sep 17 00:00:00 2001 From: Phillip Tarrant Date: Tue, 25 Nov 2025 21:30:51 -0600 Subject: [PATCH] feat(web): implement responsive modal pattern for mobile-friendly NPC chat MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- public_web/app/views/game_views.py | 58 ++- public_web/docs/RESPONSIVE_MODALS.md | 380 ++++++++++++++++++ public_web/static/css/play.css | 169 ++++++++ public_web/static/js/responsive-modals.js | 81 ++++ public_web/templates/game/npc_chat_page.html | 44 ++ .../game/partials/npc_chat_content.html | 120 ++++++ .../game/partials/npc_chat_modal.html | 118 +----- .../templates/game/partials/sidebar_npcs.html | 7 +- public_web/templates/game/play.html | 3 + 9 files changed, 861 insertions(+), 119 deletions(-) create mode 100644 public_web/docs/RESPONSIVE_MODALS.md create mode 100644 public_web/static/js/responsive-modals.js create mode 100644 public_web/templates/game/npc_chat_page.html create mode 100644 public_web/templates/game/partials/npc_chat_content.html diff --git a/public_web/app/views/game_views.py b/public_web/app/views/game_views.py index 60cd446..d2bb2d4 100644 --- a/public_web/app/views/game_views.py +++ b/public_web/app/views/game_views.py @@ -690,10 +690,66 @@ def do_travel(session_id: str): return f'
Travel failed: {e}
', 500 +@game_bp.route('/session//npc/') +@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//npc//chat') @require_auth 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() try: diff --git a/public_web/docs/RESPONSIVE_MODALS.md b/public_web/docs/RESPONSIVE_MODALS.md new file mode 100644 index 0000000..f8768ee --- /dev/null +++ b/public_web/docs/RESPONSIVE_MODALS.md @@ -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 #} +
+ + +
+``` + +### 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//feature/') +@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//feature//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 %} + +{% endblock %} + +{% block content %} +
+ {# Page Header with Back Button #} +
+ + + Back + +

{{ feature.name }}

+
+ + {# Include shared content #} + {% include 'game/partials/feature_content.html' %} +
+{% endblock %} + +{% block scripts %} + +{% 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 #} + +``` + +### 5. Add Responsive Navigation + +Update the trigger element to use responsive navigation JavaScript. + +**File**: `templates/game/partials/sidebar_features.html` + +```html +
+
{{ feature.name }}
+
+``` + +### 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 %} + + + + + +{% 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//npc/` (public_web/app/views/game_views.py:695) +- Modal: `/game/session//npc//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 diff --git a/public_web/static/css/play.css b/public_web/static/css/play.css index 55cc7be..333992b 100644 --- a/public_web/static/css/play.css +++ b/public_web/static/css/play.css @@ -1699,6 +1699,26 @@ 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 */ @media (max-width: 700px) { .npc-modal-body { @@ -1805,3 +1825,152 @@ .chat-history::-webkit-scrollbar-thumb:hover { 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; + } +} diff --git a/public_web/static/js/responsive-modals.js b/public_web/static/js/responsive-modals.js new file mode 100644 index 0000000..3cf5e82 --- /dev/null +++ b/public_web/static/js/responsive-modals.js @@ -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); +}); diff --git a/public_web/templates/game/npc_chat_page.html b/public_web/templates/game/npc_chat_page.html new file mode 100644 index 0000000..854ddc0 --- /dev/null +++ b/public_web/templates/game/npc_chat_page.html @@ -0,0 +1,44 @@ +{% extends "base.html" %} + +{% block title %}{{ npc.name }} - Code of Conquest{% endblock %} + +{% block extra_head %} + + +{% endblock %} + +{% block content %} +
+ {# Page Header with Back Button #} +
+ + + + + Back + +

{{ npc.name }}

+
+ + {# Include shared NPC chat content #} + {% include 'game/partials/npc_chat_content.html' %} +
+{% endblock %} + +{% block scripts %} + +{% endblock %} diff --git a/public_web/templates/game/partials/npc_chat_content.html b/public_web/templates/game/partials/npc_chat_content.html new file mode 100644 index 0000000..318b223 --- /dev/null +++ b/public_web/templates/game/partials/npc_chat_content.html @@ -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 +#} +
+ {# Left Column: NPC Profile #} +
+ {# NPC Portrait #} +
+ {% if npc.image_url %} + {{ npc.name }} + {% else %} +
+ {{ npc.name[0] }} +
+ {% endif %} +
+ + {# NPC Info #} +
+
{{ npc.role }}
+ {% if npc.appearance %} +
{{ npc.appearance }}
+ {% endif %} +
+ + {# NPC Tags #} + {% if npc.tags %} +
+ {% for tag in npc.tags %} + {{ tag }} + {% endfor %} +
+ {% endif %} + + {# Relationship Meter #} +
+
+ Relationship + {{ relationship_level|default(50) }}/100 +
+
+
+
+
+ {% set level = relationship_level|default(50) %} + {% if level >= 80 %} + Trusted Ally + {% elif level >= 60 %} + Friendly + {% elif level >= 40 %} + Neutral + {% elif level >= 20 %} + Wary + {% else %} + Hostile + {% endif %} +
+
+ + {# Interaction Stats #} +
+
+ Conversations + {{ interaction_count|default(0) }} +
+
+
+ + {# Center Column: Conversation #} +
+ {# Conversation History #} +
+ {% if conversation_history %} + {% for msg in conversation_history %} +
+ {{ msg.speaker_name }}: {{ msg.text }} +
+ {% endfor %} + {% else %} +
+ Start a conversation with {{ npc.name }} +
+ {% endif %} +
+ + {# Chat Input #} +
+ + + +
+
+ + {# Right Column: Message History Sidebar #} + +
diff --git a/public_web/templates/game/partials/npc_chat_modal.html b/public_web/templates/game/partials/npc_chat_modal.html index 8bbb574..0cca601 100644 --- a/public_web/templates/game/partials/npc_chat_modal.html +++ b/public_web/templates/game/partials/npc_chat_modal.html @@ -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 #}