@beastmode-develeap/beastmode 0.1.1 → 0.1.3

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.
@@ -219,24 +219,18 @@ body {
219
219
  box-shadow: 0 2px 8px rgba(0,0,0,0.15);
220
220
  }
221
221
 
222
- /* Mobile sidebar overlay */
223
- .sidebar-overlay {
222
+ /* Mobile nav overlay — must be BELOW sidebar (z-index 100) */
223
+ .mobile-nav-overlay {
224
224
  display: none;
225
225
  position: fixed;
226
226
  inset: 0;
227
227
  background: rgba(0,0,0,0.5);
228
- z-index: 99;
228
+ z-index: 98;
229
229
  }
230
- .sidebar-overlay.open { display: block; }
230
+ .mobile-nav-overlay.open { display: block; }
231
231
 
232
- @media (max-width: 768px) {
232
+ @media (max-width: 900px) {
233
233
  .mobile-menu-btn { display: block; }
234
- .sidebar {
235
- transform: translateX(-100%);
236
- }
237
- .sidebar.open {
238
- transform: translateX(0);
239
- }
240
234
  .main {
241
235
  margin-left: 0;
242
236
  padding: 56px 16px 16px;
@@ -1035,6 +1029,8 @@ input[type="range"]::-webkit-slider-thumb {
1035
1029
  color: var(--text-secondary);
1036
1030
  display: flex;
1037
1031
  justify-content: space-between;
1032
+ cursor: pointer;
1033
+ user-select: none;
1038
1034
  align-items: center;
1039
1035
  text-transform: uppercase;
1040
1036
  letter-spacing: 0.3px;
@@ -1212,6 +1208,159 @@ input[type="range"]::-webkit-slider-thumb {
1212
1208
  letter-spacing: 0.5px;
1213
1209
  }
1214
1210
 
1211
+ /* Board header row — stats + search + filters + new task */
1212
+ .board-header-row {
1213
+ display: flex;
1214
+ align-items: center;
1215
+ gap: 12px;
1216
+ margin-bottom: 16px;
1217
+ flex-wrap: wrap;
1218
+ }
1219
+ .board-header-row .board-stats-bar {
1220
+ margin-bottom: 0;
1221
+ flex-shrink: 0;
1222
+ }
1223
+ .board-search-wrap {
1224
+ position: relative;
1225
+ flex: 1;
1226
+ min-width: 180px;
1227
+ max-width: 360px;
1228
+ }
1229
+ .board-search-wrap input {
1230
+ width: 100%;
1231
+ padding: 8px 32px 8px 12px;
1232
+ background: var(--bg-input);
1233
+ border: 1px solid var(--border);
1234
+ border-radius: var(--radius-sm);
1235
+ color: var(--text);
1236
+ font-size: 13px;
1237
+ font-family: var(--font-sans);
1238
+ outline: none;
1239
+ transition: border-color 0.15s;
1240
+ }
1241
+ .board-search-wrap input:focus {
1242
+ border-color: var(--accent);
1243
+ }
1244
+ .board-search-wrap input::placeholder {
1245
+ color: var(--text-muted);
1246
+ }
1247
+ .board-search-clear {
1248
+ position: absolute;
1249
+ right: 6px;
1250
+ top: 50%;
1251
+ transform: translateY(-50%);
1252
+ background: none;
1253
+ border: none;
1254
+ color: var(--text-muted);
1255
+ cursor: pointer;
1256
+ font-size: 16px;
1257
+ padding: 2px 4px;
1258
+ line-height: 1;
1259
+ }
1260
+ .board-search-clear:hover { color: var(--text); }
1261
+
1262
+ /* Filter toggle button */
1263
+ .filter-toggle {
1264
+ display: inline-flex;
1265
+ align-items: center;
1266
+ gap: 6px;
1267
+ padding: 0 14px;
1268
+ height: 36px;
1269
+ background: var(--bg-card);
1270
+ border: 1px solid var(--border);
1271
+ border-radius: var(--radius-sm);
1272
+ color: var(--text-secondary);
1273
+ font-size: 13px;
1274
+ font-family: var(--font-sans);
1275
+ cursor: pointer;
1276
+ transition: all 0.15s;
1277
+ white-space: nowrap;
1278
+ }
1279
+ .filter-toggle:hover { border-color: var(--text-muted); color: var(--text); }
1280
+ .filter-toggle.active { border-color: var(--accent); color: var(--accent); background: var(--accent-subtle); }
1281
+ .filter-active-count {
1282
+ display: inline-flex;
1283
+ align-items: center;
1284
+ justify-content: center;
1285
+ min-width: 18px;
1286
+ height: 18px;
1287
+ border-radius: 9px;
1288
+ background: var(--accent);
1289
+ color: #1a1a1a;
1290
+ font-size: 11px;
1291
+ font-weight: 700;
1292
+ padding: 0 5px;
1293
+ }
1294
+
1295
+ /* Filter panel */
1296
+ .board-filter-panel {
1297
+ display: flex;
1298
+ flex-wrap: wrap;
1299
+ gap: 12px;
1300
+ align-items: flex-end;
1301
+ padding: 14px 16px;
1302
+ margin-bottom: 16px;
1303
+ background: var(--bg-card);
1304
+ border: 1px solid var(--border);
1305
+ border-radius: var(--radius-sm);
1306
+ animation: fadeSlideIn 0.15s ease;
1307
+ }
1308
+ .board-filter-panel .filter-group {
1309
+ display: flex;
1310
+ flex-direction: column;
1311
+ gap: 4px;
1312
+ min-width: 140px;
1313
+ }
1314
+ .board-filter-panel .filter-group label {
1315
+ font-size: 11px;
1316
+ font-weight: 600;
1317
+ color: var(--text-muted);
1318
+ text-transform: uppercase;
1319
+ letter-spacing: 0.3px;
1320
+ }
1321
+ .board-filter-panel .filter-group select {
1322
+ padding: 6px 10px;
1323
+ background: var(--bg-input);
1324
+ border: 1px solid var(--border);
1325
+ border-radius: var(--radius-xs);
1326
+ color: var(--text);
1327
+ font-size: 13px;
1328
+ font-family: var(--font-sans);
1329
+ outline: none;
1330
+ cursor: pointer;
1331
+ }
1332
+ .board-filter-panel .filter-group select:focus { border-color: var(--accent); }
1333
+ .filter-clear-link {
1334
+ font-size: 12px;
1335
+ color: var(--accent);
1336
+ cursor: pointer;
1337
+ background: none;
1338
+ border: none;
1339
+ font-family: var(--font-sans);
1340
+ padding: 6px 0;
1341
+ align-self: flex-end;
1342
+ }
1343
+ .filter-clear-link:hover { text-decoration: underline; }
1344
+
1345
+ /* Column sort indicator */
1346
+ .kanban-column-header .sort-indicator {
1347
+ font-size: 11px;
1348
+ margin-left: 4px;
1349
+ color: var(--accent);
1350
+ opacity: 0.8;
1351
+ }
1352
+
1353
+ /* Project badge on card */
1354
+ .card-badge.badge-project {
1355
+ background: rgba(96, 165, 250, 0.12);
1356
+ color: #60a5fa;
1357
+ font-size: 10px;
1358
+ max-width: 90px;
1359
+ overflow: hidden;
1360
+ text-overflow: ellipsis;
1361
+ white-space: nowrap;
1362
+ }
1363
+
1215
1364
  /* ================================================================
1216
1365
  EXTENSIONS / ITEMS LIST
1217
1366
  ================================================================ */
@@ -1342,6 +1491,7 @@ input[type="range"]::-webkit-slider-thumb {
1342
1491
  }
1343
1492
 
1344
1493
  .badge-success { background: var(--success-subtle); color: var(--success); }
1494
+ .badge-prod-verified { background: #064e3b; color: #34d399; font-weight: 700; border: 1px solid #34d399; }
1345
1495
  .badge-warning { background: var(--warning-subtle); color: var(--warning); }
1346
1496
  .badge-danger { background: var(--danger-subtle); color: var(--danger); }
1347
1497
  .badge-info { background: var(--info-subtle); color: var(--info); }
@@ -1742,17 +1892,35 @@ input[type="range"]::-webkit-slider-thumb {
1742
1892
  }
1743
1893
 
1744
1894
  @media (max-width: 900px) {
1745
- .sidebar { display: none; }
1746
- .main { margin-left: 0; padding: 20px 16px; overflow-y: auto; }
1747
- .kanban { flex-direction: column; }
1748
- .kanban-column { max-width: 100%; min-width: 100%; }
1895
+ .sidebar { position: fixed; top: 0; left: 0; bottom: 0; z-index: 99; transform: translateX(-100%); transition: transform 0.2s ease; }
1896
+ .sidebar.open { transform: translateX(0); }
1897
+ .main { margin-left: 0; padding: 20px 12px; overflow-y: auto; -webkit-overflow-scrolling: touch; }
1898
+ .kanban { flex-direction: column; overflow-x: hidden; }
1899
+ .kanban-column { max-width: 100%; min-width: 100%; margin-bottom: 8px; }
1900
+ .kanban-column .kanban-items { max-height: none; }
1749
1901
  .stat-grid { grid-template-columns: repeat(2, 1fr); }
1750
1902
  .quick-info-row { flex-direction: column; gap: 12px; }
1751
1903
  .form-row { flex-direction: column; }
1904
+ .kanban-scroll-bar { display: none; }
1905
+ /* Detail sidebar: full-width on tablets */
1906
+ .detail-sidebar { width: 100vw !important; max-width: 100vw; }
1907
+ .detail-resize-handle { display: none; }
1752
1908
  }
1753
1909
 
1754
1910
  @media (max-width: 600px) {
1755
1911
  .stat-grid { grid-template-columns: 1fr; }
1912
+ .main { padding: 16px 8px; }
1913
+ .page-header h2 { font-size: 18px; }
1914
+ .page-header-actions { flex-wrap: wrap; gap: 6px; }
1915
+ .page-header-actions button, .page-header-actions select { font-size: 12px; padding: 5px 8px; }
1916
+ /* Kanban cards: more compact on small phones */
1917
+ .card { padding: 10px; }
1918
+ .card-title { font-size: 13px; }
1919
+ .kanban-column-header { padding: 8px 10px; font-size: 12px; }
1920
+ /* Detail sidebar: full-screen overlay on phones */
1921
+ .detail-sidebar { padding: 16px; }
1922
+ .detail-header h3 { font-size: 14px; }
1923
+ .update-body { font-size: 12px; }
1756
1924
  }
1757
1925
 
1758
1926
  /* ================================================================
@@ -1781,12 +1949,19 @@ input[type="range"]::-webkit-slider-thumb {
1781
1949
  }
1782
1950
  .detail-sidebar.open { transform: translateX(0); }
1783
1951
  .detail-resize-handle {
1784
- position: absolute; top: 0; left: -4px; bottom: 0; width: 8px;
1952
+ position: absolute; top: 0; left: -6px; bottom: 0; width: 12px;
1785
1953
  cursor: col-resize; z-index: 102;
1786
1954
  }
1955
+ .detail-resize-handle::after {
1956
+ content: ''; position: absolute; top: 50%; left: 4px; width: 4px; height: 32px;
1957
+ transform: translateY(-50%); border-radius: 2px;
1958
+ background: var(--border); transition: background 0.15s;
1959
+ }
1960
+ .detail-resize-handle:hover::after, .detail-resize-handle.active::after {
1961
+ background: var(--accent);
1962
+ }
1787
1963
  .detail-resize-handle:hover, .detail-resize-handle.active {
1788
1964
  background: var(--accent-subtle);
1789
- border-left: 2px solid var(--accent);
1790
1965
  }
1791
1966
  .detail-header { display: flex; justify-content: space-between; align-items: flex-start; margin-bottom: 16px; }
1792
1967
  .detail-header h3 { font-size: 16px; font-weight: 600; line-height: 1.4; flex: 1; margin-right: 12px; }
@@ -1806,12 +1981,23 @@ input[type="range"]::-webkit-slider-thumb {
1806
1981
  .detail-updates h4 { font-size: 13px; font-weight: 600; margin-bottom: 12px; }
1807
1982
  .update-item { padding: 12px; background: var(--bg-input); border-radius: var(--radius-sm); margin-bottom: 8px; }
1808
1983
  .update-author { font-size: 11px; font-weight: 600; color: var(--accent); margin-bottom: 4px; }
1809
- .update-body { font-size: 13px; line-height: 1.5; color: var(--text); word-break: break-word; max-height: 200px; overflow-y: auto; }
1984
+ .update-body { font-size: 13px; line-height: 1.5; color: var(--text); word-break: break-word; }
1985
+ .update-body.collapsed { max-height: 200px; overflow-y: hidden; position: relative; }
1986
+ .update-body.collapsed::after { content: ''; position: absolute; bottom: 0; left: 0; right: 0; height: 40px; background: linear-gradient(transparent, var(--bg-card)); pointer-events: none; }
1987
+ .update-toggle { background: none; border: none; color: var(--accent); cursor: pointer; font-size: 12px; padding: 4px 0; font-weight: 500; }
1988
+ .update-toggle:hover { text-decoration: underline; }
1810
1989
  .update-body h1, .update-body h2, .update-body h3, .update-body h4, .update-body h5, .update-body h6 { font-size: 14px; font-weight: 600; margin: 8px 0 4px; color: var(--text); }
1811
1990
  .update-body p { margin: 4px 0; }
1812
1991
  .update-body ul, .update-body ol { padding-left: 18px; margin: 4px 0; }
1813
1992
  .update-body a { color: var(--accent); text-decoration: underline; }
1814
1993
  .update-body img { max-width: 100%; border-radius: var(--radius-sm); margin: 4px 0; }
1994
+ .update-body pre { white-space: pre-wrap; word-break: break-word; background: var(--bg-input); border: 1px solid var(--border); border-radius: var(--radius-sm); padding: 12px; font-size: 12px; line-height: 1.6; max-height: 400px; overflow-y: auto; }
1995
+ .update-body table { width: 100%; border-collapse: collapse; font-size: 12px; margin: 8px 0; }
1996
+ .update-body th, .update-body td { border: 1px solid var(--border); padding: 6px 8px; text-align: left; }
1997
+ .update-body th { background: var(--bg-input); font-weight: 600; }
1998
+ .update-body details { margin: 4px 0; }
1999
+ .update-body details summary { cursor: pointer; font-weight: 500; padding: 4px 0; }
2000
+ .update-body details summary:hover { color: var(--accent); }
1815
2001
  .update-time { font-size: 11px; color: var(--text-muted); margin-top: 4px; font-family: var(--font-mono); }
1816
2002
  .loading-text { font-size: 13px; color: var(--text-muted); }
1817
2003
  .empty-text { font-size: 13px; color: var(--text-muted); }
@@ -1858,11 +2044,16 @@ input[type="range"]::-webkit-slider-thumb {
1858
2044
 
1859
2045
  <script>
1860
2046
  // Global error handler to show errors instead of black screen
2047
+ function _escHtml(s) {
2048
+ var d = document.createElement('div');
2049
+ d.appendChild(document.createTextNode(s || ''));
2050
+ return d.innerHTML;
2051
+ }
1861
2052
  window.onerror = function(msg, url, line, col, err) {
1862
- document.getElementById('app').innerHTML = '<div style="padding:40px;font-family:monospace;"><div style="background:rgba(248,113,113,0.12);border:1px solid rgba(248,113,113,0.3);border-radius:12px;padding:20px;color:#f87171;"><strong>Error:</strong> ' + msg + '<br/>Line: ' + line + '<pre style="margin-top:12px;font-size:12px;opacity:0.7;">' + (err ? err.stack : '') + '</pre></div></div>';
2053
+ document.getElementById('app').innerHTML = '<div style="padding:40px;font-family:monospace;"><div style="background:rgba(248,113,113,0.12);border:1px solid rgba(248,113,113,0.3);border-radius:12px;padding:20px;color:#f87171;"><strong>Error:</strong> ' + _escHtml(String(msg)) + '<br/>Line: ' + _escHtml(String(line)) + '<pre style="margin-top:12px;font-size:12px;opacity:0.7;">' + _escHtml(err ? err.stack : '') + '</pre></div></div>';
1863
2054
  };
1864
2055
  window.addEventListener('unhandledrejection', function(e) {
1865
- document.getElementById('app').innerHTML = '<div style="padding:40px;font-family:monospace;"><div style="background:rgba(248,113,113,0.12);border:1px solid rgba(248,113,113,0.3);border-radius:12px;padding:20px;color:#f87171;"><strong>Unhandled error:</strong> ' + (e.reason ? (e.reason.message || e.reason) : 'Unknown') + '<pre style="margin-top:12px;font-size:12px;opacity:0.7;">' + (e.reason && e.reason.stack ? e.reason.stack : '') + '</pre></div></div>';
2056
+ document.getElementById('app').innerHTML = '<div style="padding:40px;font-family:monospace;"><div style="background:rgba(248,113,113,0.12);border:1px solid rgba(248,113,113,0.3);border-radius:12px;padding:20px;color:#f87171;"><strong>Unhandled error:</strong> ' + _escHtml(e.reason ? (e.reason.message || String(e.reason)) : 'Unknown') + '<pre style="margin-top:12px;font-size:12px;opacity:0.7;">' + _escHtml(e.reason && e.reason.stack ? e.reason.stack : '') + '</pre></div></div>';
1866
2057
  });
1867
2058
  </script>
1868
2059
 
@@ -1971,6 +2162,7 @@ function Icon({ name, size = 18, className = '' }) {
1971
2162
  chevron: html`<path d="M5 3l5 5-5 5"/>`,
1972
2163
  lightbulb: html`<path d="M8 1a5 5 0 00-3 9c.5.6.8 1.2 1 1.8V13h4v-1.2c.2-.6.5-1.2 1-1.8A5 5 0 008 1z"/><line x1="6" y1="13.5" x2="10" y2="13.5"/><line x1="6.5" y1="15" x2="9.5" y2="15"/>`,
1973
2164
  help: html`<circle cx="8" cy="8" r="6.5"/><path d="M6 6.5a2 2 0 013.7 1c0 1.5-1.7 1.5-1.7 3"/><circle cx="8" cy="12" r="0.5" fill="currentColor" stroke="none"/>`,
2165
+ filter: html`<polygon points="1.5,2 14.5,2 9.5,8.5 9.5,13 6.5,14.5 6.5,8.5"/>`,
1974
2166
  };
1975
2167
 
1976
2168
  return html`
@@ -2453,18 +2645,60 @@ function priorityBadgeClass(priority) {
2453
2645
 
2454
2646
  // ── HTML Content Renderer (for Monday.com update bodies) ──
2455
2647
 
2648
+ function _sanitizeHtml(raw) {
2649
+ const tmp = document.createElement('div');
2650
+ tmp.innerHTML = raw || '';
2651
+ tmp.querySelectorAll('script,style,iframe,object,embed,form,input').forEach(el => el.remove());
2652
+ // Strip event-handler attributes (onerror, onclick, onload, etc.)
2653
+ tmp.querySelectorAll('*').forEach(el => {
2654
+ [...el.attributes].forEach(attr => {
2655
+ if (attr.name.startsWith('on') || attr.value.trim().toLowerCase().startsWith('javascript:')) el.removeAttribute(attr.name);
2656
+ });
2657
+ });
2658
+ return tmp.innerHTML;
2659
+ }
2456
2660
  function HtmlContent({ html: content, className }) {
2457
2661
  const ref = useRef(null);
2458
2662
  useEffect(() => {
2459
2663
  if (ref.current) {
2460
- ref.current.innerHTML = content || '';
2461
- // Sanitize: remove dangerous elements
2462
- ref.current.querySelectorAll('script,style,iframe,object,embed,form,input').forEach(el => el.remove());
2664
+ ref.current.innerHTML = _sanitizeHtml(content);
2463
2665
  }
2464
2666
  }, [content]);
2465
2667
  return html`<div ref=${ref} class=${className || ''}></div>`;
2466
2668
  }
2467
2669
 
2670
+ // ── Collapsible update body ──
2671
+
2672
+ function CollapsibleUpdate({ html: content }) {
2673
+ const ref = useRef(null);
2674
+ const [collapsed, setCollapsed] = useState(true);
2675
+ const [needsCollapse, setNeedsCollapse] = useState(false);
2676
+
2677
+ useEffect(() => {
2678
+ if (ref.current) {
2679
+ ref.current.innerHTML = _sanitizeHtml(content);
2680
+ // Check if content exceeds collapse threshold
2681
+ requestAnimationFrame(() => {
2682
+ if (ref.current && ref.current.scrollHeight > 220) {
2683
+ setNeedsCollapse(true);
2684
+ } else {
2685
+ setNeedsCollapse(false);
2686
+ setCollapsed(false);
2687
+ }
2688
+ });
2689
+ }
2690
+ }, [content]);
2691
+
2692
+ return html`
2693
+ <div ref=${ref} class=${'update-body' + (needsCollapse && collapsed ? ' collapsed' : '')}></div>
2694
+ ${needsCollapse ? html`
2695
+ <button class="update-toggle" onClick=${() => setCollapsed(!collapsed)}>
2696
+ ${collapsed ? 'Show more ▾' : 'Show less ▴'}
2697
+ </button>
2698
+ ` : null}
2699
+ `;
2700
+ }
2701
+
2468
2702
  // ── Update Item with threaded replies ──
2469
2703
 
2470
2704
  function UpdateItemWithReplies({ update }) {
@@ -2503,7 +2737,7 @@ function UpdateItemWithReplies({ update }) {
2503
2737
  <div class="update-author" style="font-size:11px;font-weight:600;color:var(--accent);margin-bottom:4px;">
2504
2738
  ${update.creator_name || (update.creator && update.creator.name) || 'system'}
2505
2739
  </div>
2506
- <${HtmlContent} html=${update.body || update.text_body || ''} className="update-body" />
2740
+ <${CollapsibleUpdate} html=${update.body || update.text_body || ''} />
2507
2741
  <div style="display:flex;justify-content:space-between;align-items:center;margin-top:6px;">
2508
2742
  <div class="update-time" style="font-size:11px;color:var(--text-muted);font-family:var(--font-mono);">
2509
2743
  ${timeAgo(update.created_at)}
@@ -2548,16 +2782,113 @@ function UpdateItemWithReplies({ update }) {
2548
2782
 
2549
2783
  // ── Updates Header (sort toggle) ──
2550
2784
 
2551
- function UpdatesHeader({ count, sortNewest, onToggle }) {
2785
+ function CommentBox({ itemId, onPosted, compact = false }) {
2786
+ const [text, setText] = useState('');
2787
+ const [posting, setPosting] = useState(false);
2788
+
2789
+ const post = async () => {
2790
+ if (!text.trim() || posting) return;
2791
+ setPosting(true);
2792
+ try {
2793
+ await api('POST', '/api/board/items/' + itemId + '/updates', { body: text.trim(), creator_name: 'UI User' });
2794
+ setText('');
2795
+ if (onPosted) onPosted();
2796
+ } catch (e) { console.error('Comment failed:', e); }
2797
+ setPosting(false);
2798
+ };
2799
+
2800
+ const handleKeyDown = (e) => {
2801
+ if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) post();
2802
+ };
2803
+
2552
2804
  return html`
2553
- <div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:8px;border-bottom:1px solid var(--border);padding-bottom:8px;">
2554
- <h4 style="margin:0;font-size:13px;font-weight:600;">Updates${count > 0 ? ' (' + count + ')' : ''}</h4>
2555
- ${count > 1 ? html`
2556
- <button onClick=${onToggle}
2557
- style="font-size:11px;padding:2px 8px;border:1px solid var(--border);border-radius:6px;cursor:pointer;background:var(--bg-input);color:var(--text-secondary);font-family:var(--font-sans);">
2558
- ${sortNewest ? 'Newest first \u25BC' : 'Oldest first \u25B2'}
2805
+ <div style=${'padding:' + (compact ? '8px 0' : '12px 0') + ';'}>
2806
+ <textarea value=${text} onInput=${(e) => setText(e.target.value)} onKeyDown=${handleKeyDown}
2807
+ style=${'width:100%;height:' + (compact ? '44px' : '60px') + ';padding:8px 10px;background:var(--bg-input);border:1px solid var(--border);border-radius:var(--radius-sm);color:var(--text);font-size:13px;font-family:var(--font-sans);resize:vertical;'}
2808
+ placeholder="Write a comment... (Ctrl+Enter to post)"></textarea>
2809
+ <div style="display:flex;justify-content:flex-end;margin-top:6px;">
2810
+ <button onClick=${post} disabled=${posting || !text.trim()}
2811
+ style="padding:4px 14px;border:none;border-radius:var(--radius-xs);background:var(--accent);color:#0f1219;font-size:12px;font-weight:600;cursor:pointer;opacity:${posting || !text.trim() ? '0.5' : '1'};">
2812
+ ${posting ? 'Posting...' : 'Post'}
2559
2813
  </button>
2560
- ` : null}
2814
+ </div>
2815
+ </div>
2816
+ `;
2817
+ }
2818
+
2819
+ function UpdatesHeader({ count, sortNewest, onToggle, itemId, onPosted }) {
2820
+ useEffect(() => {
2821
+ const c = document.getElementById('updates-header-wrap');
2822
+ if (!c || !itemId || c.querySelector('.top-comment-wrap')) return;
2823
+ const wrap = document.createElement('div');
2824
+ wrap.className = 'top-comment-wrap';
2825
+ wrap.style.cssText = 'margin-bottom:10px;padding-bottom:10px;border-bottom:1px solid var(--border);';
2826
+ const ta = document.createElement('textarea');
2827
+ ta.style.cssText = 'width:100%;height:44px;padding:8px 10px;background:var(--bg-input);border:1px solid var(--border);border-radius:var(--radius-sm);color:var(--text);font-size:13px;font-family:var(--font-sans);resize:vertical;';
2828
+ ta.placeholder = 'Write a comment...';
2829
+ const row = document.createElement('div');
2830
+ row.style.cssText = 'display:flex;justify-content:flex-end;margin-top:6px;';
2831
+ const btn = document.createElement('button');
2832
+ btn.textContent = 'Post';
2833
+ btn.style.cssText = 'padding:4px 14px;border:none;border-radius:4px;background:var(--accent);color:#0f1219;font-size:12px;font-weight:600;cursor:pointer;';
2834
+ btn.onclick = async () => {
2835
+ const body = ta.value.trim();
2836
+ if (!body) return;
2837
+ btn.disabled = true; btn.textContent = 'Posting...';
2838
+ try {
2839
+ await api('POST', '/api/board/items/' + itemId + '/updates', { body, creator_name: 'UI User' });
2840
+ ta.value = '';
2841
+ if (onPosted) onPosted();
2842
+ } catch (e) { console.error(e); }
2843
+ btn.disabled = false; btn.textContent = 'Post';
2844
+ };
2845
+ row.appendChild(btn);
2846
+ wrap.appendChild(ta);
2847
+ wrap.appendChild(row);
2848
+ c.insertBefore(wrap, c.firstChild);
2849
+ }, [itemId]);
2850
+ return html`
2851
+ <div id="updates-header-wrap" style="margin-bottom:8px;">
2852
+ <div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:8px;border-bottom:1px solid var(--border);padding-bottom:8px;">
2853
+ <h4 style="margin:0;font-size:13px;font-weight:600;">Updates${count > 0 ? ' (' + count + ')' : ''}</h4>
2854
+ ${count > 1 ? html`
2855
+ <button onClick=${onToggle}
2856
+ style="font-size:11px;padding:2px 8px;border:1px solid var(--border);border-radius:6px;cursor:pointer;background:var(--bg-input);color:var(--text-secondary);font-family:var(--font-sans);">
2857
+ ${sortNewest ? 'Newest first \u25BC' : 'Oldest first \u25B2'}
2858
+ </button>
2859
+ ` : null}
2860
+ </div>
2861
+ </div>
2862
+ `;
2863
+ }
2864
+
2865
+ // ── Top Comment Box for task detail ──
2866
+
2867
+ function TopCommentBox({ itemId, onPosted }) {
2868
+ const textRef = useRef(null);
2869
+ const [posting, setPosting] = useState(false);
2870
+ const post = async () => {
2871
+ const ta = textRef.current;
2872
+ const body = ta ? ta.value.trim() : '';
2873
+ if (!body || posting) return;
2874
+ setPosting(true);
2875
+ try {
2876
+ await api('POST', '/api/board/items/' + itemId + '/updates', { body: body, creator_name: 'UI User' });
2877
+ if (ta) ta.value = '';
2878
+ if (onPosted) onPosted();
2879
+ } catch (e) { console.error('Comment failed:', e); }
2880
+ setPosting(false);
2881
+ };
2882
+ return html`
2883
+ <div style="padding:8px 0;border-bottom:1px solid var(--border);margin-bottom:8px;">
2884
+ <textarea ref=${textRef}
2885
+ style="width:100%;height:44px;padding:8px 10px;background:var(--bg-input);border:1px solid var(--border);border-radius:var(--radius-sm);color:var(--text);font-size:13px;font-family:var(--font-sans);resize:vertical;"
2886
+ placeholder="Write a comment..."></textarea>
2887
+ <div style="display:flex;justify-content:flex-end;margin-top:6px;">
2888
+ <button onClick=${post} disabled=${posting}
2889
+ style="padding:4px 14px;border:none;border-radius:var(--radius-xs);background:var(--accent);color:#0f1219;font-size:12px;font-weight:600;cursor:pointer;">
2890
+ ${posting ? 'Posting...' : 'Post'}</button>
2891
+ </div>
2561
2892
  </div>
2562
2893
  `;
2563
2894
  }
@@ -2569,6 +2900,17 @@ function ItemDetailSidebar({ item, onClose, onStatusChange }) {
2569
2900
  const [loadingUpdates, setLoadingUpdates] = useState(true);
2570
2901
  const [sortNewest, setSortNewest] = useState(true);
2571
2902
  const sidebarRef = useRef(null);
2903
+ const topCommentRef = useRef(null);
2904
+
2905
+ const refreshUpdates = useCallback(() => {
2906
+ if (!item) return;
2907
+ api('GET', '/api/board/items/' + item.id + '/updates')
2908
+ .then(data => {
2909
+ const list = Array.isArray(data) ? data : (data.updates || data.data || []);
2910
+ setUpdates(list);
2911
+ })
2912
+ .catch(() => setUpdates([]));
2913
+ }, [item && item.id]);
2572
2914
 
2573
2915
  useEffect(() => {
2574
2916
  if (!item) return;
@@ -2580,6 +2922,44 @@ function ItemDetailSidebar({ item, onClose, onStatusChange }) {
2580
2922
  })
2581
2923
  .catch(() => setUpdates([]))
2582
2924
  .finally(() => setLoadingUpdates(false));
2925
+ const interval = setInterval(refreshUpdates, 10000);
2926
+ return () => clearInterval(interval);
2927
+ }, [item && item.id]);
2928
+
2929
+ useEffect(() => {
2930
+ const area = document.getElementById('top-comment-area');
2931
+ if (!area || !item) return;
2932
+ area.innerHTML = '';
2933
+ const wrapper = document.createElement('div');
2934
+ wrapper.style.cssText = 'padding-bottom:10px;margin-bottom:10px;border-bottom:1px solid var(--border);';
2935
+ const ta = document.createElement('textarea');
2936
+ ta.style.cssText = 'width:100%;height:44px;padding:8px 10px;background:var(--bg-input);border:1px solid var(--border);border-radius:var(--radius-sm);color:var(--text);font-size:13px;font-family:var(--font-sans);resize:vertical;';
2937
+ ta.placeholder = 'Write a comment...';
2938
+ const btnRow = document.createElement('div');
2939
+ btnRow.style.cssText = 'display:flex;justify-content:flex-end;margin-top:6px;';
2940
+ const btn = document.createElement('button');
2941
+ btn.textContent = 'Post';
2942
+ btn.style.cssText = 'padding:4px 14px;border:none;border-radius:4px;background:var(--accent);color:#0f1219;font-size:12px;font-weight:600;cursor:pointer;';
2943
+ btn.onclick = async () => {
2944
+ const body = ta.value.trim();
2945
+ if (!body) return;
2946
+ btn.disabled = true;
2947
+ btn.textContent = 'Posting...';
2948
+ try {
2949
+ await api('POST', '/api/board/items/' + item.id + '/updates', { body: body, creator_name: 'UI User' });
2950
+ ta.value = '';
2951
+ refreshUpdates();
2952
+ } catch (e) { console.error('Comment failed:', e); }
2953
+ btn.disabled = false;
2954
+ btn.textContent = 'Post';
2955
+ };
2956
+ const onKeyDown = (e) => { if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) btn.click(); };
2957
+ ta.addEventListener('keydown', onKeyDown);
2958
+ btnRow.appendChild(btn);
2959
+ wrapper.appendChild(ta);
2960
+ wrapper.appendChild(btnRow);
2961
+ area.appendChild(wrapper);
2962
+ return () => { ta.removeEventListener('keydown', onKeyDown); };
2583
2963
  }, [item && item.id]);
2584
2964
 
2585
2965
  useEffect(() => {
@@ -2672,7 +3052,7 @@ function ItemDetailSidebar({ item, onClose, onStatusChange }) {
2672
3052
  <div class="detail-value">${item.parent_epic ? '#' + item.parent_epic : '\u2014'}</div>
2673
3053
  </div>
2674
3054
  <div class="detail-updates">
2675
- <${UpdatesHeader} count=${updates.length} sortNewest=${sortNewest} onToggle=${() => setSortNewest(!sortNewest)} />
3055
+ <${UpdatesHeader} count=${updates.length} sortNewest=${sortNewest} onToggle=${() => setSortNewest(!sortNewest)} itemId=${item.id} onPosted=${refreshUpdates} />
2676
3056
  ${loadingUpdates ? html`<div class="loading-text">Loading updates...</div>` :
2677
3057
  sortedUpdates.length === 0 ? html`<div class="empty-text">No updates yet</div>` :
2678
3058
  sortedUpdates.map((u, i) => html`
@@ -2680,39 +3060,8 @@ function ItemDetailSidebar({ item, onClose, onStatusChange }) {
2680
3060
  `)
2681
3061
  }
2682
3062
  </div>
2683
- <div style="border-top:1px solid var(--border);padding:12px 24px 16px;">
2684
- <textarea id="detail-reply-input"
2685
- style="width:100%;height:60px;padding:10px;background:var(--bg-input);border:1px solid var(--border);border-radius:var(--radius-sm);color:var(--text);font-size:13px;font-family:var(--font-sans);resize:vertical;"
2686
- placeholder="Write a comment..."></textarea>
2687
- <div style="display:flex;justify-content:flex-end;gap:8px;margin-top:8px;">
2688
- <label style="display:flex;align-items:center;gap:6px;cursor:pointer;font-size:12px;color:var(--text-muted);">
2689
- <input type="file" id="detail-reply-file" style="display:none;" />
2690
- <span style="padding:4px 10px;border:1px solid var(--border);border-radius:var(--radius-xs);background:var(--bg-input);">Attach File</span>
2691
- </label>
2692
- <button style="padding:4px 14px;border:none;border-radius:var(--radius-xs);background:var(--accent);color:#0f1219;font-size:13px;font-weight:600;cursor:pointer;"
2693
- onClick=${async () => {
2694
- const ta = document.getElementById('detail-reply-input');
2695
- const fileInput = document.getElementById('detail-reply-file');
2696
- const body = ta ? ta.value.trim() : '';
2697
- if (!body && !(fileInput && fileInput.files && fileInput.files.length)) return;
2698
- try {
2699
- if (body) {
2700
- await api('POST', '/api/board/items/' + item.id + '/updates', { body, creator_name: 'UI User' });
2701
- }
2702
- if (fileInput && fileInput.files && fileInput.files.length) {
2703
- const fd = new FormData();
2704
- fd.append('file', fileInput.files[0]);
2705
- await fetch('/api/board/items/' + item.id + '/attachments', { method: 'POST', body: fd });
2706
- fileInput.value = '';
2707
- }
2708
- if (ta) ta.value = '';
2709
- // Reload updates
2710
- const data = await api('GET', '/api/board/items/' + item.id + '/updates');
2711
- const list = Array.isArray(data) ? data : (data.updates || data.data || []);
2712
- setUpdates(list);
2713
- } catch (e) { console.error('Reply failed:', e); }
2714
- }}>Post</button>
2715
- </div>
3063
+ <div style="border-top:1px solid var(--border);padding:0 24px 16px;">
3064
+ <${CommentBox} itemId=${item.id} onPosted=${refreshUpdates} />
2716
3065
  </div>
2717
3066
  </aside>
2718
3067
  `;
@@ -2845,6 +3194,10 @@ function BoardPage({ selectedProject }) {
2845
3194
  const [dragId, setDragId] = useState(null);
2846
3195
  const [selectedItem, setSelectedItem] = useState(null);
2847
3196
  const [showCreateDialog, setShowCreateDialog] = useState(false);
3197
+ const [searchTerm, setSearchTerm] = useState('');
3198
+ const [filtersOpen, setFiltersOpen] = useState(false);
3199
+ const [filters, setFilters] = useState({ priority: '', taskType: '', project: '', dateRange: '', parentEpic: '' });
3200
+ const [columnSorts, setColumnSorts] = useState({});
2848
3201
 
2849
3202
  const fetchItems = useCallback(() => {
2850
3203
  setLoading(true);
@@ -2856,7 +3209,17 @@ function BoardPage({ selectedProject }) {
2856
3209
 
2857
3210
  // Re-fetch whenever the selected project changes — each project
2858
3211
  // has its own SQLite board on the server.
2859
- useEffect(() => { fetchItems(); }, [selectedProject]);
3212
+ // Also poll every 10s for live updates from the daemon.
3213
+ useEffect(() => {
3214
+ fetchItems();
3215
+ const interval = setInterval(() => {
3216
+ // Silent refresh — don't show loading spinner on polls
3217
+ api('GET', '/api/board/items')
3218
+ .then(data => setItems(data.items || []))
3219
+ .catch(() => {});
3220
+ }, 10000);
3221
+ return () => clearInterval(interval);
3222
+ }, [selectedProject]);
2860
3223
 
2861
3224
  // Sync scroll thumb position with kanban scroll + drag support
2862
3225
  useEffect(() => {
@@ -2947,7 +3310,7 @@ function BoardPage({ selectedProject }) {
2947
3310
  document.removeEventListener('mousemove', onKanbanMouseMove);
2948
3311
  document.removeEventListener('mouseup', onKanbanMouseUp);
2949
3312
  };
2950
- });
3313
+ }, []);
2951
3314
 
2952
3315
  const deleteItem = async (id) => {
2953
3316
  try {
@@ -2988,6 +3351,69 @@ function BoardPage({ selectedProject }) {
2988
3351
  } catch (err) { setError(err.message); }
2989
3352
  };
2990
3353
 
3354
+ // ── Filtering logic ──
3355
+ const filteredItems = items.filter(item => {
3356
+ // Search: case-insensitive substring, minimum 2 chars
3357
+ if (searchTerm.length >= 2) {
3358
+ const term = searchTerm.toLowerCase();
3359
+ const name = (item.name || item.title || '').toLowerCase();
3360
+ if (!name.includes(term)) return false;
3361
+ }
3362
+ // Priority filter
3363
+ if (filters.priority && (item.priority || '').toLowerCase() !== filters.priority.toLowerCase()) return false;
3364
+ // Task type filter
3365
+ if (filters.taskType && (item.task_type || '') !== filters.taskType) return false;
3366
+ // Project filter (only when viewing all projects)
3367
+ if (filters.project && (item.project_id || '') !== filters.project) return false;
3368
+ // Parent epic filter
3369
+ if (filters.parentEpic && String(item.parent_epic || '') !== filters.parentEpic) return false;
3370
+ // Date range filter
3371
+ if (filters.dateRange) {
3372
+ const d = new Date(item.created_at || item.updated_at);
3373
+ const now = new Date();
3374
+ if (filters.dateRange === 'today') {
3375
+ if (d.toDateString() !== now.toDateString()) return false;
3376
+ } else if (filters.dateRange === 'week') {
3377
+ const weekAgo = new Date(now); weekAgo.setDate(weekAgo.getDate() - 7);
3378
+ if (d < weekAgo) return false;
3379
+ } else if (filters.dateRange === 'month') {
3380
+ const monthAgo = new Date(now); monthAgo.setMonth(monthAgo.getMonth() - 1);
3381
+ if (d < monthAgo) return false;
3382
+ }
3383
+ }
3384
+ return true;
3385
+ });
3386
+
3387
+ // Active filter count
3388
+ const activeFilterCount = [filters.priority, filters.taskType, filters.project, filters.dateRange, filters.parentEpic].filter(Boolean).length;
3389
+
3390
+ // ── Column sorting ──
3391
+ const PRIORITY_ORDER = { 'Critical': 0, 'High': 1, 'Medium': 2, 'Low': 3, '': 4 };
3392
+ const sortColumnItems = (colItems, mode) => {
3393
+ if (!mode) return colItems; // default: keep API order (newest first)
3394
+ const sorted = [...colItems];
3395
+ if (mode === 'priority') {
3396
+ sorted.sort((a, b) => (PRIORITY_ORDER[a.priority || ''] ?? 4) - (PRIORITY_ORDER[b.priority || ''] ?? 4));
3397
+ } else if (mode === 'name') {
3398
+ sorted.sort((a, b) => (a.name || a.title || '').localeCompare(b.name || b.title || ''));
3399
+ }
3400
+ return sorted;
3401
+ };
3402
+
3403
+ const cycleSort = (colId) => {
3404
+ setColumnSorts(prev => {
3405
+ const current = prev[colId] || '';
3406
+ const next = current === '' ? 'priority' : current === 'priority' ? 'name' : '';
3407
+ const copy = { ...prev };
3408
+ if (next) copy[colId] = next; else delete copy[colId];
3409
+ return copy;
3410
+ });
3411
+ };
3412
+
3413
+ // Unique project IDs and parent epics for filter dropdowns
3414
+ const uniqueProjects = [...new Set(items.map(i => i.project_id).filter(Boolean))];
3415
+ const uniqueEpics = [...new Set(items.map(i => i.parent_epic).filter(Boolean))].map(String);
3416
+
2991
3417
  if (loading) return html`<${SkeletonLoader} type="cards" />`;
2992
3418
 
2993
3419
  if (items.length === 0) {
@@ -3020,8 +3446,9 @@ function BoardPage({ selectedProject }) {
3020
3446
  `;
3021
3447
  }
3022
3448
 
3023
- const totalItems = items.length;
3024
- const activeItems = items.filter(i => i.status !== 'Done').length;
3449
+ const totalItems = filteredItems.length;
3450
+ const activeItems = filteredItems.filter(i => i.status !== 'Done').length;
3451
+ const isFiltered = searchTerm.length >= 2 || activeFilterCount > 0;
3025
3452
 
3026
3453
  return html`
3027
3454
  <div class="page-content">
@@ -3032,19 +3459,86 @@ function BoardPage({ selectedProject }) {
3032
3459
 
3033
3460
  ${error && html`<div class="error-msg">${error}</div>`}
3034
3461
 
3035
- <div class="board-stats-bar">
3036
- <span class="stat-label">Board</span>
3037
- <span class="stat-item"><strong>${totalItems}</strong> total</span>
3038
- <span class="stat-item"><strong>${activeItems}</strong> active</span>
3039
- </div>
3462
+ <div class="board-header-row">
3463
+ <div class="board-stats-bar">
3464
+ <span class="stat-label">Board</span>
3465
+ <span class="stat-item"><strong>${totalItems}</strong>${isFiltered ? '/' + items.length : ''} total</span>
3466
+ <span class="stat-item"><strong>${activeItems}</strong> active</span>
3467
+ </div>
3468
+
3469
+ <div class="board-search-wrap">
3470
+ <input type="text" placeholder="Search tasks..." value=${searchTerm} onInput=${(e) => setSearchTerm(e.target.value)} />
3471
+ ${searchTerm && html`<button class="board-search-clear" onClick=${() => setSearchTerm('')}>\u00d7</button>`}
3472
+ </div>
3473
+
3474
+ <button class=${'filter-toggle' + (filtersOpen || activeFilterCount > 0 ? ' active' : '')} onClick=${() => setFiltersOpen(v => !v)}>
3475
+ <${Icon} name="filter" size=${13} />
3476
+ Filters
3477
+ ${activeFilterCount > 0 && html`<span class="filter-active-count">${activeFilterCount}</span>`}
3478
+ </button>
3040
3479
 
3041
- <div style="margin-bottom:20px;">
3042
3480
  <button class="btn btn-primary" onClick=${() => setShowCreateDialog(true)}>
3043
3481
  <${Icon} name="plus" size=${14} />
3044
3482
  New Task
3045
3483
  </button>
3046
3484
  </div>
3047
3485
 
3486
+ ${filtersOpen && html`
3487
+ <div class="board-filter-panel">
3488
+ <div class="filter-group">
3489
+ <label>Priority</label>
3490
+ <select value=${filters.priority} onChange=${(e) => setFilters(f => ({...f, priority: e.target.value}))}>
3491
+ <option value="">All</option>
3492
+ <option value="Critical">Critical</option>
3493
+ <option value="High">High</option>
3494
+ <option value="Medium">Medium</option>
3495
+ <option value="Low">Low</option>
3496
+ </select>
3497
+ </div>
3498
+ <div class="filter-group">
3499
+ <label>Type</label>
3500
+ <select value=${filters.taskType} onChange=${(e) => setFilters(f => ({...f, taskType: e.target.value}))}>
3501
+ <option value="">All</option>
3502
+ <option value="code">code</option>
3503
+ <option value="epic">epic</option>
3504
+ <option value="deep-planning">deep-planning</option>
3505
+ <option value="infra-plan">infra-plan</option>
3506
+ <option value="infrastructure">infrastructure</option>
3507
+ </select>
3508
+ </div>
3509
+ <div class="filter-group">
3510
+ <label>Date</label>
3511
+ <select value=${filters.dateRange} onChange=${(e) => setFilters(f => ({...f, dateRange: e.target.value}))}>
3512
+ <option value="">Any time</option>
3513
+ <option value="today">Today</option>
3514
+ <option value="week">This week</option>
3515
+ <option value="month">This month</option>
3516
+ </select>
3517
+ </div>
3518
+ ${selectedProject === 'all' && uniqueProjects.length > 0 && html`
3519
+ <div class="filter-group">
3520
+ <label>Project</label>
3521
+ <select value=${filters.project} onChange=${(e) => setFilters(f => ({...f, project: e.target.value}))}>
3522
+ <option value="">All</option>
3523
+ ${uniqueProjects.map(p => html`<option key=${p} value=${p}>${p}</option>`)}
3524
+ </select>
3525
+ </div>
3526
+ `}
3527
+ ${uniqueEpics.length > 0 && html`
3528
+ <div class="filter-group">
3529
+ <label>Epic</label>
3530
+ <select value=${filters.parentEpic} onChange=${(e) => setFilters(f => ({...f, parentEpic: e.target.value}))}>
3531
+ <option value="">All</option>
3532
+ ${uniqueEpics.map(ep => html`<option key=${ep} value=${ep}>${ep}</option>`)}
3533
+ </select>
3534
+ </div>
3535
+ `}
3536
+ ${activeFilterCount > 0 && html`
3537
+ <button class="filter-clear-link" onClick=${() => setFilters({ priority: '', taskType: '', project: '', dateRange: '', parentEpic: '' })}>Clear all</button>
3538
+ `}
3539
+ </div>
3540
+ `}
3541
+
3048
3542
  <div class="kanban-wrapper">
3049
3543
  <div class="kanban-scroll-bar">
3050
3544
  <button class="kanban-scroll-btn" onClick=${() => {
@@ -3068,19 +3562,21 @@ function BoardPage({ selectedProject }) {
3068
3562
  </div>
3069
3563
  <div class="kanban">
3070
3564
  ${KANBAN_COLUMNS.map(col => {
3071
- const colItems = items.filter(i => {
3565
+ const rawColItems = filteredItems.filter(i => {
3072
3566
  if (col.id === 'New') return !i.status || i.status === 'New' || i.status === '';
3073
3567
  if (i.status === col.id) return true;
3074
3568
  if (col.also && col.also.includes(i.status)) return true;
3075
3569
  return false;
3076
3570
  });
3571
+ const colItems = sortColumnItems(rawColItems, columnSorts[col.id] || '');
3572
+ const sortMode = columnSorts[col.id] || '';
3077
3573
  return html`
3078
3574
  <div class="kanban-column" key=${col.id}
3079
3575
  onDragOver=${onDragOver}
3080
3576
  onDragLeave=${onDragLeave}
3081
3577
  onDrop=${e => onDrop(e, col.id)}>
3082
- <div class="kanban-column-header" style=${'--col-color: ' + col.color} title=${col.tooltip}>
3083
- <span>${col.label}</span>
3578
+ <div class="kanban-column-header" style=${'--col-color: ' + col.color} title=${col.tooltip} onClick=${() => cycleSort(col.id)}>
3579
+ <span>${col.label}${sortMode && html`<span class="sort-indicator">${sortMode === 'priority' ? '\u25BC' : '\u25B2'}</span>`}</span>
3084
3580
  <span class=${'kanban-count' + (colItems.length === 0 ? ' kanban-count-zero' : '')}>${colItems.length}</span>
3085
3581
  </div>
3086
3582
  <div class="kanban-items">
@@ -3101,6 +3597,7 @@ function BoardPage({ selectedProject }) {
3101
3597
  ${item.parent_epic && html`<span class="card-badge badge-epic">epic:${item.parent_epic}</span>`}
3102
3598
  ${item.priority && html`<span class=${'card-badge ' + priorityBadgeClass(item.priority)}>${item.priority}</span>`}
3103
3599
  ${item.task_type && html`<span class="card-badge badge-type">${item.task_type}</span>`}
3600
+ ${selectedProject === 'all' && item.project_id && html`<span class="card-badge badge-project">${item.project_id}</span>`}
3104
3601
  <span class="card-time">${timeAgo(item.updated_at || item.created_at)}</span>
3105
3602
  </div>
3106
3603
  </div>
@@ -3122,11 +3619,12 @@ function BoardPage({ selectedProject }) {
3122
3619
  // Runs Page
3123
3620
  // ================================================================
3124
3621
 
3125
- function stageLabel(stage) {
3622
+ function stageLabel(stage, extra) {
3623
+ // If prod verified, show that regardless of checkpoint stage
3624
+ if (extra && extra.prodVerified) return { label: '\u2705 Prod Verified', cls: 'badge-prod-verified' };
3126
3625
  if (!stage || typeof stage !== 'string') return { label: 'Unknown', cls: 'badge-muted' };
3127
3626
  const s = stage.toLowerCase();
3128
- if (s === 'done') return { label: 'Done', cls: 'badge-success' };
3129
- if (s === 'ship') return { label: 'Shipped', cls: 'badge-success' };
3627
+ if (s === 'done' || s === 'ship') return { label: 'Shipped', cls: 'badge-success' };
3130
3628
  if (s.includes('build') || (s.includes('verify') && !s.includes('prod'))) return { label: 'Building', cls: 'badge-warning' };
3131
3629
  if (s.includes('awaiting') || s.includes('waiting') || s.includes('approval')) return { label: 'Awaiting Approval', cls: 'badge-info' };
3132
3630
  if (s.includes('spec') || s.includes('phase1')) return { label: 'Spec Phase', cls: 'badge-info' };
@@ -3241,7 +3739,21 @@ function RunsPage({ selectedProject }) {
3241
3739
  .finally(() => setLoading(false));
3242
3740
  };
3243
3741
 
3244
- useEffect(() => { fetchRuns(); }, []);
3742
+ useEffect(() => {
3743
+ fetchRuns();
3744
+ const interval = setInterval(() => {
3745
+ api('GET', '/api/runs')
3746
+ .then(data => {
3747
+ const list = data.runs || [];
3748
+ setRuns(list);
3749
+ const pins = {};
3750
+ for (const r of list) pins[r.id] = !!r.pinned;
3751
+ setPinnedIds(pins);
3752
+ })
3753
+ .catch(() => {});
3754
+ }, 15000);
3755
+ return () => clearInterval(interval);
3756
+ }, []);
3245
3757
 
3246
3758
  const togglePin = async (e, runId) => {
3247
3759
  e.stopPropagation();
@@ -3313,7 +3825,7 @@ function RunsPage({ selectedProject }) {
3313
3825
  const cp = run.checkpoint || {};
3314
3826
  const hist = Array.isArray(cp.satisfaction_history) ? cp.satisfaction_history : [];
3315
3827
  const satisfaction = hist.length > 0 ? hist[hist.length - 1] : null;
3316
- const stage = stageLabel(cp.current_stage);
3828
+ const stage = stageLabel(cp.current_stage, { prodVerified: run.prodVerified });
3317
3829
  const taskName = run.taskName || run.manifest?.task_description || run.manifest?.item_name || '';
3318
3830
  const projectName = run.projectId || run.manifest?.project_id || '';
3319
3831
  const shortId = run.id.replace('run-', '').slice(-6);
@@ -3321,7 +3833,7 @@ function RunsPage({ selectedProject }) {
3321
3833
  const isPinned = !!pinnedIds[run.id];
3322
3834
 
3323
3835
  return html`
3324
- <tr key=${run.id} style="cursor:pointer" onClick=${() => toggleDetail(run.id)}>
3836
+ <tr key=${run.id} style=${'cursor:pointer;' + (run.prodVerified ? 'border-left:3px solid #34d399;' : '')} onClick=${() => toggleDetail(run.id)}>
3325
3837
  <td class="mono" title=${run.id} style="display:flex;align-items:center;gap:4px;">
3326
3838
  <button
3327
3839
  style=${{background:'none',border:'none',cursor:'pointer',padding:'0 2px',fontSize:'13px',lineHeight:'1',color:isPinned ? 'var(--accent)' : 'var(--text-muted)',opacity: isPinned ? 1 : 0.5,flexShrink:0}}
@@ -4290,11 +4802,42 @@ function SettingsPage() {
4290
4802
  .finally(() => setLoading(false));
4291
4803
  }, []);
4292
4804
 
4293
- const saveConfig = async () => {
4805
+ const saveConfig = async (forceWrite = false) => {
4294
4806
  try {
4295
4807
  setSaving(true);
4296
4808
  setError(null);
4297
- const result = await api('PATCH', '/api/config', config);
4809
+ const body = forceWrite ? { ...config, force: true } : config;
4810
+ const result = await api('PATCH', '/api/config', body);
4811
+
4812
+ // Threshold safety check (feature #7): server may return a warning
4813
+ // payload instead of writing. Prompt the user and retry with force
4814
+ // if they accept.
4815
+ if (result && result.confirm_required && result.stranded_runs) {
4816
+ const lines = result.stranded_runs.map(r =>
4817
+ '• ' + r.run_id + ' (best: ' + (r.best_satisfaction * 100).toFixed(0) + '%)'
4818
+ ).join('\n');
4819
+ const oldPct = ((result.old_threshold || 0) * 100).toFixed(0);
4820
+ const newPct = (result.attempted_threshold * 100).toFixed(0);
4821
+ const msg =
4822
+ result.warning + '\n\n' +
4823
+ 'Stranded runs:\n' + lines + '\n\n' +
4824
+ 'Old threshold: ' + oldPct + '%\n' +
4825
+ 'New threshold: ' + newPct + '%\n\n' +
4826
+ 'Proceed anyway? (Runs below the new threshold will never converge.)';
4827
+ if (confirm(msg)) {
4828
+ setSaving(false);
4829
+ return saveConfig(true);
4830
+ } else {
4831
+ // User cancelled — refetch config to revert the slider UI
4832
+ setSaving(false);
4833
+ try {
4834
+ const original = await api('GET', '/api/config');
4835
+ setConfig(original);
4836
+ } catch (_) { /* ignore */ }
4837
+ return;
4838
+ }
4839
+ }
4840
+
4298
4841
  setConfig(result);
4299
4842
  setSuccess('Configuration saved');
4300
4843
  setTimeout(() => setSuccess(null), 3000);
@@ -4388,6 +4931,19 @@ function SettingsPage() {
4388
4931
  <span class="mono">${((pipeline.satisfaction_threshold || 0.85) * 100).toFixed(0)}%</span>
4389
4932
  </div>
4390
4933
  </div>
4934
+ <div class="setting-row">
4935
+ <div>
4936
+ <div class="setting-label">Prod Verification Threshold</div>
4937
+ <div class="setting-desc">Min prod satisfaction to auto-accept (0.0 - 1.0)</div>
4938
+ </div>
4939
+ <div class="setting-control">
4940
+ <input type="range" min="0" max="100" step="5"
4941
+ value=${(pipeline.prod_accept_floor ?? 0.65) * 100}
4942
+ onInput=${e => updateField('pipeline.prod_accept_floor', parseInt(e.target.value) / 100)}
4943
+ style="width:120px" />
4944
+ <span class="mono">${((pipeline.prod_accept_floor ?? 0.65) * 100).toFixed(0)}%</span>
4945
+ </div>
4946
+ </div>
4391
4947
  <div class="setting-row">
4392
4948
  <div>
4393
4949
  <div class="setting-label">Max Iterations</div>
@@ -5638,7 +6194,7 @@ function App() {
5638
6194
  <button class="mobile-menu-btn" onClick=${() => setMobileMenuOpen(!mobileMenuOpen)} aria-label="Menu">
5639
6195
  ${mobileMenuOpen ? '\u2715' : '\u2630'}
5640
6196
  </button>
5641
- <div class=${'sidebar-overlay' + (mobileMenuOpen ? ' open' : '')} onClick=${() => setMobileMenuOpen(false)} />
6197
+ <div class=${'mobile-nav-overlay' + (mobileMenuOpen ? ' open' : '')} onClick=${() => setMobileMenuOpen(false)} />
5642
6198
  <${Sidebar} currentHash=${hash} factoryName=${factoryName} theme=${theme} onToggleTheme=${toggleTheme}
5643
6199
  selectedProject=${selectedProject} onProjectChange=${setSelectedProject}
5644
6200
  mobileOpen=${mobileMenuOpen} />