From 817ed70454d523d5e395694757c91aee0f9cbb57 Mon Sep 17 00:00:00 2001 From: Florin Senoner <23100806+FlorinSenoner@users.noreply.github.com> Date: Sat, 1 Aug 2026 20:05:49 +0200 Subject: [PATCH 1/4] fix(notebook): wrap colab sliders before track collapse --- .../notebooks/ProtSpace_Preparation.ipynb | 2 +- apps/protspace/tests/test_notebook_layout.py | 43 +++++++++++++++++++ .../.openspec.yaml | 2 + .../fix-colab-slider-responsive/design.md | 39 +++++++++++++++++ .../fix-colab-slider-responsive/proposal.md | 25 +++++++++++ .../specs/colab-preparation-controls/spec.md | 17 ++++++++ .../fix-colab-slider-responsive/tasks.md | 14 ++++++ 7 files changed, 141 insertions(+), 1 deletion(-) create mode 100644 apps/protspace/tests/test_notebook_layout.py create mode 100644 openspec/changes/fix-colab-slider-responsive/.openspec.yaml create mode 100644 openspec/changes/fix-colab-slider-responsive/design.md create mode 100644 openspec/changes/fix-colab-slider-responsive/proposal.md create mode 100644 openspec/changes/fix-colab-slider-responsive/specs/colab-preparation-controls/spec.md create mode 100644 openspec/changes/fix-colab-slider-responsive/tasks.md diff --git a/apps/protspace/notebooks/ProtSpace_Preparation.ipynb b/apps/protspace/notebooks/ProtSpace_Preparation.ipynb index 16a37952..ce7576fb 100644 --- a/apps/protspace/notebooks/ProtSpace_Preparation.ipynb +++ b/apps/protspace/notebooks/ProtSpace_Preparation.ipynb @@ -446,7 +446,7 @@ "# --- DR Parameters ---\n", "_s = {\"description_width\": \"110px\"}\n", "_l = {\"width\": \"100%\"}\n", - "_g = {\"border\": \"1px solid #666\", \"padding\": \"6px 10px\", \"margin\": \"2px\", \"flex\": \"1 1 220px\", \"overflow\": \"hidden\"}\n", + "_g = {\"border\": \"1px solid #666\", \"padding\": \"6px 10px\", \"margin\": \"2px\", \"flex\": \"1 1 300px\", \"overflow\": \"hidden\"}\n", "\n", "param_groups = []\n", "w_nn = IntSlider(value=25, min=2, max=500, description=\"n_neighbors:\", style=_s, layout=_l)\n", diff --git a/apps/protspace/tests/test_notebook_layout.py b/apps/protspace/tests/test_notebook_layout.py new file mode 100644 index 00000000..ebd852de --- /dev/null +++ b/apps/protspace/tests/test_notebook_layout.py @@ -0,0 +1,43 @@ +import json +import warnings +from pathlib import Path + +NOTEBOOK_PATH = ( + Path(__file__).resolve().parents[1] / "notebooks" / "ProtSpace_Preparation.ipynb" +) +GENERATE_CELL_TITLE = "# @title 3. Generate & Download" +MIN_PARAMETER_GROUP_BASIS_PX = 300 + + +def _generate_cell_source() -> str: + notebook = json.loads(NOTEBOOK_PATH.read_text()) + for cell in notebook["cells"]: + source = cell.get("source", []) + source_text = "".join(source) if isinstance(source, list) else source + if source_text.startswith(GENERATE_CELL_TITLE): + return source_text + raise AssertionError("Generate & Download cell not found") + + +def test_parameter_groups_wrap_before_slider_tracks_collapse(): + displayed_widgets = [] + namespace = {"display": displayed_widgets.append} + + source = _generate_cell_source() + with warnings.catch_warnings(): + warnings.filterwarnings( + "ignore", + message=r"Passing unrecognized arguments to super\(Layout\).*", + category=DeprecationWarning, + ) + exec(compile(source, str(NOTEBOOK_PATH), "exec"), namespace) + + parameter_groups = namespace["param_groups"] + assert displayed_widgets, "Generate cell did not render its widget tree" + assert parameter_groups, "Generate cell did not create parameter groups" + + for group, _methods in parameter_groups: + flex_basis = group.layout.flex.split()[-1] + assert flex_basis.endswith("px") + basis_px = int(flex_basis.removesuffix("px")) + assert basis_px >= MIN_PARAMETER_GROUP_BASIS_PX diff --git a/openspec/changes/fix-colab-slider-responsive/.openspec.yaml b/openspec/changes/fix-colab-slider-responsive/.openspec.yaml new file mode 100644 index 00000000..5849c2db --- /dev/null +++ b/openspec/changes/fix-colab-slider-responsive/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-08-01 diff --git a/openspec/changes/fix-colab-slider-responsive/design.md b/openspec/changes/fix-colab-slider-responsive/design.md new file mode 100644 index 00000000..c5627c60 --- /dev/null +++ b/openspec/changes/fix-colab-slider-responsive/design.md @@ -0,0 +1,39 @@ +## Context + +The Generate cell renders each dimensionality-reduction parameter group as an `ipywidgets.VBox` inside a wrapping `HBox`. Each group currently declares `flex: 1 1 220px`, while a rendered slider's description, value readout, and internal spacing consume about 228 px before any track length is available. With UMAP and t-SNE enabled, three groups therefore remain on one row at widths where their tracks collapse to nearly zero. + +The layout lives inside the committed notebook JSON, and the fix must work in Colab without adding CSS, JavaScript, or dependencies. + +## Goals / Non-Goals + +**Goals:** + +- Wrap parameter groups before their slider tracks become unusable. +- Preserve the existing controls, values, callbacks, and ordinary desktop layout. +- Add a focused automated guard and repeatable rendered-browser verification. + +**Non-Goals:** + +- Redesigning other notebook sections or controls. +- Changing dimensionality-reduction defaults or processing behavior. +- Adding a general notebook testing framework or external dependency. + +## Decisions + +1. Increase the parameter-group flex basis from 220 px to 300 px. The measured fixed slider chrome is about 228 px, so a 300 px basis preserves roughly 72 px of track before the flex container wraps. A larger basis would wrap earlier but consume unnecessary vertical space; changing slider label widths would alter the established alignment and would not address every label/readout combination. +2. Keep `flex-flow: row wrap` and the existing `overflow: hidden`. Wrapping is already the intended responsive mechanism; correcting its threshold is smaller and less risky than injecting breakpoint CSS into Colab's per-cell widget frame. +3. Guard the responsive contract with a focused Python test that parses the committed notebook cell and asserts a minimum 300 px flex basis. Rendered browser verification at desktop and compressed widths supplies the end-to-end evidence that the structural contract produces usable tracks. + +## Risks / Trade-offs + +- [Risk] Two cards may wrap sooner in unusually narrow notebook panes. → This is the intended trade-off: added vertical space preserves accurate slider interaction. +- [Risk] Widget implementations can change their fixed internal chrome. → Keep a margin above the measured 228 px and verify actual track geometry in a real browser. +- [Risk] Notebook editors can reserialize unrelated JSON. → Make the source edit surgically and review the notebook diff before committing. + +## Migration Plan + +No migration is required. The notebook-only layout value is applied the next time the Generate cell runs; reverting the one-line basis change restores the previous behavior. + +## Open Questions + +None. diff --git a/openspec/changes/fix-colab-slider-responsive/proposal.md b/openspec/changes/fix-colab-slider-responsive/proposal.md new file mode 100644 index 00000000..2da21ef9 --- /dev/null +++ b/openspec/changes/fix-colab-slider-responsive/proposal.md @@ -0,0 +1,25 @@ +## Why + +The preparation notebook keeps three dimensionality-reduction parameter cards on one row after their slider tracks have run out of usable width. Opening Colab's terminal or otherwise narrowing the notebook can therefore collapse each track to nearly zero pixels, making the controls difficult or impossible to use. + +## What Changes + +- Make parameter cards wrap before their slider tracks collapse under a compressed notebook viewport. +- Add a focused regression check for the minimum responsive card basis used by the preparation notebook. +- Verify the current-main reproduction at desktop and compressed widths with rendered `ipywidgets` controls. + +## Capabilities + +### New Capabilities + +- `colab-preparation-controls`: Responsive layout requirements for dimensionality-reduction parameter controls in the Colab preparation notebook. + +### Modified Capabilities + +None. + +## Impact + +- `apps/protspace/notebooks/ProtSpace_Preparation.ipynb` +- Focused Python regression coverage for the notebook layout contract +- No API, dependency, bundle-format, or application-runtime changes diff --git a/openspec/changes/fix-colab-slider-responsive/specs/colab-preparation-controls/spec.md b/openspec/changes/fix-colab-slider-responsive/specs/colab-preparation-controls/spec.md new file mode 100644 index 00000000..5977eb40 --- /dev/null +++ b/openspec/changes/fix-colab-slider-responsive/specs/colab-preparation-controls/spec.md @@ -0,0 +1,17 @@ +## ADDED Requirements + +### Requirement: Parameter sliders remain usable in compressed notebook layouts + +The preparation notebook SHALL lay out dimensionality-reduction parameter groups with a wrapping flex basis of at least 300 px so a group's slider description and value readout cannot collapse its interactive track before the group wraps. + +#### Scenario: Terminal compresses the notebook content area + +- **WHEN** three parameter groups are visible and the notebook content area becomes too narrow to allocate at least 300 px plus configured gaps to each group +- **THEN** the groups wrap onto another row +- **AND** each visible slider retains a usable horizontal track instead of collapsing to its thumb + +#### Scenario: Ordinary desktop notebook width + +- **WHEN** the notebook content area can allocate at least 300 px plus configured gaps to each of three visible groups +- **THEN** the groups remain on one row +- **AND** existing slider labels, values, and interactions remain unchanged diff --git a/openspec/changes/fix-colab-slider-responsive/tasks.md b/openspec/changes/fix-colab-slider-responsive/tasks.md new file mode 100644 index 00000000..1e2231f7 --- /dev/null +++ b/openspec/changes/fix-colab-slider-responsive/tasks.md @@ -0,0 +1,14 @@ +## 1. Regression Coverage + +- [x] 1.1 Add a focused test that requires the preparation notebook's parameter groups to reserve at least 300 px before wrapping +- [x] 1.2 Run the focused test against the current 220 px basis and record the expected failure + +## 2. Responsive Fix + +- [x] 2.1 Increase only the parameter-group flex basis to the specified responsive minimum +- [x] 2.2 Rerun the focused test and record the passing result + +## 3. Verification + +- [x] 3.1 Reproduce the three-card notebook layout at desktop and compressed viewports and confirm usable slider tracks after wrapping +- [x] 3.2 Validate the OpenSpec change and run the repository-mandated `pnpm precommit` gate From e0ddb248e864810414ff5c6cac942057fdd07402 Mon Sep 17 00:00:00 2001 From: tsenoner Date: Thu, 6 Aug 2026 11:42:50 +0200 Subject: [PATCH 2/4] test(notebook): pin the wrap mechanism in the layout regression test - Drop the redundant isinstance list branch when joining notebook cell source - Assert param_grid wraps and each parameter card keeps overflow hidden, so a nowrap row can no longer reintroduce the slider-track collapse undetected - Drop 'plus configured gaps' from the spec scenarios; ipywidgets Layout has no gap trait, so the notebook's gap value never reaches the DOM Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_016qoU16kDQxz6U3H2UWbbm2 --- apps/protspace/tests/test_notebook_layout.py | 9 +++++++-- .../specs/colab-preparation-controls/spec.md | 4 ++-- 2 files changed, 9 insertions(+), 4 deletions(-) diff --git a/apps/protspace/tests/test_notebook_layout.py b/apps/protspace/tests/test_notebook_layout.py index ebd852de..5ce488d6 100644 --- a/apps/protspace/tests/test_notebook_layout.py +++ b/apps/protspace/tests/test_notebook_layout.py @@ -12,8 +12,7 @@ def _generate_cell_source() -> str: notebook = json.loads(NOTEBOOK_PATH.read_text()) for cell in notebook["cells"]: - source = cell.get("source", []) - source_text = "".join(source) if isinstance(source, list) else source + source_text = "".join(cell.get("source", [])) if source_text.startswith(GENERATE_CELL_TITLE): return source_text raise AssertionError("Generate & Download cell not found") @@ -36,7 +35,13 @@ def test_parameter_groups_wrap_before_slider_tracks_collapse(): assert displayed_widgets, "Generate cell did not render its widget tree" assert parameter_groups, "Generate cell did not create parameter groups" + param_grid = namespace["param_grid"] + assert "wrap" in (param_grid.layout.flex_flow or ""), ( + "Parameter grid must wrap; a nowrap row lets the cards shrink past their tracks" + ) + for group, _methods in parameter_groups: + assert group.layout.overflow == "hidden" flex_basis = group.layout.flex.split()[-1] assert flex_basis.endswith("px") basis_px = int(flex_basis.removesuffix("px")) diff --git a/openspec/changes/fix-colab-slider-responsive/specs/colab-preparation-controls/spec.md b/openspec/changes/fix-colab-slider-responsive/specs/colab-preparation-controls/spec.md index 5977eb40..710bceb1 100644 --- a/openspec/changes/fix-colab-slider-responsive/specs/colab-preparation-controls/spec.md +++ b/openspec/changes/fix-colab-slider-responsive/specs/colab-preparation-controls/spec.md @@ -6,12 +6,12 @@ The preparation notebook SHALL lay out dimensionality-reduction parameter groups #### Scenario: Terminal compresses the notebook content area -- **WHEN** three parameter groups are visible and the notebook content area becomes too narrow to allocate at least 300 px plus configured gaps to each group +- **WHEN** three parameter groups are visible and the notebook content area becomes too narrow to allocate at least 300 px to each group - **THEN** the groups wrap onto another row - **AND** each visible slider retains a usable horizontal track instead of collapsing to its thumb #### Scenario: Ordinary desktop notebook width -- **WHEN** the notebook content area can allocate at least 300 px plus configured gaps to each of three visible groups +- **WHEN** the notebook content area can allocate at least 300 px to each of three visible groups - **THEN** the groups remain on one row - **AND** existing slider labels, values, and interactions remain unchanged From 386e15e97d5fe5a77de72fe1b6cab3d01d9f6145 Mon Sep 17 00:00:00 2001 From: tsenoner Date: Thu, 6 Aug 2026 11:55:18 +0200 Subject: [PATCH 3/4] test(notebook): match the flex-wrap token exactly The previous assertion used `"wrap" in flex_flow`, which also passes for "row nowrap" -- the exact regression it was written to catch. Verified by mutation: swapping the notebook's flex_flow to "row nowrap" left the test green. Split on whitespace and match the flex-wrap token, and echo the observed value in the failure message. The same mutation now fails the test, and the unmutated notebook still passes. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_016qoU16kDQxz6U3H2UWbbm2 --- apps/protspace/tests/test_notebook_layout.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/apps/protspace/tests/test_notebook_layout.py b/apps/protspace/tests/test_notebook_layout.py index 5ce488d6..56d4c95b 100644 --- a/apps/protspace/tests/test_notebook_layout.py +++ b/apps/protspace/tests/test_notebook_layout.py @@ -36,8 +36,11 @@ def test_parameter_groups_wrap_before_slider_tracks_collapse(): assert parameter_groups, "Generate cell did not create parameter groups" param_grid = namespace["param_grid"] - assert "wrap" in (param_grid.layout.flex_flow or ""), ( - "Parameter grid must wrap; a nowrap row lets the cards shrink past their tracks" + # Match the flex-wrap token exactly: a substring test would accept "nowrap". + flex_flow = param_grid.layout.flex_flow or "" + assert "wrap" in flex_flow.split(), ( + f"Parameter grid must wrap (flex_flow={flex_flow!r}); " + "a nowrap row lets the cards shrink past their tracks" ) for group, _methods in parameter_groups: From ad37ede1b8f148a2aa8a853ecd976f0c5b9ef059 Mon Sep 17 00:00:00 2001 From: Florin Senoner <23100806+FlorinSenoner@users.noreply.github.com> Date: Thu, 6 Aug 2026 14:09:37 +0200 Subject: [PATCH 4/4] fix(notebook): preserve slider width below card basis --- apps/protspace/notebooks/ProtSpace_Preparation.ipynb | 2 +- apps/protspace/tests/test_notebook_layout.py | 7 +++++++ openspec/changes/fix-colab-slider-responsive/design.md | 10 ++++++---- .../specs/colab-preparation-controls/spec.md | 8 +++++++- openspec/changes/fix-colab-slider-responsive/tasks.md | 6 ++++++ 5 files changed, 27 insertions(+), 6 deletions(-) diff --git a/apps/protspace/notebooks/ProtSpace_Preparation.ipynb b/apps/protspace/notebooks/ProtSpace_Preparation.ipynb index ce7576fb..a64b06a2 100644 --- a/apps/protspace/notebooks/ProtSpace_Preparation.ipynb +++ b/apps/protspace/notebooks/ProtSpace_Preparation.ipynb @@ -446,7 +446,7 @@ "# --- DR Parameters ---\n", "_s = {\"description_width\": \"110px\"}\n", "_l = {\"width\": \"100%\"}\n", - "_g = {\"border\": \"1px solid #666\", \"padding\": \"6px 10px\", \"margin\": \"2px\", \"flex\": \"1 1 300px\", \"overflow\": \"hidden\"}\n", + "_g = {\"border\": \"1px solid #666\", \"padding\": \"6px 10px\", \"margin\": \"2px\", \"flex\": \"1 1 300px\", \"min_width\": \"300px\", \"overflow\": \"hidden\"}\n", "\n", "param_groups = []\n", "w_nn = IntSlider(value=25, min=2, max=500, description=\"n_neighbors:\", style=_s, layout=_l)\n", diff --git a/apps/protspace/tests/test_notebook_layout.py b/apps/protspace/tests/test_notebook_layout.py index 56d4c95b..539df684 100644 --- a/apps/protspace/tests/test_notebook_layout.py +++ b/apps/protspace/tests/test_notebook_layout.py @@ -49,3 +49,10 @@ def test_parameter_groups_wrap_before_slider_tracks_collapse(): assert flex_basis.endswith("px") basis_px = int(flex_basis.removesuffix("px")) assert basis_px >= MIN_PARAMETER_GROUP_BASIS_PX + + min_width = group.layout.min_width or "" + assert min_width.endswith("px") + min_width_px = int(min_width.removesuffix("px")) + assert min_width_px >= basis_px, ( + "A lone parameter group must not shrink below its responsive flex basis" + ) diff --git a/openspec/changes/fix-colab-slider-responsive/design.md b/openspec/changes/fix-colab-slider-responsive/design.md index c5627c60..8c0865c2 100644 --- a/openspec/changes/fix-colab-slider-responsive/design.md +++ b/openspec/changes/fix-colab-slider-responsive/design.md @@ -2,7 +2,7 @@ The Generate cell renders each dimensionality-reduction parameter group as an `ipywidgets.VBox` inside a wrapping `HBox`. Each group currently declares `flex: 1 1 220px`, while a rendered slider's description, value readout, and internal spacing consume about 228 px before any track length is available. With UMAP and t-SNE enabled, three groups therefore remain on one row at widths where their tracks collapse to nearly zero. -The layout lives inside the committed notebook JSON, and the fix must work in Colab without adding CSS, JavaScript, or dependencies. +The layout lives inside the committed notebook JSON, and the fix must work in Colab without adding new CSS, JavaScript, or dependencies. ## Goals / Non-Goals @@ -20,13 +20,15 @@ The layout lives inside the committed notebook JSON, and the fix must work in Co ## Decisions -1. Increase the parameter-group flex basis from 220 px to 300 px. The measured fixed slider chrome is about 228 px, so a 300 px basis preserves roughly 72 px of track before the flex container wraps. A larger basis would wrap earlier but consume unnecessary vertical space; changing slider label widths would alter the established alignment and would not address every label/readout combination. -2. Keep `flex-flow: row wrap` and the existing `overflow: hidden`. Wrapping is already the intended responsive mechanism; correcting its threshold is smaller and less risky than injecting breakpoint CSS into Colab's per-cell widget frame. -3. Guard the responsive contract with a focused Python test that parses the committed notebook cell and asserts a minimum 300 px flex basis. Rendered browser verification at desktop and compressed widths supplies the end-to-end evidence that the structural contract produces usable tracks. +1. Increase the parameter-group flex basis from 220 px to 300 px. Rendered measurements show a consistent 228 px card-to-track difference (for example, a 345 px card has a 117 px track), so a 300 px card preserves roughly 72 px of track before the flex container wraps. A larger basis would wrap earlier but consume unnecessary vertical space; changing slider label widths would alter the established alignment and would not address every label/readout combination. +2. Set the parameter-group minimum width to the same 300 px reserve. `flex-basis` is only a preferred size while `flex-shrink: 1`; without an explicit floor, a lone card can still shrink below the responsive reserve. The parent `HBox` already uses ipywidgets' scrolling box behavior, so narrower panes can scroll to the full-width control instead of clipping a collapsed track. +3. Keep `flex-flow: row wrap` and the existing card-level `overflow: hidden`. Wrapping is already the intended responsive mechanism, and the explicit minimum width removes the residual shrink path without adding breakpoint CSS to the notebook. +4. Guard the responsive contract with a focused Python test that executes the committed notebook cell and asserts both the 300 px flex basis and its matching minimum-width floor. Rendered browser verification at desktop and compressed widths supplies the end-to-end evidence that the structural contract produces usable tracks. ## Risks / Trade-offs - [Risk] Two cards may wrap sooner in unusually narrow notebook panes. → This is the intended trade-off: added vertical space preserves accurate slider interaction. +- [Risk] A pane narrower than one parameter group needs horizontal scrolling. → Preserve the 300 px control width and rely on the parent ipywidgets box's existing overflow behavior rather than collapsing the slider. - [Risk] Widget implementations can change their fixed internal chrome. → Keep a margin above the measured 228 px and verify actual track geometry in a real browser. - [Risk] Notebook editors can reserialize unrelated JSON. → Make the source edit surgically and review the notebook diff before committing. diff --git a/openspec/changes/fix-colab-slider-responsive/specs/colab-preparation-controls/spec.md b/openspec/changes/fix-colab-slider-responsive/specs/colab-preparation-controls/spec.md index 710bceb1..0b025da0 100644 --- a/openspec/changes/fix-colab-slider-responsive/specs/colab-preparation-controls/spec.md +++ b/openspec/changes/fix-colab-slider-responsive/specs/colab-preparation-controls/spec.md @@ -2,7 +2,7 @@ ### Requirement: Parameter sliders remain usable in compressed notebook layouts -The preparation notebook SHALL lay out dimensionality-reduction parameter groups with a wrapping flex basis of at least 300 px so a group's slider description and value readout cannot collapse its interactive track before the group wraps. +The preparation notebook SHALL lay out dimensionality-reduction parameter groups with a wrapping flex basis and minimum width of at least 300 px so a group's slider description and value readout cannot collapse its interactive track before or after the group wraps. #### Scenario: Terminal compresses the notebook content area @@ -10,6 +10,12 @@ The preparation notebook SHALL lay out dimensionality-reduction parameter groups - **THEN** the groups wrap onto another row - **AND** each visible slider retains a usable horizontal track instead of collapsing to its thumb +#### Scenario: Pane becomes narrower than one parameter group + +- **WHEN** the notebook content area becomes narrower than 300 px +- **THEN** each parameter group retains its minimum width +- **AND** the surrounding widget area can scroll horizontally instead of shrinking the slider track below its usable reserve + #### Scenario: Ordinary desktop notebook width - **WHEN** the notebook content area can allocate at least 300 px to each of three visible groups diff --git a/openspec/changes/fix-colab-slider-responsive/tasks.md b/openspec/changes/fix-colab-slider-responsive/tasks.md index 1e2231f7..6d42e51f 100644 --- a/openspec/changes/fix-colab-slider-responsive/tasks.md +++ b/openspec/changes/fix-colab-slider-responsive/tasks.md @@ -12,3 +12,9 @@ - [x] 3.1 Reproduce the three-card notebook layout at desktop and compressed viewports and confirm usable slider tracks after wrapping - [x] 3.2 Validate the OpenSpec change and run the repository-mandated `pnpm precommit` gate + +## 4. Review Follow-up + +- [x] 4.1 Add a regression assertion that a lone parameter group cannot shrink below the responsive reserve +- [x] 4.2 Set each parameter group's minimum width to the 300 px flex basis +- [x] 4.3 Run focused Python checks, validate the amended OpenSpec change, and rerun `pnpm precommit`