Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
00a2491
fix(26-03): release gates 8–11 — one real defect found and fixed (#124)
dknauss Aug 9, 2026
36ec8e8
docs(planning): hold the v1.5.0 tag — Gate 8 never ran (#125)
dknauss Aug 9, 2026
cb45c90
test: close the multisite gap, narrow the a11y one (#126)
dknauss Aug 10, 2026
707d9b6
fix: address ultrareview findings — collapse the per-user authorizati…
dknauss Aug 10, 2026
694b1bf
docs(planning): clear the v1.5.0 tag hold — Gate 8 satisfied (#129)
dknauss Aug 10, 2026
2f21818
docs(planning): record v1.5.0 as shipped (#130)
dknauss Aug 10, 2026
11fa8a0
docs(planning): plan Phase 25, and flag that its scope is stale (#131)
dknauss Aug 10, 2026
cd70fb1
docs(25-01): run the Phase 25 audit — one criterion struck, one downg…
dknauss Aug 10, 2026
f04dd13
fix(25-02): reserve the toolbar status slot, widen the focus ring, la…
dknauss Aug 10, 2026
1ad0b5b
docs(planning): log the V2-backlog reconciliation todo (#134)
dknauss Aug 10, 2026
dcfbd1d
docs(planning): log the persistent saved-state indicator todo (#135)
dknauss Aug 10, 2026
db971a0
docs(planning): reconcile the SPEC/PROJECT V2 backlog with the todo s…
dknauss Aug 10, 2026
760a233
docs(planning): close Phase 25 — human-verified (#137)
dknauss Aug 10, 2026
4359994
fix: close the three Phase 20 correctness follow-ups (#138)
dknauss Aug 10, 2026
2b9d943
docs(planning): plan Phase 27 — cloned-role hiding profiles (complete…
dknauss Aug 10, 2026
2ebe6fd
docs(planning): log toolbar height + #collapse-menu parity, which gat…
dknauss Aug 10, 2026
29025ce
docs(planning): record the verified collapse-menu findings and cross-…
dknauss Aug 10, 2026
6b6c204
docs(planning): decide the fold story and plan Phase 28 (menu width) …
dknauss Aug 10, 2026
a69c4a5
docs(planning): decide where menu-wide features live (#143)
dknauss Aug 10, 2026
baab892
docs(planning): write an accurate session handoff into STATE.md (#144)
dknauss Aug 10, 2026
2e6e174
docs(planning): log the AME feature-surface research todo (#145)
dknauss Aug 10, 2026
9caa65d
release: prep v1.5.1 (#146)
dknauss Aug 10, 2026
343c0f9
docs(planning): record v1.5.1 shipped, and diagnose the deploy trigge…
dknauss Aug 10, 2026
80df8cc
docs(planning): consolidate the carried v1.5.0 gaps — three become tw…
dknauss Aug 10, 2026
7931bbf
docs(planning): rescope the AME research to a comprehensive sweep wit…
dknauss Aug 10, 2026
92ee2b4
fix(ci): call the deploy workflow directly instead of relying on a de…
dknauss Aug 10, 2026
a8f8106
docs(compat): record the non-autoloaded option as differentiator D4 (…
dknauss Aug 12, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -182,6 +182,15 @@ jobs:
- name: Integration tests (PHPUnit in wp-env)
run: npm run test:php

# ROLE-02's super-admin exemption is `is_multisite() && is_super_admin()`,
# so on a single-site run the second half never evaluates and the whole
# branch is uncovered. That gap shipped through the v1.5.0 gates as a known
# unknown; this lane closes it. Reuses the same containers — WP_MULTISITE=1
# only changes how the PHPUnit bootstrap installs, so it costs one extra
# suite run rather than a second environment.
- name: Integration tests (multisite)
run: npm run test:php:multisite

- name: Install Playwright Chromium
run: npx playwright install --with-deps chromium

Expand Down
17 changes: 17 additions & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -38,3 +38,20 @@ jobs:
files: build/maestro-menu-editor.zip
generate_release_notes: true
fail_on_unmatched_files: true

# Deploy to WordPress.org by CALLING the deploy workflow, not by publishing a
# Release and hoping the `release: published` event starts it. It does not: the
# step above authenticates as the default GITHUB_TOKEN, and GitHub does not start
# workflow runs from GITHUB_TOKEN-generated events. That dead trigger is why every
# deploy in this repo's history was manual. See wp-deploy.yml's trigger comment.
#
# `needs: release` means a failed build, a failed tag/version check, or a failed
# Release publish all stop the deploy — wp.org is never reached by a tag that did
# not pass the gate above.
deploy:
name: Deploy to WordPress.org
needs: release
uses: ./.github/workflows/wp-deploy.yml
with:
tag: ${{ github.ref_name }}
secrets: inherit
66 changes: 57 additions & 9 deletions .github/workflows/wp-deploy.yml
Original file line number Diff line number Diff line change
@@ -1,40 +1,88 @@
name: Deploy to WordPress.org

# TRIGGERS — read this before adding a third one.
#
# This workflow used to declare `release: types: [published]`. That trigger NEVER
# FIRED, in six consecutive releases: `release.yml` publishes the Release with the
# default GITHUB_TOKEN, and GitHub deliberately does not start workflow runs from
# GITHUB_TOKEN-generated events. Every deploy in this repo's history was a manual
# workflow_dispatch, and STATE.md carried "remember the manual step" as a standing
# lesson for four releases — scar tissue over a trigger that could not work.
#
# It is replaced by `workflow_call`: release.yml now invokes this workflow
# DIRECTLY as a dependent job, so there is no event in the middle to be swallowed.
# workflow_dispatch stays, deliberately — it is how you re-deploy a tag whose
# commit predates these workflows, and how you recover if a deploy half-fails.
on:
workflow_dispatch:
inputs:
tag:
description: "Git tag to deploy (e.g. v1.1.1). Leave blank when triggered by a release."
description: "Git tag to deploy (e.g. v1.1.1). Leave blank to use the ref this was dispatched from."
required: false
default: ""
release:
types: [published]
workflow_call:
inputs:
tag:
description: "Git tag to deploy (e.g. v1.1.1), passed by release.yml."
required: true
type: string
secrets:
WP_ORG_SVN_USERNAME:
required: true
WP_ORG_SVN_PASSWORD:
required: true

permissions:
contents: read

concurrency:
group: wporg-deploy-${{ github.event.inputs.tag || github.ref_name }}
# `inputs` covers BOTH workflow_dispatch and workflow_call; `github.event.inputs`
# would be null on a call and silently collapse every release into one group.
group: wporg-deploy-${{ inputs.tag || github.ref_name }}
cancel-in-progress: false

jobs:
deploy:
name: Deploy to WordPress.org SVN
runs-on: ubuntu-latest

# THE HUMAN GATE. Everything above this line is automatic — tag push, build,
# version check, Release publish — and then this job STOPS and waits for an
# approval on the `wordpress-org` environment before anything reaches wp.org.
#
# This is the deliberate answer to a real tension. Wiring the deploy directly
# (see the trigger comment above) fixes a step that was silently forgotten six
# times; but it would also mean a pushed tag publishes to users with no human
# in the loop. The environment gate keeps the automation — the run is sitting
# there, notified, impossible to forget — while keeping the final confirm.
#
# ⚠️ The gate is only real because the environment EXISTS with a required
# reviewer (created 2026-08-10, reviewer: dknauss). If a workflow names an
# environment that does not exist, GitHub AUTO-CREATES IT WITH NO PROTECTION
# RULES and the job sails straight through. Deleting or recreating this
# environment without reviewers silently converts this line into a no-op —
# there is no error, and the deploy simply stops asking.
environment: wordpress-org

steps:
# Resolve which ref/version to ship. On a release event the tag comes from
# github.ref_name; on manual dispatch it comes from the `tag` input. This
# lets us deploy a tag whose commit predates these workflows (e.g. v1.0.0),
# since the workflow file itself is read from the dispatched ref (main).
# Resolve which ref/version to ship. The `tag` input carries it on both a
# workflow_call (release.yml passes github.ref_name) and a manual dispatch;
# github.ref_name is the fallback when a dispatch leaves it blank. This lets
# us deploy a tag whose commit predates these workflows (e.g. v1.0.0), since
# the workflow file itself is read from the dispatched ref (main).
- name: Resolve ref + version
id: meta
env:
# Bind the two attacker-influenceable expressions to shell vars instead
# of interpolating ${{ }} straight into the script body — GitHub sets
# these as env values, so no dispatch input can break out of the string
# and inject shell.
INPUT_TAG: ${{ github.event.inputs.tag }}
#
# `inputs.tag`, NOT `github.event.inputs.tag`: the latter is null on a
# workflow_call, which would silently fall through to github.ref_name.
# That happens to be correct today (the caller is a tag push) but only by
# accident, and it would break the moment anything else calls this.
INPUT_TAG: ${{ inputs.tag }}
REF_NAME: ${{ github.ref_name }}
run: |
REF="$INPUT_TAG"
Expand Down
87 changes: 87 additions & 0 deletions .planning/DECISION-settings-surface.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
# Decision: where menu-wide features live

**Decided 2026-08-10.** Unblocks Phase 27 (profiles) and Phase 28 (width), which
both stalled on the same question from different directions.

## The decision

**Two new icon buttons in the edit-mode toolbar's right zone, each opening a
modal dialog:**

| Icon | Modal | Owns |
|---|---|---|
| Profiles | Manage hiding profiles | create / rename / delete a profile, edit its membership (Phase 27) |
| Settings | Menu-wide configuration | `menu_width` (Phase 28); later the declutter switch and config presets |

**Per-item features stay exactly where they are** — the shared panel and its
icon/visibility popovers, acting on the selected row.

**No wp-admin settings page. Not now, not later.**

## Why this is consistent rather than a new invention

Two things already exist that make this the path of least surprise:

1. **The toolbar's right zone is already the home of menu-wide actions.**
`Reset All` lives there (`maestro.js` ~L646-660) and is global — it is the one
existing control that does not act on the selected item. Two more global
controls beside it is the established meaning of that zone, not a new one.
2. **The modal idiom already exists.** The icon picker, the visibility popover
and the coachmark are all `role="dialog"` + `aria-modal="true"` with focus
traps (`maestro.js` :758, :1009, :1942). These modals reuse that machinery
rather than introducing a second dialog pattern.

And the thing it protects: a settings page would add an entry to the admin menu.
For a plugin whose purpose is decluttering the admin menu, that is
self-parodying — and it would break the core value's "operates on the menu
itself", which is about not having to go elsewhere to configure the menu.

## Why a shared surface was worth deciding once

Four queued features are menu-wide, not per-item, and none had a home:

- `cloned-role-hiding-profiles` (Phase 27)
- `configurable-admin-menu-width` (Phase 28)
- `config-presets-export-import` (V2-06)
- `declutter-switch-non-core-menu-items`

Answering this per-phase risked two inconsistent surfaces and then four —
precisely the drift the 2026-08-09 backlog reconciliation existed to clean up.

## Guardrail: two icons is the budget

A fifth menu-wide feature goes **inside the Settings modal**, not as a third
toolbar icon. Presets and the declutter switch are already earmarked for it.

Without this rule the toolbar accretes one icon per feature and becomes the
settings screen we just said we would not build — arrived at by increments
instead of by decision.

## Consequences for the two phases

**Phase 27** — 27-01's authoring-UX question is answered: profiles are created
and managed in the Profiles modal; assigning one to an item stays in the existing
visibility popover as a fifth group. That split matters — assignment is per-item
and belongs with the other per-item axes; management is global and does not.

**Phase 28** — 28-03's placement question is answered for the *control*, but with
a deliberate exception:

> **Width should ALSO be directly draggable**, not only a field in the Settings
> modal. Dragging the menu edge and watching it resize is more in-place than any
> field, and 28-01 makes the width a CSS custom property, so live preview is
> nearly free. The Settings field is the precise/accessible path; the drag is the
> discoverable one. Neither alone is sufficient — a drag-only control is
> inaccessible, a field-only control is a settings screen in a costume.

**Sequencing:** whichever phase runs first builds the modal shell; the second
reuses it. Phase 28 is the lighter lift and would prove the surface with a single
scalar before Phase 27 puts CRUD in one.

## Still open, deliberately

- Which dashicons. Cosmetic; pick at build time.
- Whether the Settings modal shows anything at all before Phase 28 lands (if 27
goes first, it may ship with only the Profiles icon).
- Whether Reset All clears menu-wide settings — flagged in 28-03 and unchanged by
this decision.
32 changes: 32 additions & 0 deletions .planning/MILESTONES.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,37 @@
# Milestones: Maestro

## v1.5.0 Per-User Visibility (Shipped: 2026-08-09)

**Phases:** 21 (built under the v1.4 roadmap) + 26 (release). **Release tag:** `v1.5.0` (commit `694b1bf`) · GitHub Release + WordPress.org SVN `trunk`/`tags/1.5.0`, verified from SVN.

**Delivered:** ROLE-02's **per-user half**. An admin can hide a menu item from named individuals as well as whole roles, on four independent axes (role × person, item × sub-items). Cosmetic only, like every other hide in Maestro: a hidden page still opens by URL for anyone authorized, and no capability changes.

**Key accomplishments:**
- `items[slug].hidden_users` / `child_hidden_users` storage, sparse and capped at `MAX_HIDDEN_USERS`
- `is_hidden_for_current_user()` widened to independent OR'd terms, with the third reserved for the deferred `hidden_profiles` half — the seam stays a pure identity intersect and never touches a capability
- `resolved_hidden_roles()` **generalized** into a field-parameterized resolver rather than duplicated, so the user axis inherits the qualified-key, schema-v2 and Axis-1 guards from one implementation
- The feasibility note's §6 cosmetic-invariant guardrail made **enforcing** (`CosmeticInvariantUsersTest`)
- Four-group visibility popover with an async person picker on core's `wp/v2/users`, gated by `list_users` on both sides
- A multisite CI lane, closing the super-admin exemption's coverage gap

**NOT in this milestone:** the cloned-role **"profiles"** half of ROLE-02, still a backlog item (`todos/pending/2026-08-02-cloned-role-hiding-profiles.md`). ROLE-02 remains **partially delivered**. Phase 22 (demo) and Phase 25 (toolbar polish) were optional inclusions that did not land.

### What this milestone should be remembered for

**Nine defects were found by verification and review; almost none by the test suite as first written.** Three surfaced during Phase 21's own browser checks — including a guardrail that could not detect a broken seam, because `current_user_can()` answers from a cached allcaps array. Two more came from Codex rounds on the plans, both of which I then hit as live bugs anyway. The adversarial security gate found two. The final ultrareview found three.

Most instructive: **four consecutive holes in `Config::sanitize()`'s per-user authorization path**, each fix correct about the case in front of it and blind to the next — client-only gate → payload-scoped preserve → raw-key matching → item-cap starvation, plus a DELETE endpoint that bypassed all of them. After each fix the path looked settled. It was only resolved by *collapsing* three mechanisms into one normalized-key map with reserved capacity, rather than adding a fifth guard.

The transferable lesson: when a fix keeps needing another fix, the shape is wrong, not the coverage.

**Known limitations, carried deliberately rather than dropped at the finish line:**
- The final round of fixes (#128) is itself unreviewed — the ultrareview ran against the prior commit
- No human screen-reader pass on the person picker; axe is clean across empty and populated states, which is not the same claim
- 21-05 Task 5 (human browser verification) was never performed; the phase was accepted on automated evidence
- On multisite, super admins are exempt from the person axis only — deliberate, now tested both ways

---

## v1.3.0 Slug-Resolution Hardening (Shipped: 2026-06-30)

**Phases completed:** 2 phases (17–18), 6 plans. **Release tag:** `v1.3.0` (commit `884c6df`) · GitHub Release + WordPress.org SVN `trunk`/`tags/1.3.0`.
Expand Down
3 changes: 2 additions & 1 deletion .planning/PROJECT.md
Original file line number Diff line number Diff line change
Expand Up @@ -168,7 +168,7 @@ and `.planning/milestones/` for records.
- **Release assets:** Phase 4 is complete. WordPress.org icon, banner, and screenshot graphics exist under `.wordpress-org/` and are referenced from the GitHub/wp.org readmes. User-facing documentation is published in the GitHub README, WordPress.org readme, and `docs/user-guide.md`.
- **Localization:** The plugin is translation-ready with the `maestro` text domain and `Domain Path: /languages`. PHP strings use WordPress translation helpers, and JavaScript editor labels are passed through `maestroData.i18n` from PHP. The repo ships a POT template plus starter catalogs for `es_ES`, `de_DE`, `ja`, `fr_FR`, `pt_BR`, and `it_IT`; WordPress.org language packs can still override and extend them, and native-speaker/Polyglots review is welcome.
- **Submit:** Phase 5 is complete. The runtime zip builds cleanly, WPCS passes, Plugin Check 2.0.0 reports no errors on the extracted build zip, npm audit reports 0 vulnerabilities after removing unused `@wordpress/scripts`, and local unit/integration/E2E tests pass. **The plugin has been submitted to WordPress.org** and is in the review queue; approval and SVN access are pending (external, out of our hands). On approval: commit to SVN `trunk`, tag `1.0.0`, and upload `.wordpress-org/` to the SVN `assets/` dir.
- **Future roadmap (post-1.0 backlog):** reparenting (top↔sub, highlighting minefield); separator management; keyboard-accessible reordering; per-item-reset UI affordance with a "modified" indicator; custom icon upload (SVG sanitization); import/export config as JSON; optional enforcement bridge (opt-in, clearly-labelled defense-in-depth); multisite/network defaults with per-site override; configurable admin-menu width (V2-09); admin-toolbar editing feasibility research (V2-10); UI/UX design polish for edit-mode hierarchy, responsive behavior, modified-state affordances, status clarity, and icon-picker scanability (V2-12); documentation link hygiene for prose references to project files (V2-13); deterministic banner source/regeneration with the "ADMIN MENU" leader line removed (V2-14); role cloning / per-user cosmetic hiding (V2-15); third-party menu compatibility research, WooCommerce-first (V2-16 — pulled forward to v1.2 Phase 10, 2026-06-19); and a single-site "super-admin equivalent" / privileged editor tier research item (V2-17, 2026-06-19) — note it edges toward the Out-of-Scope "page locking" line; an *enforced* tier is out of scope for Maestro (which never enforces and assumes no dependency on any other plugin), so it would be entirely separate work in a separate project, not Maestro core.
- **Future roadmap (post-1.0 backlog):** see `SPEC.md`'s Roadmap section for the narrative list and `.planning/todos/pending/` for what is actually queued — that directory is the system of record (declared in SPEC.md, 2026-08-09). **Shipped since this paragraph was written:** keyboard reordering (v1.1), per-item reset + modified indicator (v1.1), custom icon support incl. the heavier bundled set V2-11 (v1.1), doc-link hygiene V2-13 and the banner pipeline V2-14 (v1.1 Phase 8), third-party compat research V2-16 (v1.2 Phase 10), UI/UX design polish V2-12 (v1.3.1 Phase 23, with dark-toolbar follow-ups in v1.5.0 Phase 25), and per-user cosmetic hiding V2-15 / ROLE-02 (v1.5.0, per-user half only — cloned-role profiles still deferred). **Still open and now schedulable as todos:** configurable admin-menu width (V2-09), admin-toolbar editing research (V2-10), the remaining icon-picker scope, config presets / import-export (V2-06), reparenting, separator management, multisite defaults, and the privileged-editor-tier research item (V2-17). The "optional enforcement bridge" is flagged IN TENSION with the Out of Scope list — it needs a decision, not implementation.

## Constraints

Expand All @@ -184,6 +184,7 @@ and `.planning/milestones/` for records.
| Sparse delta, not a stored full menu | Trivial reset, resilience to plugin churn, upstream label changes show through | ✓ Good |
| Debounced autosave, no Save button | In-place ethos; Save was implicated in early "doesn't persist" reports | ✓ Good |
| Click-to-select, whole-row drag, no handles | Per-item clusters/handles were heavy and broke folded mode + hard to grab | ✓ Good |
| **Menu-wide features get a toolbar icon opening a modal; per-item features stay in the per-item panel. No wp-admin settings page, ever.** | The toolbar's right zone ALREADY hosts the only global action (Reset All), so placement itself distinguishes global from per-item — and the modal idiom already exists (`role=dialog` + `aria-modal` + focus trap) in the icon and visibility popovers. A settings page would add an admin menu item, which is self-parodying for a menu-decluttering plugin, and would break "operates on the menu itself". Decided 2026-08-10 for Phases 27/28, which both stalled on it. | Pending (Phases 27, 28) |
| Unique slug, no `Update URI` header | Slug uniqueness is the .org collision protection; the header is disallowed by Plugin Check | ✓ Good |
| Strip `menu-icon-*` for custom image icons | Core's `background-image:none !important` on its own items hid data-URI/URL icons | ✓ Good |
| Visibility is cosmetic only | Authorization is a separate, mature concern; half-enforcement is the worst failure mode | ✓ Good |
Expand Down
Loading
Loading