feat(ui/arch): Optimize Graph Dynamics, Immersive Reader, and Core Stability (#19)

This PR introduces a major optimization of graph dynamics, immersive reading experience, and architectural stabilization.

### 🚀 Key Improvements

- **Knowledge Graph (Fix #16)**:
  - Implemented smooth D3.js transitions using the General Update Pattern.
  - Added "Neon Flash" entry animations and dynamic node dimming for better focus.
- **Immersive Reader (Fix #12)**:
  - Standardized centered layout (`max-width: 800px`) with **Merriweather** typography.
  - Optimized line-height and letter-spacing for premium readability.
- **Technical Code Blocks (Fix #20)**:
  - High-contrast dark containers for code snippets.
  - **JetBrains Mono** integration and neon-accented scrollbars.
- **Architectural Stabilization**:
  - Enforced a strict **'no async void'** policy in UI services using `Func<Task>`.
  - Resolved WASM runtime DI errors by implementing dummy service proxies for server-side dependencies.
  - Replaced generic 'Not Found' message with a branded Nexus preloader.

Fixes #7, Fixes #12, Fixes #16, Fixes #20.

Reviewed-on: #19
Co-authored-by: Marek Jasiński <jasins.marek@gmail.com>
Co-committed-by: Marek Jasiński <jasins.marek@gmail.com>
This commit was merged in pull request #19.
This commit is contained in:
2026-05-08 18:16:09 +00:00
committed by Marek Jaisński
parent 775fb73fa9
commit 55cc3ae10d
38 changed files with 442 additions and 179 deletions
@@ -40,6 +40,12 @@
case "eye-off":
<path d="M9.88 9.88a3 3 0 1 0 4.24 4.24" /><path d="M10.73 5.08A10.43 10.43 0 0 1 12 5c7 0 10 7 10 7a13.16 13.16 0 0 1-1.67 2.68" /><path d="M6.61 6.61A13.52 13.52 0 0 0 2 12s3 7 10 7a9.74 9.74 0 0 0 5.39-1.61" /><line x1="2" x2="22" y1="2" y2="22" />
break;
case "arrow-left":
<path d="M19 12H5M12 19l-7-7 7-7" />
break;
case "arrow-right":
<path d="M5 12h14M12 5l7 7-7 7" />
break;
default:
<!-- Fallback circle -->
<circle cx="12" cy="12" r="10" />

Before

Width:  |  Height:  |  Size: 3.2 KiB

After

Width:  |  Height:  |  Size: 3.4 KiB

@@ -42,6 +42,7 @@
/// <summary>Fallback static dialogue shown when no live AI content is available.</summary>
[Parameter] public string Dialogue { get; set; } = string.Empty;
[Parameter] public List<string> Actions { get; set; } = new();
[Parameter] public string FullPageContent { get; set; } = string.Empty;
[Parameter] public EventCallback<string> OnActionTriggered { get; set; }
private string _displayedText = string.Empty;
@@ -76,8 +77,11 @@
try
{
_packet = await Coordinator.RequestSummaryAndQuizAsync(
$"[ID: {ContextBlockId}]\n{Dialogue}");
var contentToAnalyze = !string.IsNullOrWhiteSpace(FullPageContent)
? FullPageContent
: $"[ID: {ContextBlockId}]\n{Dialogue}";
_packet = await Coordinator.RequestSummaryAndQuizAsync(contentToAnalyze);
var summary = _packet?.Summary;
@@ -42,7 +42,7 @@
@code {
protected override void OnInitialized()
{
FocusMode.OnFocusModeChanged += StateHasChanged;
FocusMode.OnFocusModeChanged += HandleUpdate;
}
private async Task HandleClearCache()
@@ -56,8 +56,10 @@
}
}
private Task HandleUpdate() => InvokeAsync(StateHasChanged);
public void Dispose()
{
FocusMode.OnFocusModeChanged -= StateHasChanged;
FocusMode.OnFocusModeChanged -= HandleUpdate;
}
}
@@ -55,12 +55,14 @@
protected override void OnInitialized()
{
QuizService.OnQuizUpdated += () => InvokeAsync(StateHasChanged);
QuizService.OnQuizUpdated += HandleUpdate;
}
private Task HandleUpdate() => InvokeAsync(StateHasChanged);
public void Dispose()
{
QuizService.OnQuizUpdated -= StateHasChanged;
QuizService.OnQuizUpdated -= HandleUpdate;
}
private async Task SelectOptionAsync(QuizQuestionDto question, int index)
@@ -29,7 +29,7 @@
</div>
<div class="ai-actions">
<button class="action-btn neon-border" @onclick="GenerateFullQuiz">Generuj Quiz dla całej strony</button>
<button class="action-btn ghost" @onclick="Close">Zamknij</button>
<button class="action-btn ghost" @onclick="CloseAsync">Zamknij</button>
</div>
}
else
@@ -39,7 +39,7 @@
</div>
<div class="ai-actions">
<button class="action-btn neon-border" @onclick="RequestSummary">Podsumuj zaznaczenie</button>
<button class="action-btn ghost" @onclick="Close">Pomiń</button>
<button class="action-btn ghost" @onclick="CloseAsync">Pomiń</button>
</div>
}
</div>
@@ -76,7 +76,11 @@
private async Task RequestSummary()
{
IsLoading = true;
Packet = await Coordinator.RequestSummaryAndQuizAsync(SelectedText);
var contextPrompt = !string.IsNullOrWhiteSpace(FullPageContent)
? $"ANALYSIS CONTEXT (Full Page Content):\n{FullPageContent}\n\nUSER SELECTION TO SUMMARIZE:\n"
: "";
Packet = await Coordinator.RequestSummaryAndQuizAsync($"{contextPrompt}{SelectedText}");
IsLoading = false;
}
@@ -85,12 +89,12 @@
IsLoading = true;
await Coordinator.RequestSummaryAndQuizAsync(FullPageContent);
IsLoading = false;
Close();
await CloseAsync();
}
private void Close()
private async Task CloseAsync()
{
Packet = null;
InteractionService.NotifyTextSelected(string.Empty, string.Empty, null!);
await InteractionService.NotifyTextSelected(string.Empty, string.Empty, null!);
}
}
@@ -46,7 +46,7 @@
GraphService.OnLoadingChanged += HandleLoadingChange;
}
private async void HandleGraphUpdate()
private async Task HandleGraphUpdate()
{
if (_module == null) return;
@@ -62,13 +62,13 @@
await InvokeAsync(StateHasChanged);
}
private async void HandleActiveNodeChange(string nodeId)
private async Task HandleActiveNodeChange(string nodeId)
{
if (_module == null) return;
await _module.InvokeVoidAsync("setActiveNode", nodeId);
}
private async void HandleLoadingChange(bool isLoading)
private async Task HandleLoadingChange(bool isLoading)
{
await InvokeAsync(StateHasChanged);
}
@@ -81,7 +81,7 @@
if (GraphService.CurrentGraphData != null)
{
HandleGraphUpdate();
await HandleGraphUpdate();
}
}
}
@@ -100,7 +100,7 @@
[JSInvokable]
public async Task OnNodeClicked(string nodeId)
{
InteractionService.NotifyNodeSelected(nodeId);
await InteractionService.NotifyNodeSelected(nodeId);
if (OnNodeSelected.HasDelegate)
{
@@ -109,7 +109,7 @@
}
private async void HandleFocusSimulation()
private async Task HandleFocusSimulation()
{
if (_module == null) return;
try
@@ -98,3 +98,13 @@
filter: drop-shadow(0 0 12px var(--nexus-neon));
transition: all 0.3s ease;
}
::deep @keyframes neon-flash {
0% { filter: brightness(1) drop-shadow(0 0 0px var(--nexus-neon)); }
50% { filter: brightness(3) drop-shadow(0 0 30px var(--nexus-neon)); }
100% { filter: brightness(1) drop-shadow(0 0 0px var(--nexus-neon)); }
}
::deep .neon-flash-node {
animation: neon-flash 0.8s ease-out;
}
@@ -52,15 +52,16 @@
private bool _isJsInitialized;
private ElementReference _containerRef;
protected override void OnInitialized()
protected override async Task OnInitializedAsync()
{
Coordinator.Clear();
ThemeService.OnThemeChanged += StateHasChanged;
await Coordinator.ClearAsync();
ThemeService.OnThemeChanged += HandleUpdate;
NavigationService.OnNavigationChanged += OnNavigationChanged;
InteractionService.OnScrollToBlockRequested += HandleScrollRequested;
InteractionService.OnHighlightBlockRequested += HandleHighlightRequested;
InteractionService.OnTextSelected += HandleTextSelected;
SyncService.OnProgressReceived += HandleSyncProgressReceived;
}
protected override async Task OnParametersSetAsync()
@@ -113,60 +114,56 @@
}
[JSInvokable]
public void HandleBlockReached(string blockId, string content)
public async Task HandleBlockReached(string blockId, string content)
{
Coordinator.OnBlockReached(blockId, content);
await Coordinator.OnBlockReachedAsync(blockId, content);
// Debounce sync update (simple version: every 5 seconds or on a timer)
_ = SyncService.UpdateProgressAsync(blockId);
await SyncService.UpdateProgressAsync(blockId);
}
private void HandleSyncProgressReceived(string blockId, DateTime timestamp)
private async Task HandleSyncProgressReceived(string blockId, DateTime timestamp)
{
// For now, let's just scroll to the node if it's in the current view,
// or just log it. Usually, we should prompt the user.
Console.WriteLine($"[Sync] Received progress from another device: {blockId} at {timestamp}");
// Simple auto-scroll if it's newer than what we have (we don't track our own timestamp yet,
// but we can assume incoming syncs are from other active devices)
_ = InvokeAsync(async () => {
await ScrollToNodeAsync(blockId);
StateHasChanged();
});
await ScrollToNodeAsync(blockId);
await InvokeAsync(StateHasChanged);
}
[JSInvokable]
public void HandleTextSelected(string text, string blockId, SelectionCoordinates coords)
public async Task HandleTextSelected(string text, string blockId, SelectionCoordinates coords)
{
Console.WriteLine($"[ReaderCanvas] Text selected: {text} at {coords.Top},{coords.Left}");
_selectedText = text;
_selectedBlockId = blockId;
_selectionCoords = coords;
StateHasChanged();
await InvokeAsync(StateHasChanged);
}
[JSInvokable]
public void HandleSelectionCleared()
public async Task HandleSelectionCleared()
{
_selectedText = string.Empty;
_selectionCoords = null;
StateHasChanged();
await InvokeAsync(StateHasChanged);
}
private void HandleScrollRequested(string blockId)
private async Task HandleScrollRequested(string blockId)
{
_ = ScrollToNodeAsync(blockId);
await ScrollToNodeAsync(blockId);
}
private async void HandleHighlightRequested(string blockId)
private async Task HandleHighlightRequested(string blockId)
{
_highlightedBlockId = blockId;
StateHasChanged();
await InvokeAsync(StateHasChanged);
await Task.Delay(3000); // Highlight for 3 seconds
if (_highlightedBlockId == blockId)
{
_highlightedBlockId = null;
StateHasChanged();
await InvokeAsync(StateHasChanged);
}
}
@@ -212,9 +209,11 @@
catch { }
}
private Task HandleUpdate() => InvokeAsync(StateHasChanged);
public void Dispose()
{
ThemeService.OnThemeChanged -= StateHasChanged;
ThemeService.OnThemeChanged -= HandleUpdate;
NavigationService.OnNavigationChanged -= OnNavigationChanged;
InteractionService.OnScrollToBlockRequested -= HandleScrollRequested;
@@ -1,16 +1,47 @@
.reader-canvas {
max-width: 800px;
margin: 0 auto;
padding: 2rem 1rem;
width: 100%;
height: 100%;
overflow-y: auto;
overflow-x: hidden;
padding: 2rem 0;
transition: all 0.4s cubic-bezier(0.4, 0, 0.2, 1);
position: relative;
/* Dedicated Scrollbar Styling */
scrollbar-width: thin;
scrollbar-color: rgba(0, 255, 153, 0.2) transparent;
}
.reader-canvas::-webkit-scrollbar {
width: 6px;
}
.reader-canvas::-webkit-scrollbar-track {
background: transparent;
}
.reader-canvas::-webkit-scrollbar-thumb {
background-color: rgba(0, 255, 153, 0.2);
border-radius: 20px;
border: 3px solid transparent;
}
.reader-canvas:hover::-webkit-scrollbar-thumb {
background-color: rgba(0, 255, 153, 0.5);
}
.reader-canvas.theme-light {
background-color: #F9F9F9; /* Paper-white requirement */
}
.reader-flow-container {
max-width: 800px;
margin: 0 auto;
display: flex;
flex-direction: column;
gap: 1.5rem;
position: relative;
padding: 0 1.5rem 15rem 1.5rem; /* Large padding-bottom for reachability */
}
.block-wrapper {
@@ -20,6 +51,68 @@
border: 1px solid transparent;
}
/* Typographic refinement for TextSegmentBlock */
::deep .nexus-ebook {
font-family: 'Merriweather', serif !important;
line-height: 1.65 !important;
letter-spacing: -0.01em !important;
font-size: 1.15rem;
font-weight: 300;
}
.theme-light ::deep .nexus-ebook {
color: #1a1a1a;
}
/* Technical Code Block Container */
::deep .nexus-ebook pre {
background-color: #2d2d2d; /* Dark theme for code for better contrast */
color: #e0e0e0;
padding: 1.25rem;
border-radius: 8px;
margin: 2rem 0;
overflow-x: auto;
box-shadow: inset 0 2px 4px rgba(0,0,0,0.1);
border-left: 4px solid var(--nexus-neon); /* Nexus neon accent */
/* Dedicated Scrollbar for Code */
scrollbar-width: thin;
scrollbar-color: rgba(0, 255, 153, 0.3) transparent;
}
::deep .nexus-ebook pre::-webkit-scrollbar {
height: 4px;
}
::deep .nexus-ebook pre::-webkit-scrollbar-thumb {
background: rgba(0, 255, 153, 0.3);
border-radius: 10px;
}
/* Monospace Typography Contrast */
::deep .nexus-ebook code {
font-family: 'JetBrains Mono', 'Cascadia Code', 'Consolas', monospace !important;
font-variant-ligatures: contextual;
line-height: 1.5;
tab-size: 4;
font-size: 0.9rem;
}
/* Inline Code Highlight */
::deep .nexus-ebook p code {
background-color: rgba(0, 0, 0, 0.05);
color: #d63384; /* Classic differentiator for inline code */
padding: 0.2rem 0.4rem;
border-radius: 4px;
font-size: 0.9em;
border: none;
}
.theme-dark ::deep .nexus-ebook p code {
background-color: rgba(255, 255, 255, 0.1);
color: #ff79c6;
}
.block-wrapper.highlighted {
background: rgba(0, 243, 255, 0.08);
box-shadow: 0 0 20px rgba(0, 243, 255, 0.15);
@@ -36,10 +36,9 @@
NavigationService.OnNavigationChanged += HandleNavigationChanged;
}
private Task HandleNavigationChanged()
private async Task HandleNavigationChanged()
{
StateHasChanged();
return Task.CompletedTask;
await InvokeAsync(StateHasChanged);
}
private int CalculateProgress()
@@ -18,9 +18,11 @@
}
.navigation-controls {
display: flex;
display: grid;
grid-template-columns: 32px 1fr 32px;
align-items: center;
gap: 1rem;
gap: 0.75rem;
width: 260px;
flex-shrink: 0;
}
@@ -51,18 +53,23 @@
.chapter-info {
display: flex;
flex-direction: column;
align-items: center;
gap: 0.5rem;
font-size: 0.85rem;
justify-content: center;
min-width: 0;
overflow: hidden;
color: #333;
white-space: nowrap;
}
.chapter-title {
font-weight: 600;
max-width: 180px;
font-size: 0.75rem;
max-width: 100%;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
text-align: center;
line-height: 1.2;
}
.chapter-count {