Merge origin/feat/goals-v2-architecture; reconcile beta→preview rename

Remote branch added a beta_gated_nav_item helper + 'Gating the main nav'
docs section. Main concurrently renamed the beta-features gate to
preview-features (concern, predicate, JSONB key, locale flash). Rename
the new helper / partial local / pill marker to match preview naming and
port the nav-gating docs into gating-a-preview-feature.md so the
improvement survives the rename.

Resolved conflicts:
- db/schema.rb: take the later schema version (2026_05_19_100000).
- docs/llm-guides/gating-a-beta-feature.md: accept main's deletion;
  port the 'Gating the main nav' section into the preview guide.

Renames carried through to keep the gate wired end-to-end:
- application_helper.rb: beta_gated_nav_item → preview_gated_nav_item;
  beta_features_enabled? → preview_features_enabled?; beta: → preview:.
- _nav_item.html.erb: beta: local → preview: local; shared.beta i18n
  key → shared.preview.
- application.html.erb: caller renamed to preview_gated_nav_item.
- goals/index.html.erb: pill label uses shared.preview.
- shared/en.yml: 'beta: Beta' → 'preview: Preview'.
- goals_controller, goal_pledges_controller: require_beta_features! →
  require_preview_features!.
- goals_controller_test, goal_pledges_controller_test: flip the
  preference key, flash matcher, and test names to 'preview'.
This commit is contained in:
Guillem Arias
2026-05-20 21:47:27 +02:00
37 changed files with 585 additions and 127 deletions

View File

@@ -22,31 +22,42 @@ export default class extends Controller {
presetColors: Array,
};
initialize() {
this.pickerBtnTarget.addEventListener("click", () => {
this.showPaletteSection();
});
this.colorInputTarget.addEventListener("input", (e) => {
this.picker.setColor(e.target.value);
});
this.detailsTarget.addEventListener("toggle", (e) => {
connect() {
// Bound references stored on the instance so disconnect() can remove
// them. Without this, every Turbo navigation that re-renders the
// picker stacks another listener on the same node.
this._onPickerBtnClick = () => this.showPaletteSection();
this._onColorInputInput = (e) => this.picker?.setColor(e.target.value);
this._onDetailsToggle = (e) => {
if (!this.colorInputTarget.checkValidity()) {
e.preventDefault();
this.colorInputTarget.reportValidity();
e.target.open = true;
}
this.updatePopupPosition()
});
this.updatePopupPosition();
};
this.pickerBtnTarget.addEventListener("click", this._onPickerBtnClick);
this.colorInputTarget.addEventListener("input", this._onColorInputInput);
this.detailsTarget.addEventListener("toggle", this._onDetailsToggle);
document.addEventListener("mousedown", this.handleOutsideClick);
this.selectedIcon = null;
if (!this.presetColorsValue.includes(this.colorInputTarget.value)) {
this.colorPickerRadioBtnTarget.checked = true;
}
}
document.addEventListener("mousedown", this.handleOutsideClick);
disconnect() {
this.pickerBtnTarget.removeEventListener("click", this._onPickerBtnClick);
this.colorInputTarget.removeEventListener("input", this._onColorInputInput);
this.detailsTarget.removeEventListener("toggle", this._onDetailsToggle);
document.removeEventListener("mousedown", this.handleOutsideClick);
if (this.picker) {
this.picker.destroyAndRemove();
this.picker = null;
}
}
initPicker() {

View File

@@ -433,7 +433,12 @@ export default class extends Controller {
.attr("pointer-events", "none")
.style("display", "none");
if (root.style.position !== "absolute") root.style.position = "relative";
// Only promote root to a positioned ancestor when it currently has no
// positioning context. Inline checks against `root.style.position`
// miss positions set via CSS (the inline style is empty), so we'd
// clobber a stylesheet `position: fixed/sticky/absolute` with our
// own `relative`. Read the computed style instead.
if (getComputedStyle(root).position === "static") root.style.position = "relative";
const tooltip = document.createElement("div");
tooltip.style.cssText = "position:absolute;pointer-events:none;display:none;background:var(--color-gray-900);color:var(--color-white);font-size:12px;line-height:1.35;padding:6px 8px;border-radius:6px;white-space:nowrap;z-index:5;box-shadow:0 2px 8px rgba(0,0,0,0.15);";
root.appendChild(tooltip);

View File

@@ -32,6 +32,10 @@ export default class extends Controller {
}
}
disconnect() {
clearTimeout(this._urlSyncTimer);
}
filter() {
const query = this.hasInputTarget
? this.inputTarget.value.toLocaleLowerCase().trim()
@@ -60,7 +64,16 @@ export default class extends Controller {
}
this.updateEmptyState(visible, query, active);
this.#syncUrl();
this.#scheduleUrlSync();
}
// Debounced wrapper. Firing replaceState on every keystroke is wasteful
// and produced visible jank on slow CPUs; deferring 200 ms collapses a
// typing burst into a single URL update without losing back-button
// fidelity (replaceState doesn't create history entries anyway).
#scheduleUrlSync() {
clearTimeout(this._urlSyncTimer);
this._urlSyncTimer = setTimeout(() => this.#syncUrl(), 200);
}
#hydrateFromUrl() {