style(ui): refactor reader layout grid, fix focus mode layout collapse, fix SVG rendering dots, reorganize intelligence toolbar #69

Merged
mjasin merged 10 commits from feature/reader-visual-refactor into develop 2026-06-05 09:51:29 +00:00
7 changed files with 71 additions and 65 deletions
Showing only changes of commit 46cc119c81 - Show all commits
+10
View File
@@ -36,3 +36,13 @@ Run test suite:
```bash ```bash
dotnet test --no-restore dotnet test --no-restore
``` ```
## 🗄️ Database Migrations
Automatic database migrations at startup (`MigrateAsync()`) have been disabled to ensure compatibility with Native AOT compilation and prevent locking issues in multi-instance environments.
To apply database migrations locally, run the EF Core migration command from the solution root:
```bash
dotnet ef database update --project src/NexusReader.Infrastructure --startup-project src/NexusReader.Web
```
@@ -61,7 +61,7 @@
private Task HandleUpdate() => InvokeAsync(StateHasChanged); private Task HandleUpdate() => InvokeAsync(StateHasChanged);
private async Task HandleThemeChangedAsync() => await InvokeAsync(StateHasChanged); private Task HandleThemeChangedAsync() => InvokeAsync(StateHasChanged);
mjasin marked this conversation as resolved Outdated
Outdated
Review

🟢 Minor/Suggestion: The method HandleThemeChangedAsync does not need to be marked as async Task since it only awaits a single task and does not contain other logic.

You can simplify it to avoid state machine generation overhead:

private Task HandleThemeChangedAsync() => InvokeAsync(StateHasChanged);
🟢 Minor/Suggestion: The method `HandleThemeChangedAsync` does not need to be marked as `async Task` since it only awaits a single task and does not contain other logic. You can simplify it to avoid state machine generation overhead: ```csharp private Task HandleThemeChangedAsync() => InvokeAsync(StateHasChanged); ```
public void Dispose() public void Dispose()
{ {
@@ -82,41 +82,39 @@
/* Light mode overrides */ /* Light mode overrides */
.theme-light .intelligence-toolbar { .theme-light .intelligence-toolbar {
background: #ffffff; background: #f5f5f4;
border-right: 1px solid rgba(0, 0, 0, 0.08); border-right: 1px solid rgba(0, 0, 0, 0.08);
box-shadow: inset -1px 0 0 rgba(0,0,0,0.02); box-shadow: inset -2px 0 10px rgba(0, 0, 0, 0.02);
} }
.theme-light .toolbar-item { .theme-light .toolbar-item {
color: #71717a; color: #78716c;
} }
.theme-light .toolbar-item:hover { .theme-light .toolbar-item:hover {
color: #10b981; color: #10b981;
background: rgba(16, 185, 129, 0.05); background: rgba(16, 185, 129, 0.05);
box-shadow: 0 0 15px rgba(16, 185, 129, 0.15); box-shadow: 0 0 10px rgba(16, 185, 129, 0.1);
filter: drop-shadow(0 0 5px rgba(16, 185, 129, 0.2)); filter: none;
} }
.theme-light .toolbar-item.active { .theme-light .toolbar-item.active {
color: #10b981; color: #10b981;
background: rgba(16, 185, 129, 0.08); background: rgba(16, 185, 129, 0.08);
box-shadow: 0 0 20px rgba(16, 185, 129, 0.25); box-shadow: 0 0 15px rgba(16, 185, 129, 0.15);
filter: drop-shadow(0 0 8px rgba(16, 185, 129, 0.3)); filter: none;
} }
.theme-light .toolbar-item.active::after { .theme-light .toolbar-item.active::after {
background: #10b981; background: #10b981;
box-shadow: 0 0 8px rgba(16, 185, 129, 0.5); box-shadow: none;
} }
.theme-light .toolbar-item.focus-active { .theme-light .toolbar-item.focus-active {
color: #10b981; color: #10b981;
filter: drop-shadow(0 0 8px rgba(16, 185, 129, 0.3)); filter: none;
} }
.theme-light .toolbar-separator { .theme-light .toolbar-separator {
background: rgba(0, 0, 0, 0.08); background: rgba(0, 0, 0, 0.08);
} }
@@ -99,6 +99,7 @@
private bool _isMobile = false; private bool _isMobile = false;
private DotNetObjectReference<ReaderCanvas>? _selfReference; private DotNetObjectReference<ReaderCanvas>? _selfReference;
private IJSObjectReference? _viewportModule; private IJSObjectReference? _viewportModule;
private IJSObjectReference? _selectionModule;
protected override async Task OnInitializedAsync() protected override async Task OnInitializedAsync()
{ {
@@ -201,10 +202,13 @@
{ {
try try
{ {
var module = await JS.InvokeAsync<IJSObjectReference>("import", "./_content/NexusReader.UI.Shared/js/selectionHandler.js"); if (_selectionModule == null)
{
_selectionModule = await JS.InvokeAsync<IJSObjectReference>("import", "./_content/NexusReader.UI.Shared/js/selectionHandler.js");
}
if (_selfReference != null) if (_selfReference != null)
{ {
await module.InvokeVoidAsync("initSelectionListener", _selfReference, _containerRef); await _selectionModule.InvokeVoidAsync("initSelectionListener", _selfReference, _containerRef);
} }
} }
catch (Exception ex) catch (Exception ex)
1
@@ -440,6 +444,19 @@
InteractionService.OnTextSelected -= HandleTextSelected; InteractionService.OnTextSelected -= HandleTextSelected;
SyncService.OnProgressReceived -= HandleSyncProgressReceived; SyncService.OnProgressReceived -= HandleSyncProgressReceived;
try
{
if (_selectionModule != null)
{
await _selectionModule.InvokeVoidAsync("destroySelectionListener");
await _selectionModule.DisposeAsync();
}
}
catch (Exception ex)
{
Logger.LogDebug(ex, "Failed to destroy JS selection listener.");
}
try try
{ {
if (_viewportModule != null) if (_viewportModule != null)
1
@@ -233,6 +233,10 @@ public sealed partial class KnowledgeCoordinator : IDisposable, IAsyncDisposable
{ {
SelectionSummary = result.Value.Summary; SelectionSummary = result.Value.Summary;
} }
else
{
_logger.LogWarning("[KnowledgeCoordinator] Selection summary request failed: {Errors}", string.Join(", ", result.Errors.Select(e => e.Message)));
}
} }
finally finally
{ {
+2 -48
View File
@@ -176,52 +176,6 @@
--nexus-accent: #10b981; --nexus-accent: #10b981;
} }
/* Scoped Component overrides for Light Mode (Bypassing Blazor CSS isolation) */
.theme-light .intelligence-toolbar {
background: #f5f5f4 !important;
border-right: 1px solid rgba(0, 0, 0, 0.08) !important;
box-shadow: inset -2px 0 10px rgba(0, 0, 0, 0.02) !important;
}
.theme-light .intelligence-toolbar .toolbar-item {
color: #78716c !important;
}
.theme-light .intelligence-toolbar .toolbar-item:hover {
color: #10b981 !important;
background: rgba(16, 185, 129, 0.05) !important;
box-shadow: 0 0 10px rgba(16, 185, 129, 0.1) !important;
filter: none !important;
}
.theme-light .intelligence-toolbar .toolbar-item.active {
color: #10b981 !important;
background: rgba(16, 185, 129, 0.08) !important;
box-shadow: 0 0 15px rgba(16, 185, 129, 0.15) !important;
filter: none !important;
}
.theme-light .intelligence-toolbar .toolbar-item.active::after {
background: #10b981 !important;
box-shadow: none !important;
}
.theme-light .intelligence-toolbar .toolbar-item.focus-active {
color: #10b981 !important;
filter: none !important;
}
.theme-light .intelligence-toolbar .toolbar-item.logout-item {
border-top: 1px solid rgba(0, 0, 0, 0.08) !important;
color: #a8a29e !important;
}
.theme-light .intelligence-toolbar .toolbar-item.logout-item:hover {
color: #ef4444 !important;
filter: none !important;
}
.theme-light .knowledge-graph-container svg { .theme-light .knowledge-graph-container svg {
background: radial-gradient(circle, #ffffff 0%, #e8e4da 100%) !important; background: radial-gradient(circle, #ffffff 0%, #e8e4da 100%) !important;
} }
mjasin marked this conversation as resolved
Review

🟡 Design/Architecture: Global CSS overrides for light-mode component colors (like .theme-light .intelligence-toolbar) violate the architectural separation of concerns.

Component-specific styling should live inside their respective scoped stylesheets (e.g., IntelligenceToolbar.razor.css) rather than the global app.css. Using !important here also creates styling conflicts (e.g., the scoped stylesheet has a background of #ffffff, but this global rule overrides it with #f5f5f4).

Please move these overrides into IntelligenceToolbar.razor.css.

🟡 Design/Architecture: Global CSS overrides for light-mode component colors (like `.theme-light .intelligence-toolbar`) violate the architectural separation of concerns. Component-specific styling should live inside their respective scoped stylesheets (e.g., `IntelligenceToolbar.razor.css`) rather than the global `app.css`. Using `!important` here also creates styling conflicts (e.g., the scoped stylesheet has a background of `#ffffff`, but this global rule overrides it with `#f5f5f4`). Please move these overrides into `IntelligenceToolbar.razor.css`.
@@ -350,14 +304,14 @@ body {
/* Platform Specific Tweaks */ /* Platform Specific Tweaks */
.platform-mobile .nexus-button { .platform-mobile .nexus-btn {
min-height: var(--touch-target-size); min-height: var(--touch-target-size);
min-width: var(--touch-target-size); min-width: var(--touch-target-size);
font-size: 1.1rem; font-size: 1.1rem;
padding: 12px 24px; padding: 12px 24px;
} }
.platform-desktop .nexus-button { .platform-desktop .nexus-btn {
min-height: 36px; min-height: 36px;
font-size: 0.9rem; font-size: 0.9rem;
padding: 8px 16px; padding: 8px 16px;
1
@@ -54,11 +54,17 @@ export function positionToolbar() {
below: below below: below
}; };
} }
let currentHandleSelection = null;
let currentMouseUpHandler = null;
let currentContainer = null;
export function initSelectionListener(dotNetHelper, container) { export function initSelectionListener(dotNetHelper, container) {
if (!container) return; if (!container) return;
console.log("[SelectionHandler] Initializing..."); console.log("[SelectionHandler] Initializing...");
// Clean up any existing listeners first
destroySelectionListener();
const handleSelection = () => { const handleSelection = () => {
const selection = window.getSelection(); const selection = window.getSelection();
1
@@ -104,9 +110,26 @@ export function initSelectionListener(dotNetHelper, container) {
} }
}; };
// Use multiple triggers for maximum reliability const mouseUpHandler = () => setTimeout(handleSelection, 10);
document.addEventListener('selectionchange', handleSelection);
container.addEventListener('mouseup', () => setTimeout(handleSelection, 10)); currentHandleSelection = handleSelection;
currentMouseUpHandler = mouseUpHandler;
currentContainer = container;
document.addEventListener('selectionchange', currentHandleSelection);
currentContainer.addEventListener('mouseup', currentMouseUpHandler);
}
export function destroySelectionListener() {
if (currentHandleSelection) {
document.removeEventListener('selectionchange', currentHandleSelection);
currentHandleSelection = null;
}
if (currentMouseUpHandler && currentContainer) {
currentContainer.removeEventListener('mouseup', currentMouseUpHandler);
currentMouseUpHandler = null;
currentContainer = null;
}
} }
export function getSelectionText() { export function getSelectionText() {