diff --git a/plugins/dsh-ui-tweaks/lib/client.js b/plugins/dsh-ui-tweaks/lib/client.js index d8a9e47..60476c6 100644 --- a/plugins/dsh-ui-tweaks/lib/client.js +++ b/plugins/dsh-ui-tweaks/lib/client.js @@ -89,7 +89,6 @@ window.__ModuleLoader__.load({ // ----------------------------------------------------------------------- const STYLE_ID = "dsh-ui-tweaks-style"; - let zenActive = false; /** Assemble the combined static CSS for whichever features are enabled. */ function buildCss() { @@ -214,19 +213,7 @@ window.__ModuleLoader__.load({ ); } - rules.push( - '[data-sidebar-collapsed] [class*="_sidebarCol"] [class*="_regionArea"]{display:none !important;}', - '[data-phase]{position:relative}', - '.dsh-chat-controls{z-index:12;display:flex;flex:none;align-items:center;gap:6px;margin-left:8px}', - '.dsh-chat-control{height:28px;min-width:28px;padding:0 9px;border:1px solid var(--dsw-alias-border-l2,rgba(255,255,255,.12));border-radius:14px;background:var(--dsw-specific-input-major);color:var(--dsw-alias-label-secondary);font:var(--dsw-font-xs-13);cursor:pointer;box-shadow:var(--dsw-shadow-lv1)}', - '.dsh-chat-control:hover{color:var(--dsw-alias-label-primary);background:color-mix(in srgb,var(--dsw-specific-input-major),#fff 6%)}', - 'html[data-dsh-zen="1"] [class*="_frame"][style*="grid-template-columns"]{grid-template-columns:0 minmax(0,1fr) 0 !important}', - 'html[data-dsh-zen="1"] [class*="_sidebarCol"],html[data-dsh-zen="1"] [class*="_detailsCol"]{visibility:hidden !important;pointer-events:none !important;border:0 !important}', - 'html[data-dsh-zen="1"] [class*="_handle"]{display:none !important}', - 'html[data-dsh-zen="1"] [data-phase] header[class*="_header"]{display:none !important}', - 'html[data-dsh-zen="1"] [data-composer-seat]{position:absolute !important;left:0;right:0;bottom:0;visibility:hidden !important;pointer-events:none !important}', - '.dsh-chat-controls[data-floating="1"]{position:absolute;right:24px;top:12px;margin-left:0}' - ); + rules.push('[data-sidebar-collapsed] [class*="_sidebarCol"] [class*="_regionArea"]{display:none !important;}'); return rules.join(""); } @@ -245,133 +232,6 @@ window.__ModuleLoader__.load({ // ---- (3) header-menu dynamic behavior --------------------------------- - function restoreNativeComposer() { - try { - window.localStorage.removeItem("dsh-ui-tweaks:composer-collapsed"); - } catch {} - document.querySelectorAll("[data-composer-seat]").forEach((seat) => seat.removeAttribute("data-dsh-composer-collapsed")); - } - - function repairNativeComposerLayouts() { - document.querySelectorAll("[data-input-scroll]").forEach((scrollport) => { - if (scrollport.getAttribute("data-dsh-composer-repaired") === "1") return; - scrollport.setAttribute("data-dsh-composer-repaired", "1"); - requestAnimationFrame(() => requestAnimationFrame(() => { - if (!scrollport.isConnected) return; - const mirror = scrollport.querySelector("[data-input-mirror]"); - const input = scrollport.querySelector("textarea"); - if (!mirror || !input) return; - const mirrorPosition = mirror.style.position; - mirror.style.position = "absolute"; - mirror.offsetHeight; - scrollport.offsetHeight; - mirror.style.position = mirrorPosition; - mirror.offsetHeight; - const inputHeight = input.style.height; - input.style.height = `${String(input.clientHeight + 1)}px`; - input.offsetHeight; - input.style.height = inputHeight; - input.offsetHeight; - const seat = scrollport.closest("[data-composer-seat]"); - seat?.parentElement?.style.setProperty("--dsh-composer-height", `${String(seat.offsetHeight)}px`); - })); - }); - } - - function updateChatControls() { - document.querySelectorAll(".dsh-chat-controls").forEach((controls) => { - const zen = controls.querySelector(".dsh-zen-toggle"); - if (zen) { - const zenLabel = zenActive ? "Exit Zen" : "Zen"; - if (zen.textContent !== zenLabel) zen.textContent = zenLabel; - zen.title = zenActive ? "Exit Zen mode" : "Enter Zen mode"; - zen.setAttribute("aria-pressed", zenActive ? "true" : "false"); - } - }); - } - - function conversationRoots() { - const roots = []; - document.querySelectorAll("[data-composer-seat]").forEach((seat) => { - const conversation = seat.closest("[data-phase]"); - if (conversation && !roots.includes(conversation)) roots.push(conversation); - }); - return roots; - } - - function normalizeChatControls() { - const roots = conversationRoots(); - const validRoots = new Set(roots); - const owners = new Map(); - document.querySelectorAll(".dsh-chat-controls").forEach((controls) => { - const owner = controls.closest("[data-phase]"); - if (!validRoots.has(owner) || owners.has(owner)) { - controls.remove(); - return; - } - owners.set(owner, controls); - }); - roots.forEach((conversation) => { - if (owners.has(conversation)) return; - const controls = makeChatControls(); - conversation.appendChild(controls); - owners.set(conversation, controls); - }); - return roots; - } - - function placeChatControls(roots = conversationRoots()) { - roots.forEach((conversation) => { - const controls = Array.from(conversation.querySelectorAll(".dsh-chat-controls")).find((candidate) => candidate.closest("[data-phase]") === conversation); - if (!controls) return; - const git = conversation.querySelector(".dsh-git-working-dirs"); - const utilities = conversation.querySelector('header [class*="_headerUtilities"]'); - const anchor = git ?? utilities; - if (!zenActive && anchor?.parentElement) { - controls.removeAttribute("data-floating"); - if (controls.parentElement !== anchor.parentElement) anchor.parentElement.insertBefore(controls, anchor); - return; - } - controls.setAttribute("data-floating", "1"); - if (controls.parentElement !== conversation) conversation.appendChild(controls); - }); - } - - function setZenActive(active) { - zenActive = active; - if (active) document.documentElement.setAttribute("data-dsh-zen", "1"); - else document.documentElement.removeAttribute("data-dsh-zen"); - placeChatControls(normalizeChatControls()); - updateChatControls(); - } - - function makeChatControls() { - const controls = document.createElement("div"); - controls.className = "dsh-chat-controls"; - - const zen = document.createElement("button"); - zen.type = "button"; - zen.className = "dsh-chat-control dsh-zen-toggle"; - zen.setAttribute("aria-label", "Toggle Zen mode"); - zen.addEventListener("click", () => setZenActive(!zenActive)); - - controls.append(zen); - return controls; - } - - function enhanceChatControls() { - const roots = normalizeChatControls(); - placeChatControls(roots); - updateChatControls(); - } - - function removeChatControls() { - setZenActive(false); - document.querySelectorAll(".dsh-chat-controls").forEach((controls) => controls.remove()); - document.querySelectorAll('[data-dsh-composer-repaired="1"]').forEach((scrollport) => scrollport.removeAttribute("data-dsh-composer-repaired")); - restoreNativeComposer(); - } - /** Close every open header menu (optionally sparing one host). */ function closeAll(except) { document.querySelectorAll('[data-dsh-hdr-menu="host"][data-open="1"]').forEach((host) => { @@ -409,9 +269,6 @@ window.__ModuleLoader__.load({ /** Enhance every header-utilities region currently in the DOM (idempotent). */ function enhance() { - repairNativeComposerLayouts(); - enhanceChatControls(); - if (!COLLAPSE_HEADER) return; const panels = document.querySelectorAll('[class*="_headerUtilities"]'); panels.forEach((panel) => { const host = panel.parentElement; @@ -454,7 +311,16 @@ window.__ModuleLoader__.load({ function apply(ctx) { const start = () => { installStyle(); - restoreNativeComposer(); + + // Features 1 & 2 are pure CSS — nothing more to wire. + if (!COLLAPSE_HEADER) { + return () => { + const style = document.getElementById(STYLE_ID); + if (style) style.remove(); + }; + } + + // Feature 3 — dynamic header dropdown. enhance(); const observer = new MutationObserver(scheduleEnhance); @@ -465,9 +331,7 @@ window.__ModuleLoader__.load({ if (!host) closeAll(); }; const onKey = (e) => { - if (e.key !== "Escape") return; - closeAll(); - if (zenActive) setZenActive(false); + if (e.key === "Escape") closeAll(); }; document.addEventListener("click", onDocClick, true); document.addEventListener("keydown", onKey, true); @@ -477,7 +341,6 @@ window.__ModuleLoader__.load({ document.removeEventListener("click", onDocClick, true); document.removeEventListener("keydown", onKey, true); closeAll(); - removeChatControls(); const style = document.getElementById(STYLE_ID); if (style) style.remove(); document.querySelectorAll(".dsh-hdr-menu-trigger").forEach((n) => n.remove()); diff --git a/plugins/dsh-ui-tweaks/package.json b/plugins/dsh-ui-tweaks/package.json index 6901310..1ca205c 100644 --- a/plugins/dsh-ui-tweaks/package.json +++ b/plugins/dsh-ui-tweaks/package.json @@ -1,7 +1,7 @@ { "name": "dsh-ui-tweaks", - "description": "dsh web plugin: cohesive sidebar, header, and Zen-mode controls for the DSH Web chat interface. Client-only.", - "version": "0.3.1", + "description": "dsh web plugin: sidebar appearance, collapsed-tree hiding, and conversation-header utility menu tweaks without modifying the chat composer. Client-only.", + "version": "0.4.0", "private": true, "type": "module", "engines": { diff --git a/test/run.sh b/test/run.sh index 9fe29d1..229117f 100755 --- a/test/run.sh +++ b/test/run.sh @@ -7,7 +7,6 @@ cd "$repository_directory" bash -n install.sh uninstall.sh bin/dsh-app bin/free-port-3080.sh test/*.sh node --check scripts/apply-compatibility-patches.mjs node test/profile-manifest.test.mjs -node test/ui-controls.test.mjs python3 test/editor-artifacts.test.py python3 test/editor-runtime.test.py python3 test/cloudflared-artifact.test.py diff --git a/test/ui-controls.test.mjs b/test/ui-controls.test.mjs deleted file mode 100644 index 6ab3709..0000000 --- a/test/ui-controls.test.mjs +++ /dev/null @@ -1,237 +0,0 @@ -import assert from 'node:assert/strict' -import { readFile } from 'node:fs/promises' -import vm from 'node:vm' - -class Element { - constructor(tagName) { - this.tagName = tagName - this.attributes = new Map() - this.children = [] - this.listeners = new Map() - this.dataset = {} - this.parentElement = null - this.className = '' - this.textContent = '' - this.title = '' - this.type = '' - this.clientHeight = 32 - this.offsetHeight = 32 - this.isConnected = true - const properties = new Map() - this.style = { - position: '', - height: '', - setProperty: (name, value) => properties.set(name, String(value)), - getPropertyValue: (name) => properties.get(name) ?? '', - } - } - - append(...children) { - for (const child of children) this.appendChild(child) - } - - appendChild(child) { - if (child.parentElement) child.parentElement.children = child.parentElement.children.filter((candidate) => candidate !== child) - child.parentElement = this - this.children.push(child) - return child - } - - insertBefore(child, reference) { - if (child.parentElement) child.parentElement.children = child.parentElement.children.filter((candidate) => candidate !== child) - const index = this.children.indexOf(reference) - child.parentElement = this - this.children.splice(index < 0 ? this.children.length : index, 0, child) - return child - } - - addEventListener(name, listener) { - this.listeners.set(name, listener) - } - - click() { - this.listeners.get('click')?.({ preventDefault() {}, stopPropagation() {}, target: this }) - } - - setAttribute(name, value) { - this.attributes.set(name, String(value)) - } - - getAttribute(name) { - return this.attributes.get(name) ?? null - } - - removeAttribute(name) { - this.attributes.delete(name) - } - - querySelector(selector) { - if (selector === ':scope > .dsh-chat-controls') return this.children.find((child) => child.hasClass('dsh-chat-controls')) ?? null - if (selector === 'header [class*="_headerUtilities"]') return this.descendants().find((child) => child.tagName === 'div' && child.className.includes('_headerUtilities')) ?? null - if (selector === '[data-input-mirror]') return this.descendants().find((child) => child.attributes.has('data-input-mirror')) ?? null - if (selector === 'textarea') return this.descendants().find((child) => child.tagName === 'textarea') ?? null - if (selector.startsWith('.')) return this.descendants().find((child) => child.hasClass(selector.slice(1))) ?? null - return null - } - - querySelectorAll(selector) { - if (selector.startsWith('.')) return this.descendants().filter((child) => child.hasClass(selector.slice(1))) - return [] - } - - closest(selector) { - let current = this - while (current) { - if (selector === '[data-phase]' && current.attributes.has('data-phase')) return current - if (selector === '[data-composer-seat]' && current.attributes.has('data-composer-seat')) return current - current = current.parentElement - } - return null - } - - hasClass(name) { - return this.className.split(/\s+/).includes(name) - } - - descendants() { - return this.children.flatMap((child) => [child, ...child.descendants()]) - } - - remove() { - if (!this.parentElement) return - this.parentElement.children = this.parentElement.children.filter((child) => child !== this) - this.parentElement = null - } -} - -const documentElement = new Element('html') -const head = new Element('head') -const body = new Element('body') -const conversation = new Element('div') -conversation.setAttribute('data-phase', 'active') -const header = new Element('header') -const titleRow = new Element('div') -const headerActions = new Element('div') -const git = new Element('div') -git.className = 'dsh-git-working-dirs' -const utilities = new Element('div') -utilities.className = 'fixture_headerUtilities' -headerActions.appendChild(git) -titleRow.append(headerActions, utilities) -header.appendChild(titleRow) -const seat = new Element('div') -seat.setAttribute('data-composer-seat', '') -seat.setAttribute('data-dsh-composer-collapsed', '1') -const inputScroll = new Element('div') -inputScroll.setAttribute('data-input-scroll', '') -const input = new Element('textarea') -const mirror = new Element('div') -mirror.setAttribute('data-input-mirror', '') -inputScroll.append(input, mirror) -seat.appendChild(inputScroll) -const nestedPhase = new Element('div') -nestedPhase.setAttribute('data-phase', 'streaming') -const strayControls = new Element('div') -strayControls.className = 'dsh-chat-controls' -nestedPhase.appendChild(strayControls) -conversation.append(header, nestedPhase, seat) -body.appendChild(conversation) -const documentListeners = new Map() - -const allElements = () => [documentElement, head, body, ...head.descendants(), ...body.descendants()] -const document = { - documentElement, - head, - body, - createElement: (tagName) => new Element(tagName), - getElementById: (id) => allElements().find((element) => element.id === id) ?? null, - querySelectorAll(selector) { - if (selector === '[data-phase]') return allElements().filter((element) => element.attributes.has('data-phase')) - if (selector === '[data-composer-seat]') return allElements().filter((element) => element.attributes.has('data-composer-seat')) - if (selector === '[data-input-scroll]') return allElements().filter((element) => element.attributes.has('data-input-scroll')) - if (selector === '[data-dsh-composer-repaired="1"]') return allElements().filter((element) => element.getAttribute('data-dsh-composer-repaired') === '1') - if (selector === '.dsh-chat-controls') return allElements().filter((element) => element.hasClass('dsh-chat-controls')) - if (selector === '.dsh-hdr-menu-trigger' || selector === '[data-dsh-hdr-menu]' || selector === '[class*="_headerUtilities"]') return [] - return [] - }, - addEventListener: (name, listener) => documentListeners.set(name, listener), - removeEventListener: (name) => documentListeners.delete(name), -} - -const storage = new Map([['dsh-ui-tweaks:composer-collapsed', '1']]) -const window = { - document, - localStorage: { - getItem: (key) => storage.get(key) ?? null, - setItem: (key, value) => storage.set(key, String(value)), - removeItem: (key) => storage.delete(key), - }, -} -window.window = window -let definition -window.__ModuleLoader__ = { load: (candidate) => { definition = candidate } } - -class MutationObserver { - observe() {} - disconnect() {} -} - -const source = await readFile(new URL('../plugins/dsh-ui-tweaks/lib/client.js', import.meta.url), 'utf8') -vm.runInNewContext(source, { - window, - document, - MutationObserver, - requestAnimationFrame: (callback) => callback(), - setTimeout, -}) - -const plugin = definition.factory(() => {}) -let cleanup -plugin.apply({ effect: (start) => { cleanup = start() } }) - -const controls = conversation.querySelector('.dsh-chat-controls') -assert(controls) -assert.equal(document.querySelectorAll('.dsh-chat-controls').length, 1) -assert.equal(strayControls.parentElement, null) -assert.equal(controls.parentElement, headerActions) -assert(controls.parentElement.children.indexOf(controls) < controls.parentElement.children.indexOf(git)) -const zen = controls.querySelector('.dsh-zen-toggle') -assert.equal(controls.querySelector('.dsh-composer-collapse-toggle'), null) -assert.equal(zen.textContent, 'Zen') -assert.equal(seat.getAttribute('data-dsh-composer-collapsed'), null) -assert.equal(storage.has('dsh-ui-tweaks:composer-collapsed'), false) -assert.equal(inputScroll.getAttribute('data-dsh-composer-repaired'), '1') -assert.equal(mirror.style.position, '') -assert.equal(input.style.height, '') -assert.equal(conversation.style.getPropertyValue('--dsh-composer-height'), '32px') - -zen.click() -assert.equal(documentElement.getAttribute('data-dsh-zen'), '1') -assert.equal(zen.textContent, 'Exit Zen') -assert.equal(seat.getAttribute('data-dsh-composer-collapsed'), null) -assert.equal(controls.parentElement, conversation) -assert.equal(controls.getAttribute('data-floating'), '1') - -documentListeners.get('keydown')?.({ key: 'Escape' }) -assert.equal(documentElement.getAttribute('data-dsh-zen'), null) -assert.equal(zen.textContent, 'Zen') -assert.equal(controls.parentElement, headerActions) -assert(controls.parentElement.children.indexOf(controls) < controls.parentElement.children.indexOf(git)) -assert.equal(controls.getAttribute('data-floating'), null) - -cleanup() -assert.equal(conversation.querySelector('.dsh-chat-controls'), null) -assert.equal(seat.getAttribute('data-dsh-composer-collapsed'), null) -assert.equal(inputScroll.getAttribute('data-dsh-composer-repaired'), null) -assert.equal(documentElement.getAttribute('data-dsh-zen'), null) -assert(source.includes('[data-sidebar-collapsed] [class*="_sidebarCol"] [class*="_regionArea"]')) -assert(source.includes('[class*="_detailsCol"]{visibility:hidden !important;pointer-events:none !important;border:0 !important}')) -assert(!source.includes('[class*="_detailsCol"],html[data-dsh-zen="1"] [class*="_handle"]{display:none')) -assert(source.includes('.dsh-chat-controls{z-index:12;display:flex;flex:none')) -assert(source.includes('.dsh-chat-controls[data-floating="1"]{position:absolute;right:24px;top:12px')) -assert(!source.includes('dsh-composer-collapse-toggle')) -assert(!source.includes('[data-composer-seat][data-dsh-composer-collapsed="1"]')) -assert(source.includes('[data-composer-seat]{position:absolute !important;left:0;right:0;bottom:0;visibility:hidden')) -assert(source.includes('[data-phase] header[class*="_header"]{display:none !important}')) - -console.log('UI controls test passed')