[object Object]

← back to Projects Agentabrams

left rail: collapsible category sections (7 groups, collapsed by default, auto-expand on search + current selection); build-projects.js assigns category via keyword rules

e86c0c6031a25c566911735334fe535ebcbbb768 · 2026-07-28 07:45:39 -0700 · steve@designerwallcoverings.com

Files touched

Diff

commit e86c0c6031a25c566911735334fe535ebcbbb768
Author: steve@designerwallcoverings.com <steve@designerwallcoverings.com>
Date:   Tue Jul 28 07:45:39 2026 -0700

    left rail: collapsible category sections (7 groups, collapsed by default, auto-expand on search + current selection); build-projects.js assigns category via keyword rules
---
 build-projects.js |  28 ++++++++
 projects.json     | 196 ++++++++++++++++++++++++++++++++++++------------------
 public/index.html | 105 ++++++++++++++++++++++-------
 3 files changed, 242 insertions(+), 87 deletions(-)

diff --git a/build-projects.js b/build-projects.js
index 44a7fbd..49c4d8b 100644
--- a/build-projects.js
+++ b/build-projects.js
@@ -25,6 +25,33 @@ const get = (slug) => {
   return map.get(slug);
 };
 
+// Bucket a project into a collapsible section. Ordered rules, first match wins;
+// keyword-based so newly-added projects auto-sort without editing a static list.
+const CATEGORIES = [
+  'DW Vendor Lines',
+  'DW Catalog & Marketing',
+  "Wallpaper's Back & Patterns",
+  'Consulting & Client Sites',
+  'Directories & Verticals',
+  'Webdev Accelerator',
+  'Agents, Ops & Tools',
+  'Other',
+];
+function categorize(slug) {
+  const s = slug.toLowerCase();
+  const rules = [
+    ["Wallpaper's Back & Patterns", /^wpb-|pattern-vault|patterndesignlab|wild-wallcoverings|wallco-ai/],
+    ['DW Vendor Lines',            /-internal$|-line-viewer$|-onboard$|-naturals$|^schumacher|^muralsource|^reidwitlin|viewer-local|all-designerwallcoverings/],
+    ['Consulting & Client Sites',  /consulting|fantasea|prestige-car-wash|norma-sdcc/],
+    ['DW Catalog & Marketing',     /^dw-|trending-dw|colorwheel|instagram-agent/],
+    ['Webdev Accelerator',         /webdev-accelerator|estimate-instant|trade-portal|sample-box|reno-visualizer|permit-radar/],
+    ['Directories & Verticals',    /animals|rentv|model-arena|sdcc-journalists|thesetdecorator|small-business-builder|designers?salesreps|homesonspec/],
+    ['Agents, Ops & Tools',        /abrams|agent-cabinet|ai-workforce|ticket-system|^slack-|build-pages|clipreviewbydave|holdforme|kalshi/],
+  ];
+  for (const [cat, re] of rules) if (re.test(s)) return cat;
+  return 'Other';
+}
+
 // "small-business-builder" -> "Small Business Builder"; keeps ALLCAPS acronyms.
 function pretty(slug) {
   return String(slug)
@@ -95,6 +122,7 @@ for (const r of map.values()) {
     target,
     domain: r.domain,     // may be null
     port: r.local,        // may be null
+    category: categorize(r.slug),
   });
 }
 out.sort((a, b) => a.name.localeCompare(b.name));
diff --git a/projects.json b/projects.json
index 2c5eb24..b3032df 100644
--- a/projects.json
+++ b/projects.json
@@ -5,7 +5,8 @@
     "kind": "local",
     "target": "http://127.0.0.1:9773",
     "domain": null,
-    "port": 9773
+    "port": 9773,
+    "category": "Agents, Ops & Tools"
   },
   {
     "slug": "AbramsOS",
@@ -13,7 +14,8 @@
     "kind": "local",
     "target": "http://127.0.0.1:9774",
     "domain": null,
-    "port": 9774
+    "port": 9774,
+    "category": "Agents, Ops & Tools"
   },
   {
     "slug": "agent-cabinet",
@@ -21,7 +23,8 @@
     "kind": "local",
     "target": "http://127.0.0.1:9766",
     "domain": null,
-    "port": 9766
+    "port": 9766,
+    "category": "Agents, Ops & Tools"
   },
   {
     "slug": "agentabrams-viewer",
@@ -29,7 +32,8 @@
     "kind": "local",
     "target": "http://127.0.0.1:9783",
     "domain": null,
-    "port": 9783
+    "port": 9783,
+    "category": "Agents, Ops & Tools"
   },
   {
     "slug": "ai-workforce",
@@ -37,7 +41,8 @@
     "kind": "local",
     "target": "http://127.0.0.1:9791",
     "domain": null,
-    "port": 9791
+    "port": 9791,
+    "category": "Agents, Ops & Tools"
   },
   {
     "slug": "all-designerwallcoverings",
@@ -45,7 +50,8 @@
     "kind": "local",
     "target": "http://127.0.0.1:9958",
     "domain": null,
-    "port": 9958
+    "port": 9958,
+    "category": "DW Vendor Lines"
   },
   {
     "slug": "animals",
@@ -53,15 +59,17 @@
     "kind": "local",
     "target": "http://127.0.0.1:9720",
     "domain": null,
-    "port": 9720
+    "port": 9720,
+    "category": "Directories & Verticals"
   },
   {
     "slug": "build-pages",
     "name": "Build Pages",
     "kind": "public",
-    "target": "https://projects.agentabrams.com",
-    "domain": "projects.agentabrams.com",
-    "port": null
+    "target": "https://build-pages.agentabrams.com",
+    "domain": "build-pages.agentabrams.com",
+    "port": 9703,
+    "category": "Agents, Ops & Tools"
   },
   {
     "slug": "claude-webdev-accelerator",
@@ -69,7 +77,8 @@
     "kind": "local",
     "target": "http://127.0.0.1:4901",
     "domain": null,
-    "port": 4901
+    "port": 4901,
+    "category": "Webdev Accelerator"
   },
   {
     "slug": "clipreviewbydave",
@@ -77,7 +86,8 @@
     "kind": "local",
     "target": "http://127.0.0.1:9736",
     "domain": null,
-    "port": 9736
+    "port": 9736,
+    "category": "Agents, Ops & Tools"
   },
   {
     "slug": "colorwheel",
@@ -85,7 +95,8 @@
     "kind": "local",
     "target": "http://127.0.0.1:9851",
     "domain": null,
-    "port": 9851
+    "port": 9851,
+    "category": "DW Catalog & Marketing"
   },
   {
     "slug": "consulting-intake",
@@ -93,7 +104,8 @@
     "kind": "local",
     "target": "http://127.0.0.1:9700",
     "domain": null,
-    "port": 9700
+    "port": 9700,
+    "category": "Consulting & Client Sites"
   },
   {
     "slug": "consulting-rentv-com",
@@ -101,7 +113,8 @@
     "kind": "local",
     "target": "http://127.0.0.1:9701",
     "domain": null,
-    "port": 9701
+    "port": 9701,
+    "category": "Consulting & Client Sites"
   },
   {
     "slug": "crezana-internal",
@@ -109,7 +122,8 @@
     "kind": "local",
     "target": "http://127.0.0.1:10072",
     "domain": null,
-    "port": 10072
+    "port": 10072,
+    "category": "DW Vendor Lines"
   },
   {
     "slug": "designersalesreps",
@@ -117,7 +131,8 @@
     "kind": "public",
     "target": "https://designersalesreps.com",
     "domain": "designersalesreps.com",
-    "port": null
+    "port": null,
+    "category": "Directories & Verticals"
   },
   {
     "slug": "designerssalesreps",
@@ -125,7 +140,8 @@
     "kind": "public",
     "target": "https://designerssalesreps.com",
     "domain": "designerssalesreps.com",
-    "port": null
+    "port": null,
+    "category": "Directories & Verticals"
   },
   {
     "slug": "dw-activation-calendar",
@@ -133,7 +149,8 @@
     "kind": "local",
     "target": "http://127.0.0.1:9765",
     "domain": null,
-    "port": 9765
+    "port": 9765,
+    "category": "DW Catalog & Marketing"
   },
   {
     "slug": "dw-cadence-next2",
@@ -141,7 +158,8 @@
     "kind": "local",
     "target": "http://127.0.0.1:9764",
     "domain": null,
-    "port": 9764
+    "port": 9764,
+    "category": "DW Catalog & Marketing"
   },
   {
     "slug": "dw-domain-viewer",
@@ -149,7 +167,8 @@
     "kind": "local",
     "target": "http://127.0.0.1:9784",
     "domain": null,
-    "port": 9784
+    "port": 9784,
+    "category": "DW Catalog & Marketing"
   },
   {
     "slug": "dw-internal-gateway",
@@ -157,7 +176,8 @@
     "kind": "local",
     "target": "http://127.0.0.1:8090",
     "domain": null,
-    "port": 8090
+    "port": 8090,
+    "category": "DW Catalog & Marketing"
   },
   {
     "slug": "dw-marketing-reels",
@@ -165,7 +185,8 @@
     "kind": "local",
     "target": "http://127.0.0.1:9848",
     "domain": null,
-    "port": 9848
+    "port": 9848,
+    "category": "DW Catalog & Marketing"
   },
   {
     "slug": "dw-pairs-well",
@@ -173,7 +194,8 @@
     "kind": "public",
     "target": "https://pairs.designerwallcoverings.com",
     "domain": "pairs.designerwallcoverings.com",
-    "port": null
+    "port": null,
+    "category": "DW Catalog & Marketing"
   },
   {
     "slug": "dw-pitch-followup",
@@ -181,7 +203,8 @@
     "kind": "local",
     "target": "http://127.0.0.1:9768",
     "domain": null,
-    "port": 9768
+    "port": 9768,
+    "category": "DW Catalog & Marketing"
   },
   {
     "slug": "estimate-instant",
@@ -189,7 +212,8 @@
     "kind": "local",
     "target": "http://127.0.0.1:3901",
     "domain": null,
-    "port": 3901
+    "port": 3901,
+    "category": "Webdev Accelerator"
   },
   {
     "slug": "fantasea-consulting",
@@ -197,7 +221,8 @@
     "kind": "local",
     "target": "http://127.0.0.1:9782",
     "domain": null,
-    "port": 9782
+    "port": 9782,
+    "category": "Consulting & Client Sites"
   },
   {
     "slug": "fentucci-naturals",
@@ -205,7 +230,8 @@
     "kind": "local",
     "target": "http://127.0.0.1:9981",
     "domain": null,
-    "port": 9981
+    "port": 9981,
+    "category": "DW Vendor Lines"
   },
   {
     "slug": "fromental-internal",
@@ -213,7 +239,8 @@
     "kind": "local",
     "target": "http://127.0.0.1:10071",
     "domain": null,
-    "port": 10071
+    "port": 10071,
+    "category": "DW Vendor Lines"
   },
   {
     "slug": "gracie-internal",
@@ -221,7 +248,8 @@
     "kind": "local",
     "target": "http://127.0.0.1:10073",
     "domain": null,
-    "port": 10073
+    "port": 10073,
+    "category": "DW Vendor Lines"
   },
   {
     "slug": "greenland-onboard",
@@ -229,7 +257,8 @@
     "kind": "local",
     "target": "http://127.0.0.1:9973",
     "domain": null,
-    "port": 9973
+    "port": 9973,
+    "category": "DW Vendor Lines"
   },
   {
     "slug": "holdforme",
@@ -237,7 +266,8 @@
     "kind": "public",
     "target": "https://butlr.agentabrams.com",
     "domain": "butlr.agentabrams.com",
-    "port": null
+    "port": null,
+    "category": "Agents, Ops & Tools"
   },
   {
     "slug": "homesonspec",
@@ -245,7 +275,8 @@
     "kind": "local",
     "target": "http://127.0.0.1:3100",
     "domain": null,
-    "port": 3100
+    "port": 3100,
+    "category": "Directories & Verticals"
   },
   {
     "slug": "instagram-agent",
@@ -253,7 +284,8 @@
     "kind": "local",
     "target": "http://127.0.0.1:9810",
     "domain": null,
-    "port": 9810
+    "port": 9810,
+    "category": "DW Catalog & Marketing"
   },
   {
     "slug": "kalshi-dash",
@@ -261,7 +293,8 @@
     "kind": "local",
     "target": "http://127.0.0.1:7810",
     "domain": null,
-    "port": 7810
+    "port": 7810,
+    "category": "Agents, Ops & Tools"
   },
   {
     "slug": "model-arena",
@@ -269,7 +302,8 @@
     "kind": "local",
     "target": "http://127.0.0.1:9758",
     "domain": null,
-    "port": 9758
+    "port": 9758,
+    "category": "Directories & Verticals"
   },
   {
     "slug": "muralsource-internal",
@@ -277,7 +311,8 @@
     "kind": "local",
     "target": "http://127.0.0.1:9947",
     "domain": null,
-    "port": 9947
+    "port": 9947,
+    "category": "DW Vendor Lines"
   },
   {
     "slug": "norma-sdcc-pitch",
@@ -285,7 +320,8 @@
     "kind": "local",
     "target": "http://127.0.0.1:9876",
     "domain": null,
-    "port": 9876
+    "port": 9876,
+    "category": "Consulting & Client Sites"
   },
   {
     "slug": "pattern-vault",
@@ -293,7 +329,8 @@
     "kind": "local",
     "target": "http://127.0.0.1:9779",
     "domain": null,
-    "port": 9779
+    "port": 9779,
+    "category": "Wallpaper's Back & Patterns"
   },
   {
     "slug": "patterndesignlab",
@@ -301,7 +338,8 @@
     "kind": "public",
     "target": "https://prestige.agentabrams.com",
     "domain": "prestige.agentabrams.com",
-    "port": 9781
+    "port": 9781,
+    "category": "Wallpaper's Back & Patterns"
   },
   {
     "slug": "permit-radar",
@@ -309,7 +347,8 @@
     "kind": "local",
     "target": "http://127.0.0.1:3905",
     "domain": null,
-    "port": 3905
+    "port": 3905,
+    "category": "Webdev Accelerator"
   },
   {
     "slug": "prestige-car-wash",
@@ -317,7 +356,17 @@
     "kind": "local",
     "target": "http://127.0.0.1:9808",
     "domain": null,
-    "port": 9808
+    "port": 9808,
+    "category": "Consulting & Client Sites"
+  },
+  {
+    "slug": "projects-agentabrams",
+    "name": "Projects Agentabrams",
+    "kind": "public",
+    "target": "https://projects.agentabrams.com",
+    "domain": "projects.agentabrams.com",
+    "port": 9702,
+    "category": "Agents, Ops & Tools"
   },
   {
     "slug": "reidwitlin-landing",
@@ -325,7 +374,8 @@
     "kind": "public",
     "target": "https://reidwitlin.designerwallcoverings.com",
     "domain": "reidwitlin.designerwallcoverings.com",
-    "port": 9952
+    "port": 9952,
+    "category": "DW Vendor Lines"
   },
   {
     "slug": "reno-visualizer",
@@ -333,7 +383,8 @@
     "kind": "local",
     "target": "http://127.0.0.1:3904",
     "domain": null,
-    "port": 3904
+    "port": 3904,
+    "category": "Webdev Accelerator"
   },
   {
     "slug": "rentv-v1",
@@ -341,7 +392,8 @@
     "kind": "local",
     "target": "http://127.0.0.1:9704",
     "domain": null,
-    "port": 9704
+    "port": 9704,
+    "category": "Directories & Verticals"
   },
   {
     "slug": "rigo-line-viewer",
@@ -349,7 +401,8 @@
     "kind": "local",
     "target": "http://127.0.0.1:9979",
     "domain": null,
-    "port": 9979
+    "port": 9979,
+    "category": "DW Vendor Lines"
   },
   {
     "slug": "sample-box",
@@ -357,7 +410,8 @@
     "kind": "local",
     "target": "http://127.0.0.1:3903",
     "domain": null,
-    "port": 3903
+    "port": 3903,
+    "category": "Webdev Accelerator"
   },
   {
     "slug": "schumacher-internal",
@@ -365,7 +419,8 @@
     "kind": "local",
     "target": "http://127.0.0.1:9946",
     "domain": null,
-    "port": 9946
+    "port": 9946,
+    "category": "DW Vendor Lines"
   },
   {
     "slug": "sdcc-journalists-viewer",
@@ -373,7 +428,8 @@
     "kind": "local",
     "target": "http://127.0.0.1:9878",
     "domain": null,
-    "port": 9878
+    "port": 9878,
+    "category": "Directories & Verticals"
   },
   {
     "slug": "slack-idea-board",
@@ -381,7 +437,8 @@
     "kind": "local",
     "target": "http://127.0.0.1:9820",
     "domain": null,
-    "port": 9820
+    "port": 9820,
+    "category": "Agents, Ops & Tools"
   },
   {
     "slug": "slack-to-steve",
@@ -389,7 +446,8 @@
     "kind": "local",
     "target": "http://127.0.0.1:9897",
     "domain": null,
-    "port": 9897
+    "port": 9897,
+    "category": "Agents, Ops & Tools"
   },
   {
     "slug": "small-business-builder",
@@ -397,7 +455,8 @@
     "kind": "local",
     "target": "http://127.0.0.1:9760",
     "domain": null,
-    "port": 9760
+    "port": 9760,
+    "category": "Directories & Verticals"
   },
   {
     "slug": "thesetdecorator",
@@ -405,7 +464,8 @@
     "kind": "public",
     "target": "https://thesetdecorator.com",
     "domain": "thesetdecorator.com",
-    "port": null
+    "port": null,
+    "category": "Directories & Verticals"
   },
   {
     "slug": "ticket-system",
@@ -413,7 +473,8 @@
     "kind": "local",
     "target": "http://127.0.0.1:9794",
     "domain": null,
-    "port": 9794
+    "port": 9794,
+    "category": "Agents, Ops & Tools"
   },
   {
     "slug": "trade-portal",
@@ -421,7 +482,8 @@
     "kind": "local",
     "target": "http://127.0.0.1:3902",
     "domain": null,
-    "port": 3902
+    "port": 3902,
+    "category": "Webdev Accelerator"
   },
   {
     "slug": "trending-dw",
@@ -429,7 +491,8 @@
     "kind": "public",
     "target": "https://trending.designerwallcoverings.com",
     "domain": "trending.designerwallcoverings.com",
-    "port": null
+    "port": null,
+    "category": "DW Catalog & Marketing"
   },
   {
     "slug": "vahallan-line-viewer",
@@ -437,7 +500,8 @@
     "kind": "local",
     "target": "http://127.0.0.1:9982",
     "domain": null,
-    "port": 9982
+    "port": 9982,
+    "category": "DW Vendor Lines"
   },
   {
     "slug": "viewer-local",
@@ -445,7 +509,8 @@
     "kind": "local",
     "target": "http://127.0.0.1:9931",
     "domain": null,
-    "port": 9931
+    "port": 9931,
+    "category": "DW Vendor Lines"
   },
   {
     "slug": "wallco-ai",
@@ -453,7 +518,8 @@
     "kind": "public",
     "target": "https://wallco.ai",
     "domain": "wallco.ai",
-    "port": null
+    "port": null,
+    "category": "Wallpaper's Back & Patterns"
   },
   {
     "slug": "wild-wallcoverings",
@@ -461,7 +527,8 @@
     "kind": "local",
     "target": "http://127.0.0.1:9787",
     "domain": null,
-    "port": 9787
+    "port": 9787,
+    "category": "Wallpaper's Back & Patterns"
   },
   {
     "slug": "wpb-sales-dashboard",
@@ -469,7 +536,8 @@
     "kind": "local",
     "target": "http://127.0.0.1:9812",
     "domain": null,
-    "port": 9812
+    "port": 9812,
+    "category": "Wallpaper's Back & Patterns"
   },
   {
     "slug": "wpb-uploaders",
@@ -477,7 +545,8 @@
     "kind": "local",
     "target": "http://127.0.0.1:9813",
     "domain": null,
-    "port": 9813
+    "port": 9813,
+    "category": "Wallpaper's Back & Patterns"
   },
   {
     "slug": "zuber-internal",
@@ -485,6 +554,7 @@
     "kind": "local",
     "target": "http://127.0.0.1:10074",
     "domain": null,
-    "port": 10074
+    "port": 10074,
+    "category": "DW Vendor Lines"
   }
 ]
diff --git a/public/index.html b/public/index.html
index d76c3cc..5f624d2 100644
--- a/public/index.html
+++ b/public/index.html
@@ -20,6 +20,16 @@
   #filters button { flex:1; padding:5px 6px; font-size:11.5px; }
   #filters button.on { border-color:var(--accent); color:var(--accent); background:#16221f; }
   #list { flex:1; overflow-y:auto; padding:6px; }
+  .sec { margin-bottom:1px; }
+  .sec-h { display:flex; align-items:center; gap:7px; padding:8px 9px; border-radius:7px; cursor:pointer; user-select:none;
+           color:var(--dim); font-size:11px; font-weight:700; letter-spacing:.05em; text-transform:uppercase; }
+  .sec-h:hover { background:#1e2530; color:var(--txt); }
+  .sec-h .chev { font-size:9px; transition:transform .12s ease; flex:none; color:var(--dim); }
+  .sec-h.open .chev { transform:rotate(90deg); color:var(--accent); }
+  .sec-h .sec-name { flex:1; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
+  .sec-h .cnt { flex:none; color:var(--dim); font-weight:600; display:flex; align-items:center; gap:4px; }
+  .sec-h .up-dot { width:6px; height:6px; border-radius:50%; background:#3fbf6f; display:inline-block; }
+  .sec-body { padding:2px 0 4px 6px; }
   .item { display:flex; align-items:center; gap:8px; padding:7px 9px; border-radius:7px; cursor:pointer; color:var(--txt); user-select:none; }
   .item:hover { background:#1e2530; }
   .item.active { background:#213132; outline:1px solid var(--accent); }
@@ -82,38 +92,85 @@ const cur = document.getElementById('cur'), count = document.getElementById('cou
 
 const esc = s => String(s == null ? '' : s).replace(/[&<>"']/g, c => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]));
 
+// Section order for the collapsible rail. Any category not listed sorts last.
+const CATS = ['DW Vendor Lines','DW Catalog & Marketing',"Wallpaper's Back & Patterns",
+  'Consulting & Client Sites','Directories & Verticals','Webdev Accelerator','Agents, Ops & Tools','Other'];
+// collapsed[cat] === false → user opened it. Undefined → collapsed by default (compact).
+let collapsed = {};
+try { collapsed = JSON.parse(localStorage.getItem('paa-collapsed') || '{}'); } catch (e) {}
+
+function buildRow(p, f) {
+  const s = status[p.slug];
+  const row = document.createElement('div');
+  row.className = 'item' + (p.slug === current ? ' active' : '');
+  const dotCls = !s ? '' : s.authFail ? 'down'
+    : s.authType === 'basic' ? 'auth'
+    : s.authType === 'login' ? 'login'
+    : s.authType === 'down'  ? 'down'
+    : s.up ? 'up' : 'down';
+  const tip = !s ? 'probing…' : s.authType === 'down' ? 'unreachable'
+    : s.authFail ? 'basic-auth wall — unified pw rejected'
+    : s.authType === 'basic' ? 'HTTP Basic wall (auto-credential applied)'
+    : s.authType === 'login' ? 'app login / SSO'
+    : 'up — no auth';
+  const en = esc(p.name);
+  const label = f ? en.replace(new RegExp('(' + f.replace(/[.*+?^${}()|[\]\\]/g,'\\$&') + ')','ig'), '<b>$1</b>') : en;
+  const badge = p.kind === 'public'
+    ? `<span class="badge public" title="${esc(p.domain)}">public</span>`
+    : `<span class="badge" title="127.0.0.1:${p.port}">:${p.port}</span>`;
+  row.innerHTML = `<span class="dot ${dotCls}" title="${tip}"></span>`
+    + `<span class="name">${label}</span>${badge}`
+    + (s && s.up ? `<span class="ms">${s.ms}ms</span>` : s ? `<span class="ms">—</span>` : `<span class="ms"></span>`);
+  row.onclick = () => select(p.slug);
+  return row;
+}
+
 function render() {
   const f = q.value.trim().toLowerCase();
+  const searching = f.length > 0;
   const shown = projects.filter(p =>
     (filter === 'all' || p.kind === filter) &&
     (p.name.toLowerCase().includes(f) || p.slug.toLowerCase().includes(f) || (p.domain || '').includes(f))
   );
   count.textContent = `(${shown.length}/${projects.length})`;
-  list.innerHTML = '';
+
+  // Group into ordered categories (unknown categories appended after CATS).
+  const groups = new Map(CATS.map(c => [c, []]));
   for (const p of shown) {
-    const s = status[p.slug];
-    const row = document.createElement('div');
-    row.className = 'item' + (p.slug === current ? ' active' : '');
-    const dotCls = !s ? '' : s.authFail ? 'down'
-      : s.authType === 'basic' ? 'auth'
-      : s.authType === 'login' ? 'login'
-      : s.authType === 'down'  ? 'down'
-      : s.up ? 'up' : 'down';
-    const tip = !s ? 'probing…' : s.authType === 'down' ? 'unreachable'
-      : s.authFail ? 'basic-auth wall — unified pw rejected'
-      : s.authType === 'basic' ? 'HTTP Basic wall (auto-credential applied)'
-      : s.authType === 'login' ? 'app login / SSO'
-      : 'up — no auth';
-    const en = esc(p.name);
-    const label = f ? en.replace(new RegExp('(' + f.replace(/[.*+?^${}()|[\]\\]/g,'\\$&') + ')','ig'), '<b>$1</b>') : en;
-    const badge = p.kind === 'public'
-      ? `<span class="badge public" title="${esc(p.domain)}">public</span>`
-      : `<span class="badge" title="127.0.0.1:${p.port}">:${p.port}</span>`;
-    row.innerHTML = `<span class="dot ${dotCls}" title="${tip}"></span>`
-      + `<span class="name">${label}</span>${badge}`
-      + (s && s.up ? `<span class="ms">${s.ms}ms</span>` : s ? `<span class="ms">—</span>` : `<span class="ms"></span>`);
-    row.onclick = () => select(p.slug);
-    list.appendChild(row);
+    const c = p.category || 'Other';
+    if (!groups.has(c)) groups.set(c, []);
+    groups.get(c).push(p);
+  }
+  const curCat = current ? (projects.find(p => p.slug === current) || {}).category : null;
+
+  list.innerHTML = '';
+  for (const [cat, items] of groups) {
+    if (!items.length) continue;
+    // Open when: actively searching, this holds the current selection, or the
+    // user explicitly opened it. Otherwise collapsed (the compact default).
+    const open = searching || cat === curCat || collapsed[cat] === false;
+    const sec = document.createElement('div');
+    sec.className = 'sec';
+    const head = document.createElement('div');
+    head.className = 'sec-h' + (open ? ' open' : '');
+    const upCount = items.filter(p => (status[p.slug] || {}).up).length;
+    head.innerHTML = `<span class="chev">▸</span><span class="sec-name">${esc(cat)}</span>`
+      + `<span class="cnt">${upCount ? `<i class="up-dot"></i>${upCount}/` : ''}${items.length}</span>`;
+    head.onclick = () => {
+      // Toggle across 3 states (undefined/true = closed, false = open):
+      // undefined→false(open), false→true(closed), true→false(open).
+      collapsed[cat] = (collapsed[cat] === false);
+      localStorage.setItem('paa-collapsed', JSON.stringify(collapsed));
+      render();
+    };
+    sec.appendChild(head);
+    if (open) {
+      const body = document.createElement('div');
+      body.className = 'sec-body';
+      for (const p of items) body.appendChild(buildRow(p, f));
+      sec.appendChild(body);
+    }
+    list.appendChild(sec);
   }
 }
 

← d08aba3 deploy.conf: tailnet-proxy model; live at projects.agentabra  ·  back to Projects Agentabrams  ·  (newest)