Support toggle+widget buttons in plugin-framework

Change-Id: Ibf2b80efc1eeda2f51dedf4a7f3cd5ac67325ef9
diff --git a/.gitignore b/.gitignore
index dfb29e9..69a3b4b 100644
--- a/.gitignore
+++ b/.gitignore
@@ -49,4 +49,6 @@
 /npm-debug.log
 /package-lock.json
 /kalamar.code-workspace
-slim.report.json
\ No newline at end of file
+slim.report.json
+/dev/js/src-lib/
+/local/
\ No newline at end of file
diff --git a/Changes b/Changes
index d5deedd..ccdc780 100644
--- a/Changes
+++ b/Changes
@@ -14,6 +14,7 @@
         - Restrict allow-same-origin to plugins that actually ARE AND request it (Requires the export plugin >= 0.4.1)(kupietz, tests hebasta)
         - Add data-testid to glimpse (diewald)
         - Update dependencies (diewald)
+        - Support combined toggle+widget button for plugins (diewald)
 
 0.64 2026-02-14
         - Improve 'Plugins' mounting (diewald)
diff --git a/dev/demo/plugin-client.html b/dev/demo/plugin-client.html
index 530682c..45b9852 100644
--- a/dev/demo/plugin-client.html
+++ b/dev/demo/plugin-client.html
@@ -22,7 +22,7 @@
     </style>
   </head>
   <body class="result-view">
-    <h2>Example Widget!</h2>
+    <h1>Example Widget!</h1>
     <script>
       function flood () {
         var i = 0;
@@ -78,7 +78,8 @@
         });
       }
       
-      </script>
+    </script>
+    <section>
     <ul>
       <li><a onclick="KorAPlugin.log(333, 'Huhu!')">Send log!</a></li>
       <li><a onclick="KorAPlugin.resize()">Resize</a></li>
@@ -89,7 +90,9 @@
       <li><a onclick="KorAPlugin.requestMsg({'action':'get', 'key':'KQ'}, function (d) { document.write(JSON.stringify(d.value))})">Get KQ</a></li>
       <li><a onclick="KorAPlugin.requestMsg({'action':'get', 'key':'textSigle', 'value':'textSigle'}, function (d) { document.write(JSON.stringify(d.value))})">Get textSigle</a></li>
       <li><a onclick="KorAPlugin.sendMsg({'action':'set', 'key':'QueryForm', 'value':{'q':'[This][is][a][query]'}})">Set query</a></li>
+      <li><a onclick="KorAPlugin.sendMsg({'action':'set', 'key':'Title', 'value':'Huhuhu'})">Set title</a></li>
     </ul>
     <p style="width: 2000px">------------------------------------------------------------------------------------------------------------------------</p>
+    </section>
   </body>
 </html>
diff --git a/dev/demo/plugin-server.html b/dev/demo/plugin-server.html
index 6a1e12f..2a2d047 100644
--- a/dev/demo/plugin-server.html
+++ b/dev/demo/plugin-server.html
@@ -12,52 +12,96 @@
     window.KorAP = window.KorAP || {};
 
     //]]></script>  
+
     <script data-main="plugin-serverdemo.js" src="../js/lib/require.js" async="async"></script>
   </head>
-  <body>    
+  <body class="no-js"
+        data-korap-url="/"
+        data-hint-foundries="base,corenlp,dereko,malt,marmot,opennlp,spacy,tt"
+        itemscope
+        itemtype="http://schema.org/WebApplication">    
     <p>Start the demo server with <code>morbo -l 'http://*:3003' t/server/plugin.pl</code> and open <a href="http://localhost:3003/demo/plugin-server.html"><code>this website</code></a>.</p>
 
+<div id="kalamar-bg"></div>
+    
+    
+
+
+
+
+
+<aside tabindex="0" class=" invisible off">
+  <div>
+    
+  </div>
+</aside>
+
+    
+      
+
+<nav class="navbar">
+  <a class="logo" data-testid="home" href="/instance/test"><h1><span>KorAP - Korpusanalyseplattform der nächsten Generation</span></h1></a>
+
+  <div class="navbar-group">
+    <h3 class="nav-link"><a class="link-guided-tour" data-testid="tour-start">Tour</a></h3><h3 class="nav-link"><a href="/instance/test/doc" class="tutorial" id="view-tutorial">Hilfe</a></h3>
+    
+    
+
+
+    <nav class="dropdown">
+      <div class="dropdown-btn login"></div>
+      <div class="dropdown-content dropdown-content--right">
+        <form accept-charset="utf-8" action="/instance/test/user/login" class="login" method="POST">
+          <legend><span>Anmelden</span></legend>
+          <input name="csrf_token" type="hidden" value="0a3feea1e8b1400421d18783b4aba1950a388215">
+          <input data-testid="handle-or-email" name="handle_or_email" placeholder="Benutzername oder Email" type="text">
+          <input name="fwd" type="hidden" value="/instance/test">
+          <input data-testid="pwd" name="pwd" placeholder="Passwort" type="password">
+          <button type="submit" class="btn-login" data-testid="login-submit"><span>Los!</span></button>
+</form>          <p>Anmeldung mit einem registrierten <a href="https://perso.ids-mannheim.de/registration/" style="white-space: nowrap">Cosmas-II</a>-Konto</p>
+      </div>
+    </nav>
+
+  </div>
+
+  <div class="burger-icon"></div>
+</nav>
+
+    
     <header>
       <form autocomplete="off" action="/kalamar" id="searchform">      
 	<div id="searchbar">
-	  <input type="search"
-		 placeholder="Find ..."
-		 name="q"
-		 id="q-field"
-		 value="abcdefghijklmnopqrstuvwxyz"
-		 autofocus="autofocus" />
-	  <button id="qsubmit" type="submit"><span>Go</span></button>
+          <input autocapitalize="off"
+                 autocomplete="off"
+                 autocorrect="off"
+                 autofocus="autofocus"
+                 id="q-field"
+                 name="q"
+                 placeholder="Finde ..."
+                 spellcheck="false"
+                 value="abcdefghijklmnopqrstuvwxyz"
+                 type="search">
+           <button type="submit" id="qsubmit" title="Los!"><span>Los!</span></button>           
 	  <!-- <i class="fa fa-arrow-circle-down show-hint" onclick="hint.popUp()"></i> -->
 	</div>
 
-        <!-- Search in the following virtual corpus -->
-        <div id="vc-view"></div>
-        in
-        <input id="show" name="show" type="hidden" />
-        <!--<input id="collection-name" name="collection-name" type="hidden"> -->
-        <input id="cq" name="cq" type="text" value="">
-	with <span class="menu select">
-	  <select name="ql" id="ql-field">
-	    <option value="poliqarp">Poliqarp</option>
-	    <option value="cosmas2">Cosmas II</option>
-	    <option value="annis">Annis</option>
-	    <option value="cql">CQL v1.2</option>
-	  </select>
-	</span>
-	<div class="button right">
-	  <input type="checkbox"
-		 value="1"
-		 name="cutoff"
-		 class="checkbox"
-		 id="q-cutoff-field" />
-	  <label for="q-cutoff-field"><span id="glimpse"></span>Glimpse</label>
 
-	  <!-- Todo: open tutorial - like openTutorial() -->
-	  <a href="doc/faq" tabindex="-1" class="question"><span>Question</span></a>
-	  <a href="tutorial.html" title="Tutorial" class="tutorial" id="view-tutorial"><span>Tutorial</span></a>
-	</div>
-      </form>
-    </header>
+    <!-- Search in the following virtual corpus -->
+    <div id="vc-view"></div>
+    in
+    <input id="cq" name="cq" type="text">
+
+    mit
+    <span class="select">
+      <select id="ql-field" name="ql"><option value="poliqarp">Poliqarp</option><option value="cosmas2">Cosmas II</option><option value="annis">Annis QL</option><option value="cqp">CQP (neu)</option><option value="cql">CQL v1.2</option><option value="fcsql">FCSQL</option></select>
+    </span>
+    <div class="button right">
+      <input checked class="checkbox" id="q-cutoff-field" name="cutoff" type="checkbox" value="1">
+      <label for="q-cutoff-field" title="Zeige nur die ersten Treffer in beliebiger Reihenfolge"><span id="glimpse"></span>Glimpse</label>
+    </div>
+    <div class="clear"></div>
+  </form>
+</header>
     
     <main> 
         <script>
@@ -155,5 +199,16 @@
         </ol>
       </div>
     </main>
+
+<footer>
+  <nav>
+    <div><a href="http://www.ids-mannheim.de/kl/projekte/korap/">Über KorAP</a></div><div><a href="/instance/test/doc//faq#howToCite">Zitierhilfe</a></div><div><a href="http://www.ids-mannheim.de/allgemein/impressum.html">Impressum</a></div><div><a href="http://www.ids-mannheim.de/allgemein/datenschutz/">Datenschutz</a></div><div><a class="embedded-link" href="/instance/test/doc/korap/kalamar">V 0.64.0</a></div>
+  </nav>
+  <div id="logos">
+    <a href="http://www.ids-mannheim.de/" class="logo"><p id="ids-logo"><span>Entwickelt am Leibniz-Institut für Deutsche Sprache (IDS)</span></p></a>
+  </div>
+</footer>
+
+
   </body>
 </html>
diff --git a/dev/demo/plugin-serverdemo.js b/dev/demo/plugin-serverdemo.js
index 6540591..406ea2b 100644
--- a/dev/demo/plugin-serverdemo.js
+++ b/dev/demo/plugin-serverdemo.js
@@ -17,6 +17,7 @@
     'classes' : [ 'button-icon', 'plugin', 'export' ],
     'onClick' : {
       'action' : 'addWidget',
+      'active' : true,
       'template' : 'http://localhost:3003/demo/plugin-client.html',
       "permissions": [
         "forms",
@@ -51,10 +52,28 @@
       ]
     },
   }]
+},{
+    "name": "Koral-Mapper",
+    "desc": "Mapping Service",
+    "embed": [
+      {
+        "classes": [
+          "termmapper"
+        ],
+      	"onClick" : {
+          "action"     : "setWidget",
+          "active" : false,
+          "template"   : "http://localhost:5725",
+          "permissions": ["forms", "scripts", "downloads"]
+        },
+        "panel": "query",
+        "title": "Map"
+      }
+    ]
 }]; 
 
 
-define(['plugin/server', 'lib/domReady', 'app/en', 'init'], function(pluginClass, domReady) {
+define(['plugin/server', 'pipe', 'lib/domReady', 'app/en', 'init'], function(pluginClass, pipeClass, domReady) {
     domReady(function (event) {
         if (KorAP.Plugin === undefined) {
             // Load Plugin Server first
@@ -62,6 +81,16 @@
             // Add services container to head
             document.head.appendChild(KorAP.Plugin.element());
         };
+
+        if (KorAP.Pipe === undefined) {
+            KorAP.Pipe = pipeClass.create("pipe");
+            let searchF = document.getElementById("searchform");
+            searchF.appendChild(KorAP.Pipe.element());
+
+            KorAP.ResponsePipe = pipeClass.create("response-pipe");
+            searchF.appendChild(KorAP.ResponsePipe.element());
+        };
+
         KorAP.Plugins.forEach(i => KorAP.Plugin.register(i));
     });
 });
diff --git a/dev/js/spec/buttongroupSpec.js b/dev/js/spec/buttongroupSpec.js
index 189fb24..1812607 100644
--- a/dev/js/spec/buttongroupSpec.js
+++ b/dev/js/spec/buttongroupSpec.js
@@ -275,15 +275,15 @@
       expect(check.classList.contains("checked")).toBeFalsy();
       expect(count).toEqual(0);
 
-      // Clicking the check toggles active state, but does not trigger callback.
+      // Clicking the check toggles active state and triggers the callback.
       check.click();
       expect(active.get()).toBeTruthy();
       expect(check.classList.contains("checked")).toBeTruthy();
-      expect(count).toEqual(0);
-
-      // Clicking the button still triggers the callback.
-      button.click();
       expect(count).toEqual(1);
+
+      // Clicking the button also triggers the callback.
+      button.click();
+      expect(count).toEqual(2);
     });
 
     it('should allow adoption', function () {
diff --git a/dev/js/spec/pipeSpec.js b/dev/js/spec/pipeSpec.js
index e1f47a0..e47a9a0 100644
--- a/dev/js/spec/pipeSpec.js
+++ b/dev/js/spec/pipeSpec.js
@@ -55,6 +55,22 @@
       expect(p.toString()).toEqual('service2,service1');
     });
     
+    it('should not add duplicates', function () {
+      let p = pipeClass.create();
+      p.append('service1');
+      p.append('service2');
+      expect(p.size()).toEqual(2);
+      expect(p.toString()).toEqual('service1,service2');
+
+      p.append('service1');
+      expect(p.size()).toEqual(2);
+      expect(p.toString()).toEqual('service1,service2');
+
+      p.prepend('service2');
+      expect(p.size()).toEqual(2);
+      expect(p.toString()).toEqual('service1,service2');
+    });
+
     it('should be deletable', function () {
       let p = pipeClass.create();
       p.append('service1');
diff --git a/dev/js/spec/pluginSpec.js b/dev/js/spec/pluginSpec.js
index 32a6e5e..107f44d 100644
--- a/dev/js/spec/pluginSpec.js
+++ b/dev/js/spec/pluginSpec.js
@@ -244,6 +244,457 @@
       KorAP.Panel["result"] = undefined;
     });
 
+    it('should accept valid registrations for setWidget with active', function () {
+      let p = KorAP.Panel["result"] = panelClass.create();
+      
+      let manager = pluginServerClass.create();
+
+      // Attach services container so addService works
+      document.body.appendChild(manager.element());
+
+      manager.register({
+        name : 'Check',
+        embed : [{
+          panel : 'result',
+          title : 'Map',
+          onClick : {
+            template : 'about:blank',
+            action : 'setWidget',
+            active : false
+          }
+        }]
+      });
+
+      let b = p.actions().element().firstChild;
+      expect(b.getAttribute("title")).toEqual("Map");
+
+      // Button should have a check element (marker-box)
+      expect(b.firstChild.classList.contains('check')).toBeTruthy();
+      expect(b.firstChild.classList.contains('button-icon')).toBeTruthy();
+
+      // active state should be set to false initially
+      expect(b.active).toBeDefined();
+      expect(b.active.get()).toBe(false);
+
+      // Check is not checked initially
+      expect(b.firstChild.classList.contains('checked')).toBeFalsy();
+
+      expect(p.element().querySelectorAll("iframe").length).toEqual(0);
+
+      // Click on check (marker-box) - loads background service,
+      // no visible widget in the panel
+      b.firstChild.click();
+
+      // No iframe in the panel - service is in the services container
+      expect(p.element().querySelectorAll("iframe").length).toEqual(0);
+      expect(manager.element().querySelectorAll("iframe").length).toEqual(1);
+
+      // No widget view element should exist in the panel
+      expect(p.element().querySelectorAll("div.view").length).toEqual(0);
+
+      // active state should have toggled to true
+      expect(b.active.get()).toBe(true);
+      expect(b.firstChild.classList.contains('checked')).toBeTruthy();
+
+      // Click on check again - should not add another service, just toggle active
+      b.firstChild.click();
+      expect(manager.element().querySelectorAll("iframe").length).toEqual(1);
+      expect(b.active.get()).toBe(false);
+      expect(b.firstChild.classList.contains('checked')).toBeFalsy();
+
+      // Click on title - should open the widget visibly
+      b.click();
+      expect(p.element().querySelectorAll("iframe").length).toEqual(1);
+      expect(p.element().querySelectorAll("div.view.show.widget").length).toEqual(1);
+
+      // Click title again - should toggle visibility (hide via state)
+      b.click();
+      expect(p.element().querySelectorAll("div.view.show.widget").length).toEqual(0);
+
+      manager.destroy();
+      KorAP.Panel["result"] = undefined;
+    });
+
+    it('should not show any view element when checkbox is clicked', function () {
+      let p = KorAP.Panel["result"] = panelClass.create();
+      let manager = pluginServerClass.create();
+
+      document.body.appendChild(manager.element());
+
+      manager.register({
+        name : 'NoBar',
+        embed : [{
+          panel : 'result',
+          title : 'Map',
+          onClick : {
+            template : 'about:blank',
+            action : 'setWidget',
+            active : false
+          }
+        }]
+      });
+
+      let b = p.actions().element().firstChild;
+
+      // Click checkbox
+      b.firstChild.click();
+
+      // No view element must exist in the panel at all —
+      // not visible, not hidden, not minimized. The checkbox
+      // must only create a background service, never a widget.
+      expect(p.element().querySelectorAll("div.view").length).toEqual(0);
+      expect(p.element().querySelectorAll("iframe").length).toEqual(0);
+
+      // The iframe must be in the services container instead
+      expect(manager.element().querySelectorAll("iframe").length).toEqual(1);
+
+      // Clicking the title afterwards must properly open the widget
+      b.click();
+      expect(p.element().querySelectorAll("div.view.show.widget").length).toEqual(1);
+      expect(p.element().querySelectorAll("iframe").length).toEqual(1);
+
+      manager.destroy();
+      KorAP.Panel["result"] = undefined;
+    });
+
+    it('should toggle active via checkbox when widget is open', function () {
+      let p = KorAP.Panel["result"] = panelClass.create();
+      let manager = pluginServerClass.create();
+
+      document.body.appendChild(manager.element());
+
+      manager.register({
+        name : 'Check',
+        embed : [{
+          panel : 'result',
+          title : 'Map',
+          onClick : {
+            template : 'about:blank',
+            action : 'setWidget',
+            active : false
+          }
+        }]
+      });
+
+      let b = p.actions().element().firstChild;
+
+      // Open the widget via title-click
+      b.click();
+      expect(p.element().querySelectorAll("div.view.show.widget").length).toEqual(1);
+      expect(b.active.get()).toBe(false);
+
+      let id = b['widgetID'];
+      expect(id).toBeDefined();
+
+      // Active association should exist on the widget
+      expect(b.active.associates()).toBeGreaterThan(0);
+
+      // Click checkbox to activate - should toggle active state
+      b.firstChild.click();
+      expect(b.active.get()).toBe(true);
+      expect(b.firstChild.classList.contains('checked')).toBeTruthy();
+
+      // Widget should still be visible
+      expect(p.element().querySelectorAll("div.view.show.widget").length).toEqual(1);
+
+      // widgetID should remain the same (no new service created)
+      expect(b['widgetID']).toEqual(id);
+
+      // Click checkbox again to deactivate
+      b.firstChild.click();
+      expect(b.active.get()).toBe(false);
+      expect(b.firstChild.classList.contains('checked')).toBeFalsy();
+
+      // Widget should still be visible
+      expect(p.element().querySelectorAll("div.view.show.widget").length).toEqual(1);
+
+      manager.destroy();
+      KorAP.Panel["result"] = undefined;
+    });
+
+    it('should not duplicate pipes when opening widget after checkbox', function () {
+      var tempPipe = KorAP.Pipe;
+      KorAP.Pipe = pipeClass.create();
+
+      let p = KorAP.Panel["result"] = panelClass.create();
+      let manager = pluginServerClass.create();
+
+      document.body.appendChild(manager.element());
+
+      manager.register({
+        name : 'PipeCheck',
+        embed : [{
+          panel : 'result',
+          title : 'Map',
+          onClick : {
+            template : 'about:blank',
+            action : 'setWidget',
+            active : false
+          }
+        }]
+      });
+
+      let b = p.actions().element().firstChild;
+
+      // Click checkbox - background service created, active becomes true
+      b.firstChild.click();
+      expect(b.active.get()).toBe(true);
+      expect(manager.element().querySelectorAll("iframe").length).toEqual(1);
+
+      // Simulate the plugin adding a pipe via the background service
+      let bgId = b['widgetID'];
+      manager._receiveMsg({
+        "data" : {
+          "originID" : bgId,
+          "action" : "pipe",
+          "job" : "add",
+          "service" : "https://mapper.example"
+        }
+      });
+      expect(KorAP.Pipe.toString()).toEqual("https://mapper.example");
+
+      // Click title - opens widget, closes background service
+      b.click();
+      expect(p.element().querySelectorAll("div.view.show.widget").length).toEqual(1);
+
+      // The new widget's plugin re-initializes and tries to add the
+      // same pipe again — the pipe system must deduplicate.
+      let wId = b['widgetID'];
+      manager._receiveMsg({
+        "data" : {
+          "originID" : wId,
+          "action" : "pipe",
+          "job" : "add",
+          "service" : "https://mapper.example"
+        }
+      });
+
+      // Pipe must still contain only one entry
+      expect(KorAP.Pipe.toString()).toEqual("https://mapper.example");
+      expect(KorAP.Pipe.size()).toEqual(1);
+
+      manager.destroy();
+      KorAP.Pipe = tempPipe;
+      KorAP.Panel["result"] = undefined;
+    });
+
+    it('should toggle checkbox visual when widget is open', function () {
+      let p = KorAP.Panel["result"] = panelClass.create();
+      let manager = pluginServerClass.create();
+
+      document.body.appendChild(manager.element());
+
+      manager.register({
+        name : 'VisualCheck',
+        embed : [{
+          panel : 'result',
+          title : 'Map',
+          onClick : {
+            template : 'about:blank',
+            action : 'setWidget',
+            active : false
+          }
+        }]
+      });
+
+      let b = p.actions().element().firstChild;
+      let check = b.firstChild;
+
+      // Open widget via title
+      b.click();
+      expect(p.element().querySelectorAll("div.view.show.widget").length).toEqual(1);
+      expect(check.classList.contains('checked')).toBeFalsy();
+
+      // Click checkbox - should visually check
+      check.click();
+      expect(b.active.get()).toBe(true);
+      expect(check.classList.contains('checked')).toBeTruthy();
+
+      // Click checkbox again - should visually uncheck
+      check.click();
+      expect(b.active.get()).toBe(false);
+      expect(check.classList.contains('checked')).toBeFalsy();
+
+      // Widget should still be visible throughout
+      expect(p.element().querySelectorAll("div.view.show.widget").length).toEqual(1);
+
+      manager.destroy();
+      KorAP.Panel["result"] = undefined;
+    });
+
+    it('should accept valid registrations for addWidget with active', function () {
+      let p = KorAP.Panel["result"] = panelClass.create();
+      
+      let manager = pluginServerClass.create();
+
+      manager.register({
+        name : 'Check',
+        embed : [{
+          panel : 'result',
+          title : 'Export',
+          onClick : {
+            template : 'about:blank',
+            action : 'addWidget',
+            active : true
+          }
+        }]
+      });
+
+      let b = p.actions().element().firstChild;
+
+      // active state should be set to true initially
+      expect(b.active).toBeDefined();
+      expect(b.active.get()).toBe(true);
+      expect(b.firstChild.classList.contains('checked')).toBeTruthy();
+
+      expect(p.element().querySelectorAll("iframe").length).toEqual(0);
+
+      // Click on check - only toggles active, no widget created
+      b.firstChild.click();
+      expect(p.element().querySelectorAll("iframe").length).toEqual(0);
+      expect(b.active.get()).toBe(false);
+      expect(b.firstChild.classList.contains('checked')).toBeFalsy();
+
+      // Click on title - should load a widget visibly
+      b.click();
+      expect(p.element().querySelectorAll("iframe").length).toEqual(1);
+
+      manager.destroy();
+      KorAP.Panel["result"] = undefined;
+    });
+
+    it('should support changeTitle on buttons', function () {
+      let p = KorAP.Panel["result"] = panelClass.create();
+      
+      let manager = pluginServerClass.create();
+
+      manager.register({
+        name : 'Check',
+        embed : [{
+          panel : 'result',
+          title : 'Map',
+          onClick : {
+            template : 'about:blank',
+            action : 'setWidget'
+          }
+        }]
+      });
+
+      let b = p.actions().element().firstChild;
+      expect(b.lastChild.textContent).toEqual("Map");
+
+      b.changeTitle("New Title");
+      expect(b.lastChild.textContent).toEqual("New Title");
+
+      manager.destroy();
+      KorAP.Panel["result"] = undefined;
+    });
+
+    it('should handle Title set message', function () {
+      let p = KorAP.Panel["result"] = panelClass.create();
+      
+      let manager = pluginServerClass.create();
+
+      manager.register({
+        name : 'TitlePlugin',
+        embed : [{
+          panel : 'result',
+          title : 'Original',
+          onClick : {
+            template : 'about:blank',
+            action : 'setWidget'
+          }
+        }]
+      });
+
+      let b = p.actions().element().firstChild;
+      expect(b.lastChild.textContent).toEqual("Original");
+
+      // Click to open widget
+      b.click();
+
+      let id = b['widgetID'];
+      expect(id).toBeDefined();
+
+      // Send Title set message
+      manager._receiveMsg({
+        "data" : {
+          "originID" : id,
+          "action" : "set",
+          "key" : "Title",
+          "value" : "Changed"
+        }
+      });
+
+      expect(b.lastChild.textContent).toEqual("Changed");
+
+      manager.destroy();
+      KorAP.Panel["result"] = undefined;
+    });
+
+    it('should handle Active get/set messages', function () {
+      let p = KorAP.Panel["result"] = panelClass.create();
+      
+      let manager = pluginServerClass.create();
+
+      manager.register({
+        name : 'ActivePlugin',
+        embed : [{
+          panel : 'result',
+          title : 'Map',
+          onClick : {
+            template : 'about:blank',
+            action : 'setWidget',
+            active : false
+          }
+        }]
+      });
+
+      let b = p.actions().element().firstChild;
+      expect(b.active.get()).toBe(false);
+
+      // Click title to open widget
+      b.click();
+
+      let id = b['widgetID'];
+      expect(id).toBeDefined();
+
+      // Get active state
+      let data = {
+        "originID" : id,
+        "action" : "get",
+        "key" : "Active"
+      };
+      manager._receiveMsg({ "data" : data });
+      expect(data.value).toBe(false);
+
+      // Set active state via message
+      manager._receiveMsg({
+        "data" : {
+          "originID" : id,
+          "action" : "set",
+          "key" : "Active",
+          "value" : true
+        }
+      });
+      expect(b.active.get()).toBe(true);
+      expect(b.firstChild.classList.contains('checked')).toBeTruthy();
+
+      // Roll active state via message (no value)
+      manager._receiveMsg({
+        "data" : {
+          "originID" : id,
+          "action" : "set",
+          "key" : "Active"
+        }
+      });
+      expect(b.active.get()).toBe(false);
+      expect(b.firstChild.classList.contains('checked')).toBeFalsy();
+
+      manager.destroy();
+      KorAP.Panel["result"] = undefined;
+    });
+
     it('should accept valid registrations for toggle', function () {
       let p = KorAP.Panel["result"] = panelClass.create();
       
diff --git a/dev/js/src/buttongroup.js b/dev/js/src/buttongroup.js
index 40eb61f..922ca1c 100644
--- a/dev/js/src/buttongroup.js
+++ b/dev/js/src/buttongroup.js
@@ -83,6 +83,7 @@
       const b = this._insert('span');
       let desc = title;
 
+      let that = this;
       if (data !== undefined) {
         if (data['cls'] !== undefined) {
           b.classList.add.apply(b.classList, data['cls']);
@@ -98,14 +99,21 @@
 
         if (data['active'] !== undefined) {
           let active = data['active'];
+          b['active'] = active;
 
-	  let check = _addCheck(b,active);
-	  check.addEventListener('click', function (e) {
-	    // Do not bubble
-	    e.halt();
+      	  let check = _addCheck(b,active);
+	        check.addEventListener('click', function (e) {
+	          // Do not bubble
+	          e.halt();
             // Toggle state
-	    active.roll();
-	  });
+	          active.roll();
+
+            let obj = that._bind || this;
+            obj.button = b;
+            b._activeClick = true;
+            cb.apply(obj, e);
+            b._activeClick = false;
+	        });
         };
 
 	
@@ -119,10 +127,9 @@
       innerSpan.addT(title);
 
       b["changeTitle"] = function (title) {
-	innerSpan.textContent = title;
+       	innerSpan.textContent = title;
       };
       
-      let that = this;
       b.addEventListener('click', function (e) {
 
         // Do not bubble
diff --git a/dev/js/src/pipe.js b/dev/js/src/pipe.js
index f10853e..241b126 100644
--- a/dev/js/src/pipe.js
+++ b/dev/js/src/pipe.js
@@ -37,7 +37,7 @@
      */
     append : function (service) {
       service = _notNull(service);
-      if (service) {
+      if (service && this._pipe.indexOf(service) === -1) {
         this._pipe.push(service);
         this._update();
       };
@@ -49,7 +49,7 @@
      */
     prepend : function (service) {
       service = _notNull(service);
-      if (service) {
+      if (service && this._pipe.indexOf(service) === -1) {
         this._pipe.unshift(service);
         this._update();
       };
diff --git a/dev/js/src/plugin/server.js b/dev/js/src/plugin/server.js
index f8c9855..ab84ed3 100644
--- a/dev/js/src/plugin/server.js
+++ b/dev/js/src/plugin/server.js
@@ -161,6 +161,55 @@
             // "this".button is the button
             // "that" is the server object
 
+            // Active-check click: load iframe as a background service,
+            // not as a visible widget.
+            if (this.button._activeClick && 'active' in this.button) {
+
+              // Service already running - just return
+              // (active.roll() was already called in buttongroup)
+              if (this.button['widgetID'] &&
+                  services[this.button['widgetID']]) {
+                return;
+              };
+
+              // For addWidget mode (no state), just toggle - no service
+              if (!('state' in this.button)) {
+                return;
+              };
+
+              // Create a background service (iframe in services container)
+              let id = that.addService({
+                "name": name,
+                "src": onClick["template"],
+                "permissions": onClick["permissions"]
+              });
+              plugin["widgets"].push(id);
+
+              this.button['widgetID'] = id;
+
+              // Store panel reference so 'get Active' works
+              services[id].panel = this;
+
+              let activeState = this.button.active;
+              let iframe = services[id].load();
+
+              iframe.onload = function () {
+                activeState.associate({
+                  setState : function (value) {
+                    if (services[id]) {
+                      services[id].sendMsg({
+                        action: 'state',
+                        key : 'active',
+                        value : value
+                      });
+                    };
+                  }
+                });
+              };
+
+              return;
+            };
+
             // The button has a state and the state is associated to the
             // a intermediate object to toggle the view
             if ('state' in this.button && this.button.state.associates() > 0) {
@@ -182,15 +231,26 @@
               }
             };
 
+            // If a background service exists (from active-check),
+            // close it before creating the widget
+            if ('active' in this.button &&
+                this.button['widgetID'] &&
+                services[this.button['widgetID']] &&
+                !services[this.button['widgetID']].isWidget) {
+              that._closeService(this.button['widgetID']);
+              this.button['widgetID'] = undefined;
+            };
+
             // Add the widget to the panel
             let id = that.addWidget(this, {
               "name": name,
               "src": onClick["template"], // that._interpolateURI(onClick["template"], this.match);
               "permissions": onClick["permissions"],
-              "desc":desc
+              "desc":desc,
+              "panel":panel
             });
             plugin["widgets"].push(id);
-            
+
             // If a state exists, associate with a mediator object
             if ('state' in this.button) {
               this.button['widgetID'] = id;
@@ -205,7 +265,29 @@
                   };
                 }
               });
-            }
+            };
+
+            // If an active state exists, associate it with the widget
+            // so active-state changes can be reflected in the iframe.
+            if ('active' in this.button) {
+              this.button['widgetID'] = id;
+              let first = true;
+              this.button.active.associate({
+                setState : function (value) {
+                  if (first) {
+                    first = false;
+                    return;
+                  };
+                  if (services[id]) {
+                    services[id].sendMsg({
+                      action: 'state',
+                      key : 'active',
+                      value : value
+                    });
+                  };
+                }
+              });
+            };
           };
 
 
@@ -215,6 +297,11 @@
           if (embed['desc'] != undefined)
             obj['desc'] = embed['desc'];
 
+          if (onClick['active'] !== undefined) {
+            obj['active'] = stateClass.create([true, false]);
+            obj['active'].setIfNotYet(onClick['active'] ? true : false);
+          };
+
           if (onClick["action"] && onClick["action"] == "setWidget") {
 
             // Create a boolean state value,
@@ -559,6 +646,14 @@
           v["page"] = pi.page();
           v["total"] = pi.total();
           v["count"] = pi.count();
+        }
+
+        // Get active toggle state of the widget button
+        else if (d.key == 'Active') {
+          let button = services[d["originID"]].panel.button;
+          if (button && button.active) {
+            d["value"] = button.active.get();
+          };
         };
 
         // data needs to be mirrored
@@ -596,6 +691,24 @@
           // if (v["cq"] != undefined) {};
         }
 
+	      else if (d.key == "Title") {
+          // TODO: Only support "Add title"!
+	        let v = d["value"];
+          services[d["originID"]].panel.button.changeTitle(v);
+	      }
+
+        else if (d.key == "Active") {
+          let v = d["value"];
+          let button = services[d["originID"]].panel.button;
+          if (button && button.active) {
+            if (v !== undefined) {
+              button.active.set(v);
+            } else {
+              button.active.roll();
+            };
+          };
+        };
+
         break;
         
         // Redirect to a different page relative to the current
diff --git a/dev/js/src/plugin/service.js b/dev/js/src/plugin/service.js
index ef6836c..7b4e998 100644
--- a/dev/js/src/plugin/service.js
+++ b/dev/js/src/plugin/service.js
@@ -107,10 +107,12 @@
      */
     sendMsg : function (d) {
       let iframe = this.load();
-      iframe.contentWindow.postMessage(
-        d,
-        '*'
-      ); // TODO: Fix origin
+      if (iframe && iframe.contentWindow) {
+        iframe.contentWindow.postMessage(
+          d,
+          '*'
+        ); // TODO: Fix origin
+      };
     },
 
     // onClose : function () {},