@signalk/app-dock 0.2.0 → 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.0",
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
@@ -1,5 +1,32 @@
1
1
  'use strict'
2
2
 
3
+ const DEFAULT_APPS = [
4
+ {
5
+ enabled: true,
6
+ autostart: true,
7
+ url: '/@signalk/freeboard-sk/',
8
+ label: 'Freeboard-SK',
9
+ icon: '/@signalk/freeboard-sk/assets/icons/icon-72x72.png',
10
+ color: ''
11
+ },
12
+ {
13
+ enabled: true,
14
+ autostart: false,
15
+ url: '/@mxtommy/kip/',
16
+ label: 'KIP',
17
+ icon: '/@mxtommy/kip/assets/icon-72x72.png',
18
+ color: ''
19
+ },
20
+ {
21
+ enabled: true,
22
+ autostart: false,
23
+ url: '/admin/',
24
+ label: 'Settings',
25
+ icon: '/@signalk/app-dock/icon-settings.svg',
26
+ color: '#78788c'
27
+ }
28
+ ]
29
+
3
30
  module.exports = (app) => {
4
31
  let pluginSettings = {}
5
32
  let resolvedApps = []
@@ -20,6 +47,22 @@ module.exports = (app) => {
20
47
  name: 'App Dock',
21
48
 
22
49
  start(settings) {
50
+ if (!Array.isArray(settings.apps)) {
51
+ const seeded = DEFAULT_APPS.map((a) => ({ ...a }))
52
+ const seededSettings = {
53
+ showNightModeButton: true,
54
+ showExitButton: false,
55
+ ...settings,
56
+ apps: seeded
57
+ }
58
+ app.debug('App Dock: first run, seeding %d default apps', seeded.length)
59
+ app.savePluginOptions(seededSettings, (err) => {
60
+ if (err) app.error('App Dock: failed to save default apps: ' + err.message)
61
+ else app.debug('App Dock: saved default apps to config')
62
+ })
63
+ settings = seededSettings
64
+ }
65
+
23
66
  pluginSettings = settings
24
67
 
25
68
  if (settings.showNightModeButton) {
@@ -39,49 +82,17 @@ module.exports = (app) => {
39
82
  )
40
83
  }
41
84
 
42
- setTimeout(() => {
43
- const discovered = getWebapps()
44
- if (discovered.length === 0) return
45
-
46
- const configured = settings.apps || []
47
- const configuredUrls = new Set(configured.map((a) => a.url))
48
-
49
- const merged = [...configured]
50
- let added = false
51
- for (const w of discovered) {
52
- if (!configuredUrls.has(w.url)) {
53
- merged.push({ enabled: true, url: w.url, label: '', icon: '', color: '' })
54
- added = true
55
- }
56
- }
57
-
58
- const discoveredByUrl = {}
59
- discovered.forEach((d) => {
60
- discoveredByUrl[d.url] = d
61
- })
62
-
63
- resolvedApps = merged
64
- .filter((a) => a.enabled !== false)
65
- .map((a) => {
66
- const match = discoveredByUrl[a.url]
67
- return {
68
- label: a.label || (match && match.label) || a.url,
69
- url: a.url,
70
- icon: a.icon || (match && match.icon) || null,
71
- color: a.color || null,
72
- autostart: a.autostart || false
73
- }
74
- })
75
-
76
- if (added) {
77
- app.savePluginOptions({ ...settings, apps: merged }, (err) => {
78
- if (err) app.error('Failed to save discovered apps: ' + err.message)
79
- else app.debug('Auto-discovered new webapps, saved to config')
80
- })
81
- }
82
-
83
- app.debug('Dock apps: %s', resolvedApps.map((a) => a.label).join(', '))
84
- }, 5000)
85
+ resolvedApps = (settings.apps || [])
86
+ .filter((a) => a.enabled !== false)
87
+ .map((a) => ({
88
+ label: a.label || a.url,
89
+ url: a.url,
90
+ icon: a.icon || null,
91
+ color: a.color || null,
92
+ autostart: a.autostart || false
93
+ }))
94
+
95
+ app.debug('App Dock: resolved %d apps: %s', resolvedApps.length, resolvedApps.map((a) => a.label).join(', '))
85
96
  },
86
97
 
87
98
  stop() {},
@@ -102,6 +113,19 @@ module.exports = (app) => {
102
113
  const current = app.getSelfPath('environment.mode')
103
114
  res.json({ value: (current && current.value) || 'day' })
104
115
  })
116
+
117
+ router.post('/dismiss-tour', (req, res) => {
118
+ const updated = { ...pluginSettings, tourDismissed: true }
119
+ app.savePluginOptions(updated, (err) => {
120
+ if (err) {
121
+ app.error('App Dock: failed to save tourDismissed: ' + err.message)
122
+ res.status(500).json({ error: err.message })
123
+ return
124
+ }
125
+ pluginSettings = updated
126
+ res.json({ tourDismissed: true })
127
+ })
128
+ })
105
129
  },
106
130
 
107
131
  schema: {
@@ -117,14 +141,6 @@ module.exports = (app) => {
117
141
  default: 'bottom'
118
142
  },
119
143
 
120
- triggerCorner: {
121
- type: 'string',
122
- title: 'Double-tap corner',
123
- description: 'Screen corner that activates the dock on double-tap',
124
- enum: ['bottom-right', 'bottom-left', 'top-right', 'top-left'],
125
- default: 'bottom-right'
126
- },
127
-
128
144
  iframeMode: {
129
145
  type: 'string',
130
146
  title: 'iFrame lifecycle',
@@ -157,7 +173,7 @@ module.exports = (app) => {
157
173
  type: 'boolean',
158
174
  title: 'Show night/day mode toggle',
159
175
  description: 'Adds a sun/moon button to the dock that toggles environment.mode',
160
- default: false
176
+ default: true
161
177
  },
162
178
 
163
179
  showExitButton: {
@@ -167,6 +183,14 @@ module.exports = (app) => {
167
183
  default: false
168
184
  },
169
185
 
186
+ tourDismissed: {
187
+ type: 'boolean',
188
+ title: 'Welcome tour dismissed',
189
+ description:
190
+ 'Set automatically when a user clicks "Don\'t show again" in the welcome tour. Uncheck to show the tour again on all devices.',
191
+ default: false
192
+ },
193
+
170
194
  apps: {
171
195
  type: 'array',
172
196
  title: 'Dock Apps',
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||[]),[g,u]=(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,I]=(0,a.useState)(!1),[T,F]=(0,a.useState)(!1),[A,M]=(0,a.useState)(null),[N,W]=(0,a.useState)(null),$=(0,a.useRef)(s);$.current=s;const _=(0,a.useCallback)(e=>({position:g,triggerCorner:p,iframeMode:b,iconSize:h,magnification:y,magnificationScale:E,showNightModeButton:x,showExitButton:C,apps:e}),[g,p,b,h,y,E,x,C]),O=(0,a.useCallback)(()=>{t(_(s)),R("Saved!"),I(!1)},[s,_,t]),U=()=>W(null),j=()=>{M(null),W(null)},L=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:g,onChange:u,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,...T?{opacity:.6}:{}},onClick:async()=>{F(!0),R(""),I(!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?"),I(!0),void F(!1);const n=$.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.`),I(!1)}catch(e){R("Discovery failed: "+e.message),I(!0)}F(!1)},disabled:T},T?"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=>M(e))(t),onDragOver:e=>((e,t)=>{e.preventDefault(),W(t)})(e,t),onDragLeave:U,onDrop:e=>((e,t)=>{if(e.preventDefault(),W(null),null!==A&&A!==t){const e=[...s],[n]=e.splice(A,1);e.splice(t,0,n),d(e)}M(null)})(e,t),onDragEnd:j,style:{...l.item,...!1===e.enabled?l.itemDisabled:{},...N===t?l.dragOver:{},...A===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")))),(L.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"}})),L.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},onClick:O},"Save Configuration")))}}}]);
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)))}}}]);
@@ -1,14 +1,16 @@
1
1
  <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 72 72" width="72" height="72">
2
2
  <defs>
3
3
  <linearGradient id="bg" x1="0" y1="0" x2="0" y2="1">
4
- <stop offset="0%" stop-color="#1e3a5f"/>
5
- <stop offset="100%" stop-color="#0a1628"/>
4
+ <stop offset="0%" stop-color="#60a5fa"/>
5
+ <stop offset="100%" stop-color="#2563eb"/>
6
6
  </linearGradient>
7
7
  </defs>
8
+ <!-- White base -->
9
+ <rect width="72" height="72" fill="#ffffff"/>
8
10
  <!-- Background -->
9
11
  <rect width="72" height="72" rx="16" fill="url(#bg)"/>
10
12
  <!-- Dock pill -->
11
- <rect x="8" y="48" width="56" height="16" rx="8" fill="#ffffff" fill-opacity="0.1" stroke="#ffffff" stroke-opacity="0.2" stroke-width="0.5"/>
13
+ <rect x="8" y="48" width="56" height="16" rx="8" fill="#ffffff" fill-opacity="0.25" stroke="#ffffff" stroke-opacity="0.4" stroke-width="0.5"/>
12
14
  <!-- Dock icons - 3 squares in the pill -->
13
15
  <rect x="14" y="51" width="10" height="10" rx="2.5" fill="#3b82f6" opacity="0.9"/>
14
16
  <rect x="31" y="51" width="10" height="10" rx="2.5" fill="#10b981" opacity="0.9"/>
package/public/dock.css CHANGED
@@ -120,7 +120,7 @@ body {
120
120
  /* ─── Dock shell ────────────────────────────────────────────────────────────── */
121
121
  #dock {
122
122
  position: fixed;
123
- z-index: 20;
123
+ z-index: 40;
124
124
  opacity: 0;
125
125
  pointer-events: none;
126
126
  transition:
@@ -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;
@@ -447,3 +434,115 @@ body {
447
434
  transform: rotate(360deg);
448
435
  }
449
436
  }
437
+
438
+ /* ─── Welcome tour ─────────────────────────────────────────────────────────── */
439
+ #tour-overlay {
440
+ position: fixed;
441
+ inset: 0;
442
+ z-index: 30;
443
+ display: flex;
444
+ align-items: center;
445
+ justify-content: center;
446
+ background: rgba(0, 0, 0, 0.6);
447
+ backdrop-filter: blur(4px);
448
+ -webkit-backdrop-filter: blur(4px);
449
+ opacity: 0;
450
+ pointer-events: none;
451
+ transition: opacity 0.3s ease;
452
+ padding: 24px;
453
+ }
454
+
455
+ #tour-overlay.visible {
456
+ opacity: 1;
457
+ pointer-events: auto;
458
+ }
459
+
460
+ #tour-card {
461
+ max-width: 520px;
462
+ width: 100%;
463
+ background: rgba(30, 30, 32, 0.9);
464
+ backdrop-filter: blur(40px) saturate(180%);
465
+ -webkit-backdrop-filter: blur(40px) saturate(180%);
466
+ border: 1px solid rgba(255, 255, 255, 0.18);
467
+ border-radius: 22px;
468
+ padding: 32px;
469
+ color: rgba(255, 255, 255, 0.92);
470
+ box-shadow:
471
+ 0 24px 64px rgba(0, 0, 0, 0.6),
472
+ inset 0 0.5px 0 rgba(255, 255, 255, 0.15);
473
+ /* Position above the dock so user can see both */
474
+ margin-bottom: 180px;
475
+ }
476
+
477
+ #tour-title {
478
+ font-size: 22px;
479
+ font-weight: 700;
480
+ letter-spacing: 0.01em;
481
+ margin-bottom: 16px;
482
+ }
483
+
484
+ #tour-body {
485
+ font-size: 15px;
486
+ line-height: 1.55;
487
+ color: rgba(255, 255, 255, 0.78);
488
+ }
489
+
490
+ #tour-body p {
491
+ margin-bottom: 12px;
492
+ }
493
+
494
+ #tour-body p:last-child {
495
+ margin-bottom: 0;
496
+ }
497
+
498
+ #tour-link {
499
+ color: #60a5fa;
500
+ text-decoration: none;
501
+ font-weight: 600;
502
+ }
503
+
504
+ #tour-link:hover {
505
+ text-decoration: underline;
506
+ }
507
+
508
+ #tour-buttons {
509
+ display: flex;
510
+ gap: 10px;
511
+ margin-top: 24px;
512
+ justify-content: flex-end;
513
+ }
514
+
515
+ .tour-btn {
516
+ padding: 10px 20px;
517
+ border: none;
518
+ border-radius: 10px;
519
+ font-size: 14px;
520
+ font-weight: 600;
521
+ cursor: pointer;
522
+ font-family: inherit;
523
+ transition:
524
+ background 0.15s ease,
525
+ transform 0.1s ease;
526
+ }
527
+
528
+ .tour-btn-primary {
529
+ background: #3b82f6;
530
+ color: #fff;
531
+ }
532
+
533
+ .tour-btn-primary:hover {
534
+ background: #2563eb;
535
+ }
536
+
537
+ .tour-btn-dismiss {
538
+ background: rgba(255, 255, 255, 0.08);
539
+ color: rgba(255, 255, 255, 0.8);
540
+ }
541
+
542
+ .tour-btn-dismiss:hover {
543
+ background: rgba(255, 255, 255, 0.15);
544
+ }
545
+
546
+ .tour-btn:active {
547
+ transform: scale(0.97);
548
+ }
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
 
@@ -420,6 +408,7 @@
420
408
 
421
409
  function hideDock() {
422
410
  if (!dockVisible) return
411
+ if ($tourOverlay && $tourOverlay.classList.contains('visible')) return
423
412
  dockVisible = false
424
413
  $dock.classList.remove('visible')
425
414
  $backdrop.classList.remove('visible')
@@ -478,82 +467,111 @@
478
467
  hideDock()
479
468
  }
480
469
 
481
- // ─── Corner trigger zone: position it ────────────────────────────────────────
482
- function positionCornerZone() {
483
- const [vert, horiz] = cfg.triggerCorner.split('-')
484
- const sz = 120
485
-
486
- $triggerCorner.style.width = sz + 'px'
487
- $triggerCorner.style.height = sz + 'px'
488
-
489
- $triggerCorner.style.top = ''
490
- $triggerCorner.style.bottom = ''
491
- $triggerCorner.style.left = ''
492
- $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
+ }
493
490
 
494
- $triggerCorner.style[vert] = '0'
495
- $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
+ }
496
506
  }
497
507
 
498
- // ─── Gesture: double-tap on corner (touch) + double-click (mouse) ─────────────
499
- {
500
- let lastTapTime = 0
501
- const DOUBLE_TAP_MS = 400
508
+ function attachTapListener(doc, frameEl) {
509
+ if (!doc || doc.__dockListenersAttached) return
510
+ doc.__dockListenersAttached = true
502
511
 
503
- $triggerCorner.addEventListener(
504
- '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',
505
520
  (e) => {
506
- const now = Date.now()
507
- if (now - lastTapTime < DOUBLE_TAP_MS) {
508
- e.preventDefault()
509
- lastTapTime = 0
510
- showDock()
511
- if (navigator.vibrate) navigator.vibrate(8)
512
- } else {
513
- lastTapTime = now
514
- }
521
+ const o = offset()
522
+ handleTap(e.clientX + o.x, e.clientY + o.y)
515
523
  },
516
- { passive: false }
524
+ { passive: true, capture: true }
517
525
  )
526
+ }
518
527
 
519
- $triggerCorner.addEventListener('dblclick', (e) => {
520
- e.preventDefault()
521
- showDock()
522
- })
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()
532
+
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
+ }
523
549
 
524
- $triggerCorner.addEventListener('contextmenu', (e) => e.preventDefault())
525
- }
526
-
527
- // ─── Gesture: mouse hover at screen edge ─────────────────────────────────────
528
- {
529
- const EDGE_ZONE = 4
530
- let edgeTimer = null
531
-
532
- document.addEventListener('mousemove', (e) => {
533
- if (dockVisible) return
534
- const W = window.innerWidth
535
- const H = window.innerHeight
536
- let atEdge = false
537
-
538
- if (pos === 'bottom' && e.clientY >= H - EDGE_ZONE) atEdge = true
539
- if (pos === 'top' && e.clientY <= EDGE_ZONE) atEdge = true
540
- if (pos === 'left' && e.clientX <= EDGE_ZONE) atEdge = true
541
- if (pos === 'right' && e.clientX >= W - EDGE_ZONE) atEdge = true
542
-
543
- if (atEdge && !edgeTimer) {
544
- edgeTimer = setTimeout(() => {
545
- showDock()
546
- edgeTimer = null
547
- }, 300)
548
- } else if (!atEdge && edgeTimer) {
549
- clearTimeout(edgeTimer)
550
- edgeTimer = null
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
+ })
551
565
  }
552
566
  })
567
+ obs.observe(root, { childList: true, subtree: true })
553
568
  }
554
569
 
570
+ attachTapListener(document, null)
571
+ attachedDocs.add(document)
572
+ scanAndObserveDoc(document)
573
+
555
574
  // ─── Init ────────────────────────────────────────────────────────────────────
556
- positionCornerZone()
557
575
  buildDock()
558
576
 
559
577
  if (cfg.showNightModeButton) {
@@ -561,6 +579,104 @@
561
579
  setInterval(fetchCurrentMode, 5000)
562
580
  }
563
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
+
594
+ async function refreshConfig() {
595
+ try {
596
+ const [configRes, settingsRes] = await Promise.all([
597
+ fetch('/plugins/signalk-app-dock/config'),
598
+ fetch('/plugins/signalk-app-dock/settings')
599
+ ])
600
+ const prev = cfg
601
+ let next = cfg
602
+ if (configRes.ok) {
603
+ const data = await configRes.json()
604
+ const pluginCfg = data.configuration || data
605
+ next = { ...DEFAULTS, ...pluginCfg, apps: prev.apps }
606
+ }
607
+ if (settingsRes.ok) {
608
+ const data = await settingsRes.json()
609
+ if (Array.isArray(data.apps) && data.apps.length > 0) {
610
+ next = { ...next, apps: data.apps }
611
+ }
612
+ }
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
620
+ buildDock()
621
+ } else {
622
+ cfg = next
623
+ }
624
+ } catch {
625
+ // ignore transient errors (e.g. during plugin restart)
626
+ }
627
+ }
628
+
629
+ setInterval(refreshConfig, 5000)
630
+
631
+ // ─── Welcome tour ────────────────────────────────────────────────────────────
632
+ const $tourOverlay = document.getElementById('tour-overlay')
633
+ const $tourGotIt = document.getElementById('tour-btn-got-it')
634
+ const $tourDismiss = document.getElementById('tour-btn-dismiss')
635
+
636
+ function hideTour() {
637
+ $tourOverlay.classList.remove('visible')
638
+ hideDock()
639
+ }
640
+
641
+ function showTour() {
642
+ $tourOverlay.classList.add('visible')
643
+ showDock()
644
+ }
645
+
646
+ if ($tourGotIt) $tourGotIt.addEventListener('click', hideTour)
647
+ if ($tourDismiss) {
648
+ $tourDismiss.addEventListener('click', async () => {
649
+ hideTour()
650
+ try {
651
+ await fetch('/plugins/signalk-app-dock/dismiss-tour', { method: 'POST' })
652
+ } catch {
653
+ // ignore network errors; worst case user sees the tour again next reload
654
+ }
655
+ })
656
+ }
657
+
658
+ const $tourLink = document.getElementById('tour-link')
659
+ if ($tourLink) {
660
+ $tourLink.addEventListener('click', (e) => {
661
+ e.preventDefault()
662
+ const adminUrl = '/admin/#/serverConfiguration/plugins/signalk-app-dock'
663
+ hideIdleHint()
664
+ const $frame = document.createElement('iframe')
665
+ $frame.allow = 'fullscreen; geolocation'
666
+ $frame.sandbox = 'allow-same-origin allow-scripts allow-forms allow-popups allow-pointer-lock'
667
+ $frame.style.touchAction = 'auto'
668
+ $frame.classList.add('active')
669
+ $iframeContainer.innerHTML = ''
670
+ Object.keys(iframes).forEach((k) => delete iframes[k])
671
+ $iframeContainer.appendChild($frame)
672
+ $frame.src = adminUrl
673
+ iframes[adminUrl] = $frame
674
+ hideTour()
675
+ })
676
+ }
677
+
678
+ if (!cfg.tourDismissed) showTour()
679
+
564
680
  const autostartIdx = cfg.apps.findIndex((a) => a.autostart)
565
681
  if (autostartIdx >= 0) {
566
682
  switchToApp(autostartIdx)
package/public/index.html CHANGED
@@ -27,15 +27,34 @@
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>
36
33
  <span id="loading-label"></span>
37
34
  </div>
38
35
 
36
+ <!-- First-run welcome tour -->
37
+ <div id="tour-overlay" aria-hidden="true">
38
+ <div id="tour-card" role="dialog" aria-labelledby="tour-title">
39
+ <div id="tour-title">Welcome to App Dock</div>
40
+ <div id="tour-body">
41
+ <p>App Dock gives you a macOS-style dock to quickly switch between Signal K webapps on touch screens.</p>
42
+ <p>
43
+ <strong>Double-tap anywhere</strong> on the screen to open the dock, then tap an icon to launch that app.
44
+ </p>
45
+ <p>
46
+ Freeboard-SK is loading in the background and KIP is one tap away. Customize the apps, dock position, and
47
+ more in
48
+ <a id="tour-link" href="#">Plugin Config</a>.
49
+ </p>
50
+ </div>
51
+ <div id="tour-buttons">
52
+ <button id="tour-btn-got-it" class="tour-btn tour-btn-primary">Got it</button>
53
+ <button id="tour-btn-dismiss" class="tour-btn tour-btn-dismiss">Don't show again</button>
54
+ </div>
55
+ </div>
56
+ </div>
57
+
39
58
  <script src="dock.js"></script>
40
59
  </body>
41
60
  </html>
@@ -1,4 +1,4 @@
1
- import React, { useState, useRef, useCallback } from 'react'
1
+ import React, { useState, useRef, useCallback, useEffect } from 'react'
2
2
 
3
3
  const S = {
4
4
  root: {
@@ -175,51 +175,74 @@ 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)
188
188
  const [discovering, setDiscovering] = useState(false)
189
+ const [saving, setSaving] = useState(false)
189
190
  const [dragIdx, setDragIdx] = useState(null)
190
191
  const [overIdx, setOverIdx] = useState(null)
191
192
 
192
193
  const appsRef = useRef(apps)
193
194
  appsRef.current = apps
194
195
 
196
+ const statusTimeoutRef = useRef(null)
197
+ useEffect(() => {
198
+ return () => {
199
+ if (statusTimeoutRef.current) clearTimeout(statusTimeoutRef.current)
200
+ }
201
+ }, [])
202
+
195
203
  const buildConfig = useCallback(
196
204
  (appsList) => ({
197
205
  position,
198
- triggerCorner,
199
206
  iframeMode,
200
207
  iconSize,
201
208
  magnification,
202
209
  magnificationScale,
203
210
  showNightModeButton,
204
211
  showExitButton,
212
+ tourDismissed,
205
213
  apps: appsList
206
214
  }),
207
215
  [
208
216
  position,
209
- triggerCorner,
210
217
  iframeMode,
211
218
  iconSize,
212
219
  magnification,
213
220
  magnificationScale,
214
221
  showNightModeButton,
215
- showExitButton
222
+ showExitButton,
223
+ tourDismissed
216
224
  ]
217
225
  )
218
226
 
219
- const doSave = useCallback(() => {
220
- save(buildConfig(apps))
221
- setStatus('Saved!')
227
+ const doSave = useCallback(async () => {
228
+ if (statusTimeoutRef.current) clearTimeout(statusTimeoutRef.current)
229
+ setSaving(true)
230
+ setStatus('Saving\u2026')
222
231
  setStatusError(false)
232
+ try {
233
+ const result = save(buildConfig(apps))
234
+ if (result && typeof result.then === 'function') {
235
+ await result
236
+ }
237
+ setStatus('Configuration saved \u2014 plugin will restart')
238
+ setStatusError(false)
239
+ statusTimeoutRef.current = setTimeout(() => setStatus(''), 5000)
240
+ } catch (e) {
241
+ setStatus('Save failed: ' + (e.message || e))
242
+ setStatusError(true)
243
+ } finally {
244
+ setSaving(false)
245
+ }
223
246
  }, [apps, buildConfig, save])
224
247
 
225
248
  const discover = async () => {
@@ -333,18 +356,6 @@ export default function PluginConfigurationPanel({ configuration, save }) {
333
356
  ]}
334
357
  />
335
358
 
336
- <SelectField
337
- label="Double-tap corner"
338
- value={triggerCorner}
339
- onChange={setTriggerCorner}
340
- options={[
341
- { value: 'bottom-right', label: 'Bottom-right' },
342
- { value: 'bottom-left', label: 'Bottom-left' },
343
- { value: 'top-right', label: 'Top-right' },
344
- { value: 'top-left', label: 'Top-left' }
345
- ]}
346
- />
347
-
348
359
  <SelectField
349
360
  label="iFrame lifecycle"
350
361
  value={iframeMode}
@@ -498,9 +509,14 @@ export default function PluginConfigurationPanel({ configuration, save }) {
498
509
  )}
499
510
 
500
511
  <div style={S.actions}>
501
- <button style={{ ...S.btn, ...S.btnSave }} onClick={doSave}>
502
- Save Configuration
512
+ <button
513
+ style={{ ...S.btn, ...S.btnSave, ...(saving ? { opacity: 0.6 } : {}) }}
514
+ onClick={doSave}
515
+ disabled={saving}
516
+ >
517
+ {saving ? 'Saving\u2026' : 'Save Configuration'}
503
518
  </button>
519
+ {status && <div style={{ ...S.status, color: statusError ? '#ef4444' : '#10b981' }}>{status}</div>}
504
520
  </div>
505
521
  </div>
506
522
  )
@@ -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', () => {
@@ -78,11 +79,11 @@ describe('schema', () => {
78
79
  assert.deepEqual(items.required, ['url'])
79
80
  })
80
81
 
81
- it('defines showNightModeButton with default false', () => {
82
+ it('defines showNightModeButton with default true', () => {
82
83
  const plugin = pluginFactory(createMockApp())
83
84
  const prop = plugin.schema.properties.showNightModeButton
84
85
  assert.equal(prop.type, 'boolean')
85
- assert.equal(prop.default, false)
86
+ assert.equal(prop.default, true)
86
87
  })
87
88
 
88
89
  it('defines showExitButton with default false', () => {
@@ -91,6 +92,13 @@ describe('schema', () => {
91
92
  assert.equal(prop.type, 'boolean')
92
93
  assert.equal(prop.default, false)
93
94
  })
95
+
96
+ it('defines tourDismissed with default false', () => {
97
+ const plugin = pluginFactory(createMockApp())
98
+ const prop = plugin.schema.properties.tourDismissed
99
+ assert.equal(prop.type, 'boolean')
100
+ assert.equal(prop.default, false)
101
+ })
94
102
  })
95
103
 
96
104
  describe('webapp discovery', () => {
@@ -101,7 +109,8 @@ describe('webapp discovery', () => {
101
109
  const router = {
102
110
  get: (path, handler) => {
103
111
  routes[path] = handler
104
- }
112
+ },
113
+ post: () => {}
105
114
  }
106
115
  plugin.registerWithRouter(router)
107
116
 
@@ -123,7 +132,8 @@ describe('webapp discovery', () => {
123
132
  const router = {
124
133
  get: (path, handler) => {
125
134
  routes[path] = handler
126
- }
135
+ },
136
+ post: () => {}
127
137
  }
128
138
  plugin.registerWithRouter(router)
129
139
 
@@ -146,7 +156,8 @@ describe('webapp discovery', () => {
146
156
  const router = {
147
157
  get: (path, handler) => {
148
158
  routes[path] = handler
149
- }
159
+ },
160
+ post: () => {}
150
161
  }
151
162
  plugin.registerWithRouter(router)
152
163
 
@@ -169,7 +180,8 @@ describe('webapp discovery', () => {
169
180
  const router = {
170
181
  get: (path, handler) => {
171
182
  routes[path] = handler
172
- }
183
+ },
184
+ post: () => {}
173
185
  }
174
186
  plugin.registerWithRouter(router)
175
187
 
@@ -193,7 +205,8 @@ describe('settings endpoint', () => {
193
205
  const router = {
194
206
  get: (path, handler) => {
195
207
  routes[path] = handler
196
- }
208
+ },
209
+ post: () => {}
197
210
  }
198
211
  plugin.registerWithRouter(router)
199
212
 
@@ -209,9 +222,9 @@ describe('settings endpoint', () => {
209
222
  })
210
223
  })
211
224
 
212
- describe('auto-discovery on start', () => {
213
- it('merges discovered webapps into configured apps after delay', (t, done) => {
214
- const app = createMockApp(MOCK_WEBAPPS)
225
+ describe('default apps seeding', () => {
226
+ it('seeds default apps when config has no apps key', () => {
227
+ const app = createMockApp()
215
228
  let savedOptions = null
216
229
  app.savePluginOptions = (opts, cb) => {
217
230
  savedOptions = opts
@@ -219,73 +232,46 @@ describe('auto-discovery on start', () => {
219
232
  }
220
233
 
221
234
  const plugin = pluginFactory(app)
222
-
223
- const originalSetTimeout = globalThis.setTimeout
224
- globalThis.setTimeout = (fn) => fn()
225
- try {
226
- plugin.start({ apps: [] })
227
- } finally {
228
- globalThis.setTimeout = originalSetTimeout
229
- }
235
+ plugin.start({})
230
236
 
231
237
  assert.ok(savedOptions)
232
- assert.ok(savedOptions.apps.length >= 2)
233
- assert.ok(savedOptions.apps.some((a) => a.url === '/@signalk/freeboard-sk/'))
234
- assert.ok(savedOptions.apps.some((a) => a.url === '/@mxtommy/kip/'))
235
- done()
238
+ assert.equal(savedOptions.apps[0].url, '/@signalk/freeboard-sk/')
239
+ assert.equal(savedOptions.apps[0].autostart, true)
240
+ assert.equal(savedOptions.apps[1].url, '/@mxtommy/kip/')
241
+ assert.equal(savedOptions.apps[1].autostart, false)
242
+ assert.equal(savedOptions.apps[2].url, '/admin/')
243
+ assert.equal(savedOptions.apps[2].label, 'Settings')
236
244
  })
237
245
 
238
- it('does not duplicate already-configured apps', (t, done) => {
239
- const app = createMockApp(MOCK_WEBAPPS)
240
- let savedOptions = null
241
- app.savePluginOptions = (opts, cb) => {
242
- savedOptions = opts
243
- cb(null)
246
+ it('does not seed when apps array already exists (even if empty)', () => {
247
+ const app = createMockApp()
248
+ let saveCalled = false
249
+ app.savePluginOptions = () => {
250
+ saveCalled = true
244
251
  }
245
252
 
246
253
  const plugin = pluginFactory(app)
247
- const existing = [{ enabled: true, url: '/@signalk/freeboard-sk/', label: 'FB', icon: '', color: '' }]
248
-
249
- const originalSetTimeout = globalThis.setTimeout
250
- globalThis.setTimeout = (fn) => fn()
251
- try {
252
- plugin.start({ apps: existing })
253
- } finally {
254
- globalThis.setTimeout = originalSetTimeout
255
- }
254
+ plugin.start({ apps: [] })
256
255
 
257
- const fbEntries = savedOptions.apps.filter((a) => a.url === '/@signalk/freeboard-sk/')
258
- assert.equal(fbEntries.length, 1)
259
- assert.equal(fbEntries[0].label, 'FB')
260
- done()
256
+ assert.equal(saveCalled, false)
261
257
  })
262
258
 
263
- it('skips save when no new apps are discovered', () => {
264
- const app = createMockApp(MOCK_WEBAPPS)
259
+ it('preserves existing configured apps', () => {
260
+ const app = createMockApp()
265
261
  let saveCalled = false
266
262
  app.savePluginOptions = () => {
267
263
  saveCalled = true
268
264
  }
269
265
 
270
266
  const plugin = pluginFactory(app)
271
- const allConfigured = [
272
- { enabled: true, url: '/@signalk/freeboard-sk/', label: '', icon: '', color: '' },
273
- { enabled: true, url: '/@mxtommy/kip/', label: '', icon: '', color: '' }
274
- ]
275
-
276
- const originalSetTimeout = globalThis.setTimeout
277
- globalThis.setTimeout = (fn) => fn()
278
- try {
279
- plugin.start({ apps: allConfigured })
280
- } finally {
281
- globalThis.setTimeout = originalSetTimeout
282
- }
267
+ const existing = [{ enabled: true, url: '/custom-app/', label: 'Custom', icon: '', color: '' }]
268
+ plugin.start({ apps: existing })
283
269
 
284
270
  assert.equal(saveCalled, false)
285
271
  })
286
272
 
287
273
  it('respects enabled:false when resolving apps', () => {
288
- const app = createMockApp(MOCK_WEBAPPS)
274
+ const app = createMockApp()
289
275
  app.savePluginOptions = (_, cb) => cb(null)
290
276
  const plugin = pluginFactory(app)
291
277
 
@@ -293,20 +279,14 @@ describe('auto-discovery on start', () => {
293
279
  { enabled: false, url: '/@signalk/freeboard-sk/', label: '', icon: '', color: '' },
294
280
  { enabled: true, url: '/@mxtommy/kip/', label: '', icon: '', color: '' }
295
281
  ]
296
-
297
- const originalSetTimeout = globalThis.setTimeout
298
- globalThis.setTimeout = (fn) => fn()
299
- try {
300
- plugin.start({ apps: configured })
301
- } finally {
302
- globalThis.setTimeout = originalSetTimeout
303
- }
282
+ plugin.start({ apps: configured })
304
283
 
305
284
  const routes = {}
306
285
  const router = {
307
286
  get: (path, handler) => {
308
287
  routes[path] = handler
309
- }
288
+ },
289
+ post: () => {}
310
290
  }
311
291
  plugin.registerWithRouter(router)
312
292
 
@@ -322,43 +302,6 @@ describe('auto-discovery on start', () => {
322
302
  assert.ok(!resolved.apps.some((a) => a.url === '/@signalk/freeboard-sk/'))
323
303
  assert.ok(resolved.apps.some((a) => a.url === '/@mxtommy/kip/'))
324
304
  })
325
-
326
- it('uses discovered label/icon when config overrides are empty', () => {
327
- const app = createMockApp(MOCK_WEBAPPS)
328
- app.savePluginOptions = (_, cb) => cb(null)
329
- const plugin = pluginFactory(app)
330
-
331
- const configured = [{ enabled: true, url: '/@mxtommy/kip/', label: '', icon: '', color: '' }]
332
-
333
- const originalSetTimeout = globalThis.setTimeout
334
- globalThis.setTimeout = (fn) => fn()
335
- try {
336
- plugin.start({ apps: configured })
337
- } finally {
338
- globalThis.setTimeout = originalSetTimeout
339
- }
340
-
341
- const routes = {}
342
- const router = {
343
- get: (path, handler) => {
344
- routes[path] = handler
345
- }
346
- }
347
- plugin.registerWithRouter(router)
348
-
349
- let resolved
350
- routes['/settings'](
351
- {},
352
- {
353
- json: (d) => {
354
- resolved = d
355
- }
356
- }
357
- )
358
- const kip = resolved.apps.find((a) => a.url === '/@mxtommy/kip/')
359
- assert.equal(kip.label, 'KIP Instrument MFD')
360
- assert.equal(kip.icon, '/@mxtommy/kip/assets/icon-72x72.png')
361
- })
362
305
  })
363
306
 
364
307
  describe('package.json', () => {
@@ -470,6 +413,40 @@ describe('night mode PUT handler', () => {
470
413
  })
471
414
  })
472
415
 
416
+ describe('dismiss-tour endpoint', () => {
417
+ it('saves tourDismissed: true and responds', (t, done) => {
418
+ const app = createMockApp()
419
+ let saved = null
420
+ app.savePluginOptions = (opts, cb) => {
421
+ saved = opts
422
+ cb(null)
423
+ }
424
+ const plugin = pluginFactory(app)
425
+ plugin.start({ apps: [], position: 'left' })
426
+
427
+ const routes = { get: {}, post: {} }
428
+ const router = {
429
+ get: (path, handler) => {
430
+ routes.get[path] = handler
431
+ },
432
+ post: (path, handler) => {
433
+ routes.post[path] = handler
434
+ }
435
+ }
436
+ plugin.registerWithRouter(router)
437
+
438
+ const res = {
439
+ json: (data) => {
440
+ assert.equal(data.tourDismissed, true)
441
+ assert.equal(saved.tourDismissed, true)
442
+ assert.equal(saved.position, 'left')
443
+ done()
444
+ }
445
+ }
446
+ routes.post['/dismiss-tour']({}, res)
447
+ })
448
+ })
449
+
473
450
  describe('mode endpoint', () => {
474
451
  it('returns day when no mode is set', (t, done) => {
475
452
  const app = createMockApp()
@@ -478,7 +455,8 @@ describe('mode endpoint', () => {
478
455
  const router = {
479
456
  get: (path, handler) => {
480
457
  routes[path] = handler
481
- }
458
+ },
459
+ post: () => {}
482
460
  }
483
461
  plugin.registerWithRouter(router)
484
462
 
@@ -502,7 +480,8 @@ describe('mode endpoint', () => {
502
480
  const router = {
503
481
  get: (path, handler) => {
504
482
  routes[path] = handler
505
- }
483
+ },
484
+ post: () => {}
506
485
  }
507
486
  plugin.registerWithRouter(router)
508
487