From ce93500b5610d21517a770bd3df7dae1eee6a256 Mon Sep 17 00:00:00 2001 From: Brian Love Date: Sat, 16 May 2026 08:21:03 -0700 Subject: [PATCH] fix(chat): chat-debug right-dock self-feedback (peer-only claim reads) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PR #346's edge-claim primitive had a self-feedback bug: when chat-debug docked right, it wrote --ngaf-chat-occupy-right AND read --ngaf-chat-occupy-right, so the panel offset itself by its own width (420px) instead of anchoring at right: 0. The result was a broken right-docked debug panel floating in the middle of the viewport whenever no sidebar was open. Fix: split the edge-claim primitive into two layers. 1. Per-component claim vars — only the OWNER writes them, only PEERS read them. Eliminates self-feedback by construction: --ngaf-chat-sidebar-claim-right (chat-sidebar writes, chat-debug reads) --ngaf-chat-debug-claim-{top,right,bottom,left} (chat-debug writes, chat-sidebar reads) 2. Aggregate --ngaf-chat-occupy-* vars stay as a convenience read for external consumers ("is anything on this edge?") but internal lib panels reference the peer-specific claims instead. Consumer-side changes: - chat-sidebar.__panel + .__launcher now read --ngaf-chat-debug- claim-bottom (was: --ngaf-chat-occupy-bottom) - chat-debug.panel--right + .panel--bottom now read --ngaf-chat- sidebar-claim-right (was: --ngaf-chat-occupy-right) Tests: 12 new cases in chat-tokens.spec lock the per-component vars and attribute mappings. Existing component specs updated to assert the peer-specific reads (catches the regression directly). Verified live: right-dock anchors at right: 0 with no sidebar present; auto-dock to bottom still works when chat-sidebar exists; manual right-dock-over-sidebar correctly stacks at right: 28rem. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../chat-debug/chat-debug.component.spec.ts | 10 ++++-- .../chat-debug/chat-debug.component.ts | 4 +-- .../chat-sidebar.component.spec.ts | 14 ++++---- .../chat-sidebar/chat-sidebar.component.ts | 4 +-- libs/chat/src/lib/styles/chat-tokens.spec.ts | 34 +++++++++++++++++-- libs/chat/src/lib/styles/chat-tokens.ts | 21 +++++++++++- 6 files changed, 71 insertions(+), 16 deletions(-) diff --git a/libs/chat/src/lib/compositions/chat-debug/chat-debug.component.spec.ts b/libs/chat/src/lib/compositions/chat-debug/chat-debug.component.spec.ts index 599efddb7..d3061f7a0 100644 --- a/libs/chat/src/lib/compositions/chat-debug/chat-debug.component.spec.ts +++ b/libs/chat/src/lib/compositions/chat-debug/chat-debug.component.spec.ts @@ -96,10 +96,14 @@ describe('ChatDebugComponent — edge-claim attribute', () => { document.documentElement.removeAttribute('data-ngaf-chat-debug'); }); - it('sets data-ngaf-chat-debug=dock on while open', () => { + it('reads PEER --ngaf-chat-sidebar-claim-right (not aggregate occupy-right)', () => { + // Reading the aggregate occupy-right causes self-feedback: when + // chat-debug docks right, it WRITES occupy-right; if it also READS + // occupy-right, the panel offsets itself by its own width. Read the + // peer-specific sidebar-claim-right instead. const styles = (ChatDebugComponent as unknown as { ɵcmp: { styles: string[] } }).ɵcmp.styles.join('\n'); - expect(styles).toMatch(/\.panel--bottom[^{]*\{[^}]*right:\s*var\(--ngaf-chat-occupy-right/); - expect(styles).toMatch(/\.panel--right[^{]*\{[^}]*right:\s*var\(--ngaf-chat-occupy-right/); + expect(styles).toMatch(/\.panel--bottom[^{]*\{[^}]*right:\s*var\(--ngaf-chat-sidebar-claim-right/); + expect(styles).toMatch(/\.panel--right[^{]*\{[^}]*right:\s*var\(--ngaf-chat-sidebar-claim-right/); }); }); diff --git a/libs/chat/src/lib/compositions/chat-debug/chat-debug.component.ts b/libs/chat/src/lib/compositions/chat-debug/chat-debug.component.ts index d0ccc1857..e54960198 100644 --- a/libs/chat/src/lib/compositions/chat-debug/chat-debug.component.ts +++ b/libs/chat/src/lib/compositions/chat-debug/chat-debug.component.ts @@ -100,7 +100,7 @@ interface TabEntry { } .panel--right { top: 0; - right: var(--ngaf-chat-occupy-right, 0); + right: var(--ngaf-chat-sidebar-claim-right, 0); bottom: 0; width: var(--panel-size, 420px); border-right: 0; @@ -119,7 +119,7 @@ interface TabEntry { } .panel--bottom { left: 0; - right: var(--ngaf-chat-occupy-right, 0); + right: var(--ngaf-chat-sidebar-claim-right, 0); bottom: 0; height: var(--panel-size, 40vh); border-bottom: 0; diff --git a/libs/chat/src/lib/compositions/chat-sidebar/chat-sidebar.component.spec.ts b/libs/chat/src/lib/compositions/chat-sidebar/chat-sidebar.component.spec.ts index 65bc15a88..ae64aae71 100644 --- a/libs/chat/src/lib/compositions/chat-sidebar/chat-sidebar.component.spec.ts +++ b/libs/chat/src/lib/compositions/chat-sidebar/chat-sidebar.component.spec.ts @@ -56,15 +56,17 @@ describe('ChatSidebarComponent — edge-claim attribute', () => { }); }); - it('panel CSS includes bottom: var(--ngaf-chat-occupy-bottom)', () => { - // Styles array is the second member of the @Component decorator metadata. - // Easier path: stringify the styles and look for the declaration. + it('panel CSS reads PEER --ngaf-chat-debug-claim-bottom (not aggregate)', () => { + // Components must read PEER per-component claim vars, never the + // aggregate occupy-* (which they write to themselves). The aggregate + // is for external consumer convenience; internal panels read + // peer-specific to avoid self-feedback. const styles = (ChatSidebarComponent as unknown as { ɵcmp: { styles: string[] } }).ɵcmp.styles.join('\n'); - expect(styles).toMatch(/\.chat-sidebar__panel[^{]*\{[^}]*bottom:\s*var\(--ngaf-chat-occupy-bottom/); + expect(styles).toMatch(/\.chat-sidebar__panel[^{]*\{[^}]*bottom:\s*var\(--ngaf-chat-debug-claim-bottom/); }); - it('launcher CSS includes calc(1rem + var(--ngaf-chat-occupy-bottom))', () => { + it('launcher CSS reads PEER --ngaf-chat-debug-claim-bottom (not aggregate)', () => { const styles = (ChatSidebarComponent as unknown as { ɵcmp: { styles: string[] } }).ɵcmp.styles.join('\n'); - expect(styles).toMatch(/\.chat-sidebar__launcher[^{]*\{[^}]*bottom:\s*calc\(1rem\s*\+\s*var\(--ngaf-chat-occupy-bottom/); + expect(styles).toMatch(/\.chat-sidebar__launcher[^{]*\{[^}]*bottom:\s*calc\(1rem\s*\+\s*var\(--ngaf-chat-debug-claim-bottom/); }); }); diff --git a/libs/chat/src/lib/compositions/chat-sidebar/chat-sidebar.component.ts b/libs/chat/src/lib/compositions/chat-sidebar/chat-sidebar.component.ts index 401c25de9..6fdeed391 100644 --- a/libs/chat/src/lib/compositions/chat-sidebar/chat-sidebar.component.ts +++ b/libs/chat/src/lib/compositions/chat-sidebar/chat-sidebar.component.ts @@ -27,7 +27,7 @@ import { CHAT_HOST_TOKENS } from '../../styles/chat-tokens'; .chat-sidebar__panel { position: fixed; top: 0; right: 0; - bottom: var(--ngaf-chat-occupy-bottom, 0); + bottom: var(--ngaf-chat-debug-claim-bottom, 0); width: 28rem; background: var(--ngaf-chat-bg); border-left: 1px solid var(--ngaf-chat-separator); @@ -55,7 +55,7 @@ import { CHAT_HOST_TOKENS } from '../../styles/chat-tokens'; .chat-sidebar__close:hover { background: var(--ngaf-chat-surface-alt); color: var(--ngaf-chat-text); } .chat-sidebar__launcher { position: fixed; - bottom: calc(1rem + var(--ngaf-chat-occupy-bottom, 0)); + bottom: calc(1rem + var(--ngaf-chat-debug-claim-bottom, 0)); right: 1rem; z-index: 30; transition: bottom 200ms ease-out; diff --git a/libs/chat/src/lib/styles/chat-tokens.spec.ts b/libs/chat/src/lib/styles/chat-tokens.spec.ts index 58d5dcf21..8010e9d45 100644 --- a/libs/chat/src/lib/styles/chat-tokens.spec.ts +++ b/libs/chat/src/lib/styles/chat-tokens.spec.ts @@ -59,7 +59,7 @@ describe('ROOT_TOKEN_STYLES — edge-claim primitive', () => { it('maps data-ngaf-chat-sidebar="open" to occupy-right', () => { expect(ROOT_TOKEN_STYLES).toMatch( - /:root\[data-ngaf-chat-sidebar="open"\]\s*\{\s*--ngaf-chat-occupy-right:\s*var\(--ngaf-chat-sidebar-width-drawer/, + /:root\[data-ngaf-chat-sidebar="open"\]\s*\{[^}]*--ngaf-chat-occupy-right:\s*var\(--ngaf-chat-sidebar-width-drawer/, ); }); @@ -69,7 +69,37 @@ describe('ROOT_TOKEN_STYLES — edge-claim primitive', () => { ['left', '--ngaf-chat-occupy-left', '--ngaf-chat-debug-panel-size-w'], ])('maps data-ngaf-chat-debug=%s to %s via %s', (dock, occupyVar, sizeVar) => { const pattern = new RegExp( - `:root\\[data-ngaf-chat-debug="${dock}"\\]\\s*\\{\\s*${occupyVar}:\\s*var\\(${sizeVar}`, + `:root\\[data-ngaf-chat-debug="${dock}"\\]\\s*\\{[^}]*${occupyVar}:\\s*var\\(${sizeVar}`, + ); + expect(ROOT_TOKEN_STYLES).toMatch(pattern); + }); + + // ── per-component claim vars (peer-only reads) ──────────────────────── + // Components must NOT read their own aggregate claim (would feedback). + // Each component publishes a per-component claim var that peers read. + it.each([ + '--ngaf-chat-sidebar-claim-right: 0px;', + '--ngaf-chat-debug-claim-top: 0px;', + '--ngaf-chat-debug-claim-right: 0px;', + '--ngaf-chat-debug-claim-bottom: 0px;', + '--ngaf-chat-debug-claim-left: 0px;', + ])('defines per-component default %s', (decl) => { + expect(ROOT_TOKEN_STYLES).toContain(decl); + }); + + it('sidebar attribute mapping also sets per-component claim var', () => { + expect(ROOT_TOKEN_STYLES).toMatch( + /:root\[data-ngaf-chat-sidebar="open"\]\s*\{[^}]*--ngaf-chat-sidebar-claim-right:\s*var\(--ngaf-chat-sidebar-width-drawer/, + ); + }); + + it.each([ + ['bottom', '--ngaf-chat-debug-claim-bottom', '--ngaf-chat-debug-panel-size-h'], + ['right', '--ngaf-chat-debug-claim-right', '--ngaf-chat-debug-panel-size-w'], + ['left', '--ngaf-chat-debug-claim-left', '--ngaf-chat-debug-panel-size-w'], + ])('debug attribute mapping for %s also sets %s', (dock, claimVar, sizeVar) => { + const pattern = new RegExp( + `:root\\[data-ngaf-chat-debug="${dock}"\\]\\s*\\{[^}]*${claimVar}:\\s*var\\(${sizeVar}`, ); expect(ROOT_TOKEN_STYLES).toMatch(pattern); }); diff --git a/libs/chat/src/lib/styles/chat-tokens.ts b/libs/chat/src/lib/styles/chat-tokens.ts index 4c08c740a..a8835ef4d 100644 --- a/libs/chat/src/lib/styles/chat-tokens.ts +++ b/libs/chat/src/lib/styles/chat-tokens.ts @@ -125,12 +125,27 @@ const EDGE_CLAIM_TOKENS = ` Each docked panel publishes the edge it occupies via a data-ngaf-chat-* attribute on ; other panels read these custom properties to leave room. Defaults to 0px so consumers - not using chat-sidebar/chat-debug see zero overhead. */ + not using chat-sidebar/chat-debug see zero overhead. + + TWO LAYERS: + 1. Per-component claim vars (--ngaf-chat--claim-) + are read by PEERS only — never by the component that wrote + them. This eliminates self-feedback (where a right-docked + panel would offset itself by reading its own claim). + 2. Aggregate occupy-* vars are convenience reads for external + consumers and for cases where any-panel-on-edge matters. */ --ngaf-chat-occupy-top: 0px; --ngaf-chat-occupy-right: 0px; --ngaf-chat-occupy-bottom: 0px; --ngaf-chat-occupy-left: 0px; + /* Per-component claims (peer-only reads). */ + --ngaf-chat-sidebar-claim-right: 0px; + --ngaf-chat-debug-claim-top: 0px; + --ngaf-chat-debug-claim-right: 0px; + --ngaf-chat-debug-claim-bottom: 0px; + --ngaf-chat-debug-claim-left: 0px; + /* Sizes the chat-debug dock contributes when it claims an edge. Split by orientation so consumers can override independently. */ --ngaf-chat-debug-panel-size-h: 40vh; @@ -327,15 +342,19 @@ export const ROOT_TOKEN_STYLES = ` chat-sidebar sets data-ngaf-chat-sidebar="open" while its panel is open. chat-debug sets data-ngaf-chat-debug to its current dock while open. */ :root[data-ngaf-chat-sidebar="open"] { + --ngaf-chat-sidebar-claim-right: var(--ngaf-chat-sidebar-width-drawer, 28rem); --ngaf-chat-occupy-right: var(--ngaf-chat-sidebar-width-drawer, 28rem); } :root[data-ngaf-chat-debug="bottom"] { + --ngaf-chat-debug-claim-bottom: var(--ngaf-chat-debug-panel-size-h, 40vh); --ngaf-chat-occupy-bottom: var(--ngaf-chat-debug-panel-size-h, 40vh); } :root[data-ngaf-chat-debug="right"] { + --ngaf-chat-debug-claim-right: var(--ngaf-chat-debug-panel-size-w, 420px); --ngaf-chat-occupy-right: var(--ngaf-chat-debug-panel-size-w, 420px); } :root[data-ngaf-chat-debug="left"] { + --ngaf-chat-debug-claim-left: var(--ngaf-chat-debug-panel-size-w, 420px); --ngaf-chat-occupy-left: var(--ngaf-chat-debug-panel-size-w, 420px); } }