@growthub/cli 0.9.7 → 0.9.9

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.
Files changed (17) hide show
  1. package/README.md +23 -5
  2. package/assets/worker-kits/growthub-custom-workspace-starter-v1/SKILL.md +8 -2
  3. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/.env.example +8 -8
  4. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/README.md +9 -7
  5. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/api/settings/integrations/route.js +2 -2
  6. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/api/workspace/route.js +4 -4
  7. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/globals.css +554 -19
  8. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/settings/integrations/page.jsx +111 -77
  9. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/workspace-builder.jsx +485 -77
  10. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/growthub.config.json +8 -3
  11. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/adapters/env.js +9 -7
  12. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/adapters/integrations/index.js +10 -10
  13. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/domain/integrations.js +2 -2
  14. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/workspace-config.js +62 -7
  15. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/workspace-schema.js +38 -0
  16. package/assets/worker-kits/growthub-custom-workspace-starter-v1/kit.json +3 -0
  17. package/package.json +1 -1
@@ -76,7 +76,7 @@ function commitTabs(canvas, tabs, activeTabId) {
76
76
  }
77
77
 
78
78
  function createDashboardRecord(name = "Untitled") {
79
- const tab = createEmptyTab(name);
79
+ const tab = createEmptyTab("Tab 1");
80
80
  return {
81
81
  id: generateId("dashboard"),
82
82
  name,
@@ -140,7 +140,7 @@ function updateDashboardCanvas(dashboard, canvas) {
140
140
  }
141
141
 
142
142
  function createDashboardFromTab(name, tab, source = {}) {
143
- const clonedTab = cloneTabForDashboard(tab, name);
143
+ const clonedTab = cloneTabForDashboard(tab, tab?.name || "Tab 1");
144
144
  return {
145
145
  ...source,
146
146
  id: generateId("dashboard"),
@@ -189,6 +189,24 @@ function commitDashboardCanvas(config, activeDashboardId, nextCanvas) {
189
189
  };
190
190
  }
191
191
 
192
+ function renameDashboardInConfig(config, dashboardId, name, activeDashboardId) {
193
+ const nextName = name.trim() || "Untitled";
194
+ const prevDashboards = config.dashboards || [];
195
+ const index = prevDashboards.findIndex((dashboard) => dashboard.id === dashboardId);
196
+ if (index < 0) return config;
197
+ const nextDashboardsWithTabs = prevDashboards.map((dashboard, dashboardIndex) => {
198
+ if (dashboard.id !== dashboardId) return dashboard;
199
+ const normalized = normalizeDashboard(dashboard, dashboardIndex === 0 ? config.canvas : undefined);
200
+ return { ...normalized, name: nextName, updatedAt: "new" };
201
+ });
202
+ const activeDashboard = nextDashboardsWithTabs.find((dashboard) => dashboard.id === getActiveDashboardId(nextDashboardsWithTabs, activeDashboardId));
203
+ return {
204
+ ...config,
205
+ dashboards: nextDashboardsWithTabs,
206
+ canvas: dashboardCanvasFrom(activeDashboard || nextDashboardsWithTabs[0], config.canvas)
207
+ };
208
+ }
209
+
192
210
  function findFreePosition(widgets) {
193
211
  const occupied = new Set();
194
212
  for (const widget of widgets) {
@@ -245,6 +263,18 @@ function clampPositionToFreeSpace(position, widgets) {
245
263
  return collides ? findFreePosition(widgets) : bounded;
246
264
  }
247
265
 
266
+ function clampWidgetMovePosition(position, widget, widgets) {
267
+ const bounded = {
268
+ ...widget.position,
269
+ x: Math.max(0, Math.min(position.x, GRID_COLUMNS - widget.position.w)),
270
+ y: Math.max(0, Math.min(position.y, GRID_ROWS - widget.position.h))
271
+ };
272
+ const otherWidgets = widgets.filter((item) => item.id !== widget.id);
273
+ return otherWidgets.some((item) => positionsOverlap(bounded, item.position))
274
+ ? widget.position
275
+ : bounded;
276
+ }
277
+
248
278
  function cellIndexFromGridPointer(event, gridElement) {
249
279
  if (!gridElement) return null;
250
280
  const rect = gridElement.getBoundingClientRect();
@@ -264,6 +294,13 @@ function cellIndexFromGridPointer(event, gridElement) {
264
294
  return row * GRID_COLUMNS + column;
265
295
  }
266
296
 
297
+ function cellPointFromIndex(index) {
298
+ return {
299
+ x: index % GRID_COLUMNS,
300
+ y: Math.floor(index / GRID_COLUMNS)
301
+ };
302
+ }
303
+
267
304
  function resizePositionFromCell(position, corner, index) {
268
305
  const cellX = index % GRID_COLUMNS;
269
306
  const cellY = Math.floor(index / GRID_COLUMNS);
@@ -313,6 +350,16 @@ function widgetKindLabel(kind) {
313
350
  return kind.charAt(0).toUpperCase() + kind.slice(1);
314
351
  }
315
352
 
353
+ function isLikelyHttpUrl(value) {
354
+ if (typeof value !== "string" || !value.trim()) return false;
355
+ try {
356
+ const url = new URL(value.trim());
357
+ return url.protocol === "http:" || url.protocol === "https:";
358
+ } catch {
359
+ return false;
360
+ }
361
+ }
362
+
316
363
  function cloneConfig(value) {
317
364
  return JSON.parse(JSON.stringify(value));
318
365
  }
@@ -441,8 +488,8 @@ function TemplateGallery({
441
488
  </div> : null}
442
489
  <div className="template-card-actions">
443
490
  <button type="button" onClick={() => onPreview(template.id)}>{isPreviewing ? "Previewing" : "Preview"}</button>
444
- <button type="button" onClick={() => onApplyToCurrentTab(template.id)}>Apply to Current Tab</button>
445
- <button type="button" onClick={() => onCloneAsDashboard(template.id)}>Clone as New Dashboard</button>
491
+ <button type="button" className="primary" onClick={() => onApplyToCurrentTab(template.id)}>Use Here</button>
492
+ <button type="button" onClick={() => onCloneAsDashboard(template.id)}>New Dashboard</button>
446
493
  </div>
447
494
  </article>;
448
495
  })}
@@ -455,7 +502,7 @@ function TemplateGallery({
455
502
  </div>;
456
503
  }
457
504
 
458
- function WidgetPreview({ widget, selected, onSelect, onRemove, onResizeStart }) {
505
+ function WidgetPreview({ widget, selected, onSelect, onMoveStart, onRemove, onResizeStart }) {
459
506
  const viewColumns = widget.config?.columns?.length ? widget.config.columns : ["Name", "Domain Name"];
460
507
  const viewRows = widget.config?.rows?.length ? widget.config.rows : SAMPLE_VIEW_ROWS;
461
508
  const chartValues = widget.config?.values?.length ? widget.config.values : defaultConfigFor("chart").values;
@@ -468,7 +515,11 @@ function WidgetPreview({ widget, selected, onSelect, onRemove, onResizeStart })
468
515
  }}
469
516
  >
470
517
  <div className="workspace-widget-preview-title">
471
- <span aria-hidden="true">::</span>
518
+ <span
519
+ aria-hidden="true"
520
+ className="workspace-widget-drag-handle"
521
+ onPointerDown={(event) => onMoveStart(event)}
522
+ >::</span>
472
523
  <strong>{widget.title}</strong>
473
524
  <button
474
525
  aria-label={`Remove ${widget.title}`}
@@ -507,7 +558,170 @@ function WidgetPreview({ widget, selected, onSelect, onRemove, onResizeStart })
507
558
  </article>;
508
559
  }
509
560
 
510
- function WorkspaceBuilder({ initialConfig, adapterConfig, integrationAdapter }) {
561
+ const DEFAULT_PERSISTENCE = {
562
+ mode: "filesystem",
563
+ adapter: "filesystem",
564
+ canSave: true,
565
+ saveLabel: "Save writes growthub.config.json on disk.",
566
+ reason: "Local development",
567
+ nextAction: null,
568
+ guidance: null
569
+ };
570
+
571
+ function countCanvasWidgets(canvas) {
572
+ if (!canvas) return 0;
573
+ if (Array.isArray(canvas.tabs) && canvas.tabs.length) {
574
+ return canvas.tabs.reduce((acc, tab) => acc + (Array.isArray(tab.widgets) ? tab.widgets.length : 0), 0);
575
+ }
576
+ return Array.isArray(canvas.widgets) ? canvas.widgets.length : 0;
577
+ }
578
+
579
+ function countCanvasTabs(canvas) {
580
+ if (!canvas) return 0;
581
+ if (Array.isArray(canvas.tabs) && canvas.tabs.length) return canvas.tabs.length;
582
+ return 1;
583
+ }
584
+
585
+ function WorkspaceSettingsPanel({ config, persistence, adapterConfig, integrationAdapter, onClose }) {
586
+ const branding = (config && config.branding) || {};
587
+ const dashboards = Array.isArray(config?.dashboards) ? config.dashboards : [];
588
+ const tabCount = countCanvasTabs(config?.canvas);
589
+ const widgetCount = countCanvasWidgets(config?.canvas);
590
+ const persist = persistence || DEFAULT_PERSISTENCE;
591
+ return <div className="workspace-overlay" role="dialog" aria-modal="true" aria-label="Workspace settings">
592
+ <div className="workspace-overlay-backdrop" onClick={onClose} aria-hidden="true" />
593
+ <section className="workspace-overlay-panel">
594
+ <header className="workspace-overlay-header">
595
+ <div>
596
+ <p>Workspace</p>
597
+ <h2>Workspace Settings</h2>
598
+ </div>
599
+ <button type="button" aria-label="Close workspace settings" onClick={onClose} autoFocus>x</button>
600
+ </header>
601
+ <p className="workspace-overlay-note">
602
+ Inspect-only. Sourced from <code>growthub.config.json</code> + <code>GET /api/workspace</code>.
603
+ Edit branding by updating <code>growthub.config.json</code> inside your governed fork.
604
+ The builder itself never holds tokens, never executes hosted workflows, and never bypasses the PATCH allowlist
605
+ (<code>dashboards</code>, <code>widgetTypes</code>, <code>canvas</code>).
606
+ </p>
607
+ <div className="workspace-readiness">
608
+ <article className="workspace-readiness-section">
609
+ <h3>Identity</h3>
610
+ <div className="workspace-readiness-row"><span>Name</span><strong>{config?.name || "Workspace"}</strong></div>
611
+ <div className="workspace-readiness-row"><span>Workspace ID</span><code>{config?.id || "Unknown"}</code></div>
612
+ <div className="workspace-readiness-row"><span>Brand name</span><strong>{branding.name || "Unknown"}</strong></div>
613
+ <div className="workspace-readiness-row"><span>Logo URL</span><code>{branding.logoUrl || "—"}</code></div>
614
+ <div className="workspace-readiness-row"><span>Accent</span>
615
+ <span className="workspace-readiness-badge" style={{ background: branding.accent || "#3f68ff" }}>{branding.accent || "—"}</span>
616
+ </div>
617
+ </article>
618
+ <article className="workspace-readiness-section">
619
+ <h3>Persistence</h3>
620
+ <div className="workspace-readiness-row"><span>Mode</span>
621
+ <span className={`workspace-readiness-badge mode-${persist.mode}`}>{persist.mode}</span>
622
+ </div>
623
+ <div className="workspace-readiness-row"><span>Adapter</span><code>{persist.adapter}</code></div>
624
+ <div className="workspace-readiness-row"><span>Can save</span>
625
+ <span className={`workspace-readiness-badge ${persist.canSave ? "good" : "warn"}`}>{persist.canSave ? "yes" : "no"}</span>
626
+ </div>
627
+ <div className="workspace-readiness-row"><span>Save behavior</span><strong>{persist.saveLabel}</strong></div>
628
+ <div className="workspace-readiness-row"><span>Reason</span><em>{persist.reason}</em></div>
629
+ {persist.guidance ? <div className="workspace-readiness-row"><span>Guidance</span><em>{persist.guidance}</em></div> : null}
630
+ {persist.nextAction ? <div className="workspace-readiness-row"><span>Next action</span><em>{persist.nextAction}</em></div> : null}
631
+ </article>
632
+ <article className="workspace-readiness-section">
633
+ <h3>Integrations</h3>
634
+ <div className="workspace-readiness-row"><span>Integration adapter</span><code>{adapterConfig.integrationAdapter}</code></div>
635
+ <div className="workspace-readiness-row"><span>Deploy target</span><code>{adapterConfig.deployTarget}</code></div>
636
+ <div className="workspace-readiness-row"><span>Bridge</span>
637
+ <span className={`workspace-readiness-badge ${adapterConfig.growthubBridge?.hasAccessToken ? "good" : ""}`}>
638
+ {adapterConfig.growthubBridge?.hasAccessToken ? "token configured" : "no token"}
639
+ </span>
640
+ </div>
641
+ <div className="workspace-readiness-row"><span>Bridge base URL</span><code>{adapterConfig.growthubBridge?.baseUrl || "—"}</code></div>
642
+ <div className="workspace-readiness-row"><span>Authority</span><strong>{integrationAdapter.authority}</strong></div>
643
+ </article>
644
+ <article className="workspace-readiness-section">
645
+ <h3>Counts</h3>
646
+ <div className="workspace-readiness-row"><span>Dashboards</span><strong>{dashboards.length}</strong></div>
647
+ <div className="workspace-readiness-row"><span>Tabs (active canvas)</span><strong>{tabCount}</strong></div>
648
+ <div className="workspace-readiness-row"><span>Widgets (active canvas)</span><strong>{widgetCount}</strong></div>
649
+ <div className="workspace-readiness-row"><span>Template format</span><code>growthub-workspace-template</code></div>
650
+ </article>
651
+ </div>
652
+ </section>
653
+ </div>;
654
+ }
655
+
656
+ function WorkspaceManagementPanel({ config, persistence, adapterConfig, onClose }) {
657
+ const persist = persistence || DEFAULT_PERSISTENCE;
658
+ const pipelines = Array.isArray(config?.pipelines) ? config.pipelines : [];
659
+ const integrations = Array.isArray(config?.integrations) ? config.integrations : [];
660
+ const capabilities = Array.isArray(config?.capabilities) ? config.capabilities : [];
661
+ return <div className="workspace-overlay" role="dialog" aria-modal="true" aria-label="Workspace management">
662
+ <div className="workspace-overlay-backdrop" onClick={onClose} aria-hidden="true" />
663
+ <section className="workspace-overlay-panel">
664
+ <header className="workspace-overlay-header">
665
+ <div>
666
+ <p>Workspace</p>
667
+ <h2>Management</h2>
668
+ </div>
669
+ <button type="button" aria-label="Close management panel" onClick={onClose} autoFocus>x</button>
670
+ </header>
671
+ <p className="workspace-overlay-note">
672
+ Inspect-only. Workflow execution stays in <code>growthub workflow</code> / <code>growthub bridge</code>; this panel does not
673
+ execute, does not call hosted endpoints, and does not expose tokens.
674
+ </p>
675
+ <div className="workspace-readiness">
676
+ <article className="workspace-readiness-section">
677
+ <h3>Workspace</h3>
678
+ <div className="workspace-readiness-row"><span>ID</span><code>{config?.id || "Unknown"}</code></div>
679
+ <div className="workspace-readiness-row"><span>Name</span><strong>{config?.name || "Workspace"}</strong></div>
680
+ <div className="workspace-readiness-row"><span>Capabilities</span>
681
+ <span>{capabilities.length ? capabilities.join(", ") : "none"}</span>
682
+ </div>
683
+ </article>
684
+ <article className="workspace-readiness-section">
685
+ <h3>API</h3>
686
+ <div className="workspace-readiness-row"><span>PATCH allowlist</span><code>dashboards | widgetTypes | canvas</code></div>
687
+ <div className="workspace-readiness-row"><span>Unknown field</span><code>400</code></div>
688
+ <div className="workspace-readiness-row"><span>Read-only runtime</span><code>409 + guidance</code></div>
689
+ <div className="workspace-readiness-row"><span>Can save now</span>
690
+ <span className={`workspace-readiness-badge ${persist.canSave ? "good" : "warn"}`}>{persist.canSave ? "yes" : "no"}</span>
691
+ </div>
692
+ </article>
693
+ <article className="workspace-readiness-section">
694
+ <h3>Workflows</h3>
695
+ {pipelines.length === 0 ? <div className="workspace-readiness-row workspace-readiness-empty">
696
+ <em>No workflows declared in growthub.config.json. Connect via <code>growthub workflow</code> after Bridge auth.</em>
697
+ </div> : pipelines.map((pipeline, index) => <div className="workspace-readiness-row" key={pipeline.id || index}>
698
+ <span>{pipeline.id || `pipeline-${index}`}</span><strong>{pipeline.name || "Untitled"}</strong>
699
+ </div>)}
700
+ </article>
701
+ <article className="workspace-readiness-section">
702
+ <h3>Integrations</h3>
703
+ <div className="workspace-readiness-row"><span>Adapter</span><code>{adapterConfig.integrationAdapter}</code></div>
704
+ <div className="workspace-readiness-row"><span>Deploy target</span><code>{adapterConfig.deployTarget}</code></div>
705
+ {integrations.length === 0 ? <div className="workspace-readiness-row workspace-readiness-empty">
706
+ <em>No static integrations declared. Use <code>growthub bridge agents bind</code> for hosted bindings.</em>
707
+ </div> : integrations.map((integration, index) => <div className="workspace-readiness-row" key={integration.id || index}>
708
+ <span>{integration.id || `integration-${index}`}</span><strong>{integration.name || "Untitled"}</strong>
709
+ </div>)}
710
+ </article>
711
+ <article className="workspace-readiness-section">
712
+ <h3>Persistence</h3>
713
+ <div className="workspace-readiness-row"><span>Mode</span>
714
+ <span className={`workspace-readiness-badge mode-${persist.mode}`}>{persist.mode}</span>
715
+ </div>
716
+ <div className="workspace-readiness-row"><span>Reason</span><em>{persist.reason}</em></div>
717
+ {persist.guidance ? <div className="workspace-readiness-row"><span>Guidance</span><em>{persist.guidance}</em></div> : null}
718
+ </article>
719
+ </div>
720
+ </section>
721
+ </div>;
722
+ }
723
+
724
+ function WorkspaceBuilder({ initialConfig, adapterConfig, integrationAdapter, persistence }) {
511
725
  const [config, setConfig] = useState(() => {
512
726
  const dashboards = Array.isArray(initialConfig.dashboards) && initialConfig.dashboards.length
513
727
  ? initialConfig.dashboards.map((dashboard, index) =>
@@ -523,8 +737,12 @@ function WorkspaceBuilder({ initialConfig, adapterConfig, integrationAdapter })
523
737
  const [saving, setSaving] = useState(false);
524
738
  const [panelOpen, setPanelOpen] = useState(true);
525
739
  const [templateGalleryOpen, setTemplateGalleryOpen] = useState(false);
740
+ const [settingsOpen, setSettingsOpen] = useState(false);
741
+ const [managementOpen, setManagementOpen] = useState(false);
526
742
  const [previewTemplateId, setPreviewTemplateId] = useState(null);
527
743
  const [editingDashboardId, setEditingDashboardId] = useState(null);
744
+ const [editingDashboardDraft, setEditingDashboardDraft] = useState("");
745
+ const [workspaceView, setWorkspaceView] = useState("dashboards");
528
746
  const [activeDashboardId, setActiveDashboardId] = useState(() =>
529
747
  getActiveDashboardId(
530
748
  Array.isArray(initialConfig.dashboards) && initialConfig.dashboards.length ? initialConfig.dashboards : [],
@@ -541,13 +759,16 @@ function WorkspaceBuilder({ initialConfig, adapterConfig, integrationAdapter })
541
759
  const activeTabId = getActiveTabId(canvas);
542
760
  const activeTab = tabs.find((tab) => tab.id === activeTabId) || tabs[0];
543
761
  const activeWidgets = activeTab.widgets || [];
762
+ const activeDashboard = dashboards[resolvedActiveDashboardIndex] || dashboards[0] || null;
544
763
  const [selectedPosition, setSelectedPosition] = useState(() => findFreePosition(activeWidgets));
545
764
  const [selectedWidgetId, setSelectedWidgetId] = useState(null);
546
765
  const [dragStartCell, setDragStartCell] = useState(null);
547
766
  const [dragPreview, setDragPreview] = useState(null);
548
767
  const [resizeDrag, setResizeDrag] = useState(null);
768
+ const [moveDrag, setMoveDrag] = useState(null);
549
769
  const [configMessage, setConfigMessage] = useState("");
550
770
  const resizeDragRef = useRef(null);
771
+ const moveDragRef = useRef(null);
551
772
  const importInputRef = useRef(null);
552
773
  const addSlot = dragPreview || selectedPosition;
553
774
  const selectedWidget = activeWidgets.find((widget) => widget.id === selectedWidgetId) || null;
@@ -633,7 +854,9 @@ function WorkspaceBuilder({ initialConfig, adapterConfig, integrationAdapter })
633
854
  setSelectedPosition({ ...DEFAULT_POSITION });
634
855
  setDragPreview(null);
635
856
  setEditingDashboardId(dashboard.id);
857
+ setEditingDashboardDraft(dashboard.name);
636
858
  setActiveDashboardId(dashboard.id);
859
+ setWorkspaceView("builder");
637
860
  setConfigMessage(`Created ${name}`);
638
861
  return {
639
862
  ...synced,
@@ -653,8 +876,10 @@ function WorkspaceBuilder({ initialConfig, adapterConfig, integrationAdapter })
653
876
  setSelectedWidgetId(null);
654
877
  setSelectedPosition(findFreePosition(getTabs(dashboardCanvasFrom(normalized, prev.canvas))[0]?.widgets || []));
655
878
  setDragPreview(null);
656
- setEditingDashboardId(dashboard.id);
879
+ setEditingDashboardId(null);
880
+ setEditingDashboardDraft("");
657
881
  setActiveDashboardId(dashboard.id);
882
+ setWorkspaceView("builder");
658
883
  setConfigMessage(`Editing ${dashboard.name}`);
659
884
  return {
660
885
  ...synced,
@@ -664,32 +889,12 @@ function WorkspaceBuilder({ initialConfig, adapterConfig, integrationAdapter })
664
889
  });
665
890
  }, [activeDashboardId]);
666
891
 
667
- const renameDashboard = useCallback((dashboardId, name) => {
668
- const nextName = name.trimStart();
669
- setConfig((prev) => {
670
- const prevDashboards = prev.dashboards || [];
671
- const index = prevDashboards.findIndex((dashboard) => dashboard.id === dashboardId);
672
- if (index < 0) return prev;
673
- const displayName = nextName || "Untitled";
674
- const nextDashboards = prevDashboards.map((dashboard) =>
675
- dashboard.id === dashboardId ? { ...dashboard, name: displayName, updatedAt: "new" } : dashboard
676
- );
677
- const nextDashboardsWithTabs = nextDashboards.map((dashboard, dashboardIndex) => {
678
- if (dashboardIndex !== index) return dashboard;
679
- const normalized = normalizeDashboard(dashboard, index === 0 ? prev.canvas : undefined);
680
- const renamedTabs = normalized.tabs.map((tab, tabIndex) =>
681
- tabIndex === 0 ? { ...tab, name: displayName } : tab
682
- );
683
- return { ...normalized, tabs: renamedTabs };
684
- });
685
- const activeDashboard = nextDashboardsWithTabs.find((dashboard) => dashboard.id === getActiveDashboardId(nextDashboardsWithTabs, activeDashboardId));
686
- return {
687
- ...prev,
688
- dashboards: nextDashboardsWithTabs,
689
- canvas: dashboardCanvasFrom(activeDashboard || nextDashboardsWithTabs[0], prev.canvas)
690
- };
691
- });
692
- }, [activeDashboardId]);
892
+ const enterDashboardTitleEdit = useCallback((dashboard) => {
893
+ if (!dashboard) return;
894
+ setEditingDashboardId(dashboard.id);
895
+ setEditingDashboardDraft(dashboard.name);
896
+ setWorkspaceView("dashboards");
897
+ }, []);
693
898
 
694
899
  const updateDashboardStatus = useCallback((dashboardId, status) => {
695
900
  setConfig((prev) => ({
@@ -714,9 +919,7 @@ function WorkspaceBuilder({ initialConfig, adapterConfig, integrationAdapter })
714
919
  name,
715
920
  updatedAt: "new",
716
921
  status: "draft",
717
- tabs: normalizedSource.tabs.map((tab, tabIndex) =>
718
- cloneTabForDashboard(tab, tabIndex === 0 ? name : tab.name)
719
- )
922
+ tabs: normalizedSource.tabs.map((tab) => cloneTabForDashboard(tab, tab.name || "Tab 1"))
720
923
  };
721
924
  dashboard.activeTabId = dashboard.tabs[0].id;
722
925
  setSelectedWidgetId(null);
@@ -724,6 +927,7 @@ function WorkspaceBuilder({ initialConfig, adapterConfig, integrationAdapter })
724
927
  setDragPreview(null);
725
928
  setEditingDashboardId(dashboard.id);
726
929
  setActiveDashboardId(dashboard.id);
930
+ setWorkspaceView("builder");
727
931
  setConfigMessage(`Cloned ${sourceDashboard.name}`);
728
932
  return {
729
933
  ...synced,
@@ -840,7 +1044,7 @@ function WorkspaceBuilder({ initialConfig, adapterConfig, integrationAdapter })
840
1044
  const nextCanvas = commitTabs(prev.canvas, nextTabs, prevActiveId);
841
1045
  const nextDashboards = (prev.dashboards || []).map((dashboard, index) =>
842
1046
  index === dashboardIndex
843
- ? updateDashboardCanvas({ ...dashboard, name: template.name, updatedAt: "new", status: "draft" }, nextCanvas)
1047
+ ? updateDashboardCanvas({ ...dashboard, updatedAt: "new", status: "draft" }, nextCanvas)
844
1048
  : dashboard
845
1049
  );
846
1050
  return {
@@ -937,14 +1141,14 @@ function WorkspaceBuilder({ initialConfig, adapterConfig, integrationAdapter })
937
1141
  }
938
1142
  }, []);
939
1143
 
940
- const save = useCallback(async () => {
1144
+ const persistWorkspaceConfig = useCallback(async (nextConfig, nextActiveDashboardId = activeDashboardId) => {
941
1145
  if (saving) return;
942
1146
  setSaving(true);
943
1147
  try {
944
1148
  const stamp = todayIsoDate();
945
- const syncedConfig = syncActiveDashboard(config, activeDashboardId);
1149
+ const syncedConfig = syncActiveDashboard(nextConfig, nextActiveDashboardId);
946
1150
  const updatedDashboards = (syncedConfig.dashboards || []).map((dashboard) =>
947
- dashboard.id === getActiveDashboardId(syncedConfig.dashboards || [], activeDashboardId)
1151
+ dashboard.id === getActiveDashboardId(syncedConfig.dashboards || [], nextActiveDashboardId)
948
1152
  ? { ...dashboard, updatedAt: stamp }
949
1153
  : dashboard
950
1154
  );
@@ -962,13 +1166,12 @@ function WorkspaceBuilder({ initialConfig, adapterConfig, integrationAdapter })
962
1166
  const savedDashboards = (payload.workspaceConfig.dashboards || []).map((dashboard, index) =>
963
1167
  normalizeDashboard(dashboard, index === 0 ? payload.workspaceConfig.canvas : undefined)
964
1168
  );
965
- const savedActiveDashboard = savedDashboards.find((dashboard) => dashboard.id === activeDashboardId) || savedDashboards[0];
1169
+ const savedActiveDashboard = savedDashboards.find((dashboard) => dashboard.id === nextActiveDashboardId) || savedDashboards[0];
966
1170
  setConfig({
967
1171
  ...payload.workspaceConfig,
968
1172
  dashboards: savedDashboards,
969
1173
  canvas: savedActiveDashboard ? dashboardCanvasFrom(savedActiveDashboard, payload.workspaceConfig.canvas) : payload.workspaceConfig.canvas
970
1174
  });
971
- setConfigMessage("Saved dashboard config");
972
1175
  } else {
973
1176
  setConfigMessage(payload.error || "Save failed");
974
1177
  }
@@ -979,7 +1182,33 @@ function WorkspaceBuilder({ initialConfig, adapterConfig, integrationAdapter })
979
1182
  }
980
1183
  }, [activeDashboardId, saving, config]);
981
1184
 
982
- const reopenPanel = useCallback(() => setPanelOpen(true), []);
1185
+ const save = useCallback(async () => {
1186
+ await persistWorkspaceConfig(config, activeDashboardId);
1187
+ }, [activeDashboardId, config, persistWorkspaceConfig]);
1188
+
1189
+ const confirmDashboardTitleEdit = useCallback(async (dashboardId) => {
1190
+ const nextConfig = renameDashboardInConfig(config, dashboardId, editingDashboardDraft, activeDashboardId);
1191
+ setEditingDashboardId(null);
1192
+ setEditingDashboardDraft("");
1193
+ setConfig(nextConfig);
1194
+ await persistWorkspaceConfig(nextConfig, activeDashboardId);
1195
+ }, [activeDashboardId, config, editingDashboardDraft, persistWorkspaceConfig]);
1196
+
1197
+ const cancelDashboardTitleEdit = useCallback((dashboard) => {
1198
+ if (!dashboard) return;
1199
+ if (editingDashboardDraft.trim() !== dashboard.name) {
1200
+ const discard = window.confirm("Discard dashboard title changes?");
1201
+ if (!discard) {
1202
+ requestAnimationFrame(() => {
1203
+ document.querySelector(`[data-dashboard-title-input="${dashboard.id}"]`)?.focus();
1204
+ });
1205
+ return;
1206
+ }
1207
+ }
1208
+ setEditingDashboardId(null);
1209
+ setEditingDashboardDraft("");
1210
+ }, [editingDashboardDraft]);
1211
+
983
1212
  const closePanel = useCallback(() => setPanelOpen(false), []);
984
1213
  const beginCellDrag = useCallback((index, event) => {
985
1214
  const x = index % GRID_COLUMNS;
@@ -1052,6 +1281,55 @@ function WorkspaceBuilder({ initialConfig, adapterConfig, integrationAdapter })
1052
1281
  resizeDragRef.current = null;
1053
1282
  setResizeDrag(null);
1054
1283
  }, []);
1284
+ const beginMoveDrag = useCallback((widget, event) => {
1285
+ event.preventDefault();
1286
+ event.stopPropagation();
1287
+ event.currentTarget.setPointerCapture?.(event.pointerId);
1288
+ const pointerIndex = cellIndexFromGridPointer(event, gridRef.current);
1289
+ const pointerCell = pointerIndex === null ? { x: widget.position.x, y: widget.position.y } : cellPointFromIndex(pointerIndex);
1290
+ const nextMoveDrag = {
1291
+ widgetId: widget.id,
1292
+ offsetX: Math.max(0, Math.min(widget.position.w - 1, pointerCell.x - widget.position.x)),
1293
+ offsetY: Math.max(0, Math.min(widget.position.h - 1, pointerCell.y - widget.position.y))
1294
+ };
1295
+ setSelectedWidgetId(widget.id);
1296
+ setPanelOpen(true);
1297
+ moveDragRef.current = nextMoveDrag;
1298
+ setMoveDrag(nextMoveDrag);
1299
+ }, []);
1300
+ const updateMoveDrag = useCallback((event) => {
1301
+ const activeMoveDrag = moveDragRef.current;
1302
+ if (!activeMoveDrag) return;
1303
+ event.preventDefault();
1304
+ const index = cellIndexFromGridPointer(event, gridRef.current);
1305
+ if (index === null) return;
1306
+ const point = cellPointFromIndex(index);
1307
+ const movingWidget = activeWidgets.find((widget) => widget.id === activeMoveDrag.widgetId);
1308
+ if (!movingWidget) return;
1309
+ const nextPosition = clampWidgetMovePosition({
1310
+ x: point.x - activeMoveDrag.offsetX,
1311
+ y: point.y - activeMoveDrag.offsetY
1312
+ }, movingWidget, activeWidgets);
1313
+ setConfig((prev) => {
1314
+ const prevTabs = getTabs(prev.canvas);
1315
+ const prevActiveId = getActiveTabId(prev.canvas);
1316
+ const nextTabs = prevTabs.map((tab) => {
1317
+ if (tab.id !== prevActiveId) return tab;
1318
+ return {
1319
+ ...tab,
1320
+ widgets: (tab.widgets || []).map((widget) =>
1321
+ widget.id === activeMoveDrag.widgetId ? { ...widget, position: nextPosition } : widget
1322
+ )
1323
+ };
1324
+ });
1325
+ return commitDashboardCanvas(prev, activeDashboardId, commitTabs(prev.canvas, nextTabs, prevActiveId));
1326
+ });
1327
+ }, [activeDashboardId, activeWidgets]);
1328
+ const finishMoveDrag = useCallback(() => {
1329
+ if (!moveDragRef.current) return;
1330
+ moveDragRef.current = null;
1331
+ setMoveDrag(null);
1332
+ }, []);
1055
1333
  const selectWidget = useCallback((widgetId) => {
1056
1334
  setSelectedWidgetId(widgetId);
1057
1335
  setPanelOpen(true);
@@ -1092,10 +1370,56 @@ function WorkspaceBuilder({ initialConfig, adapterConfig, integrationAdapter })
1092
1370
  });
1093
1371
  }, [activeDashboardId]);
1094
1372
 
1373
+ const duplicateSelectedWidget = useCallback(() => {
1374
+ if (!selectedWidget) return;
1375
+ setConfig((prev) => {
1376
+ const prevTabs = getTabs(prev.canvas);
1377
+ const prevActiveId = getActiveTabId(prev.canvas);
1378
+ const tabWidgets = prevTabs.find((tab) => tab.id === prevActiveId)?.widgets || [];
1379
+ const position = clampPositionToFreeSpace(
1380
+ { ...selectedWidget.position, x: selectedWidget.position.x, y: selectedWidget.position.y },
1381
+ tabWidgets
1382
+ );
1383
+ const cloned = {
1384
+ ...cloneConfig(selectedWidget),
1385
+ id: generateId("widget"),
1386
+ title: `${selectedWidget.title} Copy`,
1387
+ position
1388
+ };
1389
+ const nextTabs = prevTabs.map((tab) => {
1390
+ if (tab.id !== prevActiveId) return tab;
1391
+ return { ...tab, widgets: [...(tab.widgets || []), cloned] };
1392
+ });
1393
+ setSelectedWidgetId(cloned.id);
1394
+ setSelectedPosition(findFreePosition([...tabWidgets, cloned]));
1395
+ setConfigMessage(`Duplicated ${selectedWidget.title}`);
1396
+ return commitDashboardCanvas(prev, activeDashboardId, commitTabs(prev.canvas, nextTabs, prevActiveId));
1397
+ });
1398
+ }, [activeDashboardId, selectedWidget]);
1399
+
1095
1400
  const closeTemplateGallery = useCallback(() => {
1096
1401
  setTemplateGalleryOpen(false);
1097
1402
  setPreviewTemplateId(null);
1098
1403
  }, []);
1404
+ const closeSettings = useCallback(() => setSettingsOpen(false), []);
1405
+ const closeManagement = useCallback(() => setManagementOpen(false), []);
1406
+ const resetWidgetSelection = useCallback(() => {
1407
+ setSelectedWidgetId(null);
1408
+ setPanelOpen(true);
1409
+ }, []);
1410
+ const showDashboardHome = useCallback(() => {
1411
+ setEditingDashboardId(null);
1412
+ setEditingDashboardDraft("");
1413
+ setWorkspaceView("dashboards");
1414
+ }, []);
1415
+ const resetWidgetSelectionOnOutsidePointer = useCallback((event) => {
1416
+ if (!selectedWidgetId) return;
1417
+ if (resizeDragRef.current || moveDragRef.current) return;
1418
+ const target = event.target;
1419
+ if (!(target instanceof Element)) return;
1420
+ if (target.closest(".workspace-widget-preview, .workspace-widget-panel, .workspace-overlay, .template-gallery")) return;
1421
+ resetWidgetSelection();
1422
+ }, [resetWidgetSelection, selectedWidgetId]);
1099
1423
 
1100
1424
  useEffect(() => {
1101
1425
  if (!templateGalleryOpen) return undefined;
@@ -1106,20 +1430,39 @@ function WorkspaceBuilder({ initialConfig, adapterConfig, integrationAdapter })
1106
1430
  return () => window.removeEventListener("keydown", handler);
1107
1431
  }, [templateGalleryOpen, closeTemplateGallery]);
1108
1432
 
1109
- const builderStyle = panelOpen ? undefined : { gridTemplateColumns: COLLAPSED_GRID_COLUMNS };
1433
+ useEffect(() => {
1434
+ if (!settingsOpen) return undefined;
1435
+ const handler = (event) => {
1436
+ if (event.key === "Escape") closeSettings();
1437
+ };
1438
+ window.addEventListener("keydown", handler);
1439
+ return () => window.removeEventListener("keydown", handler);
1440
+ }, [settingsOpen, closeSettings]);
1110
1441
 
1111
- return <main className="workspace-builder" style={builderStyle}>
1442
+ useEffect(() => {
1443
+ if (!managementOpen) return undefined;
1444
+ const handler = (event) => {
1445
+ if (event.key === "Escape") closeManagement();
1446
+ };
1447
+ window.addEventListener("keydown", handler);
1448
+ return () => window.removeEventListener("keydown", handler);
1449
+ }, [managementOpen, closeManagement]);
1450
+
1451
+ const builderStyle = workspaceView === "dashboards" || !panelOpen
1452
+ ? { gridTemplateColumns: COLLAPSED_GRID_COLUMNS }
1453
+ : undefined;
1454
+
1455
+ return <main className="workspace-builder" onPointerDownCapture={resetWidgetSelectionOnOutsidePointer} style={builderStyle}>
1112
1456
  <aside className="workspace-rail" aria-label="Workspace navigation">
1113
1457
  <div className="workspace-brand">
1114
1458
  <span className="workspace-mark">G</span>
1115
1459
  <span>Growthub Workspace</span>
1116
1460
  </div>
1117
1461
  <nav className="workspace-nav">
1118
- <a className="active" href="#dashboards">Dashboards</a>
1119
- <a href="#canvas">Canvas</a>
1120
- <a href="#widgets" onClick={reopenPanel}>Widgets</a>
1121
- <a href="#bindings" onClick={reopenPanel}>Bindings</a>
1462
+ <button type="button" className={workspaceView === "dashboards" ? "active workspace-nav-button" : "workspace-nav-button"} onClick={showDashboardHome}>Dashboards</button>
1122
1463
  <Link href="/settings/integrations">Integrations</Link>
1464
+ <button type="button" className="workspace-nav-button" onClick={() => setSettingsOpen(true)}>Workspace Settings</button>
1465
+ <button type="button" className="workspace-nav-button" onClick={() => setManagementOpen(true)}>Management</button>
1123
1466
  </nav>
1124
1467
  <div className="workspace-rail-status">
1125
1468
  <span className="status-dot" />
@@ -1130,8 +1473,13 @@ function WorkspaceBuilder({ initialConfig, adapterConfig, integrationAdapter })
1130
1473
  <section className="workspace-surface">
1131
1474
  <header className="workspace-toolbar">
1132
1475
  <div>
1133
- <p>Official starter</p>
1134
- <h1>{config.name}</h1>
1476
+ {workspaceView === "builder" ? <>
1477
+ <p>{activeTab?.name || "Tab 1"}</p>
1478
+ <h1>{activeDashboard?.name || "Untitled"}</h1>
1479
+ </> : <>
1480
+ <p>Workspace home</p>
1481
+ <h1>Dashboards</h1>
1482
+ </>}
1135
1483
  </div>
1136
1484
  <div className="workspace-toolbar-actions">
1137
1485
  <button type="button" onClick={() => setTemplateGalleryOpen(true)}>Templates</button>
@@ -1149,12 +1497,11 @@ function WorkspaceBuilder({ initialConfig, adapterConfig, integrationAdapter })
1149
1497
  onChange={importConfig}
1150
1498
  />
1151
1499
  </header>
1152
- {configMessage ? <p className="workspace-config-message">{configMessage}</p> : null}
1153
1500
 
1154
- <section className="workspace-table" id="dashboards" aria-label="Dashboards">
1501
+ {workspaceView === "dashboards" ? <section className="workspace-table" id="dashboards" aria-label="Dashboards">
1155
1502
  <div className="workspace-table-heading">
1156
1503
  <strong>Dashboards</strong>
1157
- <span>{dashboards.length} template</span>
1504
+ <span>{dashboards.length} dashboard{dashboards.length === 1 ? "" : "s"}</span>
1158
1505
  </div>
1159
1506
  <div className="workspace-table-row workspace-table-head">
1160
1507
  <span>Title</span>
@@ -1165,19 +1512,35 @@ function WorkspaceBuilder({ initialConfig, adapterConfig, integrationAdapter })
1165
1512
  </div>
1166
1513
  {dashboards.map((dashboard, index) => <div className="workspace-table-row" key={dashboard.id}>
1167
1514
  <span className="workspace-dashboard-title">
1168
- {editingDashboardId === dashboard.id ? <input
1169
- aria-label={`Rename ${dashboard.name}`}
1170
- onBlur={() => setEditingDashboardId(null)}
1171
- onChange={(event) => renameDashboard(dashboard.id, event.target.value)}
1172
- onKeyDown={(event) => {
1173
- if (event.key === "Enter" || event.key === "Escape") {
1174
- event.currentTarget.blur();
1175
- }
1176
- }}
1177
- value={dashboard.name}
1178
- /> : <button
1515
+ {editingDashboardId === dashboard.id ? <span className="workspace-dashboard-title-editor">
1516
+ <input
1517
+ aria-label={`Rename ${dashboard.name}`}
1518
+ autoFocus
1519
+ data-dashboard-title-input={dashboard.id}
1520
+ onBlur={() => cancelDashboardTitleEdit(dashboard)}
1521
+ onChange={(event) => setEditingDashboardDraft(event.target.value)}
1522
+ onKeyDown={(event) => {
1523
+ if (event.key === "Enter") {
1524
+ event.preventDefault();
1525
+ confirmDashboardTitleEdit(dashboard.id);
1526
+ }
1527
+ if (event.key === "Escape") {
1528
+ event.preventDefault();
1529
+ cancelDashboardTitleEdit(dashboard);
1530
+ }
1531
+ }}
1532
+ value={editingDashboardDraft}
1533
+ />
1534
+ <button
1535
+ aria-label={`Confirm ${dashboard.name} title`}
1536
+ className="workspace-dashboard-title-confirm"
1537
+ onMouseDown={(event) => event.preventDefault()}
1538
+ onClick={() => confirmDashboardTitleEdit(dashboard.id)}
1539
+ type="button"
1540
+ >✓</button>
1541
+ </span> : <button
1179
1542
  className={index === resolvedActiveDashboardIndex ? "active" : ""}
1180
- onClick={() => selectDashboard(index)}
1543
+ onClick={() => enterDashboardTitleEdit(dashboard)}
1181
1544
  type="button"
1182
1545
  >{dashboard.name}</button>}
1183
1546
  </span>
@@ -1196,14 +1559,14 @@ function WorkspaceBuilder({ initialConfig, adapterConfig, integrationAdapter })
1196
1559
  </span>
1197
1560
  <span className="workspace-dashboard-actions">
1198
1561
  <button type="button" onClick={() => selectDashboard(index)}>Edit</button>
1199
- <button type="button" onClick={() => setEditingDashboardId(dashboard.id)}>Rename</button>
1562
+ <button type="button" onClick={() => enterDashboardTitleEdit(dashboard)}>Rename</button>
1200
1563
  <button type="button" onClick={() => cloneDashboard(index)}>Clone</button>
1201
1564
  <button type="button" onClick={() => deleteDashboard(index)}>Delete</button>
1202
1565
  </span>
1203
1566
  </div>)}
1204
- </section>
1567
+ </section> : null}
1205
1568
 
1206
- <section className="workspace-canvas" id="canvas" aria-label="Composable dashboard canvas">
1569
+ {workspaceView === "builder" ? <section className="workspace-canvas" id="canvas" aria-label="Composable dashboard canvas">
1207
1570
  <div className="workspace-tabs">
1208
1571
  {tabs.map((tab) => <button
1209
1572
  key={tab.id}
@@ -1234,15 +1597,22 @@ function WorkspaceBuilder({ initialConfig, adapterConfig, integrationAdapter })
1234
1597
  <button type="button" onClick={duplicateTab}>Duplicate Tab</button>
1235
1598
  </div>
1236
1599
  <div
1237
- className="workspace-grid"
1600
+ className={`workspace-grid${moveDrag ? " moving-widget" : ""}`}
1238
1601
  ref={gridRef}
1239
1602
  onPointerMove={updatePointerDrag}
1240
1603
  onPointerUp={(event) => {
1241
1604
  finishPointerDrag(event);
1242
1605
  finishResizeDrag();
1606
+ finishMoveDrag();
1607
+ }}
1608
+ onPointerLeave={() => {
1609
+ finishResizeDrag();
1610
+ finishMoveDrag();
1611
+ }}
1612
+ onPointerMoveCapture={(event) => {
1613
+ updateResizeDrag(event);
1614
+ updateMoveDrag(event);
1243
1615
  }}
1244
- onPointerLeave={finishResizeDrag}
1245
- onPointerMoveCapture={updateResizeDrag}
1246
1616
  style={{ "--workspace-columns": canvas.layout.columns, "--workspace-rows": GRID_ROWS }}
1247
1617
  >
1248
1618
  {Array.from({ length: GRID_CELL_COUNT }).map((_, index) => {
@@ -1274,6 +1644,7 @@ function WorkspaceBuilder({ initialConfig, adapterConfig, integrationAdapter })
1274
1644
  </button>
1275
1645
  {activeWidgets.map((widget) => <WidgetPreview
1276
1646
  key={widget.id}
1647
+ onMoveStart={(event) => beginMoveDrag(widget, event)}
1277
1648
  onRemove={() => removeSelectedWidget(widget.id)}
1278
1649
  onResizeStart={(corner, event) => beginResizeDrag(widget, corner, event)}
1279
1650
  onSelect={() => selectWidget(widget.id)}
@@ -1281,7 +1652,7 @@ function WorkspaceBuilder({ initialConfig, adapterConfig, integrationAdapter })
1281
1652
  widget={widget}
1282
1653
  />)}
1283
1654
  </div>
1284
- </section>
1655
+ </section> : null}
1285
1656
  </section>
1286
1657
 
1287
1658
  {templateGalleryOpen ? <TemplateGallery
@@ -1293,13 +1664,32 @@ function WorkspaceBuilder({ initialConfig, adapterConfig, integrationAdapter })
1293
1664
  onCloneAsDashboard={cloneTemplateAsDashboard}
1294
1665
  /> : null}
1295
1666
 
1296
- {panelOpen ? <aside className="workspace-widget-panel" id="widgets" aria-label="Widget configuration">
1667
+ {settingsOpen ? <WorkspaceSettingsPanel
1668
+ config={config}
1669
+ persistence={persistence}
1670
+ adapterConfig={adapterConfig}
1671
+ integrationAdapter={integrationAdapter}
1672
+ onClose={closeSettings}
1673
+ /> : null}
1674
+
1675
+ {managementOpen ? <WorkspaceManagementPanel
1676
+ config={config}
1677
+ persistence={persistence}
1678
+ adapterConfig={adapterConfig}
1679
+ onClose={closeManagement}
1680
+ /> : null}
1681
+
1682
+ {workspaceView === "builder" && panelOpen ? <aside className="workspace-widget-panel" id="widgets" aria-label="Widget configuration">
1297
1683
  <div className="workspace-panel-title">
1298
1684
  <button type="button" aria-label="Close widget panel" onClick={closePanel}>x</button>
1299
1685
  <span aria-hidden="true">+</span>
1300
1686
  <strong>{selectedWidget ? selectedWidget.title : "New widget"}</strong>
1301
1687
  {selectedWidget ? <em>{widgetKindLabel(selectedWidget.kind)}</em> : null}
1302
1688
  </div>
1689
+ {selectedWidget ? <div className="workspace-widget-actions" role="group" aria-label="Widget actions">
1690
+ <button type="button" onClick={duplicateSelectedWidget}>Duplicate</button>
1691
+ <button type="button" className="danger" onClick={() => removeSelectedWidget(selectedWidget.id)}>Remove</button>
1692
+ </div> : null}
1303
1693
  {selectedWidget ? <section className="workspace-widget-settings">
1304
1694
  <label>
1305
1695
  <span>Title</span>
@@ -1326,21 +1716,31 @@ function WorkspaceBuilder({ initialConfig, adapterConfig, integrationAdapter })
1326
1716
  </select>
1327
1717
  </label>
1328
1718
  </section> : null}
1329
- {selectedWidget.kind === "iframe" ? <label>
1719
+ {selectedWidget.kind === "iframe" ? <label className="workspace-field-with-hint">
1330
1720
  <span>URL to Embed</span>
1331
1721
  <input
1332
1722
  placeholder="https://example.com/embed"
1333
1723
  value={selectedWidget.config?.url || ""}
1334
1724
  onChange={(event) => updateSelectedWidgetConfig({ url: event.target.value })}
1335
1725
  />
1726
+ <small className={isLikelyHttpUrl(selectedWidget.config?.url) ? "workspace-field-hint good" : "workspace-field-hint warn"}>
1727
+ {isLikelyHttpUrl(selectedWidget.config?.url)
1728
+ ? "Looks like a valid http(s) URL"
1729
+ : selectedWidget.config?.url
1730
+ ? "URL must start with http:// or https://"
1731
+ : "Add an http(s) URL to embed"}
1732
+ </small>
1336
1733
  </label> : null}
1337
- {selectedWidget.kind === "rich-text" ? <label>
1734
+ {selectedWidget.kind === "rich-text" ? <label className="workspace-field-with-hint">
1338
1735
  <span>Content</span>
1339
1736
  <textarea
1340
1737
  placeholder="Write text..."
1341
1738
  value={selectedWidget.config?.text || ""}
1342
1739
  onChange={(event) => updateSelectedWidgetConfig({ text: event.target.value })}
1343
1740
  />
1741
+ <small className="workspace-field-hint">
1742
+ {(selectedWidget.config?.text || "").length} characters · plain text only at V1
1743
+ </small>
1344
1744
  </label> : null}
1345
1745
  {selectedWidget.kind === "view" ? <section className="workspace-field-stack">
1346
1746
  <label>
@@ -1410,6 +1810,14 @@ function WorkspaceBuilder({ initialConfig, adapterConfig, integrationAdapter })
1410
1810
  <div><span>Origin</span><code>{selectedWidget.position.x + 1}, {selectedWidget.position.y + 1}</code></div>
1411
1811
  </div>
1412
1812
  </section> : <section>
1813
+ <div className="workspace-widget-empty">
1814
+ <strong>Pick a widget kind</strong>
1815
+ <p>
1816
+ Widgets snap to the 12-column × 16-row grid. {addSlot.w} × {addSlot.h} cells
1817
+ selected at column {addSlot.x + 1}, row {addSlot.y + 1}. Drag empty cells in the
1818
+ canvas to reshape the placement.
1819
+ </p>
1820
+ </div>
1413
1821
  <p className="workspace-panel-label">Widget type</p>
1414
1822
  <div className="workspace-widget-types">
1415
1823
  {widgetTypes.map((widget) => <button type="button" key={widget.kind} onClick={() => addWidget(widget.kind)}>