();
/**
* Always starts with DIFF_EXAMPLES[0] so server-prerendered HTML and the
@@ -118,13 +119,17 @@ export class AnalysisPanelComponent {
onEscape(event: Event): void {
const target = event.target as Element | null;
if (target?.closest?.('.cdk-overlay-container')) return;
- this.close(true);
+ this.closeAndRestoreFocus();
}
- close(restoreFocus = false): void {
- if (!this.open()) return;
+ close(): void {
this.open.set(false);
- if (restoreFocus) this.toggleButton().nativeElement.focus();
+ }
+
+ closeAndRestoreFocus(): void {
+ if (!this.open()) return;
+ this.close();
+ this.restoreFocusRequested.emit();
}
/** Heading for the selected-key section, branching on provenance not mechanism. */
diff --git a/packages/ui/src/app/components/array-matching/array-matching.component.css b/packages/ui/src/app/components/array-matching/array-matching.component.css
index c484bdd..80e1a08 100644
--- a/packages/ui/src/app/components/array-matching/array-matching.component.css
+++ b/packages/ui/src/app/components/array-matching/array-matching.component.css
@@ -20,7 +20,7 @@ app-array-picker {
.toggle-row {
display: flex;
- align-items: flex-start;
+ align-items: center;
gap: 9px;
border-radius: var(--radius);
padding: 6px 8px;
@@ -36,7 +36,7 @@ app-array-picker {
}
.toggle-row input {
- margin: 1px 0 0;
+ margin: 0;
accent-color: var(--primary);
cursor: pointer;
}
@@ -47,6 +47,7 @@ app-array-picker {
}
.toggle-text {
+ flex: 1;
display: flex;
flex-direction: column;
gap: 2px;
@@ -58,9 +59,29 @@ app-array-picker {
font: 500 12px var(--sans);
}
-.toggle-hint {
- color: var(--muted-foreground);
- font: 11px/1.4 var(--sans);
+.hint-icon {
+ flex: none;
+ width: 16px;
+ height: 16px;
+ display: grid;
+ place-items: center;
+ border: 1px solid var(--border);
+ border-radius: 999px;
+ background: transparent;
+ color: var(--subtle-foreground);
+ font: 600 10px var(--sans);
+ line-height: 1;
+ cursor: help;
+}
+
+.hint-icon:hover {
+ color: var(--foreground);
+ border-color: var(--border-strong);
+}
+
+.hint-icon:focus-visible {
+ outline: 2px solid var(--primary);
+ outline-offset: 2px;
}
.field-list {
@@ -171,7 +192,8 @@ app-array-picker {
}
.matching-fieldset:disabled .toggle-row,
-.matching-fieldset:disabled .toggle-row input {
+.matching-fieldset:disabled .toggle-row input,
+.matching-fieldset:disabled .hint-icon {
cursor: not-allowed;
}
diff --git a/packages/ui/src/app/components/array-matching/array-matching.component.html b/packages/ui/src/app/components/array-matching/array-matching.component.html
index 46d8112..6aa2e1e 100644
--- a/packages/ui/src/app/components/array-matching/array-matching.component.html
+++ b/packages/ui/src/app/components/array-matching/array-matching.component.html
@@ -6,24 +6,45 @@
Auto
- JSON Semantic Diff chooses the best identity.
+
@@ -38,16 +59,30 @@
Auto
- JSON Semantic Diff chooses the best identity.
+
@if (strategy() === 'key') {
@@ -84,8 +119,15 @@
Position
- Compare elements by array position.
+
@if (context.analysis.duplicateKeyCount) {
diff --git a/packages/ui/src/app/components/array-matching/array-matching.component.ts b/packages/ui/src/app/components/array-matching/array-matching.component.ts
index 758d65b..3dfcd77 100644
--- a/packages/ui/src/app/components/array-matching/array-matching.component.ts
+++ b/packages/ui/src/app/components/array-matching/array-matching.component.ts
@@ -10,6 +10,7 @@ import {
} from 'json-semantic-diff';
import { percent } from '../../shared/format';
import { MatchingOverrideChange, overridePatternFor } from '../../shared/node-actions';
+import { TooltipDirective } from '../../shared/tooltip/tooltip.directive';
import { ArrayPickerComponent } from '../array-picker/array-picker.component';
/** One configurable array: its core analysis plus the node holding the rows. */
@@ -30,7 +31,7 @@ type Strategy = 'auto' | 'key' | 'position';
@Component({
selector: 'app-array-matching',
standalone: true,
- imports: [ArrayPickerComponent],
+ imports: [ArrayPickerComponent, TooltipDirective],
changeDetection: ChangeDetectionStrategy.OnPush,
templateUrl: './array-matching.component.html',
styleUrl: './array-matching.component.css'
diff --git a/packages/ui/src/app/components/sidebar/sidebar.component.css b/packages/ui/src/app/components/sidebar/sidebar.component.css
index 1203698..8be2750 100644
--- a/packages/ui/src/app/components/sidebar/sidebar.component.css
+++ b/packages/ui/src/app/components/sidebar/sidebar.component.css
@@ -5,28 +5,6 @@
min-height: 0;
}
-.sidebar-toggle {
- display: none;
- align-items: center;
- gap: 7px;
- border: 1px solid var(--border);
- border-radius: var(--radius);
- background: var(--card);
- color: var(--foreground);
- font: 500 12px var(--sans);
- padding: 8px 12px;
- cursor: pointer;
-}
-
-.sidebar-toggle:hover {
- background: var(--accent);
-}
-
-.sidebar-toggle:focus-visible {
- outline: 2px solid var(--primary);
- outline-offset: 2px;
-}
-
.sidebar-backdrop {
display: none;
}
@@ -62,7 +40,7 @@
display: flex;
flex-direction: column;
gap: 2px;
- margin-top: 32px;
+ margin-top: 20px;
}
.sidebar-section:first-of-type {
@@ -81,16 +59,37 @@
.toggle-row {
display: flex;
align-items: center;
- gap: 9px;
+ gap: 6px;
border-radius: var(--radius);
padding: 6px 8px;
cursor: pointer;
}
+/*
+ * Re-skinned native checkbox: keeps for free
+ * keyboard support/screen-reader semantics, appearance:none just strips the
+ * default box so a pill track + sliding thumb can be drawn instead.
+ */
.toggle-row input {
+ flex: none;
margin: 0;
- accent-color: var(--primary);
+ appearance: none;
+
+ /* Firefox (Gecko) doesn't render ::before/::after on at all unless
+ * -moz-appearance is explicitly reset - Chrome/Safari/Edge already allow it
+ * without this. Without this line the switch's thumb is invisible in Firefox. */
+ /* stylelint-disable-next-line property-no-vendor-prefix -- required, not accidental prefix creep; see comment above */
+ -moz-appearance: none;
+ width: 24px;
+ height: 14px;
+ border: 1px solid var(--border-strong);
+ border-radius: 999px;
+ background: var(--muted);
cursor: pointer;
+ position: relative;
+ transition:
+ background-color 0.15s ease,
+ border-color 0.15s ease;
}
.toggle-row:has(input:focus-visible) {
@@ -113,8 +112,8 @@
.hint-icon {
flex: none;
- width: 16px;
- height: 16px;
+ width: 14px;
+ height: 14px;
display: grid;
place-items: center;
border: 1px solid var(--border);
@@ -232,6 +231,35 @@
outline-color: var(--danger);
}
+/* Toggle-switch thumb/checked state; kept below .ignore-row's `input` rules to satisfy stylelint's no-descending-specificity ordering. */
+.toggle-row input::after {
+ content: '';
+ position: absolute;
+ top: 1px;
+ left: 1px;
+ width: 10px;
+ height: 10px;
+ border-radius: 50%;
+
+ /* var(--card) is a no-go here: it's identical to var(--muted) (the track's
+ * unchecked background) in dark theme, making the thumb invisible.
+ * var(--muted-foreground) contrasts against the track in both themes and
+ * both checked/unchecked states. */
+ background: var(--muted-foreground);
+ box-shadow: 0 1px 2px rgb(0 0 0 / 25%);
+ transition: transform 0.15s ease;
+}
+
+.toggle-row input:checked {
+ background: var(--primary);
+ border-color: var(--primary);
+}
+
+.toggle-row input:checked::after {
+ transform: translateX(10px);
+ background: #fff;
+}
+
.ignore-error {
margin-top: 4px;
font: 11px / 1.3 var(--sans);
@@ -367,7 +395,7 @@
display: flex;
align-items: center;
gap: 6px;
- font: 500 11px var(--mono);
+ font: 400 11px var(--mono);
color: var(--subtle-foreground);
letter-spacing: 0.02em;
}
@@ -402,10 +430,6 @@
height: auto;
}
- .sidebar-toggle {
- display: inline-flex;
- }
-
.sidebar-backdrop {
display: block;
position: fixed;
diff --git a/packages/ui/src/app/components/sidebar/sidebar.component.html b/packages/ui/src/app/components/sidebar/sidebar.component.html
index 196c08f..ad95787 100644
--- a/packages/ui/src/app/components/sidebar/sidebar.component.html
+++ b/packages/ui/src/app/components/sidebar/sidebar.component.html
@@ -1,14 +1,3 @@
-
-
@if (open()) {
@@ -16,7 +5,7 @@
}
diff --git a/packages/ui/src/app/home/home.component.ts b/packages/ui/src/app/home/home.component.ts
index c1474d4..3ed0b15 100644
--- a/packages/ui/src/app/home/home.component.ts
+++ b/packages/ui/src/app/home/home.component.ts
@@ -1,4 +1,15 @@
-import { AnimationCallbackEvent, ChangeDetectionStrategy, Component, afterNextRender, inject, signal } from '@angular/core';
+import {
+ AnimationCallbackEvent,
+ ChangeDetectionStrategy,
+ Component,
+ ElementRef,
+ afterNextRender,
+ computed,
+ effect,
+ inject,
+ signal,
+ viewChild
+} from '@angular/core';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { NavigationCancel, NavigationEnd, NavigationError, NavigationStart, Router } from '@angular/router';
import { filter } from 'rxjs';
@@ -15,11 +26,16 @@ import {
ANALYSIS_PANEL_MAX_WIDTH,
ANALYSIS_PANEL_MIN_WIDTH,
clampWidth,
+ readStoredAnalysisPanelCollapsed,
readStoredAnalysisPanelWidth,
+ storeAnalysisPanelCollapsed,
storeAnalysisPanelWidth
} from '../shared/resizable-panel';
import { WorkspaceStateService } from './workspace-state.service';
+/** Pointer movement (px) required before a resize-handle pointerdown counts as a drag rather than a click. */
+const PANEL_DRAG_THRESHOLD_PX = 4;
+
@Component({
selector: 'app-home',
standalone: true,
@@ -44,19 +60,85 @@ export class HomeComponent {
readonly analysisPanelWidth = signal(ANALYSIS_PANEL_DEFAULT_WIDTH);
readonly resizingAnalysisPanel = signal(false);
+ /**
+ * Below the drawer breakpoints, ``/`` no
+ * longer render their own trigger buttons - two lone buttons stranded at
+ * opposite edges of the screen, each in its own full-height flex column,
+ * read as bolted-on rather than intentional. Both triggers live together
+ * here instead, in `.mobile-panel-bar`, and drive each panel's drawer via
+ * `[(open)]`. See SidebarComponent's doc comment for the full rationale.
+ */
+ readonly sidebarDrawerOpen = signal(false);
+ readonly analysisDrawerOpen = signal(false);
+
+ private readonly sidebarToggleButton = viewChild>('sidebarToggleButton');
+ private readonly analysisToggleButton = viewChild>('analysisToggleButton');
+
+ /**
+ * Collapsing hides the panel without discarding `analysisPanelWidth` - the
+ * width the user dragged it to is preserved so expanding (via the handle's
+ * chevron, or by dragging the handle itself) restores exactly what they
+ * had. Kept separate from `analysisPanelWidth` rather than overloading it
+ * with a 0 sentinel, which would lose that width on collapse.
+ */
+ /**
+ * Starts `false` even though the persisted value may be `true` - this
+ * component is hydrated from build-time-prerendered HTML (see
+ * AGENTS.md's Prerendering section), which has no `localStorage` and
+ * always bakes in `false`. Starting from the real stored value here would
+ * make the client's first render disagree with that prerendered DOM and
+ * trip an Angular hydration mismatch. The inline script in `src/index.html`
+ * (`data-analysis-panel-collapsed`) is what actually prevents the reload
+ * flash, via a pure CSS override that doesn't touch what Angular thinks is
+ * rendered - mirroring the `tourSeen`/theme scripts there. This signal is
+ * then corrected to the real value below, once hydration has settled.
+ */
+ readonly analysisPanelCollapsed = signal(false);
+
+ /** What the panel's `[style.width.px]` actually renders: 0 while collapsed, the real width otherwise. */
+ readonly effectiveAnalysisPanelWidth = computed(() => (this.analysisPanelCollapsed() ? 0 : this.analysisPanelWidth()));
+
private panelResizeStartX = 0;
private panelResizeStartWidth = 0;
+ private panelResizeActive = false;
+ private panelDragConfirmed = false;
- private readonly applyStoredPanelWidth = afterNextRender(() => {
+ private readonly applyStoredPanelState = afterNextRender(() => {
const storedWidth = readStoredAnalysisPanelWidth();
if (storedWidth !== null) {
this.analysisPanelWidth.set(clampWidth(storedWidth, ANALYSIS_PANEL_MIN_WIDTH, ANALYSIS_PANEL_MAX_WIDTH));
}
+ this.analysisPanelCollapsed.set(readStoredAnalysisPanelCollapsed());
+ // The pre-hydration script in `index.html` injects a temporary global
+ // `