@signalk/app-dock 0.2.1 → 0.3.0-beta.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -9,10 +9,10 @@ A macOS-style app dock for switching between Signal K webapps on touch screens.
9
9
  - **macOS dock magnification** -- icons scale up with parabolic falloff as your finger moves along the dock
10
10
  - **Frosted glass pill** with spring bounce animation, positioned on any screen edge
11
11
  - **Lazy loading** -- apps load only when first tapped (important for Raspberry Pi and low-power devices)
12
- - **Double-tap corner** to reveal the dock -- works on touch and mouse, no conflict with OS gestures or browser fullscreen
13
- - **Mouse edge hover** to reveal on desktop
12
+ - **Double-tap anywhere** to reveal the dock -- works on touch and mouse. Single taps and clicks pass through to the underlying webapp unchanged, so interactive elements (e.g. KIP's corner widgets) keep working. The pass-through recurses into nested iframes too, so double-tap still opens the dock when one webapp embeds another (e.g. KIP's split view with Freeboard-SK).
14
13
  - **Autostart** -- optionally load a default app immediately on open
15
14
  - **keep-alive or destroy** iframe lifecycle
15
+ - **Night/day mode toggle** -- optional sun/moon button that flips `environment.mode` via a PUT handler
16
16
  - **Active dot indicator**, label tooltip, haptic feedback
17
17
  - **Embedded config panel** in the admin UI with webapp discovery, drag-to-reorder, and live preview
18
18
 
@@ -29,9 +29,11 @@ Open: `http://your-sk-server:3000/@signalk/app-dock/`
29
29
 
30
30
  ## Usage
31
31
 
32
- **Double-tap** the configured screen corner (default: bottom-right) to open the dock. Tap an app icon to switch. The dock auto-dismisses after selection, or tap the backdrop to close it.
32
+ **Double-tap anywhere** on the screen to open the dock. Tap an app icon to switch. The dock auto-dismisses after selection, or tap the backdrop to close it. Double-click works the same way for mouse users.
33
33
 
34
- On desktop, move the mouse to the screen edge corresponding to the dock position.
34
+ Tip: double-tapping a clickable element in the underlying webapp will activate that element once before the dock opens -- aim double-taps at non-interactive regions (maps, gauge backgrounds, empty space).
35
+
36
+ Config changes made in **Plugin Config** apply automatically within ~5 seconds: structural changes (position, icon size, etc.) reload the dock page; changes to the app list hot-update in place.
35
37
 
36
38
  ## Configuration
37
39
 
@@ -45,38 +47,45 @@ Open **Plugin Config > App Dock** in the admin UI. The embedded configurator pro
45
47
 
46
48
  ### Settings
47
49
 
48
- | Setting | Default | Description |
49
- | -------------------- | -------------- | ------------------------------------------- |
50
- | `position` | `bottom` | Dock edge: `bottom`, `top`, `left`, `right` |
51
- | `triggerCorner` | `bottom-right` | Which corner activates double-tap |
52
- | `iframeMode` | `keep-alive` | `keep-alive` or `destroy` |
53
- | `iconSize` | `56` | Base icon size in px |
54
- | `magnification` | `true` | Enable macOS-style magnification effect |
55
- | `magnificationScale` | `1.7` | Max icon scale (1.0-2.5) |
50
+ | Setting | Default | Description |
51
+ | --------------------- | ------------ | ------------------------------------------------------------------------------------------------ |
52
+ | `position` | `bottom` | Dock edge: `bottom`, `top`, `left`, `right` |
53
+ | `iframeMode` | `keep-alive` | `keep-alive` loads each app once and hides/shows (faster, more RAM); `destroy` reloads each time |
54
+ | `iconSize` | `56` | Base icon size in px |
55
+ | `magnification` | `true` | Enable macOS-style magnification effect |
56
+ | `magnificationScale` | `1.7` | Max icon scale (1.0-2.5) |
57
+ | `showNightModeButton` | `true` | Show sun/moon button in dock; PUT-writes `environment.mode` |
58
+ | `showExitButton` | `false` | Show X button that returns to the Signal K admin UI |
56
59
 
57
60
  ## Development
58
61
 
59
62
  ```bash
60
- cd ~/.signalk
63
+ cd ~/.signalk # or a throwaway SK config dir for dev, e.g. ~/.signalk-app-dock
61
64
  npm link /path/to/app-dock
62
65
  ```
63
66
 
64
- Edit files in `public/`, reload the browser. No build step needed for the dock itself.
65
-
66
- To rebuild the config panel (after changing `src/configpanel/`):
67
+ For side-by-side development without touching your real SK setup, point the server at a dedicated config dir:
67
68
 
68
69
  ```bash
69
- npm run build:config
70
+ PORT=4000 npm start -- -c ~/.signalk-app-dock
70
71
  ```
71
72
 
73
+ ### Architecture
74
+
75
+ The repo ships three cooperating surfaces:
76
+
77
+ - **`plugin/index.js`** -- CommonJS SK-server plugin. Registers the schema, seeds default apps on first run, exposes HTTP endpoints for the dock and config panel, and owns the `environment.mode` PUT handler.
78
+ - **`public/dock.js` + `dock.css` + `index.html`** -- the dock UI itself. Plain JS, served as-is. **No build step**: edit and reload the browser.
79
+ - **`src/configpanel/`** -- React 19 component exposed to the admin UI via Webpack Module Federation. Rebuild with `npm run build:config` after changes here; output lands in `public/remoteEntry.js` and companion chunks.
80
+
72
81
  ### Scripts
73
82
 
74
- | Command | Description |
75
- | ---------------------- | --------------------------------- |
76
- | `npm test` | Run tests |
77
- | `npm run format` | Prettier + ESLint fix |
78
- | `npm run lint` | ESLint check |
79
- | `npm run build:config` | Rebuild the admin UI config panel |
83
+ | Command | Description |
84
+ | ---------------------- | ----------------------------------------------------------------- |
85
+ | `npm test` | Run plugin tests (`node --test`) |
86
+ | `npm run format` | Prettier + ESLint fix |
87
+ | `npm run lint` | ESLint check |
88
+ | `npm run build:config` | Rebuild the admin UI config panel (required after `src/` changes) |
80
89
 
81
90
  ## License
82
91
 
package/eslint.config.js CHANGED
@@ -44,7 +44,8 @@ module.exports = [
44
44
  requestAnimationFrame: 'readonly',
45
45
  Promise: 'readonly',
46
46
  Set: 'readonly',
47
- Infinity: 'readonly'
47
+ Infinity: 'readonly',
48
+ MutationObserver: 'readonly'
48
49
  }
49
50
  },
50
51
  rules: {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@signalk/app-dock",
3
- "version": "0.2.1",
3
+ "version": "0.3.0-beta.1",
4
4
  "description": "macOS-style configurable app dock for switching between SignalK webapps on touch screens",
5
5
  "repository": {
6
6
  "url": "https://github.com/SignalK/app-dock"
package/plugin/index.js CHANGED
@@ -141,14 +141,6 @@ module.exports = (app) => {
141
141
  default: 'bottom'
142
142
  },
143
143
 
144
- triggerCorner: {
145
- type: 'string',
146
- title: 'Double-tap corner',
147
- description: 'Screen corner that activates the dock on double-tap',
148
- enum: ['bottom-right', 'bottom-left', 'top-right', 'top-left'],
149
- default: 'bottom-right'
150
- },
151
-
152
144
  iframeMode: {
153
145
  type: 'string',
154
146
  title: 'iFrame lifecycle',
package/public/805.js CHANGED
@@ -1 +1 @@
1
- "use strict";(self.webpackChunk_signalk_app_dock=self.webpackChunk_signalk_app_dock||[]).push([[805],{805(e,t,n){n.r(t),n.d(t,{default:()=>s});var a=n(231);const l={root:{fontFamily:'-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif',color:"#333",padding:"16px 0"},sectionTitle:{fontSize:13,fontWeight:600,color:"#888",textTransform:"uppercase",letterSpacing:"0.05em",marginBottom:10,marginTop:24},btn:{display:"inline-flex",alignItems:"center",gap:8,padding:"8px 16px",border:"none",borderRadius:6,fontSize:13,fontWeight:600,cursor:"pointer"},btnPrimary:{background:"#3b82f6",color:"#fff"},btnSave:{background:"#10b981",color:"#fff"},btnDanger:{background:"#ef4444",color:"#fff",padding:"4px 10px",fontSize:11},status:{marginTop:8,fontSize:12,minHeight:18},item:{display:"flex",alignItems:"center",gap:10,padding:"8px 12px",background:"#f8f9fa",border:"1px solid #e0e0e0",borderRadius:8,marginBottom:6,cursor:"grab"},itemDisabled:{opacity:.4},dragOver:{borderColor:"#3b82f6",background:"#eef4ff"},handle:{color:"#bbb",fontSize:16,cursor:"grab",userSelect:"none",flexShrink:0,width:18,textAlign:"center"},iconBox:{width:36,height:36,borderRadius:8,display:"flex",alignItems:"center",justifyContent:"center",background:"#e2e8f0",overflow:"hidden",flexShrink:0,fontSize:18},iconImg:{width:"100%",height:"100%",objectFit:"contain",padding:5},info:{flex:1,minWidth:0},name:{fontSize:13,fontWeight:600,color:"#333"},url:{fontSize:10,color:"#999",overflow:"hidden",textOverflow:"ellipsis",whiteSpace:"nowrap"},toggle:{flexShrink:0},checkbox:{width:16,height:16,cursor:"pointer",accentColor:"#3b82f6"},preview:{display:"flex",alignItems:"flex-end",justifyContent:"center",gap:6,padding:"12px 18px",background:"rgba(30,30,32,0.85)",border:"1px solid rgba(255,255,255,0.18)",borderRadius:18,margin:"16px auto",width:"fit-content"},actions:{display:"flex",gap:10,alignItems:"center",marginTop:16},empty:{textAlign:"center",padding:"30px 16px",color:"#999",fontSize:13},fieldRow:{display:"flex",alignItems:"center",gap:12,marginBottom:10},label:{fontSize:13,fontWeight:500,color:"#555",width:160,flexShrink:0},select:{padding:"6px 10px",borderRadius:6,border:"1px solid #ccc",fontSize:13,background:"#fff",color:"#333"},input:{padding:"6px 10px",borderRadius:6,border:"1px solid #ccc",fontSize:13,width:80,background:"#fff",color:"#333"},checkboxField:{width:16,height:16,accentColor:"#3b82f6"},hint:{fontSize:11,color:"#aaa",marginLeft:8}};function o({icon:e,label:t,color:n,size:o}){const i=o||36,r={...l.iconBox,width:i,height:i,borderRadius:.22*i,...n?{background:n}:{}},c=e||"";return c.startsWith("/")||c.startsWith("http")?a.createElement("div",{style:r},a.createElement("img",{src:c,alt:t,style:{...l.iconImg,padding:.12*i},onError:e=>{e.target.style.display="none",e.target.parentNode.textContent=(t||"??").slice(0,2).toUpperCase()}})):a.createElement("div",{style:r},c||(t||"??").slice(0,2).toUpperCase())}function i({label:e,value:t,options:n,onChange:o,hint:i}){return a.createElement("div",{style:l.fieldRow},a.createElement("span",{style:l.label},e),a.createElement("select",{style:l.select,value:t,onChange:e=>o(e.target.value)},n.map(e=>a.createElement("option",{key:e.value,value:e.value},e.label))),i&&a.createElement("span",{style:l.hint},i))}function r({label:e,value:t,onChange:n,hint:o}){return a.createElement("div",{style:l.fieldRow},a.createElement("span",{style:l.label},e),a.createElement("input",{style:l.input,type:"number",value:t,onChange:e=>n(Number(e.target.value))}),o&&a.createElement("span",{style:l.hint},o))}function c({label:e,checked:t,onChange:n,hint:o}){return a.createElement("div",{style:l.fieldRow},a.createElement("span",{style:l.label},e),a.createElement("input",{type:"checkbox",style:l.checkboxField,checked:t,onChange:e=>n(e.target.checked)}),o&&a.createElement("span",{style:l.hint},o))}function s({configuration:e,save:t}){const n=e||{},[s,d]=(0,a.useState)(()=>n.apps||[]),[u,g]=(0,a.useState)(n.position||"bottom"),[p,m]=(0,a.useState)(n.triggerCorner||"bottom-right"),[b,f]=(0,a.useState)(n.iframeMode||"keep-alive"),[h,v]=(0,a.useState)(n.iconSize||56),[y,k]=(0,a.useState)(!1!==n.magnification),[E,S]=(0,a.useState)(n.magnificationScale||1.7),[x,w]=(0,a.useState)(n.showNightModeButton||!1),[C,z]=(0,a.useState)(n.showExitButton||!1),[D,R]=(0,a.useState)(""),[B,T]=(0,a.useState)(!1),[I,F]=(0,a.useState)(!1),[A,M]=(0,a.useState)(!1),[N,W]=(0,a.useState)(null),[$,_]=(0,a.useState)(null),O=(0,a.useRef)(s);O.current=s;const U=(0,a.useRef)(null);(0,a.useEffect)(()=>()=>{U.current&&clearTimeout(U.current)},[]);const j=(0,a.useCallback)(e=>({position:u,triggerCorner:p,iframeMode:b,iconSize:h,magnification:y,magnificationScale:E,showNightModeButton:x,showExitButton:C,apps:e}),[u,p,b,h,y,E,x,C]),L=(0,a.useCallback)(async()=>{U.current&&clearTimeout(U.current),M(!0),R("Saving…"),T(!1);try{const e=t(j(s));e&&"function"==typeof e.then&&await e,R("Configuration saved — plugin will restart"),T(!1),U.current=setTimeout(()=>R(""),5e3)}catch(e){R("Save failed: "+(e.message||e)),T(!0)}finally{M(!1)}},[s,j,t]),P=()=>_(null),K=()=>{W(null),_(null)},H=s.filter(e=>!1!==e.enabled);return a.createElement("div",{style:l.root},a.createElement("div",{style:l.sectionTitle},"Dock Settings"),a.createElement(i,{label:"Dock position",value:u,onChange:g,options:[{value:"bottom",label:"Bottom"},{value:"top",label:"Top"},{value:"left",label:"Left"},{value:"right",label:"Right"}]}),a.createElement(i,{label:"Double-tap corner",value:p,onChange:m,options:[{value:"bottom-right",label:"Bottom-right"},{value:"bottom-left",label:"Bottom-left"},{value:"top-right",label:"Top-right"},{value:"top-left",label:"Top-left"}]}),a.createElement(i,{label:"iFrame lifecycle",value:b,onChange:f,options:[{value:"keep-alive",label:"Keep-alive (faster switching, more RAM)"},{value:"destroy",label:"Destroy (reload each time, less RAM)"}]}),a.createElement(r,{label:"Icon size",value:h,onChange:v,hint:"px"}),a.createElement(c,{label:"Magnification effect",checked:y,onChange:k}),y&&a.createElement(r,{label:"Magnification scale",value:E,onChange:S,hint:"1.0 - 2.5"}),a.createElement("div",{style:l.sectionTitle},"Utility Buttons"),a.createElement(c,{label:"Night/Day mode toggle",checked:x,onChange:w,hint:"Sun/moon button to toggle environment.mode"}),a.createElement(c,{label:"Exit button",checked:C,onChange:z,hint:"X button to return to Signal K admin UI"}),a.createElement("div",{style:l.sectionTitle},"Webapps"),a.createElement("button",{style:{...l.btn,...l.btnPrimary,...I?{opacity:.6}:{}},onClick:async()=>{F(!0),R(""),T(!1);try{const e=await fetch("/skServer/webapps"),t=(await e.json()).filter(e=>"@signalk/app-dock"!==e.name).map(e=>"@signalk/server-admin-ui"===e.name?{name:e.name,label:"Settings",url:"/admin/",icon:"/@signalk/app-dock/icon-settings.svg",isAdmin:!0}:{name:e.name,label:e.signalk?.displayName||e.name,url:`/${e.name}/`,icon:e.signalk?.appIcon?`/${e.name}/${e.signalk.appIcon}`:null});if(0===t.length)return R("No webapps found. Is the server fully started?"),T(!0),void F(!1);const n=O.current,a=new Set(n.map(e=>e.url));let l=0;const o=[...n];for(const e of t)a.has(e.url)||(o.push({enabled:!0,autostart:!1,url:e.url,label:e.label||"",icon:e.icon||"",color:e.isAdmin?"#78788c":""}),l++);d(o),R(l>0?`Found ${t.length} webapps, added ${l} new.`:`All ${t.length} webapps already in list.`),T(!1)}catch(e){R("Discovery failed: "+e.message),T(!0)}F(!1)},disabled:I},I?"Discovering…":"Discover Installed Webapps"),D&&a.createElement("div",{style:{...l.status,color:B?"#ef4444":"#10b981"}},D),a.createElement("div",{style:l.sectionTitle},"Dock Apps (drag to reorder)"),0===s.length?a.createElement("div",{style:l.empty},"No apps yet. Click Discover above."):a.createElement("div",null,s.map((e,t)=>a.createElement("div",{key:e.url+t,draggable:!0,onDragStart:()=>(e=>W(e))(t),onDragOver:e=>((e,t)=>{e.preventDefault(),_(t)})(e,t),onDragLeave:P,onDrop:e=>((e,t)=>{if(e.preventDefault(),_(null),null!==N&&N!==t){const e=[...s],[n]=e.splice(N,1);e.splice(t,0,n),d(e)}W(null)})(e,t),onDragEnd:K,style:{...l.item,...!1===e.enabled?l.itemDisabled:{},...$===t?l.dragOver:{},...N===t?{opacity:.4}:{}}},a.createElement("span",{style:l.handle},"≡"),a.createElement(o,{icon:e.icon,label:e.label||e.url,color:e.color}),a.createElement("div",{style:l.info},a.createElement("div",{style:l.name},e.label||e.url),a.createElement("div",{style:l.url},e.url)),a.createElement("div",{style:l.toggle,title:"Enabled"},a.createElement("input",{type:"checkbox",checked:!1!==e.enabled,onChange:()=>(e=>d(s.map((t,n)=>n===e?{...t,enabled:!t.enabled}:t)))(t),style:l.checkbox})),a.createElement("div",{style:{...l.toggle,cursor:"pointer",fontSize:16,opacity:e.autostart?1:.25},onClick:()=>(e=>d(s.map((t,n)=>n===e?{...t,autostart:!t.autostart}:{...t,autostart:!1})))(t),title:e.autostart?"Autostart: ON (click to disable)":"Click to set as autostart app"},"▶"),a.createElement("button",{style:{...l.btn,...l.btnDanger},onClick:()=>(e=>d(s.filter((t,n)=>n!==e)))(t)},"Remove")))),(H.length>0||x||C)&&a.createElement(a.Fragment,null,a.createElement("div",{style:l.sectionTitle},"Preview"),a.createElement("div",{style:l.preview},x&&a.createElement(a.Fragment,null,a.createElement("div",{style:{...l.iconBox,width:40,height:40,background:"rgba(255,255,255,0.08)",border:"1px solid rgba(255,255,255,0.12)",fontSize:20,borderRadius:10}},"☀️"),a.createElement("div",{style:{width:1,height:24,background:"rgba(255,255,255,0.15)",alignSelf:"center"}})),H.map((e,t)=>a.createElement(o,{key:t,icon:e.icon,label:e.label||e.url,color:e.color,size:40})),C&&a.createElement(a.Fragment,null,a.createElement("div",{style:{width:1,height:24,background:"rgba(255,255,255,0.15)",alignSelf:"center"}}),a.createElement("div",{style:{...l.iconBox,width:40,height:40,background:"rgba(255,255,255,0.08)",border:"1px solid rgba(255,255,255,0.12)",fontSize:16,borderRadius:10,color:"#fff"}},"✕")))),a.createElement("div",{style:l.actions},a.createElement("button",{style:{...l.btn,...l.btnSave,...A?{opacity:.6}:{}},onClick:L,disabled:A},A?"Saving…":"Save Configuration"),D&&a.createElement("div",{style:{...l.status,color:B?"#ef4444":"#10b981"}},D)))}}}]);
1
+ "use strict";(self.webpackChunk_signalk_app_dock=self.webpackChunk_signalk_app_dock||[]).push([[805],{805(e,t,n){n.r(t),n.d(t,{default:()=>s});var a=n(231);const l={root:{fontFamily:'-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif',color:"#333",padding:"16px 0"},sectionTitle:{fontSize:13,fontWeight:600,color:"#888",textTransform:"uppercase",letterSpacing:"0.05em",marginBottom:10,marginTop:24},btn:{display:"inline-flex",alignItems:"center",gap:8,padding:"8px 16px",border:"none",borderRadius:6,fontSize:13,fontWeight:600,cursor:"pointer"},btnPrimary:{background:"#3b82f6",color:"#fff"},btnSave:{background:"#10b981",color:"#fff"},btnDanger:{background:"#ef4444",color:"#fff",padding:"4px 10px",fontSize:11},status:{marginTop:8,fontSize:12,minHeight:18},item:{display:"flex",alignItems:"center",gap:10,padding:"8px 12px",background:"#f8f9fa",border:"1px solid #e0e0e0",borderRadius:8,marginBottom:6,cursor:"grab"},itemDisabled:{opacity:.4},dragOver:{borderColor:"#3b82f6",background:"#eef4ff"},handle:{color:"#bbb",fontSize:16,cursor:"grab",userSelect:"none",flexShrink:0,width:18,textAlign:"center"},iconBox:{width:36,height:36,borderRadius:8,display:"flex",alignItems:"center",justifyContent:"center",background:"#e2e8f0",overflow:"hidden",flexShrink:0,fontSize:18},iconImg:{width:"100%",height:"100%",objectFit:"contain",padding:5},info:{flex:1,minWidth:0},name:{fontSize:13,fontWeight:600,color:"#333"},url:{fontSize:10,color:"#999",overflow:"hidden",textOverflow:"ellipsis",whiteSpace:"nowrap"},toggle:{flexShrink:0},checkbox:{width:16,height:16,cursor:"pointer",accentColor:"#3b82f6"},preview:{display:"flex",alignItems:"flex-end",justifyContent:"center",gap:6,padding:"12px 18px",background:"rgba(30,30,32,0.85)",border:"1px solid rgba(255,255,255,0.18)",borderRadius:18,margin:"16px auto",width:"fit-content"},actions:{display:"flex",gap:10,alignItems:"center",marginTop:16},empty:{textAlign:"center",padding:"30px 16px",color:"#999",fontSize:13},fieldRow:{display:"flex",alignItems:"center",gap:12,marginBottom:10},label:{fontSize:13,fontWeight:500,color:"#555",width:160,flexShrink:0},select:{padding:"6px 10px",borderRadius:6,border:"1px solid #ccc",fontSize:13,background:"#fff",color:"#333"},input:{padding:"6px 10px",borderRadius:6,border:"1px solid #ccc",fontSize:13,width:80,background:"#fff",color:"#333"},checkboxField:{width:16,height:16,accentColor:"#3b82f6"},hint:{fontSize:11,color:"#aaa",marginLeft:8}};function o({icon:e,label:t,color:n,size:o}){const i=o||36,r={...l.iconBox,width:i,height:i,borderRadius:.22*i,...n?{background:n}:{}},c=e||"";return c.startsWith("/")||c.startsWith("http")?a.createElement("div",{style:r},a.createElement("img",{src:c,alt:t,style:{...l.iconImg,padding:.12*i},onError:e=>{e.target.style.display="none",e.target.parentNode.textContent=(t||"??").slice(0,2).toUpperCase()}})):a.createElement("div",{style:r},c||(t||"??").slice(0,2).toUpperCase())}function i({label:e,value:t,options:n,onChange:o,hint:i}){return a.createElement("div",{style:l.fieldRow},a.createElement("span",{style:l.label},e),a.createElement("select",{style:l.select,value:t,onChange:e=>o(e.target.value)},n.map(e=>a.createElement("option",{key:e.value,value:e.value},e.label))),i&&a.createElement("span",{style:l.hint},i))}function r({label:e,value:t,onChange:n,hint:o}){return a.createElement("div",{style:l.fieldRow},a.createElement("span",{style:l.label},e),a.createElement("input",{style:l.input,type:"number",value:t,onChange:e=>n(Number(e.target.value))}),o&&a.createElement("span",{style:l.hint},o))}function c({label:e,checked:t,onChange:n,hint:o}){return a.createElement("div",{style:l.fieldRow},a.createElement("span",{style:l.label},e),a.createElement("input",{type:"checkbox",style:l.checkboxField,checked:t,onChange:e=>n(e.target.checked)}),o&&a.createElement("span",{style:l.hint},o))}function s({configuration:e,save:t}){const n=e||{},[s,d]=(0,a.useState)(()=>n.apps||[]),[u,g]=(0,a.useState)(n.position||"bottom"),[p,m]=(0,a.useState)(n.iframeMode||"keep-alive"),[b,f]=(0,a.useState)(n.iconSize||56),[h,y]=(0,a.useState)(!1!==n.magnification),[v,k]=(0,a.useState)(n.magnificationScale||1.7),[E,S]=(0,a.useState)(n.showNightModeButton||!1),[x,w]=(0,a.useState)(n.showExitButton||!1),[C,D]=(0,a.useState)(n.tourDismissed||!1),[z,R]=(0,a.useState)(""),[I,B]=(0,a.useState)(!1),[T,F]=(0,a.useState)(!1),[A,M]=(0,a.useState)(!1),[N,W]=(0,a.useState)(null),[$,_]=(0,a.useState)(null),O=(0,a.useRef)(s);O.current=s;const U=(0,a.useRef)(null);(0,a.useEffect)(()=>()=>{U.current&&clearTimeout(U.current)},[]);const j=(0,a.useCallback)(e=>({position:u,iframeMode:p,iconSize:b,magnification:h,magnificationScale:v,showNightModeButton:E,showExitButton:x,tourDismissed:C,apps:e}),[u,p,b,h,v,E,x,C]),L=(0,a.useCallback)(async()=>{U.current&&clearTimeout(U.current),M(!0),R("Saving…"),B(!1);try{const e=t(j(s));e&&"function"==typeof e.then&&await e,R("Configuration saved — plugin will restart"),B(!1),U.current=setTimeout(()=>R(""),5e3)}catch(e){R("Save failed: "+(e.message||e)),B(!0)}finally{M(!1)}},[s,j,t]),P=()=>_(null),K=()=>{W(null),_(null)},H=s.filter(e=>!1!==e.enabled);return a.createElement("div",{style:l.root},a.createElement("div",{style:l.sectionTitle},"Dock Settings"),a.createElement(i,{label:"Dock position",value:u,onChange:g,options:[{value:"bottom",label:"Bottom"},{value:"top",label:"Top"},{value:"left",label:"Left"},{value:"right",label:"Right"}]}),a.createElement(i,{label:"iFrame lifecycle",value:p,onChange:m,options:[{value:"keep-alive",label:"Keep-alive (faster switching, more RAM)"},{value:"destroy",label:"Destroy (reload each time, less RAM)"}]}),a.createElement(r,{label:"Icon size",value:b,onChange:f,hint:"px"}),a.createElement(c,{label:"Magnification effect",checked:h,onChange:y}),h&&a.createElement(r,{label:"Magnification scale",value:v,onChange:k,hint:"1.0 - 2.5"}),a.createElement("div",{style:l.sectionTitle},"Utility Buttons"),a.createElement(c,{label:"Night/Day mode toggle",checked:E,onChange:S,hint:"Sun/moon button to toggle environment.mode"}),a.createElement(c,{label:"Exit button",checked:x,onChange:w,hint:"X button to return to Signal K admin UI"}),a.createElement("div",{style:l.sectionTitle},"Webapps"),a.createElement("button",{style:{...l.btn,...l.btnPrimary,...T?{opacity:.6}:{}},onClick:async()=>{F(!0),R(""),B(!1);try{const e=await fetch("/skServer/webapps"),t=(await e.json()).filter(e=>"@signalk/app-dock"!==e.name).map(e=>"@signalk/server-admin-ui"===e.name?{name:e.name,label:"Settings",url:"/admin/",icon:"/@signalk/app-dock/icon-settings.svg",isAdmin:!0}:{name:e.name,label:e.signalk?.displayName||e.name,url:`/${e.name}/`,icon:e.signalk?.appIcon?`/${e.name}/${e.signalk.appIcon}`:null});if(0===t.length)return R("No webapps found. Is the server fully started?"),B(!0),void F(!1);const n=O.current,a=new Set(n.map(e=>e.url));let l=0;const o=[...n];for(const e of t)a.has(e.url)||(o.push({enabled:!0,autostart:!1,url:e.url,label:e.label||"",icon:e.icon||"",color:e.isAdmin?"#78788c":""}),l++);d(o),R(l>0?`Found ${t.length} webapps, added ${l} new.`:`All ${t.length} webapps already in list.`),B(!1)}catch(e){R("Discovery failed: "+e.message),B(!0)}F(!1)},disabled:T},T?"Discovering…":"Discover Installed Webapps"),z&&a.createElement("div",{style:{...l.status,color:I?"#ef4444":"#10b981"}},z),a.createElement("div",{style:l.sectionTitle},"Dock Apps (drag to reorder)"),0===s.length?a.createElement("div",{style:l.empty},"No apps yet. Click Discover above."):a.createElement("div",null,s.map((e,t)=>a.createElement("div",{key:e.url+t,draggable:!0,onDragStart:()=>(e=>W(e))(t),onDragOver:e=>((e,t)=>{e.preventDefault(),_(t)})(e,t),onDragLeave:P,onDrop:e=>((e,t)=>{if(e.preventDefault(),_(null),null!==N&&N!==t){const e=[...s],[n]=e.splice(N,1);e.splice(t,0,n),d(e)}W(null)})(e,t),onDragEnd:K,style:{...l.item,...!1===e.enabled?l.itemDisabled:{},...$===t?l.dragOver:{},...N===t?{opacity:.4}:{}}},a.createElement("span",{style:l.handle},"≡"),a.createElement(o,{icon:e.icon,label:e.label||e.url,color:e.color}),a.createElement("div",{style:l.info},a.createElement("div",{style:l.name},e.label||e.url),a.createElement("div",{style:l.url},e.url)),a.createElement("div",{style:l.toggle,title:"Enabled"},a.createElement("input",{type:"checkbox",checked:!1!==e.enabled,onChange:()=>(e=>d(s.map((t,n)=>n===e?{...t,enabled:!t.enabled}:t)))(t),style:l.checkbox})),a.createElement("div",{style:{...l.toggle,cursor:"pointer",fontSize:16,opacity:e.autostart?1:.25},onClick:()=>(e=>d(s.map((t,n)=>n===e?{...t,autostart:!t.autostart}:{...t,autostart:!1})))(t),title:e.autostart?"Autostart: ON (click to disable)":"Click to set as autostart app"},"▶"),a.createElement("button",{style:{...l.btn,...l.btnDanger},onClick:()=>(e=>d(s.filter((t,n)=>n!==e)))(t)},"Remove")))),(H.length>0||E||x)&&a.createElement(a.Fragment,null,a.createElement("div",{style:l.sectionTitle},"Preview"),a.createElement("div",{style:l.preview},E&&a.createElement(a.Fragment,null,a.createElement("div",{style:{...l.iconBox,width:40,height:40,background:"rgba(255,255,255,0.08)",border:"1px solid rgba(255,255,255,0.12)",fontSize:20,borderRadius:10}},"☀️"),a.createElement("div",{style:{width:1,height:24,background:"rgba(255,255,255,0.15)",alignSelf:"center"}})),H.map((e,t)=>a.createElement(o,{key:t,icon:e.icon,label:e.label||e.url,color:e.color,size:40})),x&&a.createElement(a.Fragment,null,a.createElement("div",{style:{width:1,height:24,background:"rgba(255,255,255,0.15)",alignSelf:"center"}}),a.createElement("div",{style:{...l.iconBox,width:40,height:40,background:"rgba(255,255,255,0.08)",border:"1px solid rgba(255,255,255,0.12)",fontSize:16,borderRadius:10,color:"#fff"}},"✕")))),a.createElement("div",{style:l.actions},a.createElement("button",{style:{...l.btn,...l.btnSave,...A?{opacity:.6}:{}},onClick:L,disabled:A},A?"Saving…":"Save Configuration"),z&&a.createElement("div",{style:{...l.status,color:I?"#ef4444":"#10b981"}},z)))}}}]);
package/public/dock.css CHANGED
@@ -396,19 +396,6 @@ body {
396
396
  background: rgba(239, 68, 68, 0.25);
397
397
  }
398
398
 
399
- /* ─── Invisible corner trigger zone ────────────────────────────────────────── */
400
- #trigger-corner {
401
- position: fixed;
402
- z-index: 9;
403
- width: 120px;
404
- height: 120px;
405
- pointer-events: auto;
406
- touch-action: none;
407
- -webkit-touch-callout: none;
408
- -webkit-user-select: none;
409
- user-select: none;
410
- }
411
-
412
399
  /* ─── Loading overlay ───────────────────────────────────────────────────────── */
413
400
  #loading-overlay {
414
401
  position: fixed;
package/public/dock.js CHANGED
@@ -3,12 +3,11 @@
3
3
 
4
4
  const DEFAULTS = {
5
5
  position: 'bottom',
6
- triggerCorner: 'bottom-right',
7
6
  iframeMode: 'keep-alive',
8
7
  iconSize: 56,
9
8
  magnification: true,
10
9
  magnificationScale: 1.7,
11
- showNightModeButton: false,
10
+ showNightModeButton: true,
12
11
  showExitButton: false,
13
12
  apps: []
14
13
  }
@@ -44,7 +43,6 @@
44
43
  const $backdrop = document.getElementById('dock-backdrop')
45
44
  const $dock = document.getElementById('dock')
46
45
  const $dockInner = document.getElementById('dock-inner')
47
- const $triggerCorner = document.getElementById('trigger-corner')
48
46
  const $loadingOverlay = document.getElementById('loading-overlay')
49
47
  const $loadingLabel = document.getElementById('loading-label')
50
48
  const $idleHint = document.getElementById('idle-hint')
@@ -130,18 +128,8 @@
130
128
  return
131
129
  }
132
130
 
133
- const cornerNames = {
134
- 'bottom-right': 'bottom-right',
135
- 'bottom-left': 'bottom-left',
136
- 'top-right': 'top-right',
137
- 'top-left': 'top-left'
138
- }
139
- const corner = cornerNames[cfg.triggerCorner] || 'bottom-right'
140
-
141
131
  $idleHintText.innerHTML =
142
- 'Double-tap the <strong>' +
143
- corner +
144
- ' corner</strong> to open the dock' +
132
+ 'Double-tap <strong>anywhere</strong> to open the dock' +
145
133
  '<br><br><span style="font-size:12px;opacity:0.6">Configure in Admin UI \u2192 Plugin Config \u2192 App Dock</span>'
146
134
  }
147
135
 
@@ -479,82 +467,111 @@
479
467
  hideDock()
480
468
  }
481
469
 
482
- // ─── Corner trigger zone: position it ────────────────────────────────────────
483
- function positionCornerZone() {
484
- const [vert, horiz] = cfg.triggerCorner.split('-')
485
- const sz = 120
486
-
487
- $triggerCorner.style.width = sz + 'px'
488
- $triggerCorner.style.height = sz + 'px'
489
-
490
- $triggerCorner.style.top = ''
491
- $triggerCorner.style.bottom = ''
492
- $triggerCorner.style.left = ''
493
- $triggerCorner.style.right = ''
470
+ // ─── Gesture: full-screen double-tap ────────────────────────────────────────
471
+ // No overlay element. We attach passive, non-preventDefault listeners to the
472
+ // parent document AND each same-origin iframe's contentDocument (recursively,
473
+ // to handle webapps like KIP that embed other apps), so webapps receive every
474
+ // tap/click normally. A double-tap anywhere opens the dock. Side effect:
475
+ // double-tapping a button fires that button once before the dock opens —
476
+ // users are expected to aim double-taps at non-interactive regions.
477
+ const DOUBLE_TAP_MS = 280
478
+ const DOUBLE_TAP_MIN_GAP_MS = 60
479
+ const DOUBLE_TAP_SLOP = 24
480
+
481
+ let lastTapTime = 0
482
+ let lastTapX = 0
483
+ let lastTapY = 0
484
+
485
+ function triggerDock() {
486
+ if (dockVisible) return
487
+ showDock()
488
+ if (navigator.vibrate) navigator.vibrate(8)
489
+ }
494
490
 
495
- $triggerCorner.style[vert] = '0'
496
- $triggerCorner.style[horiz] = '0'
491
+ function handleTap(clientX, clientY) {
492
+ if (dockVisible) return
493
+ const now = Date.now()
494
+ const dx = clientX - lastTapX
495
+ const dy = clientY - lastTapY
496
+ const close = dx * dx + dy * dy <= DOUBLE_TAP_SLOP * DOUBLE_TAP_SLOP
497
+ const gap = now - lastTapTime
498
+ if (gap >= DOUBLE_TAP_MIN_GAP_MS && gap < DOUBLE_TAP_MS && close) {
499
+ lastTapTime = 0
500
+ triggerDock()
501
+ } else {
502
+ lastTapTime = now
503
+ lastTapX = clientX
504
+ lastTapY = clientY
505
+ }
497
506
  }
498
507
 
499
- // ─── Gesture: double-tap on corner (touch) + double-click (mouse) ─────────────
500
- {
501
- let lastTapTime = 0
502
- const DOUBLE_TAP_MS = 400
508
+ function attachTapListener(doc, frameEl) {
509
+ if (!doc || doc.__dockListenersAttached) return
510
+ doc.__dockListenersAttached = true
503
511
 
504
- $triggerCorner.addEventListener(
505
- 'touchend',
512
+ const offset = () => {
513
+ if (!frameEl) return { x: 0, y: 0 }
514
+ const r = frameEl.getBoundingClientRect()
515
+ return { x: r.left, y: r.top }
516
+ }
517
+
518
+ doc.addEventListener(
519
+ 'pointerdown',
506
520
  (e) => {
507
- const now = Date.now()
508
- if (now - lastTapTime < DOUBLE_TAP_MS) {
509
- e.preventDefault()
510
- lastTapTime = 0
511
- showDock()
512
- if (navigator.vibrate) navigator.vibrate(8)
513
- } else {
514
- lastTapTime = now
515
- }
521
+ const o = offset()
522
+ handleTap(e.clientX + o.x, e.clientY + o.y)
516
523
  },
517
- { passive: false }
524
+ { passive: true, capture: true }
518
525
  )
526
+ }
519
527
 
520
- $triggerCorner.addEventListener('dblclick', (e) => {
521
- e.preventDefault()
522
- showDock()
523
- })
528
+ // Recursively attach to iframes at any nesting depth. Webapps like KIP
529
+ // embed other webapps (e.g. FSK) in their own iframes, so we walk the tree
530
+ // and observe each same-origin contentDocument for future iframe additions.
531
+ const attachedDocs = new WeakSet()
524
532
 
525
- $triggerCorner.addEventListener('contextmenu', (e) => e.preventDefault())
526
- }
527
-
528
- // ─── Gesture: mouse hover at screen edge ─────────────────────────────────────
529
- {
530
- const EDGE_ZONE = 4
531
- let edgeTimer = null
532
-
533
- document.addEventListener('mousemove', (e) => {
534
- if (dockVisible) return
535
- const W = window.innerWidth
536
- const H = window.innerHeight
537
- let atEdge = false
538
-
539
- if (pos === 'bottom' && e.clientY >= H - EDGE_ZONE) atEdge = true
540
- if (pos === 'top' && e.clientY <= EDGE_ZONE) atEdge = true
541
- if (pos === 'left' && e.clientX <= EDGE_ZONE) atEdge = true
542
- if (pos === 'right' && e.clientX >= W - EDGE_ZONE) atEdge = true
543
-
544
- if (atEdge && !edgeTimer) {
545
- edgeTimer = setTimeout(() => {
546
- showDock()
547
- edgeTimer = null
548
- }, 300)
549
- } else if (!atEdge && edgeTimer) {
550
- clearTimeout(edgeTimer)
551
- edgeTimer = null
533
+ function attachToIframe(frame) {
534
+ const wire = () => {
535
+ let doc
536
+ try {
537
+ doc = frame.contentDocument
538
+ } catch {
539
+ return // cross-origin; can't reach
540
+ }
541
+ if (!doc || attachedDocs.has(doc)) return
542
+ attachedDocs.add(doc)
543
+ attachTapListener(doc, frame)
544
+ scanAndObserveDoc(doc)
545
+ }
546
+ frame.addEventListener('load', wire)
547
+ wire()
548
+ }
549
+
550
+ function scanAndObserveDoc(doc) {
551
+ // Attach to iframes already in this document.
552
+ doc.querySelectorAll('iframe').forEach((f) => attachToIframe(f))
553
+
554
+ // Observe future iframe additions anywhere in the doc tree.
555
+ const root = doc.documentElement || doc.body || doc
556
+ const obs = new MutationObserver((records) => {
557
+ for (const r of records) {
558
+ r.addedNodes.forEach((n) => {
559
+ if (!n || n.nodeType !== 1) return
560
+ if (n.tagName === 'IFRAME') attachToIframe(n)
561
+ // Descendants: catch iframes added as part of a subtree.
562
+ const nested = n.querySelectorAll && n.querySelectorAll('iframe')
563
+ if (nested) nested.forEach((f) => attachToIframe(f))
564
+ })
552
565
  }
553
566
  })
567
+ obs.observe(root, { childList: true, subtree: true })
554
568
  }
555
569
 
570
+ attachTapListener(document, null)
571
+ attachedDocs.add(document)
572
+ scanAndObserveDoc(document)
573
+
556
574
  // ─── Init ────────────────────────────────────────────────────────────────────
557
- positionCornerZone()
558
575
  buildDock()
559
576
 
560
577
  if (cfg.showNightModeButton) {
@@ -562,26 +579,47 @@
562
579
  setInterval(fetchCurrentMode, 5000)
563
580
  }
564
581
 
582
+ // Changes to any of these require a full rebuild (dock classes, magnification
583
+ // constants, dock items, etc.) — easiest to just reload the page.
584
+ const STRUCTURAL_KEYS = [
585
+ 'position',
586
+ 'iframeMode',
587
+ 'iconSize',
588
+ 'magnification',
589
+ 'magnificationScale',
590
+ 'showNightModeButton',
591
+ 'showExitButton'
592
+ ]
593
+
565
594
  async function refreshConfig() {
566
595
  try {
567
596
  const [configRes, settingsRes] = await Promise.all([
568
597
  fetch('/plugins/signalk-app-dock/config'),
569
598
  fetch('/plugins/signalk-app-dock/settings')
570
599
  ])
571
- const prev = JSON.stringify(cfg)
600
+ const prev = cfg
601
+ let next = cfg
572
602
  if (configRes.ok) {
573
603
  const data = await configRes.json()
574
604
  const pluginCfg = data.configuration || data
575
- cfg = { ...DEFAULTS, ...pluginCfg }
605
+ next = { ...DEFAULTS, ...pluginCfg, apps: prev.apps }
576
606
  }
577
607
  if (settingsRes.ok) {
578
608
  const data = await settingsRes.json()
579
609
  if (Array.isArray(data.apps) && data.apps.length > 0) {
580
- cfg.apps = data.apps
610
+ next = { ...next, apps: data.apps }
581
611
  }
582
612
  }
583
- if (JSON.stringify(cfg) !== prev) {
613
+ const structuralChanged = STRUCTURAL_KEYS.some((k) => prev[k] !== next[k])
614
+ if (structuralChanged) {
615
+ window.location.reload()
616
+ return
617
+ }
618
+ if (JSON.stringify(next.apps) !== JSON.stringify(prev.apps)) {
619
+ cfg = next
584
620
  buildDock()
621
+ } else {
622
+ cfg = next
585
623
  }
586
624
  } catch {
587
625
  // ignore transient errors (e.g. during plugin restart)
package/public/index.html CHANGED
@@ -27,9 +27,6 @@
27
27
  <div id="dock-inner"></div>
28
28
  </nav>
29
29
 
30
- <!-- Invisible corner hot-zone for long-press trigger -->
31
- <div id="trigger-corner" aria-hidden="true"></div>
32
-
33
30
  <!-- Loading indicator shown while an iframe is loading for the first time -->
34
31
  <div id="loading-overlay" aria-hidden="true">
35
32
  <div class="spinner"></div>
@@ -43,8 +40,7 @@
43
40
  <div id="tour-body">
44
41
  <p>App Dock gives you a macOS-style dock to quickly switch between Signal K webapps on touch screens.</p>
45
42
  <p>
46
- <strong>Double-tap the bottom-right corner</strong> of the screen to open the dock, then tap an icon to
47
- launch that app.
43
+ <strong>Double-tap anywhere</strong> on the screen to open the dock, then tap an icon to launch that app.
48
44
  </p>
49
45
  <p>
50
46
  Freeboard-SK is loading in the background and KIP is one tap away. Customize the apps, dock position, and
@@ -175,13 +175,13 @@ export default function PluginConfigurationPanel({ configuration, save }) {
175
175
  const cfg = configuration || {}
176
176
  const [apps, setApps] = useState(() => cfg.apps || [])
177
177
  const [position, setPosition] = useState(cfg.position || 'bottom')
178
- const [triggerCorner, setTriggerCorner] = useState(cfg.triggerCorner || 'bottom-right')
179
178
  const [iframeMode, setIframeMode] = useState(cfg.iframeMode || 'keep-alive')
180
179
  const [iconSize, setIconSize] = useState(cfg.iconSize || 56)
181
180
  const [magnification, setMagnification] = useState(cfg.magnification !== false)
182
181
  const [magnificationScale, setMagnificationScale] = useState(cfg.magnificationScale || 1.7)
183
182
  const [showNightModeButton, setShowNightModeButton] = useState(cfg.showNightModeButton || false)
184
183
  const [showExitButton, setShowExitButton] = useState(cfg.showExitButton || false)
184
+ const [tourDismissed, setTourDismissed] = useState(cfg.tourDismissed || false)
185
185
 
186
186
  const [status, setStatus] = useState('')
187
187
  const [statusError, setStatusError] = useState(false)
@@ -203,24 +203,24 @@ export default function PluginConfigurationPanel({ configuration, save }) {
203
203
  const buildConfig = useCallback(
204
204
  (appsList) => ({
205
205
  position,
206
- triggerCorner,
207
206
  iframeMode,
208
207
  iconSize,
209
208
  magnification,
210
209
  magnificationScale,
211
210
  showNightModeButton,
212
211
  showExitButton,
212
+ tourDismissed,
213
213
  apps: appsList
214
214
  }),
215
215
  [
216
216
  position,
217
- triggerCorner,
218
217
  iframeMode,
219
218
  iconSize,
220
219
  magnification,
221
220
  magnificationScale,
222
221
  showNightModeButton,
223
- showExitButton
222
+ showExitButton,
223
+ tourDismissed
224
224
  ]
225
225
  )
226
226
 
@@ -356,18 +356,6 @@ export default function PluginConfigurationPanel({ configuration, save }) {
356
356
  ]}
357
357
  />
358
358
 
359
- <SelectField
360
- label="Double-tap corner"
361
- value={triggerCorner}
362
- onChange={setTriggerCorner}
363
- options={[
364
- { value: 'bottom-right', label: 'Bottom-right' },
365
- { value: 'bottom-left', label: 'Bottom-left' },
366
- { value: 'top-right', label: 'Top-right' },
367
- { value: 'top-left', label: 'Top-left' }
368
- ]}
369
- />
370
-
371
359
  <SelectField
372
360
  label="iFrame lifecycle"
373
361
  value={iframeMode}
@@ -58,12 +58,13 @@ describe('schema', () => {
58
58
  const plugin = pluginFactory(createMockApp())
59
59
  const props = Object.keys(plugin.schema.properties)
60
60
  assert.ok(props.includes('position'))
61
- assert.ok(props.includes('triggerCorner'))
62
61
  assert.ok(props.includes('iframeMode'))
63
62
  assert.ok(props.includes('iconSize'))
64
63
  assert.ok(props.includes('magnification'))
65
64
  assert.ok(props.includes('magnificationScale'))
66
65
  assert.ok(props.includes('apps'))
66
+ assert.ok(!props.includes('triggerCorner'), 'triggerCorner should be removed')
67
+ assert.ok(!props.includes('longPressActivation'), 'longPressActivation should be removed')
67
68
  })
68
69
 
69
70
  it('position enum contains all four edges', () => {
@@ -92,11 +93,6 @@ describe('schema', () => {
92
93
  assert.equal(prop.default, false)
93
94
  })
94
95
 
95
- it('triggerCorner defaults to bottom-right', () => {
96
- const plugin = pluginFactory(createMockApp())
97
- assert.equal(plugin.schema.properties.triggerCorner.default, 'bottom-right')
98
- })
99
-
100
96
  it('defines tourDismissed with default false', () => {
101
97
  const plugin = pluginFactory(createMockApp())
102
98
  const prop = plugin.schema.properties.tourDismissed