pressship 0.2.0 → 0.2.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/assets/web/app.js CHANGED
@@ -219,6 +219,7 @@ function createInitialStudioRelease() {
219
219
  newTagError: "",
220
220
  customVersionDraft: "",
221
221
  customVersionError: "",
222
+ skipReadmeValidation: false,
222
223
  bumpInFlight: null,
223
224
  bumpSuccess: null,
224
225
  bumpError: "",
@@ -412,6 +413,8 @@ function onStudioResizerKeydown(event) {
412
413
 
413
414
  const state = {
414
415
  bootstrap: null,
416
+ pluginCheckSummaries: {},
417
+ latestWordPressVersion: "",
415
418
  remote: [],
416
419
  remoteUsername: "",
417
420
  remoteLoading: true,
@@ -642,6 +645,12 @@ document.addEventListener("input", (event) => {
642
645
  });
643
646
 
644
647
  document.addEventListener("change", (event) => {
648
+ if (event.target?.id === "studio-skip-readme-validation") {
649
+ state.studio.release.skipReadmeValidation = Boolean(event.target.checked);
650
+ updateStudioSidebar();
651
+ return;
652
+ }
653
+
645
654
  if (event.target?.id !== "studio-plugin-picker") {
646
655
  return;
647
656
  }
@@ -921,6 +930,8 @@ async function boot() {
921
930
  state.settings = state.bootstrap.settings ?? null;
922
931
  state.playgrounds = state.bootstrap.playgrounds ?? [];
923
932
  state.aiAssistance.harnesses = state.bootstrap.aiHarnesses ?? [];
933
+ state.pluginCheckSummaries = state.bootstrap.pluginCheckSummaries ?? {};
934
+ state.latestWordPressVersion = state.bootstrap.latestWordPressVersion ?? "";
924
935
  const initialRoute = parseLocationRoute();
925
936
  primeInitialRouteState(initialRoute);
926
937
  renderAccount();
@@ -1311,8 +1322,9 @@ function renderRemote() {
1311
1322
  }
1312
1323
 
1313
1324
  function remoteCard(plugin) {
1314
- const inLibrary = state.local.find((entry) => entry.slug === plugin.slug);
1315
- const primaryLabel = inLibrary ? "Open in Studio" : "Open in Library";
1325
+ const localState = remotePluginLocalState(plugin);
1326
+ const inLibrary = localState.entry;
1327
+ const primaryLabel = inLibrary ? "Open in Studio" : "Clone to Local";
1316
1328
  const primaryAction = inLibrary
1317
1329
  ? `data-action="studio" data-scope="local" data-id="${escapeAttr(inLibrary.id)}"`
1318
1330
  : `data-action="open-in-library" data-slug="${escapeAttr(plugin.slug)}"`;
@@ -1321,7 +1333,7 @@ function remoteCard(plugin) {
1321
1333
  const description = plugin.author ? `By ${plugin.author}` : "WordPress.org plugin";
1322
1334
 
1323
1335
  return `
1324
- <article class="ps-plugin-card${inLibrary ? " is-in-library" : ""}" data-slug="${escapeAttr(plugin.slug)}">
1336
+ <article class="ps-plugin-card${inLibrary ? " is-in-library" : " is-not-cloned"}" data-slug="${escapeAttr(plugin.slug)}">
1325
1337
  <header class="ps-plugin-card-header">
1326
1338
  <span class="ps-plugin-card-icon" aria-hidden="true">${escapeHtml(initials)}</span>
1327
1339
  <div class="ps-plugin-card-title">
@@ -1345,6 +1357,13 @@ function remoteCard(plugin) {
1345
1357
  <dd>${roleBadges}</dd>
1346
1358
  </div>
1347
1359
  </dl>
1360
+ <div class="ps-plugin-card-status ps-plugin-card-local-state" title="${escapeAttr(localState.title)}">
1361
+ <span class="badge badge-${escapeAttr(localState.tone)}">
1362
+ <span class="dashicons ${escapeAttr(localState.icon)}" aria-hidden="true"></span>
1363
+ ${escapeHtml(localState.label)}
1364
+ </span>
1365
+ <small>${escapeHtml(localState.note)}</small>
1366
+ </div>
1348
1367
  <footer class="ps-plugin-card-footer">
1349
1368
  <button type="button" class="button button-primary ps-plugin-card-primary" ${primaryAction}>
1350
1369
  <span class="dashicons ${inLibrary ? "dashicons-editor-code" : "dashicons-download"}" aria-hidden="true"></span>
@@ -1358,6 +1377,55 @@ function remoteCard(plugin) {
1358
1377
  `;
1359
1378
  }
1360
1379
 
1380
+ function remotePluginLocalState(plugin) {
1381
+ const matches = state.local.filter((entry) => entry.slug === plugin.slug);
1382
+ const cloned = matches.find((entry) => entry.source === "clone" && entry.exists !== false);
1383
+ const tracked = matches.find((entry) => entry.exists !== false);
1384
+ const missing = matches.find((entry) => entry.exists === false);
1385
+
1386
+ if (cloned) {
1387
+ return {
1388
+ entry: cloned,
1389
+ label: "Cloned locally",
1390
+ note: cloned.path || "SVN checkout is tracked in Studio.",
1391
+ title: cloned.path || "This WordPress.org plugin has a local SVN checkout.",
1392
+ tone: "soft-success",
1393
+ icon: "dashicons-yes-alt"
1394
+ };
1395
+ }
1396
+
1397
+ if (tracked) {
1398
+ return {
1399
+ entry: tracked,
1400
+ label: "Tracked locally",
1401
+ note: tracked.path || "Matching local folder is tracked in Studio.",
1402
+ title: tracked.path || "A matching local plugin folder is tracked in Studio.",
1403
+ tone: "soft-success",
1404
+ icon: "dashicons-admin-site-alt3"
1405
+ };
1406
+ }
1407
+
1408
+ if (missing) {
1409
+ return {
1410
+ entry: null,
1411
+ label: "Local path missing",
1412
+ note: "Clone to recreate the local checkout.",
1413
+ title: missing.path || "The previous local folder is no longer available.",
1414
+ tone: "soft-warning",
1415
+ icon: "dashicons-warning"
1416
+ };
1417
+ }
1418
+
1419
+ return {
1420
+ entry: null,
1421
+ label: "Not cloned",
1422
+ note: "Clone this WordPress.org plugin to work on it locally.",
1423
+ title: "This WordPress.org plugin is not cloned into the local library.",
1424
+ tone: "soft-warning",
1425
+ icon: "dashicons-download"
1426
+ };
1427
+ }
1428
+
1361
1429
  function remoteRoleChips(roles = []) {
1362
1430
  const safeRoles = Array.isArray(roles) ? roles : [];
1363
1431
  if (!safeRoles.length) {
@@ -1400,6 +1468,9 @@ async function loadLocal() {
1400
1468
  state.local.map((plugin, index) => [plugin.id, versionStates[index]])
1401
1469
  );
1402
1470
  renderLocal();
1471
+ if (!state.remoteLoading && state.remote.length) {
1472
+ renderRemote();
1473
+ }
1403
1474
  } catch (error) {
1404
1475
  state.localError = error.message;
1405
1476
  els.local.innerHTML = emptyState({
@@ -1931,7 +2002,7 @@ function normalizeStudioPlaygroundVersion(value) {
1931
2002
 
1932
2003
  async function runStudioCheck() {
1933
2004
  if (state.studio.scope !== "local" || !state.studio.id) {
1934
- notice("Plugin Check is available for local plugins.", "warning");
2005
+ notice("Verify is available for local plugins.", "warning");
1935
2006
  return;
1936
2007
  }
1937
2008
 
@@ -1948,13 +2019,22 @@ async function runStudioCheck() {
1948
2019
  state.studio.checkRanAt = null;
1949
2020
  state.studio.terminalOpen = true;
1950
2021
  captureStudioEditorValue();
1951
- appendStudioTerminal("Running WordPress.org Plugin Check…", "status");
2022
+ appendStudioTerminal(
2023
+ state.studio.release.skipReadmeValidation
2024
+ ? "Running verify with readme validator skipped…"
2025
+ : "Running verify with readme validation and Plugin Check…",
2026
+ "status"
2027
+ );
1952
2028
  applyStudioCheckMarkers();
1953
2029
  renderStudio();
1954
2030
  remountStudioEditorIfNeeded();
1955
2031
  updateStudioControls();
1956
2032
 
1957
- const job = await createJob({ type: "check", localId: state.studio.id });
2033
+ const job = await createJob({
2034
+ type: "check",
2035
+ localId: state.studio.id,
2036
+ skipReadmeValidator: Boolean(state.studio.release.skipReadmeValidation)
2037
+ });
1958
2038
  state.studio.checkJobId = job.id;
1959
2039
  updateStudioControls();
1960
2040
  }
@@ -2284,9 +2364,9 @@ function renderStudio() {
2284
2364
  <span class="dashicons dashicons-saved" aria-hidden="true"></span>
2285
2365
  <span>Save</span>
2286
2366
  </button>
2287
- <button class="studio-action-button studio-compact-button" type="button" data-action="studio-check" id="studio-check-button" aria-label="Run Plugin Check" title="Run Plugin Check" disabled>
2367
+ <button class="studio-action-button studio-compact-button" type="button" data-action="studio-check" id="studio-check-button" aria-label="Run Verify" title="Run Verify" disabled>
2288
2368
  <span class="dashicons dashicons-yes-alt" aria-hidden="true"></span>
2289
- <span>Check</span>
2369
+ <span>Verify</span>
2290
2370
  </button>
2291
2371
  ${renderStudioPackageSizeButton()}
2292
2372
  ${renderStudioThemeToggle()}
@@ -2418,7 +2498,7 @@ function renderStudioStatusBar() {
2418
2498
  const file = state.studio.selectedFile?.path ?? "No file selected";
2419
2499
  const check = state.studio.checkSummary
2420
2500
  ? `${state.studio.checkSummary.error || 0} errors, ${state.studio.checkSummary.warning || 0} warnings`
2421
- : "Plugin Check idle";
2501
+ : "Verify idle";
2422
2502
  const assistant = selectedStudioAiAssistant();
2423
2503
  return `
2424
2504
  <footer class="studio-statusbar" aria-label="Studio status">
@@ -3598,10 +3678,10 @@ function renderStudioTerminal() {
3598
3678
  function renderStudioCheckNotes() {
3599
3679
  if (state.studio.checking) {
3600
3680
  return `
3601
- <aside class="studio-check-notes" aria-label="Plugin Check notes">
3681
+ <aside class="studio-check-notes" aria-label="Validation notes">
3602
3682
  <div class="studio-check-note studio-check-note-status">
3603
3683
  <span class="dashicons dashicons-update" aria-hidden="true"></span>
3604
- <span>Plugin Check is running…</span>
3684
+ <span>Verify is running…</span>
3605
3685
  </div>
3606
3686
  </aside>
3607
3687
  `;
@@ -3615,19 +3695,19 @@ function renderStudioCheckNotes() {
3615
3695
  if (!findings.length) {
3616
3696
  const total = state.studio.checkSummary.total ?? 0;
3617
3697
  return `
3618
- <aside class="studio-check-notes" aria-label="Plugin Check notes">
3698
+ <aside class="studio-check-notes" aria-label="Validation notes">
3619
3699
  <div class="studio-check-note studio-check-note-${total ? "info" : "success"}">
3620
3700
  <span class="dashicons ${total ? "dashicons-info-outline" : "dashicons-yes-alt"}" aria-hidden="true"></span>
3621
- <span>${escapeHtml(total ? `${formatCheckCounts(state.studio.checkSummary)} in other files.` : "Plugin Check reported no findings.")}</span>
3701
+ <span>${escapeHtml(total ? `${formatCheckCounts(state.studio.checkSummary)} in other files.` : "Verify reported no findings.")}</span>
3622
3702
  </div>
3623
3703
  </aside>
3624
3704
  `;
3625
3705
  }
3626
3706
 
3627
3707
  return `
3628
- <aside class="studio-check-notes" aria-label="Plugin Check notes">
3708
+ <aside class="studio-check-notes" aria-label="Validation notes">
3629
3709
  <header>
3630
- <strong>Plugin Check</strong>
3710
+ <strong>Validation</strong>
3631
3711
  <span>${escapeHtml(formatCheckCounts(studioCheckCountsForPath(state.studio.selectedFile?.path)))}</span>
3632
3712
  </header>
3633
3713
  <div class="studio-check-note-list">
@@ -3899,7 +3979,7 @@ function studioCliCommandForJob(input) {
3899
3979
  return studioCliCommand([
3900
3980
  "verify",
3901
3981
  localPluginCliTarget(input.localId),
3902
- "--skip-readme-validator",
3982
+ ...(input.skipReadmeValidator ? ["--skip-readme-validator"] : []),
3903
3983
  ...studioCliIgnoreFlags(input.localId)
3904
3984
  ]);
3905
3985
  case "dry-run-publish":
@@ -3980,6 +4060,18 @@ function applyStudioCheckState(checkState) {
3980
4060
  state.studio.checkFindings = checkState.findings ?? [];
3981
4061
  state.studio.checkSummary = checkState.summary ?? null;
3982
4062
  state.studio.checkRanAt = checkState.checkedAt ?? null;
4063
+
4064
+ if (state.studio.id && state.studio.scope === "local" && checkState.summary) {
4065
+ state.pluginCheckSummaries[state.studio.id] = {
4066
+ slug: checkState.slug ?? state.studio.plugin?.slug,
4067
+ name: checkState.name ?? state.studio.plugin?.name,
4068
+ checkedAt: checkState.checkedAt,
4069
+ skipped: checkState.skipped ?? false,
4070
+ available: checkState.available ?? true,
4071
+ summary: checkState.summary
4072
+ };
4073
+ renderDashboard();
4074
+ }
3983
4075
  }
3984
4076
 
3985
4077
  function applyStudioIgnoreState(ignoreState) {
@@ -4633,11 +4725,11 @@ function updateStudioControls() {
4633
4725
  }
4634
4726
  if (studioCheck) {
4635
4727
  studioCheck.disabled = !canCheck;
4636
- studioCheck.setAttribute("aria-label", state.studio.checkSummary ? "Re-run Plugin Check" : "Run Plugin Check");
4637
- studioCheck.title = state.studio.checkSummary ? "Re-run Plugin Check" : "Run Plugin Check";
4728
+ studioCheck.setAttribute("aria-label", state.studio.checkSummary ? "Re-run Verify" : "Run Verify");
4729
+ studioCheck.title = state.studio.checkSummary ? "Re-run Verify" : "Run Verify";
4638
4730
  studioCheck.innerHTML = state.studio.checking
4639
- ? `<span class="dashicons dashicons-update" aria-hidden="true"></span><span>Checking</span>`
4640
- : `<span class="dashicons dashicons-yes-alt" aria-hidden="true"></span><span>${state.studio.checkSummary ? "Re-check" : "Check"}</span>`;
4731
+ ? `<span class="dashicons dashicons-update" aria-hidden="true"></span><span>Verifying</span>`
4732
+ : `<span class="dashicons dashicons-yes-alt" aria-hidden="true"></span><span>${state.studio.checkSummary ? "Re-verify" : "Verify"}</span>`;
4641
4733
  }
4642
4734
  if (studioSave) {
4643
4735
  studioSave.disabled = !canSaveStudioFile();
@@ -5269,13 +5361,17 @@ function renderDashboard() {
5269
5361
  <div id="dashboard-widgets" class="metabox-holder columns-2">
5270
5362
  <div id="postbox-container-1" class="postbox-container">
5271
5363
  <div class="meta-box-sortables">
5364
+ ${renderDashboardOnboardingCard()}
5272
5365
  ${renderDashboardLocalWidget()}
5273
- ${renderDashboardAiCard()}
5366
+ ${renderDashboardReleaseReadinessWidget()}
5367
+ ${renderDashboardActivityWidget()}
5274
5368
  </div>
5275
5369
  </div>
5276
5370
  <div id="postbox-container-2" class="postbox-container">
5277
5371
  <div class="meta-box-sortables">
5278
5372
  ${renderDashboardAtGlanceWidget()}
5373
+ ${renderDashboardPluginCheckWidget()}
5374
+ ${renderDashboardCompatibilityWidget()}
5279
5375
  ${renderDashboardPlaygroundsCard()}
5280
5376
  ${renderDashboardAccountCard()}
5281
5377
  </div>
@@ -5363,8 +5459,6 @@ function renderDashboardAtGlanceWidget() {
5363
5459
  const localLabel = state.local.length === 1 ? "local plugin" : "local plugins";
5364
5460
  const remoteLabel = state.remote.length === 1 ? "WordPress.org plugin" : "WordPress.org plugins";
5365
5461
  const playgroundLabel = state.playgrounds.length === 1 ? "Playground running" : "Playgrounds running";
5366
- const account = state.bootstrap?.account?.username;
5367
- const accountLabel = account ? `Signed in as ${account}` : "WordPress.org not connected";
5368
5462
 
5369
5463
  return renderDashboardPostbox({
5370
5464
  id: "dashboard-at-a-glance",
@@ -5393,14 +5487,51 @@ function renderDashboardAtGlanceWidget() {
5393
5487
  view: "studio"
5394
5488
  })}
5395
5489
  </ul>
5396
- <div class="ps-dashboard-status">
5397
- <span class="dashicons ${account ? "dashicons-yes-alt" : "dashicons-warning"}" aria-hidden="true"></span>
5398
- <span>${escapeHtml(accountLabel)}</span>
5399
- </div>
5490
+ ${renderDashboardReleaseStatusLine()}
5400
5491
  `
5401
5492
  });
5402
5493
  }
5403
5494
 
5495
+ function renderDashboardReleaseStatusLine() {
5496
+ if (state.localLoading && !state.local.length) {
5497
+ return `
5498
+ <div class="ps-dashboard-status">
5499
+ <span class="dashicons dashicons-update" aria-hidden="true"></span>
5500
+ <span>Checking release state…</span>
5501
+ </div>
5502
+ `;
5503
+ }
5504
+
5505
+ const summary = dashboardReleaseSummary();
5506
+ let tone = "info";
5507
+ let icon = "dashicons-info";
5508
+ let text = "No local plugins to release yet.";
5509
+
5510
+ if (summary.total) {
5511
+ if (summary.blocked > 0) {
5512
+ tone = "error";
5513
+ icon = "dashicons-warning";
5514
+ text = `${summary.blocked} blocked from release · ${summary.ready} ready`;
5515
+ } else if (summary.behind > 0) {
5516
+ tone = "warning";
5517
+ icon = "dashicons-update";
5518
+ text = `${summary.behind} behind WordPress.org · ${summary.ready} ready`;
5519
+ } else {
5520
+ tone = "success";
5521
+ icon = "dashicons-yes-alt";
5522
+ text = `All ${summary.ready} plugin${summary.ready === 1 ? "" : "s"} ready to ship`;
5523
+ }
5524
+ }
5525
+
5526
+ return `
5527
+ <button class="ps-dashboard-status ps-dashboard-status-${escapeAttr(tone)}" type="button" data-view-button="release">
5528
+ <span class="dashicons ${escapeAttr(icon)}" aria-hidden="true"></span>
5529
+ <span>${escapeHtml(text)}</span>
5530
+ <span class="dashicons dashicons-arrow-right-alt2 ps-dashboard-status-arrow" aria-hidden="true"></span>
5531
+ </button>
5532
+ `;
5533
+ }
5534
+
5404
5535
  function renderDashboardGlanceItem({ icon, value, label, view, loading }) {
5405
5536
  return `
5406
5537
  <li class="ps-glance-item${loading ? " is-loading" : ""}">
@@ -5549,11 +5680,94 @@ function renderDashboardPlaygroundsCard() {
5549
5680
  `;
5550
5681
  }
5551
5682
 
5683
+ function dashboardRemoteSummary() {
5684
+ let cloned = 0;
5685
+ let committer = 0;
5686
+ let contributor = 0;
5687
+
5688
+ for (const plugin of state.remote) {
5689
+ if (remotePluginLocalState(plugin).entry) {
5690
+ cloned += 1;
5691
+ }
5692
+ const roles = Array.isArray(plugin.roles) ? plugin.roles : [];
5693
+ if (roles.includes("committer")) {
5694
+ committer += 1;
5695
+ } else if (roles.includes("contributor")) {
5696
+ contributor += 1;
5697
+ }
5698
+ }
5699
+
5700
+ const total = state.remote.length;
5701
+ return { total, cloned, notCloned: Math.max(0, total - cloned), committer, contributor };
5702
+ }
5703
+
5552
5704
  function renderDashboardAccountCard() {
5553
- const account = state.bootstrap?.account?.username;
5705
+ const account = state.bootstrap?.account;
5706
+ const username = account?.username;
5707
+ const displayName = account?.displayName;
5708
+ const profileUrl = account?.profileUrl;
5554
5709
  const loggedIn = Boolean(state.bootstrap?.loggedIn);
5555
5710
  const tone = loggedIn ? "success" : "warning";
5556
5711
  const label = loggedIn ? "Signed in" : "Not signed in";
5712
+
5713
+ if (!loggedIn) {
5714
+ return renderDashboardPostbox({
5715
+ id: "dashboard-wordpress-org",
5716
+ title: "WordPress.org",
5717
+ icon: "dashicons-admin-users",
5718
+ actions: badge(label, tone),
5719
+ body: `
5720
+ <div class="ps-account-body">
5721
+ <div class="ps-account-row">
5722
+ <span class="ps-account-icon" aria-hidden="true">
5723
+ <span class="dashicons dashicons-admin-users"></span>
5724
+ </span>
5725
+ <span class="ps-account-text">
5726
+ <strong>Not connected</strong>
5727
+ <small>Run <code>pressship login</code> in a terminal to clone, submit, and release.</small>
5728
+ </span>
5729
+ </div>
5730
+ <div class="ps-widget-footer">
5731
+ <button class="button button-ghost button-small" type="button" data-view-button="settings">
5732
+ Settings
5733
+ <span class="dashicons dashicons-arrow-right-alt2" aria-hidden="true"></span>
5734
+ </button>
5735
+ </div>
5736
+ </div>
5737
+ `
5738
+ });
5739
+ }
5740
+
5741
+ const summary = dashboardRemoteSummary();
5742
+ const remoteValue = state.remoteLoading ? "…" : String(summary.total);
5743
+ const clonedValue = state.remoteLoading ? "…" : String(summary.cloned);
5744
+
5745
+ const reach = dashboardReachSummary();
5746
+ const reachBlock = !state.remoteLoading && reach.known
5747
+ ? `<div class="ps-account-reach">
5748
+ <span class="ps-account-reach-value">${escapeHtml(formatInstallCount(reach.total))}${reach.plus ? "+" : ""}</span>
5749
+ <span class="ps-account-reach-label">combined active installs${reach.topName ? ` · ${escapeHtml(reach.topName)} leads` : ""}</span>
5750
+ </div>`
5751
+ : "";
5752
+
5753
+ const roleParts = [];
5754
+ if (summary.committer) {
5755
+ roleParts.push(`Committer on ${summary.committer}`);
5756
+ }
5757
+ if (summary.contributor) {
5758
+ roleParts.push(`Contributor on ${summary.contributor}`);
5759
+ }
5760
+ const roleLine = !state.remoteLoading && roleParts.length
5761
+ ? `<div class="ps-account-roles"><span class="dashicons dashicons-groups" aria-hidden="true"></span><span>${escapeHtml(roleParts.join(" · "))}</span></div>`
5762
+ : "";
5763
+
5764
+ const profileLink = profileUrl
5765
+ ? `<a class="button button-ghost button-small" href="${escapeAttr(profileUrl)}" target="_blank" rel="noopener noreferrer">
5766
+ <span class="dashicons dashicons-external" aria-hidden="true"></span>
5767
+ View profile
5768
+ </a>`
5769
+ : "";
5770
+
5557
5771
  return renderDashboardPostbox({
5558
5772
  id: "dashboard-wordpress-org",
5559
5773
  title: "WordPress.org",
@@ -5566,11 +5780,28 @@ function renderDashboardAccountCard() {
5566
5780
  <span class="dashicons dashicons-admin-users"></span>
5567
5781
  </span>
5568
5782
  <span class="ps-account-text">
5569
- <strong>${escapeHtml(account ?? "not logged in")}</strong>
5570
- <small>${escapeHtml(loggedIn ? "Used for clone, submit, and release." : "Run pressship login in a terminal to connect.")}</small>
5783
+ <strong>${escapeHtml(displayName || username || "WordPress.org account")}</strong>
5784
+ <small>${escapeHtml(username ? `@${username}` : "Used for clone, submit, and release.")}</small>
5571
5785
  </span>
5572
5786
  </div>
5787
+ ${reachBlock}
5788
+ <ul class="ps-account-stats">
5789
+ <li>
5790
+ <button class="ps-account-stat" type="button" data-view-button="remote">
5791
+ <strong>${escapeHtml(remoteValue)}</strong>
5792
+ <span>on WordPress.org</span>
5793
+ </button>
5794
+ </li>
5795
+ <li>
5796
+ <button class="ps-account-stat" type="button" data-view-button="local">
5797
+ <strong>${escapeHtml(clonedValue)}</strong>
5798
+ <span>cloned locally</span>
5799
+ </button>
5800
+ </li>
5801
+ </ul>
5802
+ ${roleLine}
5573
5803
  <div class="ps-widget-footer">
5804
+ ${profileLink}
5574
5805
  <button class="button button-ghost button-small" type="button" data-view-button="settings">
5575
5806
  Settings
5576
5807
  <span class="dashicons dashicons-arrow-right-alt2" aria-hidden="true"></span>
@@ -5581,73 +5812,681 @@ function renderDashboardAccountCard() {
5581
5812
  });
5582
5813
  }
5583
5814
 
5584
- function renderDashboardAiCard() {
5585
- const selected = state.settings?.aiAssistant ?? "none";
5586
- const providers = aiAssistanceProviders();
5587
- const ready = providers.filter((p) => p.status === "ready");
5588
- const installed = providers.filter((p) => p.status === "installed" || p.status === "ready");
5589
- const active = providers.find((p) => p.id === selected);
5815
+ const VERSION_STATUS_META = {
5816
+ missing_version: { label: "Missing version", tone: "error", icon: "dashicons-warning", action: "manage-release", actionLabel: "Fix" },
5817
+ header_readme_mismatch: { label: "Version mismatch", tone: "error", icon: "dashicons-randomize", action: "manage-release", actionLabel: "Fix" },
5818
+ duplicate_tag_blocked: { label: "Tag already shipped", tone: "error", icon: "dashicons-tag", action: "manage-release", actionLabel: "Bump" },
5819
+ remote_newer: { label: "Behind WordPress.org", tone: "warning", icon: "dashicons-update", action: "version-state", actionLabel: "Details" },
5820
+ unknown_svn_state: { label: "SVN state unknown", tone: "warning", icon: "dashicons-editor-help", action: "version-state", actionLabel: "Details" },
5821
+ ready: { label: "Ready", tone: "success", icon: "dashicons-yes-alt", action: "manage-release", actionLabel: "Release" }
5822
+ };
5823
+
5824
+ const BLOCKING_STATUS_ORDER = ["missing_version", "header_readme_mismatch", "duplicate_tag_blocked"];
5825
+
5826
+ function versionStatusMeta(status) {
5827
+ return VERSION_STATUS_META[status] ?? { label: labelize(status), tone: "info", icon: "dashicons-info", action: "version-state", actionLabel: "Details" };
5828
+ }
5829
+
5830
+ function primaryVersionIssue(versionState) {
5831
+ const statuses = versionState?.statuses ?? [];
5832
+ const status =
5833
+ BLOCKING_STATUS_ORDER.find((candidate) => statuses.includes(candidate)) ??
5834
+ (statuses.includes("remote_newer") ? "remote_newer" : statuses.find((candidate) => candidate !== "ready"));
5835
+ if (!status) {
5836
+ return null;
5837
+ }
5838
+ const meta = versionStatusMeta(status);
5839
+ const messageIndex = statuses.indexOf(status);
5840
+ const message = versionState.messages?.[messageIndex] ?? versionState.messages?.[0] ?? meta.label;
5841
+ return { status, message, ...meta };
5842
+ }
5843
+
5844
+ function dashboardReleaseSummary() {
5845
+ let ready = 0;
5846
+ let blocked = 0;
5847
+ let behind = 0;
5848
+ let unknown = 0;
5849
+ const attention = [];
5850
+
5851
+ for (const plugin of state.local) {
5852
+ const versionState = state.versionStates.get(plugin.id);
5853
+ if (!versionState || versionState.error || !Array.isArray(versionState.statuses)) {
5854
+ unknown += 1;
5855
+ continue;
5856
+ }
5857
+
5858
+ const isBehind = versionState.statuses.includes("remote_newer");
5859
+ if (versionState.releaseBlocked) {
5860
+ blocked += 1;
5861
+ } else if (isBehind) {
5862
+ behind += 1;
5863
+ } else {
5864
+ ready += 1;
5865
+ }
5866
+
5867
+ if (versionState.releaseBlocked || isBehind) {
5868
+ const issue = primaryVersionIssue(versionState);
5869
+ if (issue) {
5870
+ attention.push({ plugin, versionState, issue });
5871
+ }
5872
+ }
5873
+ }
5874
+
5875
+ attention.sort((left, right) => issueWeight(right.issue) - issueWeight(left.issue));
5876
+ return { total: state.local.length, ready, blocked, behind, unknown, attention };
5877
+ }
5878
+
5879
+ function issueWeight(issue) {
5880
+ if (issue.tone === "error") return 2;
5881
+ if (issue.tone === "warning") return 1;
5882
+ return 0;
5883
+ }
5884
+
5885
+ function renderDashboardReleaseReadinessWidget() {
5886
+ const loading = state.localLoading && !state.local.length;
5887
+ const summary = dashboardReleaseSummary();
5590
5888
 
5591
5889
  let tone = "info";
5592
- let label = "Disabled";
5593
- if (selected === "none") {
5594
- tone = "warning";
5595
- label = "Disabled";
5596
- } else if (active?.status === "ready") {
5597
- tone = "success";
5598
- label = "Ready";
5599
- } else if (active?.status === "not_authenticated") {
5600
- tone = "warning";
5601
- label = "Needs login";
5602
- } else if (active?.status === "not_installed") {
5603
- tone = "error";
5604
- label = "Not installed";
5605
- }
5606
-
5607
- const chips = providers
5608
- .map((provider) => {
5609
- const isSelected = provider.id === selected && selected !== "none";
5610
- return `
5611
- <span class="ps-ai-chip ps-ai-chip-${escapeAttr(provider.status)}${isSelected ? " is-selected" : ""}" title="${escapeAttr(provider.detail)}">
5612
- <span class="ps-ai-chip-dot" aria-hidden="true"></span>
5613
- ${escapeHtml(provider.label)}
5890
+ let label = "";
5891
+ if (!loading) {
5892
+ if (!summary.total) {
5893
+ tone = "info";
5894
+ label = "No plugins";
5895
+ } else if (summary.blocked > 0) {
5896
+ tone = "error";
5897
+ label = `${summary.blocked} blocked`;
5898
+ } else if (summary.behind > 0) {
5899
+ tone = "warning";
5900
+ label = `${summary.behind} behind`;
5901
+ } else {
5902
+ tone = "success";
5903
+ label = "All ready";
5904
+ }
5905
+ }
5906
+
5907
+ return renderDashboardPostbox({
5908
+ id: "dashboard-release-readiness",
5909
+ title: "Release readiness",
5910
+ icon: "ps-icon-rocket",
5911
+ className: "ps-dashboard-main",
5912
+ actions: loading ? "" : badge(label, tone),
5913
+ body: renderDashboardReadinessBody(summary, loading)
5914
+ });
5915
+ }
5916
+
5917
+ function renderDashboardReadinessBody(summary, loading) {
5918
+ if (loading) {
5919
+ return `
5920
+ <p class="ps-widget-intro">Checking version and release state for every local plugin…</p>
5921
+ ${renderDashboardSkeletonRows(2)}
5922
+ `;
5923
+ }
5924
+
5925
+ if (!summary.total) {
5926
+ return `
5927
+ <div class="ps-empty-card">
5928
+ <span class="dashicons ps-icon-rocket" aria-hidden="true"></span>
5929
+ <strong>Nothing to release yet</strong>
5930
+ <p>Add a local plugin folder, then Pressship flags version mismatches and duplicate tags before you publish.</p>
5931
+ <div class="ps-empty-card-actions">
5932
+ <button class="button button-primary" type="button" data-action="choose-local-folder">
5933
+ <span class="dashicons dashicons-open-folder" aria-hidden="true"></span>
5934
+ Choose Folder
5935
+ </button>
5936
+ </div>
5937
+ </div>
5938
+ `;
5939
+ }
5940
+
5941
+ if (!summary.attention.length) {
5942
+ return `
5943
+ <div class="ps-readiness-clear">
5944
+ <span class="ps-readiness-clear-icon" aria-hidden="true">
5945
+ <span class="dashicons dashicons-yes-alt"></span>
5614
5946
  </span>
5947
+ <div class="ps-readiness-clear-text">
5948
+ <strong>Everything looks release-ready</strong>
5949
+ <p>${escapeHtml(`All ${summary.ready} plugin${summary.ready === 1 ? "" : "s"} pass version checks. Open Release Management to ship.`)}</p>
5950
+ </div>
5951
+ <button class="button button-primary button-small" type="button" data-view-button="release">
5952
+ <span class="dashicons ps-icon-rocket" aria-hidden="true"></span>
5953
+ Release Management
5954
+ </button>
5955
+ </div>
5956
+ `;
5957
+ }
5958
+
5959
+ const visible = summary.attention.slice(0, 5);
5960
+ const rows = visible.map(dashboardReadinessRow).join("");
5961
+ const overflow = summary.attention.length > visible.length
5962
+ ? `<div class="ps-widget-footer"><button class="button button-ghost button-small" type="button" data-view-button="release">See all ${summary.attention.length} flagged plugins<span class="dashicons dashicons-arrow-right-alt2" aria-hidden="true"></span></button></div>`
5963
+ : "";
5964
+
5965
+ const blockedNote = summary.blocked
5966
+ ? `${summary.blocked} blocked from release`
5967
+ : `${summary.behind} behind WordPress.org`;
5968
+
5969
+ return `
5970
+ <p class="ps-widget-intro">${escapeHtml(`${summary.attention.length} plugin${summary.attention.length === 1 ? "" : "s"} need a fix before release — ${blockedNote}.`)}</p>
5971
+ <ul class="ps-attention-list">${rows}</ul>
5972
+ ${overflow}
5973
+ `;
5974
+ }
5975
+
5976
+ function dashboardReadinessRow({ plugin, issue }) {
5977
+ const name = plugin.name || plugin.slug || plugin.id;
5978
+ return `
5979
+ <li class="ps-attention-row ps-attention-${escapeAttr(issue.tone)}">
5980
+ <span class="ps-attention-icon" aria-hidden="true">
5981
+ <span class="dashicons ${escapeAttr(issue.icon)}"></span>
5982
+ </span>
5983
+ <button class="ps-attention-main" type="button" data-action="studio" data-scope="local" data-id="${escapeAttr(plugin.id)}" title="Open ${escapeAttr(name)} in Studio">
5984
+ <span class="ps-attention-text">
5985
+ <strong>${escapeHtml(name)}</strong>
5986
+ <small>${escapeHtml(issue.message)}</small>
5987
+ </span>
5988
+ </button>
5989
+ <span class="ps-attention-action">
5990
+ ${badge(issue.label, issue.tone)}
5991
+ <button class="button button-small" type="button" data-action="${escapeAttr(issue.action)}" data-id="${escapeAttr(plugin.id)}">${escapeHtml(issue.actionLabel)}</button>
5992
+ </span>
5993
+ </li>
5994
+ `;
5995
+ }
5996
+
5997
+ /* ===================================================================
5998
+ * Dashboard: Recent activity
5999
+ * =================================================================== */
6000
+
6001
+ function renderDashboardActivityWidget() {
6002
+ const jobs = Array.from(state.jobs.values()).sort((left, right) =>
6003
+ String(right.createdAt ?? "").localeCompare(String(left.createdAt ?? ""))
6004
+ );
6005
+ const running = jobs.filter((job) => job.status === "running" || job.status === "queued").length;
6006
+
6007
+ const body = jobs.length
6008
+ ? `<ul class="ps-activity-list">${jobs.slice(0, 5).map(dashboardActivityRow).join("")}</ul>`
6009
+ : `
6010
+ <div class="ps-side-empty ps-activity-empty">
6011
+ <span class="dashicons dashicons-clock" aria-hidden="true"></span>
6012
+ <span>No recent activity yet. Clone, check, dry-run, or release a plugin to see it here.</span>
6013
+ </div>
5615
6014
  `;
5616
- })
5617
- .join("");
5618
6015
 
5619
- const hint =
5620
- selected === "none"
5621
- ? "Pick an assistant in Settings to enable AI inside Studio."
5622
- : active?.status === "ready"
5623
- ? `${active.label} is ready in Studio.`
5624
- : active?.status === "not_authenticated"
5625
- ? `${active.label} is installed but not signed in.`
5626
- : `${active?.label ?? "Assistant"} ${active?.status === "not_installed" ? "is not on PATH." : "needs attention."}`;
6016
+ return renderDashboardPostbox({
6017
+ id: "dashboard-activity",
6018
+ title: "Recent activity",
6019
+ icon: "dashicons-backup",
6020
+ className: "ps-dashboard-main",
6021
+ actions: running ? `<span class="ps-count-pill">${running} active</span>` : "",
6022
+ body
6023
+ });
6024
+ }
6025
+
6026
+ function dashboardActivityRow(job) {
6027
+ const tone =
6028
+ job.status === "failed"
6029
+ ? "error"
6030
+ : job.status === "succeeded"
6031
+ ? "success"
6032
+ : job.status === "cancelled"
6033
+ ? "warning"
6034
+ : "info";
6035
+ return `
6036
+ <li class="ps-activity-row">
6037
+ <span class="ps-activity-icon ps-activity-${escapeAttr(tone)}" aria-hidden="true">
6038
+ <span class="dashicons ${jobIcon(job.type)}"></span>
6039
+ </span>
6040
+ <span class="ps-activity-text">
6041
+ <strong>${escapeHtml(job.title || jobTypeLabel(job.type))}</strong>
6042
+ <small>${escapeHtml(formatRelativeTime(job.createdAt))}</small>
6043
+ </span>
6044
+ ${badge(job.status, tone)}
6045
+ </li>
6046
+ `;
6047
+ }
6048
+
6049
+ function jobTypeLabel(type) {
6050
+ switch (type) {
6051
+ case "clone":
6052
+ return "Clone plugin";
6053
+ case "play":
6054
+ return "Launch Playground";
6055
+ case "check":
6056
+ return "Verify plugin";
6057
+ case "dry-run-publish":
6058
+ return "Dry-run publish";
6059
+ case "confirm-publish":
6060
+ return "Publish";
6061
+ default:
6062
+ return "Task";
6063
+ }
6064
+ }
6065
+
6066
+ function formatRelativeTime(iso) {
6067
+ if (!iso) {
6068
+ return "";
6069
+ }
6070
+ const then = new Date(iso).getTime();
6071
+ if (!Number.isFinite(then)) {
6072
+ return String(iso);
6073
+ }
6074
+ const diffSeconds = Math.round((Date.now() - then) / 1000);
6075
+ if (diffSeconds < 45) {
6076
+ return "just now";
6077
+ }
6078
+ const minutes = Math.round(diffSeconds / 60);
6079
+ if (minutes < 60) {
6080
+ return `${minutes}m ago`;
6081
+ }
6082
+ const hours = Math.round(minutes / 60);
6083
+ if (hours < 24) {
6084
+ return `${hours}h ago`;
6085
+ }
6086
+ const days = Math.round(hours / 24);
6087
+ if (days < 7) {
6088
+ return `${days}d ago`;
6089
+ }
6090
+ try {
6091
+ return new Date(iso).toLocaleDateString([], { month: "short", day: "numeric" });
6092
+ } catch {
6093
+ return String(iso);
6094
+ }
6095
+ }
6096
+
6097
+ /* ===================================================================
6098
+ * Dashboard: Validation health
6099
+ * =================================================================== */
6100
+
6101
+ function dashboardPluginCheckSummary() {
6102
+ let checked = 0;
6103
+ let errors = 0;
6104
+ let warnings = 0;
6105
+ let clean = 0;
6106
+ const flagged = [];
6107
+
6108
+ for (const plugin of state.local) {
6109
+ const entry = state.pluginCheckSummaries?.[plugin.id];
6110
+ if (!entry || !entry.summary) {
6111
+ continue;
6112
+ }
6113
+ checked += 1;
6114
+ const summary = entry.summary;
6115
+ if (summary.error > 0) {
6116
+ errors += 1;
6117
+ flagged.push({ plugin, entry, tone: "error" });
6118
+ } else if (summary.warning > 0) {
6119
+ warnings += 1;
6120
+ flagged.push({ plugin, entry, tone: "warning" });
6121
+ } else {
6122
+ clean += 1;
6123
+ }
6124
+ }
6125
+
6126
+ flagged.sort(
6127
+ (left, right) =>
6128
+ right.entry.summary.error - left.entry.summary.error ||
6129
+ right.entry.summary.warning - left.entry.summary.warning
6130
+ );
6131
+
6132
+ return { checked, errors, warnings, clean, unchecked: state.local.length - checked, flagged };
6133
+ }
6134
+
6135
+ function renderDashboardPluginCheckWidget() {
6136
+ if (!state.local.length) {
6137
+ return "";
6138
+ }
6139
+
6140
+ const summary = dashboardPluginCheckSummary();
6141
+
6142
+ let tone = "info";
6143
+ let label = "Not run";
6144
+ if (summary.checked > 0) {
6145
+ if (summary.errors > 0) {
6146
+ tone = "error";
6147
+ label = `${summary.errors} with errors`;
6148
+ } else if (summary.warnings > 0) {
6149
+ tone = "warning";
6150
+ label = `${summary.warnings} with warnings`;
6151
+ } else {
6152
+ tone = "success";
6153
+ label = "All clean";
6154
+ }
6155
+ }
6156
+
6157
+ let body;
6158
+ if (summary.checked === 0) {
6159
+ body = `
6160
+ <div class="ps-side-empty">
6161
+ <span class="dashicons dashicons-yes-alt" aria-hidden="true"></span>
6162
+ <span>No Verify results yet. Open a plugin in Studio and run Verify.</span>
6163
+ </div>
6164
+ `;
6165
+ } else if (!summary.flagged.length) {
6166
+ body = `
6167
+ <div class="ps-readiness-clear">
6168
+ <span class="ps-readiness-clear-icon" aria-hidden="true">
6169
+ <span class="dashicons dashicons-yes-alt"></span>
6170
+ </span>
6171
+ <div class="ps-readiness-clear-text">
6172
+ <strong>No findings</strong>
6173
+ <p>${escapeHtml(`All ${summary.checked} checked plugin${summary.checked === 1 ? "" : "s"} passed Verify.`)}</p>
6174
+ </div>
6175
+ </div>
6176
+ ${dashboardPluginCheckFooter(summary)}
6177
+ `;
6178
+ } else {
6179
+ const rows = summary.flagged.slice(0, 4).map(dashboardPluginCheckRow).join("");
6180
+ body = `
6181
+ <ul class="ps-attention-list">${rows}</ul>
6182
+ ${dashboardPluginCheckFooter(summary)}
6183
+ `;
6184
+ }
5627
6185
 
5628
6186
  return renderDashboardPostbox({
5629
- id: "dashboard-ai-assistance",
5630
- title: "AI Assistance",
5631
- icon: "dashicons-superhero",
6187
+ id: "dashboard-plugin-check",
6188
+ title: "Validation health",
6189
+ icon: "dashicons-shield",
5632
6190
  actions: badge(label, tone),
5633
- body: `
5634
- <div class="ps-ai-chips">${chips}</div>
5635
- <p class="ps-widget-hint">${escapeHtml(hint)}</p>
5636
- <div class="ps-widget-footer">
5637
- <button class="button button-ghost button-small" type="button" data-action="refresh-ai-assistance">
5638
- <span class="dashicons dashicons-update" aria-hidden="true"></span>
5639
- Refresh
5640
- </button>
5641
- <button class="button button-ghost button-small" type="button" data-view-button="settings">
5642
- Configure
5643
- <span class="dashicons dashicons-arrow-right-alt2" aria-hidden="true"></span>
5644
- </button>
6191
+ body
6192
+ });
6193
+ }
6194
+
6195
+ function dashboardPluginCheckFooter(summary) {
6196
+ return `
6197
+ <small class="ps-widget-meta">${escapeHtml(
6198
+ `${summary.checked} of ${state.local.length} checked · ${summary.clean} clean${summary.unchecked ? ` · ${summary.unchecked} not run` : ""}`
6199
+ )}</small>
6200
+ `;
6201
+ }
6202
+
6203
+ function dashboardPluginCheckRow({ plugin, entry, tone }) {
6204
+ const name = entry.name || plugin.name || plugin.slug || plugin.id;
6205
+ const counts = [];
6206
+ if (entry.summary.error) {
6207
+ counts.push(`${entry.summary.error} error${entry.summary.error === 1 ? "" : "s"}`);
6208
+ }
6209
+ if (entry.summary.warning) {
6210
+ counts.push(`${entry.summary.warning} warning${entry.summary.warning === 1 ? "" : "s"}`);
6211
+ }
6212
+ const detail = `${counts.join(" · ")}${entry.checkedAt ? ` · ${formatRelativeTime(entry.checkedAt)}` : ""}`;
6213
+ return `
6214
+ <li class="ps-attention-row ps-attention-${escapeAttr(tone)}">
6215
+ <span class="ps-attention-icon" aria-hidden="true">
6216
+ <span class="dashicons ${tone === "error" ? "dashicons-warning" : "dashicons-flag"}"></span>
6217
+ </span>
6218
+ <button class="ps-attention-main" type="button" data-action="studio" data-scope="local" data-id="${escapeAttr(plugin.id)}" title="Open ${escapeAttr(name)} in Studio">
6219
+ <span class="ps-attention-text">
6220
+ <strong>${escapeHtml(name)}</strong>
6221
+ <small>${escapeHtml(detail)}</small>
6222
+ </span>
6223
+ </button>
6224
+ <span class="ps-attention-action">
6225
+ <button class="button button-small" type="button" data-action="studio" data-scope="local" data-id="${escapeAttr(plugin.id)}">Open</button>
6226
+ </span>
6227
+ </li>
6228
+ `;
6229
+ }
6230
+
6231
+ /* ===================================================================
6232
+ * Dashboard: Compatibility watch
6233
+ * =================================================================== */
6234
+
6235
+ function dashboardCompatibilitySummary() {
6236
+ const latest = state.latestWordPressVersion || "";
6237
+ const latestBranch = latest ? versionBranch(latest) : "";
6238
+ let behind = 0;
6239
+ let current = 0;
6240
+ let unknown = 0;
6241
+ const outdated = [];
6242
+
6243
+ for (const plugin of state.remote) {
6244
+ const tested = parseTestedWith(plugin.testedWith);
6245
+ if (!tested) {
6246
+ unknown += 1;
6247
+ continue;
6248
+ }
6249
+ if (latestBranch && compareVersionStrings(versionBranch(tested), latestBranch) < 0) {
6250
+ behind += 1;
6251
+ outdated.push({ plugin, tested });
6252
+ } else {
6253
+ current += 1;
6254
+ }
6255
+ }
6256
+
6257
+ outdated.sort((left, right) => compareVersionStrings(left.tested, right.tested));
6258
+ return { latest, latestBranch, behind, current, unknown, outdated };
6259
+ }
6260
+
6261
+ function renderDashboardCompatibilityWidget() {
6262
+ if (!state.bootstrap?.loggedIn || (!state.remote.length && !state.remoteLoading)) {
6263
+ return "";
6264
+ }
6265
+
6266
+ const summary = dashboardCompatibilitySummary();
6267
+ const latestLabel = summary.latest ? `WP ${summary.latest}` : "WordPress";
6268
+
6269
+ let tone = "info";
6270
+ let label = summary.latestBranch ? "Up to date" : "Unknown";
6271
+ if (summary.behind > 0) {
6272
+ tone = "warning";
6273
+ label = `${summary.behind} behind`;
6274
+ } else if (summary.latestBranch && summary.current > 0) {
6275
+ tone = "success";
6276
+ label = "Up to date";
6277
+ }
6278
+
6279
+ let body;
6280
+ if (state.remoteLoading) {
6281
+ body = `<div class="ps-side-empty"><span class="dashicons dashicons-update" aria-hidden="true"></span><span>Loading WordPress.org plugins…</span></div>`;
6282
+ } else if (!summary.latestBranch) {
6283
+ body = `<div class="ps-side-empty"><span class="dashicons dashicons-editor-help" aria-hidden="true"></span><span>Could not determine the latest WordPress version right now.</span></div>`;
6284
+ } else if (!summary.outdated.length) {
6285
+ body = `
6286
+ <div class="ps-readiness-clear">
6287
+ <span class="ps-readiness-clear-icon" aria-hidden="true">
6288
+ <span class="dashicons dashicons-yes-alt"></span>
6289
+ </span>
6290
+ <div class="ps-readiness-clear-text">
6291
+ <strong>${escapeHtml(`Tested with ${latestLabel}`)}</strong>
6292
+ <p>${escapeHtml(`All ${summary.current} plugin${summary.current === 1 ? "" : "s"} declare compatibility with the latest WordPress.`)}</p>
5645
6293
  </div>
5646
- <small class="ps-widget-meta">${installed.length} installed · ${ready.length} ready</small>
5647
- `
6294
+ </div>
6295
+ `;
6296
+ } else {
6297
+ const rows = summary.outdated.slice(0, 4).map((item) => dashboardCompatibilityRow(item, summary)).join("");
6298
+ body = `
6299
+ <p class="ps-widget-intro">${escapeHtml(`Latest WordPress is ${summary.latest}. Bump "Tested up to" so users keep installing.`)}</p>
6300
+ <ul class="ps-attention-list">${rows}</ul>
6301
+ `;
6302
+ }
6303
+
6304
+ return renderDashboardPostbox({
6305
+ id: "dashboard-compatibility",
6306
+ title: "Compatibility watch",
6307
+ icon: "dashicons-wordpress",
6308
+ actions: badge(label, tone),
6309
+ body
5648
6310
  });
5649
6311
  }
5650
6312
 
6313
+ function dashboardCompatibilityRow({ plugin, tested }, summary) {
6314
+ return `
6315
+ <li class="ps-attention-row ps-attention-warning">
6316
+ <span class="ps-attention-icon" aria-hidden="true">
6317
+ <span class="dashicons dashicons-wordpress"></span>
6318
+ </span>
6319
+ <button class="ps-attention-main" type="button" data-action="details" data-scope="remote" data-id="${escapeAttr(plugin.slug)}" title="View ${escapeAttr(plugin.name || plugin.slug)}">
6320
+ <span class="ps-attention-text">
6321
+ <strong>${escapeHtml(plugin.name || plugin.slug)}</strong>
6322
+ <small>${escapeHtml(`Tested up to ${tested} · WordPress is ${summary.latest}`)}</small>
6323
+ </span>
6324
+ </button>
6325
+ <span class="ps-attention-action">
6326
+ ${badge(`${tested}`, "warning")}
6327
+ </span>
6328
+ </li>
6329
+ `;
6330
+ }
6331
+
6332
+ function parseTestedWith(value) {
6333
+ const match = String(value ?? "").match(/(\d+\.\d+(?:\.\d+)?)/);
6334
+ return match ? match[1] : "";
6335
+ }
6336
+
6337
+ function versionBranch(value) {
6338
+ const parts = String(value ?? "").split(".");
6339
+ return `${parts[0] ?? "0"}.${parts[1] ?? "0"}`;
6340
+ }
6341
+
6342
+ function compareVersionStrings(left, right) {
6343
+ const leftParts = String(left).split(/[.-]/).map((part) => Number.parseInt(part, 10) || 0);
6344
+ const rightParts = String(right).split(/[.-]/).map((part) => Number.parseInt(part, 10) || 0);
6345
+ for (let index = 0; index < Math.max(leftParts.length, rightParts.length); index += 1) {
6346
+ const diff = (leftParts[index] ?? 0) - (rightParts[index] ?? 0);
6347
+ if (diff !== 0) {
6348
+ return diff > 0 ? 1 : -1;
6349
+ }
6350
+ }
6351
+ return 0;
6352
+ }
6353
+
6354
+ /* ===================================================================
6355
+ * Dashboard: WordPress.org reach
6356
+ * =================================================================== */
6357
+
6358
+ function dashboardReachSummary() {
6359
+ let total = 0;
6360
+ let known = false;
6361
+ let plus = false;
6362
+ let topName = "";
6363
+ let topCount = -1;
6364
+
6365
+ for (const plugin of state.remote) {
6366
+ const parsed = parseActiveInstalls(plugin.activeInstalls);
6367
+ if (!parsed.known) {
6368
+ continue;
6369
+ }
6370
+ known = true;
6371
+ total += parsed.count;
6372
+ if (parsed.plus) {
6373
+ plus = true;
6374
+ }
6375
+ if (parsed.count > topCount) {
6376
+ topCount = parsed.count;
6377
+ topName = plugin.name || plugin.slug || "";
6378
+ }
6379
+ }
6380
+
6381
+ return { total, known, plus, topName: total > 0 ? topName : "" };
6382
+ }
6383
+
6384
+ function parseActiveInstalls(value) {
6385
+ const text = String(value ?? "").replace(/,/g, "");
6386
+ const match = text.match(/(\d+)/);
6387
+ if (!match) {
6388
+ return { count: 0, plus: false, known: false };
6389
+ }
6390
+ return { count: Number.parseInt(match[1], 10), plus: /\+/.test(text), known: true };
6391
+ }
6392
+
6393
+ function formatInstallCount(value) {
6394
+ try {
6395
+ return Number(value).toLocaleString();
6396
+ } catch {
6397
+ return String(value);
6398
+ }
6399
+ }
6400
+
6401
+ /* ===================================================================
6402
+ * Dashboard: Getting-started checklist
6403
+ * =================================================================== */
6404
+
6405
+ function dashboardOnboardingSteps() {
6406
+ const loggedIn = Boolean(state.bootstrap?.loggedIn);
6407
+ const hasLocal = state.local.length > 0;
6408
+ const ranCheck = Object.values(state.pluginCheckSummaries ?? {}).some((entry) => entry?.summary);
6409
+ const released =
6410
+ state.local.some((plugin) => state.versionStates.get(plugin.id)?.remoteVersion) ||
6411
+ Array.from(state.jobs.values()).some(
6412
+ (job) => job.type === "confirm-publish" && job.status === "succeeded"
6413
+ );
6414
+
6415
+ return [
6416
+ {
6417
+ id: "login",
6418
+ done: loggedIn,
6419
+ label: "Connect your WordPress.org account",
6420
+ hint: "Run pressship login in a terminal, then refresh.",
6421
+ actionAttrs: 'data-view-button="settings"',
6422
+ actionLabel: "Settings"
6423
+ },
6424
+ {
6425
+ id: "add",
6426
+ done: hasLocal,
6427
+ label: "Add a local plugin",
6428
+ hint: "Point Pressship at a plugin folder you're working on.",
6429
+ actionAttrs: 'data-action="choose-local-folder"',
6430
+ actionLabel: "Choose Folder"
6431
+ },
6432
+ {
6433
+ id: "check",
6434
+ done: ranCheck,
6435
+ label: "Run Verify on a plugin",
6436
+ hint: "Open a plugin in Studio and run Verify.",
6437
+ actionAttrs: 'data-view-button="local"',
6438
+ actionLabel: "Open Library"
6439
+ },
6440
+ {
6441
+ id: "release",
6442
+ done: released,
6443
+ label: "Prepare or ship a release",
6444
+ hint: "Review version state and walk the publish funnel.",
6445
+ actionAttrs: 'data-view-button="release"',
6446
+ actionLabel: "Release"
6447
+ }
6448
+ ];
6449
+ }
6450
+
6451
+ function renderDashboardOnboardingCard() {
6452
+ if (state.localLoading && !state.local.length) {
6453
+ return "";
6454
+ }
6455
+
6456
+ const steps = dashboardOnboardingSteps();
6457
+ const done = steps.filter((step) => step.done).length;
6458
+ if (done === steps.length) {
6459
+ return "";
6460
+ }
6461
+
6462
+ return renderDashboardPostbox({
6463
+ id: "dashboard-getting-started",
6464
+ title: "Getting started",
6465
+ icon: "dashicons-flag",
6466
+ className: "ps-dashboard-main ps-onboarding-card",
6467
+ actions: `<span class="ps-count-pill">${done}/${steps.length}</span>`,
6468
+ body: `<ol class="ps-onboarding-list">${steps.map(dashboardOnboardingRow).join("")}</ol>`
6469
+ });
6470
+ }
6471
+
6472
+ function dashboardOnboardingRow(step) {
6473
+ const action = step.done
6474
+ ? ""
6475
+ : `<button class="button button-small" type="button" ${step.actionAttrs}>${escapeHtml(step.actionLabel)}</button>`;
6476
+ return `
6477
+ <li class="ps-onboarding-row${step.done ? " is-done" : ""}">
6478
+ <span class="ps-onboarding-check" aria-hidden="true">
6479
+ <span class="dashicons ${step.done ? "dashicons-yes-alt" : "dashicons-marker"}"></span>
6480
+ </span>
6481
+ <span class="ps-onboarding-label">
6482
+ <strong>${escapeHtml(step.label)}</strong>
6483
+ ${step.hint && !step.done ? `<small>${escapeHtml(step.hint)}</small>` : ""}
6484
+ </span>
6485
+ ${action}
6486
+ </li>
6487
+ `;
6488
+ }
6489
+
5651
6490
  function renderPlaygroundsMenu() {
5652
6491
  if (!els.playgroundsSection || !els.playgroundsMenu) {
5653
6492
  return;
@@ -7331,14 +8170,14 @@ function escapeAttr(value) {
7331
8170
  }
7332
8171
 
7333
8172
  /* ===================================================================
7334
- * Open in Library — clones from WordPress.org SVN or jumps to existing
8173
+ * Clone to Local — clones from WordPress.org SVN or jumps to existing
7335
8174
  * =================================================================== */
7336
8175
 
7337
8176
  async function openInLibrary(slug) {
7338
8177
  if (!slug) {
7339
8178
  return;
7340
8179
  }
7341
- const existing = state.local.find((entry) => entry.slug === slug);
8180
+ const existing = remotePluginLocalState({ slug }).entry;
7342
8181
  if (existing) {
7343
8182
  await openStudio("local", existing.id);
7344
8183
  return;
@@ -7791,13 +8630,13 @@ function renderStudioReleaseTagRow(tag) {
7791
8630
  function renderStudioReleaseStepValidate(versionState, release) {
7792
8631
  const summary = state.studio.checkSummary;
7793
8632
  const summaryLine = summary
7794
- ? `Plugin Check: ${escapeHtml(String(summary.error || 0))} errors, ${escapeHtml(String(summary.warning || 0))} warnings`
7795
- : "Plugin Check has not been run yet.";
8633
+ ? `Verify: ${escapeHtml(String(summary.error || 0))} errors, ${escapeHtml(String(summary.warning || 0))} warnings`
8634
+ : "Verify has not been run yet.";
7796
8635
  const validationBlocked = release.dryRun?.validationBlocked;
7797
8636
  const body = `
7798
8637
  <ul class="ps-release-validate-list">
7799
8638
  <li>
7800
- <span><span class="dashicons dashicons-yes-alt" aria-hidden="true"></span>Plugin Check</span>
8639
+ <span><span class="dashicons dashicons-yes-alt" aria-hidden="true"></span>Readme + Plugin Check</span>
7801
8640
  <span class="ps-release-validate-state">${escapeHtml(summary ? "Ran" : "Pending")}</span>
7802
8641
  <button class="button button-small" type="button" data-action="studio-check">${summary ? "Re-run" : "Run"}</button>
7803
8642
  </li>
@@ -7811,6 +8650,10 @@ function renderStudioReleaseStepValidate(versionState, release) {
7811
8650
  <span class="ps-release-validate-state">${escapeHtml(release.dryRun?.package?.file ? "Ready" : "Build on dry-run")}</span>
7812
8651
  </li>
7813
8652
  </ul>
8653
+ <label class="ps-release-skip-readme">
8654
+ <input type="checkbox" id="studio-skip-readme-validation" ${release.skipReadmeValidation ? "checked" : ""} ${state.studio.checking ? "disabled" : ""} />
8655
+ <span>skip readme validation</span>
8656
+ </label>
7814
8657
  ${validationBlocked
7815
8658
  ? `<p class="ps-release-step-error">Validation reported blocking findings. Fix them before publishing.</p>`
7816
8659
  : ""}