spectoflow 0.24.0 → 0.27.0

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 (53) hide show
  1. package/README.md +28 -6
  2. package/bin/spectoflow.js +88 -6
  3. package/lib/dashboard/connector.js +193 -0
  4. package/lib/dashboard/handlers.js +2 -27
  5. package/lib/dashboard/hub-server.js +103 -11
  6. package/lib/dashboard/inject-design.js +41 -0
  7. package/lib/dashboard/meeting.js +116 -0
  8. package/lib/dashboard/ops.js +83 -2
  9. package/lib/dashboard/public/app.js +738 -118
  10. package/lib/dashboard/public/charts.js +3 -3
  11. package/lib/dashboard/public/commands.js +77 -0
  12. package/lib/dashboard/public/designs/console.css +7 -19
  13. package/lib/dashboard/public/designs/orbit.css +3 -4
  14. package/lib/dashboard/public/designs.js +2 -2
  15. package/lib/dashboard/public/fonts/bricolage-grotesque-400.woff2 +0 -0
  16. package/lib/dashboard/public/fonts/bricolage-grotesque-600.woff2 +0 -0
  17. package/lib/dashboard/public/fonts/bricolage-grotesque-700.woff2 +0 -0
  18. package/lib/dashboard/public/hub.html +1 -1
  19. package/lib/dashboard/public/hub.js +27 -4
  20. package/lib/dashboard/public/i18n.js +60 -18
  21. package/lib/dashboard/public/icons.js +2 -0
  22. package/lib/dashboard/public/index.html +106 -13
  23. package/lib/dashboard/public/styles.css +161 -26
  24. package/lib/dashboard/public/vendor/prism/prism-bash.min.js +1 -0
  25. package/lib/dashboard/public/vendor/prism/prism-c.min.js +1 -0
  26. package/lib/dashboard/public/vendor/prism/prism-clike.min.js +1 -0
  27. package/lib/dashboard/public/vendor/prism/prism-core.min.js +1 -0
  28. package/lib/dashboard/public/vendor/prism/prism-cpp.min.js +1 -0
  29. package/lib/dashboard/public/vendor/prism/prism-csharp.min.js +1 -0
  30. package/lib/dashboard/public/vendor/prism/prism-css.min.js +1 -0
  31. package/lib/dashboard/public/vendor/prism/prism-docker.min.js +1 -0
  32. package/lib/dashboard/public/vendor/prism/prism-go.min.js +1 -0
  33. package/lib/dashboard/public/vendor/prism/prism-java.min.js +1 -0
  34. package/lib/dashboard/public/vendor/prism/prism-json.min.js +1 -0
  35. package/lib/dashboard/public/vendor/prism/prism-kotlin.min.js +1 -0
  36. package/lib/dashboard/public/vendor/prism/prism-markdown.min.js +1 -0
  37. package/lib/dashboard/public/vendor/prism/prism-markup-templating.min.js +1 -0
  38. package/lib/dashboard/public/vendor/prism/prism-markup.min.js +1 -0
  39. package/lib/dashboard/public/vendor/prism/prism-php.min.js +1 -0
  40. package/lib/dashboard/public/vendor/prism/prism-python.min.js +1 -0
  41. package/lib/dashboard/public/vendor/prism/prism-ruby.min.js +1 -0
  42. package/lib/dashboard/public/vendor/prism/prism-rust.min.js +1 -0
  43. package/lib/dashboard/public/vendor/prism/prism-sql.min.js +1 -0
  44. package/lib/dashboard/public/vendor/prism/prism-swift.min.js +1 -0
  45. package/lib/dashboard/public/vendor/prism/prism-yaml.min.js +1 -0
  46. package/lib/dashboard/routes.js +38 -0
  47. package/lib/dashboard/runner.js +7 -2
  48. package/lib/workspace.js +36 -1
  49. package/package.json +3 -3
  50. package/templates/config.json +23 -0
  51. package/lib/dashboard/public/fonts/space-grotesk-400.woff2 +0 -0
  52. package/lib/dashboard/public/fonts/space-grotesk-500.woff2 +0 -0
  53. package/lib/dashboard/public/fonts/space-grotesk-700.woff2 +0 -0
@@ -167,8 +167,8 @@
167
167
  }
168
168
 
169
169
  // ring(pct, opts) — a progress ring: track circle + foreground arc via
170
- // stroke-dasharray/offset (stroke="url(#grad)" — caller provides the
171
- // <linearGradient id="grad"> once), centre pct% text.
170
+ // stroke-dasharray/offset (flat var(--signal) fill by default; caller may
171
+ // override via opts.stroke2), centre pct% text.
172
172
  function ring(pct, opts) {
173
173
  opts = opts || {};
174
174
  pct = Math.max(0, Math.min(100, pct || 0));
@@ -180,7 +180,7 @@
180
180
  const offset = c * (1 - pct / 100);
181
181
  return `<svg viewBox="0 0 ${size} ${size}" width="${size}" height="${size}" class="ring-svg">` +
182
182
  `<circle cx="${cx}" cy="${cy}" r="${n2(r)}" fill="none" stroke="var(--line)" stroke-width="${stroke}"/>` +
183
- `<circle cx="${cx}" cy="${cy}" r="${n2(r)}" fill="none" stroke="${opts.stroke2 || 'url(#grad)'}" stroke-width="${stroke}" stroke-linecap="round" ` +
183
+ `<circle cx="${cx}" cy="${cy}" r="${n2(r)}" fill="none" stroke="${opts.stroke2 || 'var(--signal)'}" stroke-width="${stroke}" stroke-linecap="round" ` +
184
184
  `stroke-dasharray="${n2(c)}" stroke-dashoffset="${n2(offset)}" transform="rotate(-90 ${cx} ${cy})"/>` +
185
185
  `<text x="${cx}" y="${cy}" text-anchor="middle" dominant-baseline="middle" class="ring-label" style="fill:var(--ink)">${pct}%</text>` +
186
186
  `</svg>`;
@@ -0,0 +1,77 @@
1
+ 'use strict';
2
+ /*
3
+ * Slash-command logic for the dashboard chat. A command is a saved prompt macro: the user types
4
+ * "/trigger <text>" and it expands, client-side, into the command's full `instruction` (with an
5
+ * optional {{input}} placeholder for the trailing text). The agent never sees the "/" — expansion
6
+ * happens before /api/run, so this is fully agent-agnostic.
7
+ *
8
+ * Zero-dependency, loaded both in the browser (via <script> — exposes window.SpectoCommands) and in
9
+ * node --test (via require — module.exports), the same pattern as stats.js / charts.js. This is the
10
+ * single source of truth for the built-in command set and for trigger validation.
11
+ */
12
+ (function (root) {
13
+ const TRIGGER_RE = /^[a-z0-9][a-z0-9_-]{0,39}$/;
14
+
15
+ // Shipped defaults. Kept ONLY here (not duplicated into templates/config.json): a project with no
16
+ // config.commands field uses these; the first user edit persists the full list into config.json.
17
+ const BUILTIN_COMMANDS = [
18
+ { trigger: 'spec', description: 'Write or update a spec',
19
+ instruction: "Write or update the specification for the following. Follow the project's spec conventions under .spectoflow (specs/ markdown: clear goals, non-goals, acceptance criteria). Focus: {{input}}",
20
+ enabled: true },
21
+ { trigger: 'plan', description: 'Break work into a task plan',
22
+ instruction: "Break the following work into an ordered plan of small, independently testable checkbox tasks, following the project's plan conventions (plans/ markdown). Work: {{input}}",
23
+ enabled: true },
24
+ { trigger: 'revue', description: 'Code-review the current diff',
25
+ instruction: 'Review the current working-tree diff for correctness bugs and quality issues (reuse, simplification, efficiency). Report findings most-severe first. {{input}}',
26
+ enabled: true },
27
+ { trigger: 'resume', description: 'Summarize progress',
28
+ instruction: 'Give a concise summary of the current project progress: what is done, what is in progress, and what is next. {{input}}',
29
+ enabled: true },
30
+ { trigger: 'rapport_jour', description: 'Structured daily report',
31
+ instruction: "Write today's daily report with these sections: 1) Highlights (what was accomplished), 2) Blockers, 3) Next steps. Keep it concise and factual. {{input}}",
32
+ enabled: true },
33
+ ];
34
+
35
+ function effectiveCommands(config) {
36
+ config = config || {};
37
+ return Array.isArray(config.commands) ? config.commands : BUILTIN_COMMANDS.slice();
38
+ }
39
+
40
+ function validateTrigger(trigger, existingTriggers) {
41
+ if (typeof trigger !== 'string') return { ok: false, error: 'invalidTrigger' };
42
+ const lc = trigger.toLowerCase();
43
+ if (!TRIGGER_RE.test(lc)) return { ok: false, error: 'invalidTrigger' };
44
+ if ((existingTriggers || []).some((t) => String(t).toLowerCase() === lc)) return { ok: false, error: 'duplicateTrigger' };
45
+ return { ok: true };
46
+ }
47
+
48
+ function matchCommands(query, commands) {
49
+ const q = String(query || '').toLowerCase();
50
+ return (commands || []).filter((c) => c && c.enabled !== false
51
+ && typeof c.trigger === 'string' && c.trigger.toLowerCase().startsWith(q));
52
+ }
53
+
54
+ function parseInvocation(text) {
55
+ const m = String(text == null ? '' : text).trim().match(/^\/([a-z0-9][a-z0-9_-]*)(?:\s+([\s\S]*))?$/i);
56
+ if (!m) return null;
57
+ return { trigger: m[1], rest: m[2] || '' };
58
+ }
59
+
60
+ function expandCommand(text, commands) {
61
+ const inv = parseInvocation(text);
62
+ if (!inv) return null;
63
+ const cmd = (commands || []).find((c) => c && c.enabled !== false
64
+ && typeof c.trigger === 'string' && c.trigger.toLowerCase() === inv.trigger.toLowerCase()
65
+ && typeof c.instruction === 'string' && c.instruction.trim());
66
+ if (!cmd) return null;
67
+ const rest = inv.rest.trim();
68
+ const prompt = cmd.instruction.indexOf('{{input}}') !== -1
69
+ ? cmd.instruction.split('{{input}}').join(rest)
70
+ : (rest ? cmd.instruction + '\n\n' + rest : cmd.instruction);
71
+ return { prompt: prompt.trim(), display: String(text).trim() };
72
+ }
73
+
74
+ const api = { BUILTIN_COMMANDS, effectiveCommands, validateTrigger, matchCommands, parseInvocation, expandCommand };
75
+ if (typeof module !== 'undefined' && module.exports) module.exports = api;
76
+ else root.SpectoCommands = api;
77
+ })(typeof window !== 'undefined' ? window : globalThis);
@@ -1,4 +1,4 @@
1
- /* Spectral Console — dark-first bento control room. Amber accent (--signal), cyan flow (--cool).
1
+ /* Spectral Console — dark-first bento control room. Violet accent (--signal), cyan flow (--cool).
2
2
  Scoped entirely under html[data-design="console"] so other designs are untouched.
3
3
  Fonts (self-hosted, declared once in ../styles.css @font-face rules — reused here by family name):
4
4
  Sora (display), IBM Plex Sans (sans), JetBrains Mono (mono). No external font requests. */
@@ -8,12 +8,12 @@ html[data-design="console"] {
8
8
  --bg:#0b1220; --surface:#111a2b; --surface-2:#172236; --surface-3:#1d2a42;
9
9
  --line:#223049; --line-2:#2c3c5a;
10
10
  --ink:#e8eef8; --muted:#8a9bb5; --faint:#5f6f8a;
11
- --signal:#e6a54b; --cool:#38c5d9; --on-accent:#1a1206;
11
+ --signal:#7c5cff; --cool:#38c5d9; --on-accent:#ffffff;
12
12
  --s-todo:#6b7a90; --s-in_progress:#4f8cff; --s-to_validate:#ff9b3d; --s-to_analyze:#ff5fd2;
13
13
  --s-done:#3fbf7f; --s-blocked:#ff5c5c;
14
14
  --radius:14px; --shadow:0 12px 40px rgba(0,0,0,.45), 0 1px 0 rgba(255,255,255,.04) inset;
15
15
  --display:'Sora',var(--sans); --sans:'IBM Plex Sans',system-ui,sans-serif; --mono:'JetBrains Mono',ui-monospace,Menlo,Consolas,monospace;
16
- --cx-glow-signal:0 0 0 1px rgba(230,165,75,.35), 0 0 32px rgba(230,165,75,.18);
16
+ --cx-glow-signal:0 0 0 1px rgba(124,92,255,.35), 0 0 32px rgba(124,92,255,.20);
17
17
  --cx-glow-cool:0 0 0 1px rgba(56,197,217,.35), 0 0 32px rgba(56,197,217,.16);
18
18
  --cx-grid:rgba(255,255,255,.035);
19
19
  --cx-rail-w:72px;
@@ -22,11 +22,11 @@ html[data-design="console"][data-theme="light"] {
22
22
  --bg:#f3f5f9; --surface:#ffffff; --surface-2:#eef2f8; --surface-3:#e4eaf4;
23
23
  --line:#dbe2ee; --line-2:#c9d3e3;
24
24
  --ink:#0f1728; --muted:#55657f; --faint:#8593ab;
25
- --signal:#b8781f; --cool:#0e8fa3; --on-accent:#fff8ec;
25
+ --signal:#5b3fc4; --cool:#0e8fa3; --on-accent:#ffffff;
26
26
  --s-todo:#7c8aa3; --s-in_progress:#2f6fe0; --s-to_validate:#d97a17; --s-to_analyze:#c93fae;
27
27
  --s-done:#1f9c5c; --s-blocked:#d93a3a;
28
28
  --shadow:0 10px 30px rgba(20,30,60,.10), 0 1px 0 rgba(255,255,255,.6) inset;
29
- --cx-glow-signal:0 0 0 1px rgba(184,120,31,.35), 0 0 28px rgba(184,120,31,.14);
29
+ --cx-glow-signal:0 0 0 1px rgba(91,63,196,.35), 0 0 28px rgba(91,63,196,.16);
30
30
  --cx-glow-cool:0 0 0 1px rgba(14,143,163,.35), 0 0 28px rgba(14,143,163,.14);
31
31
  --cx-grid:rgba(15,23,40,.06);
32
32
  }
@@ -166,20 +166,10 @@ html[data-design="console"] .flatlist li.run-live { padding-left:18px; }
166
166
  /* sync dot pulse (reinforces the base pulse with a cyan tint on this design) */
167
167
  html[data-design="console"] .sync-dot { box-shadow:0 0 0 0 color-mix(in srgb,var(--cool) 45%,transparent); }
168
168
 
169
- /* ============ WORKFLOW PIPELINE — flowing particles on enabled connectors ============ */
169
+ /* ============ WORKFLOW PIPELINE — connector styling ============ */
170
170
  html[data-design="console"] .wf-link { position:relative; overflow:visible; }
171
- html[data-design="console"] .wf-link.on { background:linear-gradient(90deg,color-mix(in srgb,var(--signal) 55%,var(--line)),var(--line)); }
172
- html[data-design="console"] .wf-link.on::after {
173
- content:""; position:absolute; top:-2px; left:0; width:6px; height:6px; border-radius:50%;
174
- background:var(--cool); box-shadow:0 0 8px var(--cool); opacity:0;
175
- animation:cx-travel 2.2s linear infinite;
176
- }
171
+ html[data-design="console"] .wf-link.on { background:var(--signal); }
177
172
  html[data-design="console"] .wf-strip .wf-arrow:not(.off) { position:relative; overflow:visible; }
178
- html[data-design="console"] .wf-strip .wf-arrow:not(.off)::after {
179
- content:""; position:absolute; top:50%; left:0; width:5px; height:5px; margin-top:-2.5px; border-radius:50%;
180
- background:var(--cool); box-shadow:0 0 6px var(--cool); opacity:0; animation:cx-travel 2s linear infinite;
181
- }
182
- @keyframes cx-travel { 0%{ left:0; opacity:0; } 10%{ opacity:1; } 90%{ opacity:1; } 100%{ left:100%; opacity:0; } }
183
173
 
184
174
  /* ============ COMMAND PALETTE (injected by console.js) ============ */
185
175
  .cx-search-btn {
@@ -223,8 +213,6 @@ html[data-design="console"][data-theme="light"] .cx-cmdk { background:rgba(15,23
223
213
  html[data-design="console"] .ocard,
224
214
  html[data-design="console"] .acard,
225
215
  html[data-design="console"] .card { animation:none; }
226
- html[data-design="console"] .wf-link.on::after,
227
- html[data-design="console"] .wf-strip .wf-arrow::after,
228
216
  html[data-design="console"] .flatlist li.run-live::before { animation:none; display:none; }
229
217
  }
230
218
 
@@ -1,6 +1,6 @@
1
1
  /* ============================================================================================
2
2
  * Orbit — light-first, airy, circular design skin. Teal signal + amber accent, dotted ground,
3
- * Space Grotesk display / IBM Plex Sans body / JetBrains Mono numerics (all self-hosted, see the
3
+ * Bricolage Grotesque display / IBM Plex Sans body / JetBrains Mono numerics (all self-hosted, see the
4
4
  * @font-face rules already declared in styles.css). Navigation is NOT the tab bar — the tab bar is
5
5
  * hidden and replaced by a round "hub" button in the header that opens a full-screen radial menu
6
6
  * (orbit.js builds it from the very same #tabs buttons, so routing logic is fully reused).
@@ -15,7 +15,7 @@ html[data-design="orbit"] {
15
15
  --s-done:#1f9c5c; --s-blocked:#d93a3a;
16
16
  --radius:20px; --shadow:0 14px 40px rgba(14,29,33,.12);
17
17
  --sans:'IBM Plex Sans',ui-sans-serif,system-ui,sans-serif;
18
- --display:'Space Grotesk',ui-sans-serif,system-ui,sans-serif;
18
+ --display:'Bricolage Grotesque',ui-sans-serif,system-ui,sans-serif;
19
19
  --mono:'JetBrains Mono',ui-monospace,Menlo,Consolas,monospace;
20
20
  --ob-dot: rgba(14,29,33,.07);
21
21
  }
@@ -77,8 +77,7 @@ html[data-design="orbit"] .av.running::after {
77
77
  @keyframes ob-ripple { 0%{ transform:scale(.9); opacity:.8; } 100%{ transform:scale(1.3); opacity:0; } }
78
78
 
79
79
  /* flowing teal particles along the workflow pipeline connectors */
80
- html[data-design="orbit"] .wf-arrow::after { background:linear-gradient(90deg,transparent,var(--signal),transparent); }
81
- html[data-design="orbit"] .wf-conn { position:relative; overflow:hidden; background:linear-gradient(90deg,var(--cool),var(--signal)); }
80
+ html[data-design="orbit"] .wf-conn { position:relative; overflow:hidden; background:var(--signal); }
82
81
  html[data-design="orbit"] .wf-conn::after {
83
82
  content:""; position:absolute; top:-2px; left:0; width:6px; height:6px; border-radius:50%;
84
83
  background:var(--signal); box-shadow:0 0 8px var(--signal); animation: ob-flow 2.4s linear infinite;
@@ -20,11 +20,11 @@
20
20
  */
21
21
  (function (root) {
22
22
  const DESIGNS = [
23
- { id: 'console', name: 'Spectral Console', desc: 'Default — dark real-time console: left icon rail, ⌘K palette, bento overview, amber + cyan flow.' },
23
+ { id: 'console', name: 'Spectral Console', desc: 'Default — dark real-time console: left icon rail, ⌘K palette, bento overview, violet + cyan flow.' },
24
24
  { id: 'orbit', name: 'Orbit', desc: 'Light, airy, circular — a radial menu opens on click of the hub; teal dial + amber brand.' },
25
25
  { id: 'control-room', name: 'Control Room', desc: 'Dark violet engineering control room — the original.' },
26
26
  { id: 'obsidian', name: 'Obsidian Ops', desc: 'Near-black mission-control — electric lime + cyan, mono type, Linear/Vercel precision.' },
27
- { id: 'neon-command', name: 'Neon Command', desc: 'Glassmorphism — aurora violet + cyan, Space Grotesk display, control-room ambiance.' },
27
+ { id: 'neon-command', name: 'Neon Command', desc: 'Glassmorphism — aurora violet + cyan, Bricolage Grotesque display, control-room ambiance.' },
28
28
  { id: 'mission', name: 'Mission Control', desc: 'Indigo control panel on solid slate — clean, flat, status-coloured.' },
29
29
  ];
30
30
  if (typeof module !== 'undefined' && module.exports) module.exports = DESIGNS;
@@ -34,7 +34,7 @@
34
34
  <div id="hubEmpty" class="hub-empty" hidden>
35
35
  <svg class="hub-empty-icon" viewBox="0 0 18 18" width="34" height="34" fill="none" stroke="currentColor" stroke-width="1.3" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M9 2.2 15.5 6v6L9 15.8 2.5 12V6z"/><path d="M9 6.6v4.8"/><circle cx="9" cy="13.4" r=".9" fill="currentColor" stroke="none"/></svg>
36
36
  <p class="hub-empty-title">No projects yet</p>
37
- <p class="hub-empty-sub">Add your first project to get started — point spectoflow at any folder on your computer.</p>
37
+ <p class="hub-empty-sub" id="hubEmptySub">Add your first project to get started — point spectoflow at any folder on your computer.</p>
38
38
  <button class="hub-add-btn hub-add-btn-lg" id="hubAddBtnEmpty">+ Add your first project</button>
39
39
  </div>
40
40
 
@@ -34,30 +34,43 @@
34
34
  const r = await fetch('/api/hub/projects');
35
35
  const data = await r.json();
36
36
  const rows = data.projects || [];
37
+ const remote = data.mode === 'remote'; // served by the online relay: read-only list
38
+ const canPublish = !remote && !!(data.remote && data.remote.configured); // local hub logged in to one
39
+ document.body.classList.toggle('hub-remote', remote);
37
40
  empty.hidden = rows.length > 0;
38
41
  if (subtitle) {
39
42
  subtitle.textContent = rows.length
40
43
  ? `${rows.length} project${rows.length === 1 ? '' : 's'} · click a card to open its board`
41
- : 'One dashboard, every project — pick one to open its board.';
44
+ : remote ? 'Nothing published yet.' : 'One dashboard, every project — pick one to open its board.';
42
45
  }
46
+ const emptySub = document.getElementById('hubEmptySub');
47
+ if (emptySub && remote) emptySub.textContent = 'On a machine: spectoflow dashboard login --url=… --token=…, then spectoflow dashboard publish inside a project.';
43
48
  grid.innerHTML = rows.map((p) => {
44
49
  const pct = p.stats && p.stats.total ? Math.round(100 * p.stats.done / p.stats.total) : null;
45
50
  const stage = pct === null ? 'new' : pct >= 100 ? 'done' : pct > 0 ? 'active' : 'new';
46
51
  const stageLabel = { new: 'New', active: 'In progress', done: 'Done' }[stage];
52
+ const where = remote
53
+ ? `<span class="hub-card-machine"><i class="hub-dot${p.online ? ' is-on' : ''}"></i>${esc(p.machine || 'machine')}${p.online ? '' : ' · offline'}</span>`
54
+ : esc(p.path);
55
+ const meta = remote ? `Updated ${esc(timeAgo(p.lastOpened))}` : `Opened ${esc(timeAgo(p.lastOpened))}`;
56
+ const online = canPublish
57
+ ? `<button class="hub-card-online${p.published ? ' is-on' : ''}" data-publish="${p.id}" data-published="${p.published ? '1' : '0'}" title="${p.published ? 'Published to your online dashboard — click to unpublish' : 'Publish to your online dashboard'}">${p.published ? '● Online' : '○ Online'}</button>`
58
+ : '';
47
59
  return `<div class="hub-card" data-id="${p.id}">
48
60
  <a class="hub-card-open" href="/p/${p.id}/board">
49
61
  <div class="hub-card-top">
50
62
  <div class="hub-card-name">${esc(p.name)}</div>
51
63
  <span class="hub-card-stage is-${stage}">${stageLabel}</span>
52
64
  </div>
53
- <div class="hub-card-path">${esc(p.path)}</div>
65
+ <div class="hub-card-path">${where}</div>
54
66
  <div class="hub-card-progress"><div class="hub-card-progress-fill" style="width:${pct ?? 0}%"></div></div>
55
67
  <div class="hub-card-foot">
56
68
  <span class="hub-card-pct">${pct !== null ? `${pct}% · ${p.stats.done}/${p.stats.total} tasks` : 'No tasks yet'}</span>
57
- <span class="hub-card-meta">Opened ${esc(timeAgo(p.lastOpened))}</span>
69
+ <span class="hub-card-meta">${meta}</span>
58
70
  </div>
59
71
  </a>
60
- <button class="hub-card-remove" data-remove="${p.id}" title="Remove from this list" aria-label="Remove ${esc(p.name)}">&times;</button>
72
+ ${online}
73
+ ${remote ? '' : `<button class="hub-card-remove" data-remove="${p.id}" title="Remove from this list" aria-label="Remove ${esc(p.name)}">&times;</button>`}
61
74
  </div>`;
62
75
  }).join('');
63
76
  }
@@ -77,6 +90,16 @@
77
90
  return false;
78
91
  }
79
92
  grid.addEventListener('click', async (e) => {
93
+ const pub = e.target.closest('[data-publish]');
94
+ if (pub) {
95
+ e.preventDefault();
96
+ const id = pub.getAttribute('data-publish');
97
+ const published = pub.getAttribute('data-published') !== '1';
98
+ pub.disabled = true;
99
+ await fetch('/api/hub/projects/' + encodeURIComponent(id) + '/publish', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ published }) });
100
+ loadProjects();
101
+ return;
102
+ }
80
103
  const btn = e.target.closest('[data-remove]');
81
104
  if (!btn) return;
82
105
  e.preventDefault();