max / makeover-timing
- Co-Authored-By
- Claude Opus 5 (1M context) <noreply@anthropic.com>
11 files changed,
+1295 insertions,
-0 deletions
| @@ -1,0 +1,5 @@ | |||
| 1 | + | /target | |
| 2 | + | ||
| 3 | + | # Claude Code instructions (project-local; not for the public repo) | |
| 4 | + | CLAUDE.md | |
| 5 | + | /Cargo.lock |
| @@ -1,0 +1,46 @@ | |||
| 1 | + | [package] | |
| 2 | + | name = "makeover-timing" | |
| 3 | + | version = "0.1.0" | |
| 4 | + | edition = "2024" | |
| 5 | + | description = "The time axis of the make-family design system: a duration is named by what it is waiting for, never by a number. Four intents, one duration each, the same on every renderer." | |
| 6 | + | license = "MIT" | |
| 7 | + | repository = "https://makenot.work/git/max/makeover-timing" | |
| 8 | + | ||
| 9 | + | [dependencies] | |
| 10 | + | # For the cascade layer name and nothing else. makeover-geometry is the crate | |
| 11 | + | # every CSS-emitting member of the family already depends on, and it is where | |
| 12 | + | # the family keeps the one spelling of `@layer makeover` on purpose: a second | |
| 13 | + | # copy of that string is the drift the constant exists to prevent. | |
| 14 | + | makeover-geometry = "0.7" | |
| 15 | + | ||
| 16 | + | [lints.rust] | |
| 17 | + | unused = "warn" | |
| 18 | + | unreachable_pub = "warn" | |
| 19 | + | ||
| 20 | + | [lints.clippy] | |
| 21 | + | pedantic = { level = "warn", priority = -1 } | |
| 22 | + | # Allow-list tuned from a measured breakdown across server/multithreaded/pter | |
| 23 | + | # (2026-07-22). These are the high-churn / low-signal pedantic lints; everything | |
| 24 | + | # else in `pedantic` stays a warning. Keep this block identical across repos. | |
| 25 | + | module_name_repetitions = "allow" | |
| 26 | + | # Doc lints. No docs-completeness push is underway. | |
| 27 | + | missing_errors_doc = "allow" | |
| 28 | + | missing_panics_doc = "allow" | |
| 29 | + | doc_markdown = "allow" | |
| 30 | + | # Numeric casts. Endemic and mostly intentional in size and byte math. | |
| 31 | + | cast_possible_truncation = "allow" | |
| 32 | + | cast_sign_loss = "allow" | |
| 33 | + | cast_precision_loss = "allow" | |
| 34 | + | cast_possible_wrap = "allow" | |
| 35 | + | cast_lossless = "allow" | |
| 36 | + | # Subjective structure and style nags. High churn, low signal. | |
| 37 | + | must_use_candidate = "allow" | |
| 38 | + | too_many_lines = "allow" | |
| 39 | + | struct_excessive_bools = "allow" | |
| 40 | + | similar_names = "allow" | |
| 41 | + | items_after_statements = "allow" | |
| 42 | + | single_match_else = "allow" | |
| 43 | + | # Frequent false-positives in TUI and router-heavy code. | |
| 44 | + | match_same_arms = "allow" | |
| 45 | + | unnecessary_wraps = "allow" | |
| 46 | + | type_complexity = "allow" |
| @@ -1,0 +1,21 @@ | |||
| 1 | + | MIT License | |
| 2 | + | ||
| 3 | + | Copyright (c) 2026 Make Creative, LLC | |
| 4 | + | ||
| 5 | + | Permission is hereby granted, free of charge, to any person obtaining a copy | |
| 6 | + | of this software and associated documentation files (the "Software"), to deal | |
| 7 | + | in the Software without restriction, including without limitation the rights | |
| 8 | + | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell | |
| 9 | + | copies of the Software, and to permit persons to whom the Software is | |
| 10 | + | furnished to do so, subject to the following conditions: | |
| 11 | + | ||
| 12 | + | The above copyright notice and this permission notice shall be included in all | |
| 13 | + | copies or substantial portions of the Software. | |
| 14 | + | ||
| 15 | + | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR | |
| 16 | + | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, | |
| 17 | + | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE | |
| 18 | + | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER | |
| 19 | + | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, | |
| 20 | + | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE | |
| 21 | + | SOFTWARE. |
| @@ -1,0 +1,126 @@ | |||
| 1 | + | # makeover-timing | |
| 2 | + | ||
| 3 | + | The time axis of the make-family design system. | |
| 4 | + | [`makeover`](https://makenot.work/git/max/makeover) resolves colour, | |
| 5 | + | [`makeover-geometry`](https://makenot.work/git/max/makeover-geometry) resolves | |
| 6 | + | distance, and | |
| 7 | + | [`makeover-layout`](https://makenot.work/git/max/makeover-layout) names what a | |
| 8 | + | thing is without resolving anything. This crate answers the remaining question | |
| 9 | + | of the same shape: how long. | |
| 10 | + | ||
| 11 | + | ## A duration is named by what it is waiting for | |
| 12 | + | ||
| 13 | + | | Intent | What it waits for | Resolves to | | |
| 14 | + | |---|---|---| | |
| 15 | + | | `revert` | a control returning to its resting label after confirming | 1500ms | | |
| 16 | + | | `clear` | a status line emptying itself | 2000ms | | |
| 17 | + | | `dismiss` | a transient notice's lifetime | 3000ms | | |
| 18 | + | | `debounce` | typing settling before the work starts | 150ms | | |
| 19 | + | ||
| 20 | + | Whether a toast should live 3000ms or 3500ms is unanswerable on its own. | |
| 21 | + | Whether a message is a toast or a banner is not. That is the whole argument for | |
| 22 | + | the layer, and it is the same one `gap-peer` makes about six pixels. | |
| 23 | + | ||
| 24 | + | ## One duration per intent, on every renderer | |
| 25 | + | ||
| 26 | + | An intent resolves to exactly one duration everywhere. Not one per renderer, | |
| 27 | + | not one per theme. | |
| 28 | + | ||
| 29 | + | Distance has a renderer axis because a fingertip is coarser than a cursor and a | |
| 30 | + | terminal cell is coarser than a pixel: the surface differs. Time does not | |
| 31 | + | differ that way. A second is a second in a browser, in egui and in a terminal, | |
| 32 | + | and the reader waiting it out is the same reader. | |
| 33 | + | ||
| 34 | + | What looked like a renderer disagreement in the tree turned out to be drift. | |
| 35 | + | MNW debounced one typeahead at 150ms and two others at 200ms, in one repo, on | |
| 36 | + | one renderer, and a per-renderer table would not have caught it. So a | |
| 37 | + | divergence here is a bug report, not an axis. | |
| 38 | + | ||
| 39 | + | ## What is deliberately not a timing intent | |
| 40 | + | ||
| 41 | + | **A race is not an intent.** Waiting 300ms before navigating because the write | |
| 42 | + | "should" have landed, or 150ms before closing a dropdown so a mousedown can | |
| 43 | + | beat the blur, is a synchronisation bug wearing a duration's clothes. Naming | |
| 44 | + | those would launder them into design decisions and give every future one a | |
| 45 | + | token to hide behind. They get fixed per screen. | |
| 46 | + | ||
| 47 | + | **Severity is not an intent, and it is not a fifth number.** Two renderers | |
| 48 | + | already reached for one: MNW gives an error toast 6000ms against an ordinary | |
| 49 | + | one's 3000ms, and audiofiles' footer never expires an error at all. Read | |
| 50 | + | together those are one statement, not two durations. A message the user must | |
| 51 | + | not miss does not go away on its own, `makeover-layout` already has the word | |
| 52 | + | for that (such a message is not `Notice::transient`), and a banner has no | |
| 53 | + | lifetime. | |
| 54 | + | ||
| 55 | + | **A poll interval is not an intent.** A retry backoff, a health check, an | |
| 56 | + | update check: those are answerable from what they talk to, not from what a | |
| 57 | + | reader can follow. | |
| 58 | + | ||
| 59 | + | ## Motion is a separate axis | |
| 60 | + | ||
| 61 | + | `Intent` says how long a state lasts. `Motion` says how long a change takes. | |
| 62 | + | CSS itself draws that line, a `setTimeout` against a `transition-duration`, and | |
| 63 | + | folding the two together is what makes a "timing scale" unusable: 300ms of fade | |
| 64 | + | and 3000ms of toast are not two rungs of one ramp. | |
| 65 | + | ||
| 66 | + | `Motion` has one rung, `fade` at 300ms, because the tree has one measured | |
| 67 | + | transition. It grows when something is measured, not when a scale looks short. | |
| 68 | + | ||
| 69 | + | ## Using it | |
| 70 | + | ||
| 71 | + | Web surfaces bake the stylesheet in at build time. Nothing here changes at | |
| 72 | + | runtime, so there is no load-time JS step: | |
| 73 | + | ||
| 74 | + | ```rust | |
| 75 | + | use makeover_timing::timing_css; | |
| 76 | + | ||
| 77 | + | std::fs::write("static/timing.css", timing_css())?; | |
| 78 | + | ``` | |
| 79 | + | ||
| 80 | + | ```css | |
| 81 | + | :root { | |
| 82 | + | --timing-revert: 1500ms; | |
| 83 | + | --timing-clear: 2000ms; | |
| 84 | + | --timing-dismiss: 3000ms; | |
| 85 | + | --timing-debounce: 150ms; | |
| 86 | + | ||
| 87 | + | --motion-fade: 300ms; | |
| 88 | + | } | |
| 89 | + | ``` | |
| 90 | + | ||
| 91 | + | egui and ratatui surfaces resolve through `Duration` instead, which is the | |
| 92 | + | reason this is a crate rather than a stylesheet: | |
| 93 | + | ||
| 94 | + | ```rust | |
| 95 | + | use makeover_timing::{Intent, Motion, notice_lifetime}; | |
| 96 | + | ||
| 97 | + | let settle = Intent::Debounce.duration(); | |
| 98 | + | let gone = notice_lifetime(true).map(|life| life + Motion::Fade.duration()); | |
| 99 | + | ``` | |
| 100 | + | ||
| 101 | + | `notice_lifetime` is the seam the crate was built for. `makeover-layout` | |
| 102 | + | documents `Notice::Toast` as "transient, stacked, dismisses itself" and says | |
| 103 | + | nothing about when; this says when, on the renderer's side of the line. It | |
| 104 | + | takes the bool rather than the enum so this crate stays off `makeover-layout`'s | |
| 105 | + | dependency graph, and the bool is exactly what the description asserts. | |
| 106 | + | ||
| 107 | + | ## Where the numbers came from | |
| 108 | + | ||
| 109 | + | Every value is a count from the tree, taken 2026-08-18 and re-checked | |
| 110 | + | 2026-08-21, not a preference: | |
| 111 | + | ||
| 112 | + | ```text | |
| 113 | + | revert 1500ms MNW: 6 hand-rolled sites, plus core/clipboard.ts's own default | |
| 114 | + | clear 2000ms MNW: 4 sites | |
| 115 | + | dismiss 3000ms MNW: the toast renderer's lifetime | |
| 116 | + | debounce 150ms audiofiles SEARCH_DEBOUNCE, MNW docs-search.js | |
| 117 | + | fade 300ms MNW: TOAST_FADE_MS, matching the .fade-out transition | |
| 118 | + | ``` | |
| 119 | + | ||
| 120 | + | The one contested value is the debounce, where MNW's two category typeaheads | |
| 121 | + | sit at 200ms against everything else's 150ms. 150 wins on the count and on the | |
| 122 | + | cross-renderer agreement, and the 200s conform. | |
| 123 | + | ||
| 124 | + | ## Licence | |
| 125 | + | ||
| 126 | + | MIT. |
| @@ -1,0 +1,8 @@ | |||
| 1 | + | # How Bento releases makeover-timing. Lives here rather than in the daemon's config so it is | |
| 2 | + | # versioned with the code it describes. | |
| 3 | + | ||
| 4 | + | # A crate, not an app: one publish.rhai rather than a recipe per platform, and | |
| 5 | + | # the target below names the host that uploads rather than a build matrix. | |
| 6 | + | kind = "library" | |
| 7 | + | ||
| 8 | + | targets = ["linux/x86_64"] |
| @@ -1,0 +1,64 @@ | |||
| 1 | + | // Publish this crate to crates.io. | |
| 2 | + | // | |
| 3 | + | // A library has no per-platform artifact, so this is the whole release: one | |
| 4 | + | // recipe, run on whichever host the manifest names. | |
| 5 | + | // | |
| 6 | + | // The preflight step is the point of routing this through Bento. crates.io | |
| 7 | + | // versions can be yanked but never edited, so a wrong repository URL, a | |
| 8 | + | // missing license, or a duplicate version is permanent the moment it uploads. | |
| 9 | + | // pter 0.1.0 went out with a dead repository link and could only be corrected | |
| 10 | + | // by releasing again. | |
| 11 | + | ||
| 12 | + | let h = build_host(); | |
| 13 | + | let r = repo(); | |
| 14 | + | let v = version(); | |
| 15 | + | ||
| 16 | + | // No pull here. The runner's release preflight has already fetched and run | |
| 17 | + | // `git checkout v<version>` on every host, then compared `rev-parse HEAD` | |
| 18 | + | // across them so a release cannot be built from two different commits. That | |
| 19 | + | // leaves the checkout on the tag, detached. Pulling would move it off the tag | |
| 20 | + | // onto the branch tip, publishing something other than what was tagged — and on | |
| 21 | + | // a detached HEAD it just fails, which is how this was found, while publishing | |
| 22 | + | // makeover 2.1.0 as the first library to go through Bento. | |
| 23 | + | // | |
| 24 | + | // So this step asserts the pin instead of re-doing it: HEAD must be exactly a | |
| 25 | + | // tag, or the release is not coming from where it claims. | |
| 26 | + | // | |
| 27 | + | // The tree does not STAY detached: the runner records each host's branch before | |
| 28 | + | // it pins the tag and checks it back out once the build settles. It did not | |
| 29 | + | // always, and makeover shipped 2.3.0 from a checkout three commits ahead of a | |
| 30 | + | // `main` that never moved, with the published commit on no branch and no remote. | |
| 31 | + | step("checkout"); | |
| 32 | + | sh_ok(h, "cd " + r + " && git describe --exact-match --tags HEAD"); | |
| 33 | + | ||
| 34 | + | // Gate: nothing reaches crates.io from code that fails formatting, clippy or | |
| 35 | + | // its tests. A published version can be yanked but never edited, so this is the | |
| 36 | + | // last point at which a break is still cheap. | |
| 37 | + | // | |
| 38 | + | // fmt runs first, and it is here because it was the one gate missing. It takes | |
| 39 | + | // no features and touches no dependency, so it is the cheapest of the three and | |
| 40 | + | // the one whose failure is never interesting — which is exactly why it drifted: | |
| 41 | + | // makeover-webview's `main` failed `cargo fmt --check` across three releases | |
| 42 | + | // (0.10.0, 0.13.0, 0.14.0) and nothing objected, because the gate ran clippy | |
| 43 | + | // and the suite and never asked. A formatting break costs nothing to fix and | |
| 44 | + | // nothing to catch; leaving it uncaught is what let it accumulate. | |
| 45 | + | // | |
| 46 | + | // `--all` rather than `--workspace`: fmt spells the same idea with the other | |
| 47 | + | // word, and the two are not interchangeable on this subcommand. | |
| 48 | + | step("prebuild"); | |
| 49 | + | sh_ok(h, "cd " + r + " && cargo fmt --all --check"); | |
| 50 | + | sh_ok(h, "cd " + r + " && cargo clippy --workspace --all-targets " + feature_flags() + " -- -D warnings"); | |
| 51 | + | sh_ok(h, "cd " + r + " && cargo test --workspace " + feature_flags()); | |
| 52 | + | ||
| 53 | + | step("verify"); | |
| 54 | + | // Credentials are checked here too, and deliberately not passed through Bento: | |
| 55 | + | // the token stays in cargo's own 0600 store on the publishing host, where cargo | |
| 56 | + | // finds it. Handing it to a shell command would put it in the process list for | |
| 57 | + | // the length of the upload, and ops-exec renders env pairs into the shell line. | |
| 58 | + | // Aborts the run with the specific problems if anything is wrong. | |
| 59 | + | log(crate_preflight()); | |
| 60 | + | sh_ok(h, "cd " + r + " && cargo publish --dry-run " + feature_flags()); | |
| 61 | + | ||
| 62 | + | step("publish"); | |
| 63 | + | sh_ok(h, "cd " + r + " && cargo publish " + feature_flags()); | |
| 64 | + | log("published " + v + " to crates.io"); |
| @@ -1,0 +1,4 @@ | |||
| 1 | + | [toolchain] | |
| 2 | + | channel = "1.97.1" | |
| 3 | + | profile = "minimal" | |
| 4 | + | components = ["rustfmt", "clippy"] |
| @@ -1,0 +1,284 @@ | |||
| 1 | + | #!/usr/bin/env python3 | |
| 2 | + | """Do the tree's in-house `version` requirements still resolve? | |
| 3 | + | ||
| 4 | + | DO NOT EDIT IN PLACE. The master is _private/infra/bootstrap/githooks/internal-deps.py. | |
| 5 | + | ||
| 6 | + | Usage: | |
| 7 | + | internal-deps.py <tree-root> [repo-root] | |
| 8 | + | ||
| 9 | + | With a repo root, only pairs that repo is on either side of can fail the run; | |
| 10 | + | everything else is reported as a note. Without one, every pair is graded, which | |
| 11 | + | is the whole-tree report: | |
| 12 | + | ||
| 13 | + | python3 internal-deps.py ~/Code | |
| 14 | + | ||
| 15 | + | WHAT IT GRADES. Every dependency in the tree that carries both a `git` URL on one | |
| 16 | + | of our forges and a `version` requirement, against the version in the working | |
| 17 | + | copy of the crate that URL names. That is the pairing cargo enforces and the one | |
| 18 | + | that broke: a requirement of "0.11" against a sibling that has moved to 0.14 is | |
| 19 | + | not a warning, it is a graph that will not resolve on any machine. | |
| 20 | + | ||
| 21 | + | WHY WORKING COPIES AND NOT REMOTES. `~/Code/.cargo/config.toml` patches every one | |
| 22 | + | of these dependencies to the working copy in the tree, so what is on disk here is | |
| 23 | + | what every local build reads. A bump that has not been pushed yet breaks its | |
| 24 | + | consumers just as thoroughly, and finding that out at push time is the point. | |
| 25 | + | ||
| 26 | + | WHAT IT DOES NOT GRADE, on purpose: | |
| 27 | + | ||
| 28 | + | crates.io deps the makeover suite and friends resolve from the registry, | |
| 29 | + | where working ahead of a release is normal and a tree | |
| 30 | + | version above the published one is not a finding. The | |
| 31 | + | sweep's `coherence` check grades those against the index. | |
| 32 | + | path deps no version requirement to be wrong about. | |
| 33 | + | ranges and wildcards `>=`, `<`, `*` and comma lists are deliberate statements | |
| 34 | + | about a span, not a pin that drifts. Counted as unchecked. | |
| 35 | + | """ | |
| 36 | + | ||
| 37 | + | import os | |
| 38 | + | import re | |
| 39 | + | import sys | |
| 40 | + | import tomllib | |
| 41 | + | ||
| 42 | + | # The forges that make a git URL ours. A dependency on somebody else's git repo | |
| 43 | + | # is not something this tree can forward-fix. | |
| 44 | + | OURS = re.compile(r"(makenot\.work|git\.sr\.ht/~maxmj)", re.I) | |
| 45 | + | ||
| 46 | + | # Directories that hold code we do not grade: retired, staged for deletion, or | |
| 47 | + | # not ours. Mirrors the sweep's exclusions rather than inventing a second list. | |
| 48 | + | SKIP_DIRS = { | |
| 49 | + | "target", ".git", "node_modules", "dist", "vendor", | |
| 50 | + | "_archive", "_scratch", "trash", "_meta", "vtebench", | |
| 51 | + | } | |
| 52 | + | MAX_DEPTH = 4 | |
| 53 | + | ||
| 54 | + | DEP_SECTIONS = ("dependencies", "dev-dependencies", "build-dependencies") | |
| 55 | + | ||
| 56 | + | ||
| 57 | + | def manifests(root): | |
| 58 | + | """Every Cargo.toml in the tree, shallow-walked.""" | |
| 59 | + | out = [] | |
| 60 | + | stack = [(root, 0)] | |
| 61 | + | while stack: | |
| 62 | + | d, depth = stack.pop() | |
| 63 | + | try: | |
| 64 | + | entries = list(os.scandir(d)) | |
| 65 | + | except OSError: | |
| 66 | + | continue | |
| 67 | + | for e in entries: | |
| 68 | + | if e.is_file() and e.name == "Cargo.toml": | |
| 69 | + | out.append(e.path) | |
| 70 | + | elif e.is_dir() and e.name not in SKIP_DIRS and depth < MAX_DEPTH: | |
| 71 | + | stack.append((e.path, depth + 1)) | |
| 72 | + | return out | |
| 73 | + | ||
| 74 | + | ||
| 75 | + | def load(path): | |
| 76 | + | try: | |
| 77 | + | with open(path, "rb") as fh: | |
| 78 | + | return tomllib.load(fh) | |
| 79 | + | except (OSError, tomllib.TOMLDecodeError): | |
| 80 | + | return None | |
| 81 | + | ||
| 82 | + | ||
| 83 | + | def dep_tables(doc): | |
| 84 | + | """Every dependency table in a manifest, including per-target and workspace.""" | |
| 85 | + | for section in DEP_SECTIONS: | |
| 86 | + | table = doc.get(section) | |
| 87 | + | if isinstance(table, dict): | |
| 88 | + | yield table | |
| 89 | + | for cfg in (doc.get("target") or {}).values(): | |
| 90 | + | if not isinstance(cfg, dict): | |
| 91 | + | continue | |
| 92 | + | for section in DEP_SECTIONS: | |
| 93 | + | table = cfg.get(section) | |
| 94 | + | if isinstance(table, dict): | |
| 95 | + | yield table | |
| 96 | + | ws = doc.get("workspace") or {} | |
| 97 | + | table = ws.get("dependencies") | |
| 98 | + | if isinstance(table, dict): | |
| 99 | + | yield table | |
| 100 | + | ||
| 101 | + | ||
| 102 | + | def parse_version(v): | |
| 103 | + | """A version as a 3-tuple, prerelease dropped. Junk sorts as (0, 0, 0).""" | |
| 104 | + | core = str(v).split("+")[0].split("-")[0] | |
| 105 | + | parts = [] | |
| 106 | + | for piece in core.split(".")[:3]: | |
| 107 | + | try: | |
| 108 | + | parts.append(int(piece)) | |
| 109 | + | except ValueError: | |
| 110 | + | parts.append(0) | |
| 111 | + | while len(parts) < 3: | |
| 112 | + | parts.append(0) | |
| 113 | + | return tuple(parts) | |
| 114 | + | ||
| 115 | + | ||
| 116 | + | def satisfies(req, version): | |
| 117 | + | """Cargo's default (caret) requirement semantics. None means 'not graded'. | |
| 118 | + | ||
| 119 | + | The rule that matters here is the 0.x one: under 0.1.0 and above, the MINOR | |
| 120 | + | is the compatibility boundary, which is why a 0.11 requirement rejects 0.14 | |
| 121 | + | outright rather than treating it as a newer patch. | |
| 122 | + | """ | |
| 123 | + | req = req.strip() | |
| 124 | + | if not req or any(c in req for c in "<>*,~"): | |
| 125 | + | return None | |
| 126 | + | # A prerelease satisfies nothing that does not ask for a prerelease of the | |
| 127 | + | # same version, so a plain requirement rejects it. This is the shape the | |
| 128 | + | # maturity ladder produces at beta entry: a sibling at 1.0.0-beta.1 does not | |
| 129 | + | # resolve for a consumer requiring "1.0", and cargo says so. | |
| 130 | + | if "-" in str(version).split("+")[0] and "-" not in req: | |
| 131 | + | return False | |
| 132 | + | exact = req.startswith("=") | |
| 133 | + | req = req.lstrip("^=").strip() | |
| 134 | + | if not req: | |
| 135 | + | return None | |
| 136 | + | given = req.split(".") | |
| 137 | + | try: | |
| 138 | + | r = [int(p) for p in given[:3]] | |
| 139 | + | except ValueError: | |
| 140 | + | return None | |
| 141 | + | v = parse_version(version) | |
| 142 | + | if exact: | |
| 143 | + | return tuple(v[: len(r)]) == tuple(r) | |
| 144 | + | if r[0] > 0: | |
| 145 | + | return v[0] == r[0] and v[1:] >= tuple(r[1:] + [0] * (2 - len(r[1:]))) | |
| 146 | + | if len(r) == 1: | |
| 147 | + | return v[0] == 0 | |
| 148 | + | if r[1] > 0: | |
| 149 | + | return v[0] == 0 and v[1] == r[1] and v[2] >= (r[2] if len(r) > 2 else 0) | |
| 150 | + | # 0.0.x: every patch is its own compatibility island. | |
| 151 | + | if len(r) > 2: | |
| 152 | + | return v[:3] == (0, 0, r[2]) | |
| 153 | + | return v[0] == 0 and v[1] == 0 | |
| 154 | + | ||
| 155 | + | ||
| 156 | + | def main(): | |
| 157 | + | if len(sys.argv) < 2: | |
| 158 | + | print(__doc__.strip(), file=sys.stderr) | |
| 159 | + | return 2 | |
| 160 | + | tree = os.path.realpath(sys.argv[1]) | |
| 161 | + | repo = os.path.realpath(sys.argv[2]) if len(sys.argv) > 2 else None | |
| 162 | + | ||
| 163 | + | paths = manifests(tree) | |
| 164 | + | docs = {p: load(p) for p in paths} | |
| 165 | + | ||
| 166 | + | # Workspace versions first: a member saying `version.workspace = true` gets | |
| 167 | + | # its number from the root, and reporting it as 0.0.0 would be a false break. | |
| 168 | + | ws_version = {} | |
| 169 | + | for p, doc in docs.items(): | |
| 170 | + | if not doc: | |
| 171 | + | continue | |
| 172 | + | v = ((doc.get("workspace") or {}).get("package") or {}).get("version") | |
| 173 | + | if isinstance(v, str): | |
| 174 | + | ws_version[os.path.dirname(p)] = v | |
| 175 | + | ||
| 176 | + | def resolve_version(manifest_path, pkg): | |
| 177 | + | v = pkg.get("version") | |
| 178 | + | if isinstance(v, str): | |
| 179 | + | return v | |
| 180 | + | d = os.path.dirname(manifest_path) | |
| 181 | + | while d.startswith(tree): | |
| 182 | + | if d in ws_version: | |
| 183 | + | return ws_version[d] | |
| 184 | + | d = os.path.dirname(d) | |
| 185 | + | return None | |
| 186 | + | ||
| 187 | + | # crate name -> (version, manifest path) | |
| 188 | + | versions = {} | |
| 189 | + | for p, doc in docs.items(): | |
| 190 | + | if not doc: | |
| 191 | + | continue | |
| 192 | + | pkg = doc.get("package") | |
| 193 | + | if not isinstance(pkg, dict) or not isinstance(pkg.get("name"), str): | |
| 194 | + | continue | |
| 195 | + | v = resolve_version(p, pkg) | |
| 196 | + | if v: | |
| 197 | + | versions[pkg["name"]] = (v, p) | |
| 198 | + | ||
| 199 | + | broken, unchecked, absent, graded = [], 0, set(), 0 | |
| 200 | + | for p, doc in docs.items(): | |
| 201 | + | if not doc: | |
| 202 | + | continue | |
| 203 | + | for table in dep_tables(doc): | |
| 204 | + | for key, spec in table.items(): | |
| 205 | + | if not isinstance(spec, dict): | |
| 206 | + | continue | |
| 207 | + | git = spec.get("git") | |
| 208 | + | req = spec.get("version") | |
| 209 | + | if not isinstance(git, str) or not isinstance(req, str): | |
| 210 | + | continue | |
| 211 | + | if not OURS.search(git): | |
| 212 | + | continue | |
| 213 | + | name = spec.get("package") if isinstance(spec.get("package"), str) else key | |
| 214 | + | known = versions.get(name) | |
| 215 | + | if known is None: | |
| 216 | + | # A repo that is not on this machine (ripgrow lives on mbp | |
| 217 | + | # only). Not a finding: nothing here can be wrong about it. | |
| 218 | + | absent.add(name) | |
| 219 | + | continue | |
| 220 | + | verdict = satisfies(req, known[0]) | |
| 221 | + | if verdict is None: | |
| 222 | + | unchecked += 1 | |
| 223 | + | continue | |
| 224 | + | graded += 1 | |
| 225 | + | if not verdict: | |
| 226 | + | broken.append((p, name, req, known[0], known[1])) | |
| 227 | + | ||
| 228 | + | if not broken: | |
| 229 | + | print( | |
| 230 | + | f"pre-push: internal deps coherent ({graded} requirements" | |
| 231 | + | + (f", {unchecked} unchecked" if unchecked else "") | |
| 232 | + | + (f", {len(absent)} crates not in this tree" if absent else "") | |
| 233 | + | + ")." | |
| 234 | + | ) | |
| 235 | + | return 0 | |
| 236 | + | ||
| 237 | + | def rel(path): | |
| 238 | + | return os.path.relpath(path, tree) | |
| 239 | + | ||
| 240 | + | ours, theirs = [], [] | |
| 241 | + | for item in broken: | |
| 242 | + | consumer_manifest, name, req, have, provider_manifest = item | |
| 243 | + | mine = repo is not None and ( | |
| 244 | + | consumer_manifest.startswith(repo + os.sep) | |
| 245 | + | or provider_manifest.startswith(repo + os.sep) | |
| 246 | + | ) | |
| 247 | + | (ours if mine else theirs).append(item) | |
| 248 | + | ||
| 249 | + | for consumer_manifest, name, req, have, provider_manifest in ours + theirs: | |
| 250 | + | print( | |
| 251 | + | f" {rel(consumer_manifest)}: requires {name} \"{req}\", " | |
| 252 | + | f"the tree has {have} ({rel(provider_manifest)})", | |
| 253 | + | file=sys.stderr, | |
| 254 | + | ) | |
| 255 | + | ||
| 256 | + | if repo is None: | |
| 257 | + | print(f"internal deps: {len(broken)} unresolvable requirements.", file=sys.stderr) | |
| 258 | + | return 1 | |
| 259 | + | ||
| 260 | + | if not ours: | |
| 261 | + | # Somebody else's skew. Worth seeing, never worth blocking this push on: | |
| 262 | + | # a gate that fails for a reason the pusher cannot fix is a gate that | |
| 263 | + | # gets bypassed by reflex, and then it is not a gate. | |
| 264 | + | print( | |
| 265 | + | f"pre-push: {len(theirs)} unresolvable requirements elsewhere in the " | |
| 266 | + | "tree (listed above, not this push's).", | |
| 267 | + | ) | |
| 268 | + | return 0 | |
| 269 | + | ||
| 270 | + | print("", file=sys.stderr) | |
| 271 | + | print( | |
| 272 | + | "pre-push: this push leaves a dependency that cannot resolve.\n" | |
| 273 | + | " A version requirement states which major a consumer was written against,\n" | |
| 274 | + | " so bumping a library and fixing its consumers is one pass (CLAUDE.md,\n" | |
| 275 | + | " \"a breaking bump of an in-house crate is forward-fixed, in the same pass\").\n" | |
| 276 | + | " Fix: bump the requirement in the manifests above, make the consumers\n" | |
| 277 | + | " compile, and push them with this one.", | |
| 278 | + | file=sys.stderr, | |
| 279 | + | ) | |
| 280 | + | return 1 | |
| 281 | + | ||
| 282 | + | ||
| 283 | + | if __name__ == "__main__": | |
| 284 | + | sys.exit(main()) |
| @@ -1,0 +1,212 @@ | |||
| 1 | + | #!/bin/bash | |
| 2 | + | # Canonical pre-commit gate. Byte-identical in every repo under ~/Code. | |
| 3 | + | # | |
| 4 | + | # DO NOT EDIT IN PLACE. The master is _private/infra/bootstrap/githooks/pre-commit | |
| 5 | + | # and install-githooks.sh --check reports any copy that has drifted from it. Edit | |
| 6 | + | # the master, re-run the installer, commit the repos it touched. | |
| 7 | + | # | |
| 8 | + | # Activate in a fresh clone (one-time): | |
| 9 | + | # git config core.hooksPath scripts/githooks | |
| 10 | + | # clone-tree.sh does this for every repo it clones, so only a hand clone needs it. | |
| 11 | + | # | |
| 12 | + | # Bypass for a work-in-progress commit: git commit --no-verify | |
| 13 | + | # | |
| 14 | + | # Every gate below decides for itself whether it applies, from what is in the repo | |
| 15 | + | # and what is staged. That is what lets one file serve a library, an app and a | |
| 16 | + | # server: the repo's shape selects the gates rather than a per-repo edit, which is | |
| 17 | + | # the drift that let makeover-immediate 0.18.0 reach its release preflight | |
| 18 | + | # unformatted and left eight violations sitting on quasi's main (infra a33fdaab). | |
| 19 | + | # | |
| 20 | + | # NOT here, deliberately: clippy. It is slow enough that a commit-time gate is one | |
| 21 | + | # people bypass, so it stays in CI and the sweep. | |
| 22 | + | # | |
| 23 | + | # Genuinely repo-local extras go in scripts/githooks/pre-commit.local, which this | |
| 24 | + | # runs last if it exists. | |
| 25 | + | set -euo pipefail | |
| 26 | + | ||
| 27 | + | ROOT="$(git rev-parse --show-toplevel)" | |
| 28 | + | cd "$ROOT" | |
| 29 | + | ||
| 30 | + | # git invoked from an editor, a cron job, or a non-interactive shell does not | |
| 31 | + | # source the profile that puts ~/.local/bin on PATH, and a hook that silently | |
| 32 | + | # cannot find gitleaks or cargo is worse than no hook. (Lesson from _private's | |
| 33 | + | # own hook, which is stricter still: it refuses to commit blind.) | |
| 34 | + | export PATH="$HOME/.local/bin:$HOME/.cargo/bin:/opt/homebrew/bin:/usr/local/bin:$PATH" | |
| 35 | + | ||
| 36 | + | # --- secret scan (gitleaks) ------------------------------------------------- | |
| 37 | + | # Independent guardrail: blocks a commit whose staged changes contain a secret, | |
| 38 | + | # regardless of whether a human judged the value "safe". Shared ruleset lives at | |
| 39 | + | # ~/Code/.gitleaks.toml. Degrades gracefully if gitleaks is not installed (the | |
| 40 | + | # astra pre-receive hook is the backstop that always runs). Task: infra 97ffeda0. | |
| 41 | + | if command -v gitleaks >/dev/null 2>&1; then | |
| 42 | + | GL_CFG="" | |
| 43 | + | if [ -f "$ROOT/.gitleaks.toml" ]; then | |
| 44 | + | GL_CFG="$ROOT/.gitleaks.toml" | |
| 45 | + | elif [ -f "$HOME/Code/.gitleaks.toml" ]; then | |
| 46 | + | GL_CFG="$HOME/Code/.gitleaks.toml" | |
| 47 | + | fi | |
| 48 | + | gl_args=(git --staged --no-banner --redact) | |
| 49 | + | [ -n "$GL_CFG" ] && gl_args+=(-c "$GL_CFG") | |
| 50 | + | if ! gitleaks "${gl_args[@]}"; then | |
| 51 | + | echo "pre-commit: gitleaks found a secret in the staged changes." | |
| 52 | + | echo " remove it (or allowlist a false positive), then restage." | |
| 53 | + | echo " bypass: git commit --no-verify." | |
| 54 | + | exit 1 | |
| 55 | + | fi | |
| 56 | + | echo "pre-commit: gitleaks clean." | |
| 57 | + | else | |
| 58 | + | echo "pre-commit: gitleaks not installed; skipping secret scan (astra gates on push)." | |
| 59 | + | fi | |
| 60 | + | ||
| 61 | + | # --- migration immutability ------------------------------------------------- | |
| 62 | + | # Applies to any repo with migrations, which is why it is not MNW-local: sqlx | |
| 63 | + | # checksums a migration's whole file when it runs it and refuses one whose bytes | |
| 64 | + | # changed since, so editing an already-applied migration breaks every deploy | |
| 65 | + | # against that database with "previously applied but modified" -- for a comment | |
| 66 | + | # edit, and for a line-ending change, exactly as much as for a schema change. | |
| 67 | + | # | |
| 68 | + | # The 2026-07-27 exorcise sweep rewrote comments in 29 applied MNW migrations and | |
| 69 | + | # converted one from CRLF to LF. Nothing in the test suite checksums a migration, | |
| 70 | + | # so it stayed invisible while it blocked every server deploy for three days. | |
| 71 | + | # | |
| 72 | + | # goingson and balanced_breakfast are in scope too: both kept their | |
| 73 | + | # `_sqlx_migrations` ledger verbatim through the 2026-08-07 rusqlite migration, so | |
| 74 | + | # an upgraded install still reads those rows and an edited file still contradicts | |
| 75 | + | # them. Adding a new migration is always fine; this only blocks M/D/R. | |
| 76 | + | touched="$(git diff --cached --name-only --diff-filter=MDR -- '*migrations/*.sql')" | |
| 77 | + | if [ -n "$touched" ]; then | |
| 78 | + | echo "pre-commit: these already-committed migrations were modified, renamed, or deleted:" | |
| 79 | + | while IFS= read -r m; do | |
| 80 | + | [ -n "$m" ] && echo " $m" | |
| 81 | + | done <<< "$touched" | |
| 82 | + | echo " A migration is immutable once applied; the runner checksums the" | |
| 83 | + | echo " whole file, comments included. Write a new migration instead." | |
| 84 | + | echo " Bypass ONLY if it has never been applied anywhere, including" | |
| 85 | + | echo " prod, staging, and your dev database: git commit --no-verify." | |
| 86 | + | exit 1 | |
| 87 | + | fi | |
| 88 | + | ||
| 89 | + | # --- frontend design-system lint -------------------------------------------- | |
| 90 | + | # Runs any scripts/lint-frontend.sh the repo carries when the commit touches a | |
| 91 | + | # frontend asset. Each of those scripts resolves its own paths from its location, | |
| 92 | + | # so finding them is enough and no path knowledge belongs here. Both known scripts | |
| 93 | + | # live at repo root (goingson, balanced_breakfast) or one level down (MNW's is | |
| 94 | + | # server/scripts/lint-frontend.sh), hence the depth-2 search. | |
| 95 | + | # | |
| 96 | + | # This sits ABOVE the rustfmt gate deliberately: that gate exits early when no .rs | |
| 97 | + | # files are staged, which is exactly the case where a frontend commit needs | |
| 98 | + | # checking. goingson's copy also runs the JS suite, which carries the CHRONIC-XSS | |
| 99 | + | # escaping gate. | |
| 100 | + | staged_fe="$(git diff --cached --name-only --diff-filter=ACMR -- '*.js' '*.css' '*.html')" | |
| 101 | + | if [ -n "$staged_fe" ]; then | |
| 102 | + | while IFS= read -r lint; do | |
| 103 | + | [ -n "$lint" ] || continue | |
| 104 | + | if ! fe_out=$(bash "$lint" 2>&1); then | |
| 105 | + | echo "$fe_out" | |
| 106 | + | echo "pre-commit: frontend lint failed ($lint)." | |
| 107 | + | echo " fix the rules above, then restage." | |
| 108 | + | echo " bypass: git commit --no-verify." | |
| 109 | + | exit 1 | |
| 110 | + | fi | |
| 111 | + | echo "pre-commit: frontend lint clean ($lint)." | |
| 112 | + | done <<< "$(find . -maxdepth 3 -path ./target -prune -o \ | |
| 113 | + | -path '*/scripts/lint-frontend.sh' -print 2>/dev/null | sort)" | |
| 114 | + | fi | |
| 115 | + | ||
| 116 | + | # --- rustfmt ---------------------------------------------------------------- | |
| 117 | + | # Blocks a commit whose staged Rust files are not formatted. Only crates with | |
| 118 | + | # staged .rs changes are checked, so the hook stays fast on a large repo. Each | |
| 119 | + | # file maps to the nearest enclosing Cargo.toml and the check runs as `cargo fmt` | |
| 120 | + | # there, which picks up that crate's edition and any rustfmt.toml rather than | |
| 121 | + | # guessing -- and is why this works unchanged in MNW, which has no root workspace. | |
| 122 | + | # | |
| 123 | + | # SKIP_PATHS is an extended regex of repo-relative paths to ignore. Empty means | |
| 124 | + | # check everything. Set it in pre-commit.local if a repo ever needs one. | |
| 125 | + | SKIP_PATHS="${SKIP_PATHS:-}" | |
| 126 | + | ||
| 127 | + | staged="$(git diff --cached --name-only --diff-filter=ACMR -- '*.rs')" | |
| 128 | + | if [ -n "$SKIP_PATHS" ]; then | |
| 129 | + | staged="$(printf '%s\n' "$staged" | grep -Ev "$SKIP_PATHS" || true)" | |
| 130 | + | fi | |
| 131 | + | ||
| 132 | + | if [ -n "$staged" ]; then | |
| 133 | + | # Map each staged file to the directory of its nearest Cargo.toml. | |
| 134 | + | crates="" | |
| 135 | + | while IFS= read -r f; do | |
| 136 | + | [ -n "$f" ] || continue | |
| 137 | + | d="$(dirname "$f")" | |
| 138 | + | while [ "$d" != "." ] && [ ! -f "$d/Cargo.toml" ]; do | |
| 139 | + | d="$(dirname "$d")" | |
| 140 | + | done | |
| 141 | + | [ -f "$d/Cargo.toml" ] || continue | |
| 142 | + | crates="$crates$d"$'\n' | |
| 143 | + | done <<< "$staged" | |
| 144 | + | ||
| 145 | + | crates="$(printf '%s' "$crates" | sort -u)" | |
| 146 | + | ||
| 147 | + | failed=0 | |
| 148 | + | while IFS= read -r c; do | |
| 149 | + | [ -n "$c" ] || continue | |
| 150 | + | if ! (cd "$c" && cargo fmt --check >/dev/null 2>&1); then | |
| 151 | + | echo "pre-commit: rustfmt gate failed in $c" | |
| 152 | + | failed=1 | |
| 153 | + | fi | |
| 154 | + | done <<< "$crates" | |
| 155 | + | ||
| 156 | + | if [ "$failed" -ne 0 ]; then | |
| 157 | + | echo "pre-commit: run 'cargo fmt' in the crates above, then restage." | |
| 158 | + | echo "pre-commit: commit aborted (use --no-verify to bypass)." | |
| 159 | + | exit 1 | |
| 160 | + | fi | |
| 161 | + | echo "pre-commit: rustfmt gate clean." | |
| 162 | + | fi | |
| 163 | + | ||
| 164 | + | # --- openapi.json staleness ------------------------------------------------- | |
| 165 | + | # Only fires in a repo that commits a generated spec, which today is MNW alone. | |
| 166 | + | # | |
| 167 | + | # server/openapi.json is a committed artifact and `openapi::tests:: | |
| 168 | + | # committed_spec_matches_generated` asserts it matches the generated spec. The | |
| 169 | + | # spec embeds CARGO_PKG_VERSION, so EVERY version bump invalidates it even when no | |
| 170 | + | # route changed. | |
| 171 | + | # | |
| 172 | + | # Nothing local caught that. The /deploy pre-push guard is a `cargo test --no-run` | |
| 173 | + | # compile check, and the spec is read at runtime by path rather than include_str!, | |
| 174 | + | # so a stale copy compiles fine. On 2026-08-06 the v0.11.8 bump left the spec at | |
| 175 | + | # 0.11.7, pushed clean to all three remotes, and killed Sando run 38 about fifteen | |
| 176 | + | # minutes in -- two full remote build cycles for a one-line diff in info.version. | |
| 177 | + | # | |
| 178 | + | # So: regenerate to stdout and compare against the STAGED copy (not the working | |
| 179 | + | # tree one -- regenerating without restaging is the same bug wearing a hat). | |
| 180 | + | if [ -f "$ROOT/server/openapi.json" ]; then | |
| 181 | + | specish="$(git diff --cached --name-only --diff-filter=ACMR \ | |
| 182 | + | -- 'server/Cargo.toml' 'server/src/*.rs' 'server/src/**/*.rs' 'server/openapi.json')" | |
| 183 | + | if [ -n "$specish" ]; then | |
| 184 | + | echo "pre-commit: checking openapi.json against the generated spec..." | |
| 185 | + | gen="$(mktemp)" | |
| 186 | + | trap 'rm -f "$gen"' EXIT | |
| 187 | + | if (cd "$ROOT/server" && cargo run --quiet --bin export-openapi -- --stdout) > "$gen" 2>/dev/null; then | |
| 188 | + | if ! git show :server/openapi.json 2>/dev/null | diff -q - "$gen" >/dev/null; then | |
| 189 | + | echo "pre-commit: server/openapi.json is stale (or regenerated but not staged)." | |
| 190 | + | echo " cd server && cargo run --bin export-openapi" | |
| 191 | + | echo " git add server/openapi.json" | |
| 192 | + | echo " Then vendor the same bytes into the OTHER repo, which this" | |
| 193 | + | echo " commit cannot carry and Sando will fail on:" | |
| 194 | + | echo " cp server/openapi.json ../synckit/synckit-client/tests/openapi.json" | |
| 195 | + | echo " Bypass: git commit --no-verify." | |
| 196 | + | exit 1 | |
| 197 | + | fi | |
| 198 | + | echo "pre-commit: openapi.json current." | |
| 199 | + | else | |
| 200 | + | echo "pre-commit: could not build export-openapi; skipping spec check." | |
| 201 | + | echo " cargo_test in Sando is the backstop, 15 minutes into the build." | |
| 202 | + | fi | |
| 203 | + | fi | |
| 204 | + | fi | |
| 205 | + | ||
| 206 | + | # --- repo-local extras ------------------------------------------------------ | |
| 207 | + | # The escape hatch for a gate that cannot be selected from the repo's shape. Keep | |
| 208 | + | # it small: anything a second repo wants belongs in the canonical file above, | |
| 209 | + | # guarded by its own detection. | |
| 210 | + | if [ -f "$ROOT/scripts/githooks/pre-commit.local" ]; then | |
| 211 | + | bash "$ROOT/scripts/githooks/pre-commit.local" || exit 1 | |
| 212 | + | fi |
| @@ -1,0 +1,84 @@ | |||
| 1 | + | #!/bin/bash | |
| 2 | + | # Canonical pre-push gate. Two gates, and they answer different questions: | |
| 3 | + | # | |
| 4 | + | # internal deps does every in-house `version` requirement in the tree still | |
| 5 | + | # resolve against the crate it names? Runs in EVERY repo. | |
| 6 | + | # test targets do this repo's test targets build? Runs where there is a | |
| 7 | + | # root Cargo.toml to run one command in. | |
| 8 | + | # | |
| 9 | + | # DO NOT EDIT IN PLACE. The master is _private/infra/bootstrap/githooks/pre-push. | |
| 10 | + | # | |
| 11 | + | # Bypass for a work-in-progress push: git push --no-verify | |
| 12 | + | # | |
| 13 | + | # WHY THE FIRST GATE EXISTS. Every cross-repo dependency carries a `version` | |
| 14 | + | # alongside its `git` URL, so cargo refuses a sibling it was not written against | |
| 15 | + | # instead of compiling something surprising. That requirement is the protection | |
| 16 | + | # and it is also the maintenance: bumping a library's minor breaks every consumer | |
| 17 | + | # whose requirement excludes it, and CLAUDE.md's rule is that the bump and the | |
| 18 | + | # forward fix are one pass. Nothing enforced the rule, so quasi went 0.11 -> 0.14 | |
| 19 | + | # over two evenings and MNW's server could not resolve at all for a day. The | |
| 20 | + | # nightly sweep found it and a red cell in a grid is not the same as being told. | |
| 21 | + | # | |
| 22 | + | # This gate is that rule, mechanised, at the moment it is broken: the push that | |
| 23 | + | # would leave a consumer unable to build is the push that is refused. It reads | |
| 24 | + | # the WORKING COPIES in the tree, not the remotes, because `~/Code/.cargo/config.toml` | |
| 25 | + | # redirects every one of these dependencies to the working copy -- so a local bump | |
| 26 | + | # breaks a consumer's build here whether or not it has been pushed anywhere. | |
| 27 | + | # | |
| 28 | + | # `cargo check` and `cargo clippy` both compile only the lib and bin targets, so a | |
| 29 | + | # break confined to `tests/` or a `#[cfg(test)]` module is clean under both and | |
| 30 | + | # lands unnoticed (goingson's sqlx 0.9 upgrade shipped exactly that way). | |
| 31 | + | # `--no-run` builds every test target without running them, which is the cheap | |
| 32 | + | # half of the suite and enough to catch a compile break. Tests still run | |
| 33 | + | # separately. | |
| 34 | + | # | |
| 35 | + | # `--workspace` is load-bearing wherever default-members is narrower than the | |
| 36 | + | # workspace: goingson's is src-tauri alone, so a bare `cargo test --no-run` would | |
| 37 | + | # skip core, db-sqlite, go-mcp and got. | |
| 38 | + | set -euo pipefail | |
| 39 | + | ||
| 40 | + | ROOT="$(git rev-parse --show-toplevel)" | |
| 41 | + | cd "$ROOT" | |
| 42 | + | ||
| 43 | + | # See the canonical pre-commit: a hook run from an editor or a cron job does not | |
| 44 | + | # get the profile's PATH, and a hook that cannot find cargo is worse than none. | |
| 45 | + | export PATH="$HOME/.cargo/bin:$HOME/.local/bin:/opt/homebrew/bin:/usr/local/bin:$PATH" | |
| 46 | + | ||
| 47 | + | # Refs arrive on stdin as "<local ref> <local sha> <remote ref> <remote sha>". | |
| 48 | + | # A branch deletion has an all-zero local sha and no tree to push. Read once, | |
| 49 | + | # ahead of both gates: stdin is not seekable and a second reader gets nothing. | |
| 50 | + | pushing=0 | |
| 51 | + | while read -r _local_ref local_sha _remote_ref _remote_sha; do | |
| 52 | + | case "$local_sha" in | |
| 53 | + | *[!0]*) pushing=1 ;; | |
| 54 | + | esac | |
| 55 | + | done | |
| 56 | + | [ "$pushing" -eq 1 ] || exit 0 | |
| 57 | + | ||
| 58 | + | # ── gate 1: internal dependency coherence ────────────────────────────────── | |
| 59 | + | # | |
| 60 | + | # Deliberately no cargo: this reads manifests and answers in well under a second, | |
| 61 | + | # where `cargo metadata` on the server is tens of seconds and fails outright on | |
| 62 | + | # exactly the state being detected. | |
| 63 | + | CODE_ROOT="${CODE_ROOT:-$HOME/Code}" | |
| 64 | + | if [ -d "$CODE_ROOT" ] && command -v python3 >/dev/null 2>&1; then | |
| 65 | + | if ! python3 "$ROOT/scripts/githooks/internal-deps.py" "$CODE_ROOT" "$ROOT"; then | |
| 66 | + | echo "pre-push: push aborted (use --no-verify to bypass)." | |
| 67 | + | exit 1 | |
| 68 | + | fi | |
| 69 | + | fi | |
| 70 | + | ||
| 71 | + | # ── gate 2: test targets build ───────────────────────────────────────────── | |
| 72 | + | # | |
| 73 | + | # MNW and synckit have no root Cargo.toml by design (standalone crates, no root | |
| 74 | + | # workspace), so there is no one command to run and they get gate 1 only. | |
| 75 | + | [ -f "$ROOT/Cargo.toml" ] || exit 0 | |
| 76 | + | ||
| 77 | + | echo "pre-push: building test targets (cargo test --no-run --workspace)..." | |
| 78 | + | if ! cargo test --no-run --workspace; then | |
| 79 | + | echo "pre-push: test targets failed to build." | |
| 80 | + | echo "pre-push: push aborted (use --no-verify to bypass)." | |
| 81 | + | exit 1 | |
| 82 | + | fi | |
| 83 | + | ||
| 84 | + | echo "pre-push: test targets build clean." |
| @@ -1,0 +1,441 @@ | |||
| 1 | + | //! The time axis of the make-family design system. | |
| 2 | + | //! | |
| 3 | + | //! <!-- wiki: makeover-timing --> | |
| 4 | + | //! | |
| 5 | + | //! [`makeover`] resolves colour, `makeover-geometry` resolves distance, and | |
| 6 | + | //! `makeover-layout` names what a thing is without resolving anything. This | |
| 7 | + | //! crate answers the remaining question of the same shape: **how long**. | |
| 8 | + | //! | |
| 9 | + | //! The move is the one the family makes everywhere. A duration is named by | |
| 10 | + | //! what it is waiting for, and the number follows: | |
| 11 | + | //! | |
| 12 | + | //! | Intent | What it waits for | Resolves to | | |
| 13 | + | //! |---|---|---| | |
| 14 | + | //! | [`Intent::Revert`] | a control returning to its resting label after confirming | 1500ms | | |
| 15 | + | //! | [`Intent::Clear`] | a status line emptying itself | 2000ms | | |
| 16 | + | //! | [`Intent::Dismiss`] | a transient notice's lifetime | 3000ms | | |
| 17 | + | //! | [`Intent::Debounce`] | typing settling before the work starts | 150ms | | |
| 18 | + | //! | |
| 19 | + | //! Whether a toast should live 3000ms or 3500ms is unanswerable on its own. | |
| 20 | + | //! Whether a message is a toast or a banner is not. That is the whole argument | |
| 21 | + | //! for the layer, and it is the same one [`Gap`](makeover_geometry::Gap) makes | |
| 22 | + | //! about six pixels. | |
| 23 | + | //! | |
| 24 | + | //! # One duration per intent, on every renderer | |
| 25 | + | //! | |
| 26 | + | //! An intent resolves to exactly one duration everywhere. Not one per renderer, | |
| 27 | + | //! not one per theme. | |
| 28 | + | //! | |
| 29 | + | //! The alternative was considered and rejected on 2026-08-21: a per-renderer | |
| 30 | + | //! table, resolving the way [`Density`](makeover_geometry::Density) resolves | |
| 31 | + | //! gaps. Distance has a renderer axis because a fingertip is coarser than a | |
| 32 | + | //! cursor and a terminal cell is coarser than a pixel — the *surface* differs. | |
| 33 | + | //! Time does not differ that way. A second is a second in a browser, in egui | |
| 34 | + | //! and in a terminal, and the reader waiting it out is the same reader. What | |
| 35 | + | //! looked like a renderer disagreement in the tree turned out to be drift: MNW | |
| 36 | + | //! debounced one typeahead at 150ms and two others at 200ms, in one repo, on | |
| 37 | + | //! one renderer, and no per-renderer table would have caught that. | |
| 38 | + | //! | |
| 39 | + | //! So a divergence here is a bug report, not an axis. | |
| 40 | + | //! | |
| 41 | + | //! # What is deliberately not a timing intent | |
| 42 | + | //! | |
| 43 | + | //! Three classes were measured out of scope on 2026-08-18, and leaving them | |
| 44 | + | //! out is most of what makes the four above coherent. | |
| 45 | + | //! | |
| 46 | + | //! **A race is not an intent.** Waiting 300ms before navigating because the | |
| 47 | + | //! write "should" have landed, or 150ms before closing a dropdown so a | |
| 48 | + | //! mousedown can beat the blur, is a synchronisation bug wearing a duration's | |
| 49 | + | //! clothes. Naming those would launder them into design decisions and give | |
| 50 | + | //! every future one a token to hide behind. They get fixed per screen. | |
| 51 | + | //! | |
| 52 | + | //! **Severity is not an intent either, and it is not a fifth number.** Two | |
| 53 | + | //! renderers already reached for one: MNW gives an error toast 6000ms against | |
| 54 | + | //! an ordinary one's 3000ms, and audiofiles' footer never expires an error at | |
| 55 | + | //! all while an ordinary message goes at 30s. Read together those are not two | |
| 56 | + | //! durations, they are one statement — *a message the user must not miss does | |
| 57 | + | //! not go away on its own* — and `makeover-layout` already has the word for | |
| 58 | + | //! it: such a message is not `Notice::transient`. It is a banner, and a banner | |
| 59 | + | //! has no lifetime. See [`notice_lifetime`]. | |
| 60 | + | //! | |
| 61 | + | //! **A poll interval is not an intent.** A retry backoff, a health check, an | |
| 62 | + | //! update check: those are answerable from what they talk to, not from what a | |
| 63 | + | //! reader can follow, and nothing here has an opinion about them. | |
| 64 | + | //! | |
| 65 | + | //! # Motion is a separate axis | |
| 66 | + | //! | |
| 67 | + | //! [`Intent`] says how long a state lasts. [`Motion`] says how long a change | |
| 68 | + | //! takes. CSS itself draws that line — a `setTimeout` against a | |
| 69 | + | //! `transition-duration` — and folding the two together is what makes a | |
| 70 | + | //! "timing scale" unusable: 300ms of fade and 3000ms of toast are not two | |
| 71 | + | //! rungs of one ramp, they are answers to different questions. | |
| 72 | + | //! | |
| 73 | + | //! [`Motion`] has one rung today because the tree has one measured transition. | |
| 74 | + | //! It is an enum rather than a constant so the second one has somewhere to go, | |
| 75 | + | //! and it grows when something is measured, not when a scale looks short. | |
| 76 | + | //! | |
| 77 | + | //! # Where the numbers came from | |
| 78 | + | //! | |
| 79 | + | //! Every value below is a count from the tree, taken 2026-08-18 and re-checked | |
| 80 | + | //! 2026-08-21, not a preference: | |
| 81 | + | //! | |
| 82 | + | //! ```text | |
| 83 | + | //! revert 1500ms MNW: 6 hand-rolled sites, plus core/clipboard.ts's own default | |
| 84 | + | //! clear 2000ms MNW: 4 sites | |
| 85 | + | //! dismiss 3000ms MNW: the toast renderer's lifetime | |
| 86 | + | //! debounce 150ms audiofiles SEARCH_DEBOUNCE, MNW docs-search.js | |
| 87 | + | //! fade 300ms MNW: TOAST_FADE_MS, matching the .fade-out transition | |
| 88 | + | //! ``` | |
| 89 | + | //! | |
| 90 | + | //! The one contested value is the debounce, where MNW's two category | |
| 91 | + | //! typeaheads sit at 200ms against everything else's 150ms. 150 wins on the | |
| 92 | + | //! count and on the cross-renderer agreement, and the 200s conform. | |
| 93 | + | //! | |
| 94 | + | //! # Consumers | |
| 95 | + | //! | |
| 96 | + | //! Web surfaces bake [`timing_css`] in at build time. Nothing here changes at | |
| 97 | + | //! runtime, so there is no load-time JS step, exactly as with geometry. egui | |
| 98 | + | //! and ratatui surfaces read [`Intent::duration`] instead, which is why this | |
| 99 | + | //! is a crate rather than a stylesheet. | |
| 100 | + | //! | |
| 101 | + | //! [`makeover`]: https://makenot.work/git/max/makeover | |
| 102 | + | ||
| 103 | + | #![forbid(unsafe_code)] | |
| 104 | + | ||
| 105 | + | use std::fmt::Write as _; | |
| 106 | + | use std::time::Duration; | |
| 107 | + | ||
| 108 | + | use makeover_geometry::in_css_layer; | |
| 109 | + | ||
| 110 | + | /// A duration named by what it is waiting for. | |
| 111 | + | /// | |
| 112 | + | /// Four members, and the set is closed on purpose: each one is a thing a | |
| 113 | + | /// reader is waiting through, and the crate header says what was measured out. | |
| 114 | + | /// Adding a fifth means naming a wait nobody here is already having. | |
| 115 | + | #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)] | |
| 116 | + | pub enum Intent { | |
| 117 | + | /// How long a control shows that it did something before returning to its | |
| 118 | + | /// resting label. | |
| 119 | + | /// | |
| 120 | + | /// "Copied!" on a button that said "Copy link". Long enough to be read | |
| 121 | + | /// after the eye has moved back to it, short enough that the control is | |
| 122 | + | /// honest about its own label again before the next click. | |
| 123 | + | Revert, | |
| 124 | + | /// How long a status line holds a message before emptying itself. | |
| 125 | + | /// | |
| 126 | + | /// The message is a receipt for something the user just did, so it is read | |
| 127 | + | /// or not read immediately. Holding it longer means the next glance at that | |
| 128 | + | /// line reports stale news. | |
| 129 | + | Clear, | |
| 130 | + | /// How long a transient notice lives before it starts to leave. | |
| 131 | + | /// | |
| 132 | + | /// Excludes the leaving itself, which is [`Motion::Fade`]. A notice the | |
| 133 | + | /// user must not miss is not transient and gets no lifetime at all; see | |
| 134 | + | /// [`notice_lifetime`]. | |
| 135 | + | Dismiss, | |
| 136 | + | /// How long input waits to settle before the work behind it starts. | |
| 137 | + | /// | |
| 138 | + | /// A search field that queries on every keystroke, filtered through this. | |
| 139 | + | /// The number is a claim about typing rather than about the query: below | |
| 140 | + | /// roughly 100ms an ordinary typist trips it mid-word, and above roughly | |
| 141 | + | /// 250ms the field feels like it stopped listening. | |
| 142 | + | Debounce, | |
| 143 | + | } | |
| 144 | + | ||
| 145 | + | impl Intent { | |
| 146 | + | /// The duration in whole milliseconds. | |
| 147 | + | /// | |
| 148 | + | /// The primary resolution. [`Self::duration`] and [`Self::css`] are both | |
| 149 | + | /// spellings of this number, so there is exactly one place it lives. | |
| 150 | + | #[must_use] | |
| 151 | + | pub const fn ms(self) -> u32 { | |
| 152 | + | match self { | |
| 153 | + | Self::Revert => 1500, | |
| 154 | + | Self::Clear => 2000, | |
| 155 | + | Self::Dismiss => 3000, | |
| 156 | + | Self::Debounce => 150, | |
| 157 | + | } | |
| 158 | + | } | |
| 159 | + | ||
| 160 | + | /// The duration as a [`Duration`], for the renderers that are not a | |
| 161 | + | /// browser. | |
| 162 | + | #[must_use] | |
| 163 | + | pub const fn duration(self) -> Duration { | |
| 164 | + | Duration::from_millis(self.ms() as u64) | |
| 165 | + | } | |
| 166 | + | ||
| 167 | + | /// The CSS custom property name, without the leading dashes. | |
| 168 | + | #[must_use] | |
| 169 | + | pub const fn token(self) -> &'static str { | |
| 170 | + | match self { | |
| 171 | + | Self::Revert => "timing-revert", | |
| 172 | + | Self::Clear => "timing-clear", | |
| 173 | + | Self::Dismiss => "timing-dismiss", | |
| 174 | + | Self::Debounce => "timing-debounce", | |
| 175 | + | } | |
| 176 | + | } | |
| 177 | + | ||
| 178 | + | /// The CSS value, as a `ms` time. | |
| 179 | + | /// | |
| 180 | + | /// Milliseconds rather than seconds at every rung, including the ones that | |
| 181 | + | /// divide evenly: a stylesheet where some durations read `1.5s` and others | |
| 182 | + | /// `150ms` cannot be scanned for the odd one out. | |
| 183 | + | #[must_use] | |
| 184 | + | pub fn css(self) -> String { | |
| 185 | + | format!("{}ms", self.ms()) | |
| 186 | + | } | |
| 187 | + | ||
| 188 | + | /// Every intent, in the order they are emitted. | |
| 189 | + | #[must_use] | |
| 190 | + | pub const fn all() -> [Self; 4] { | |
| 191 | + | [Self::Revert, Self::Clear, Self::Dismiss, Self::Debounce] | |
| 192 | + | } | |
| 193 | + | } | |
| 194 | + | ||
| 195 | + | /// How long a change takes, as opposed to how long a state lasts. | |
| 196 | + | /// | |
| 197 | + | /// See the crate header for why this is not a fifth [`Intent`]. One rung, and | |
| 198 | + | /// it grows from a measurement rather than from the scale looking short. | |
| 199 | + | #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)] | |
| 200 | + | pub enum Motion { | |
| 201 | + | /// A thing leaving: opacity to zero, then gone. | |
| 202 | + | /// | |
| 203 | + | /// The whole of the departure. A renderer that removes the node itself has | |
| 204 | + | /// to wait this out first, or it drops the animation mid-stroke. | |
| 205 | + | Fade, | |
| 206 | + | } | |
| 207 | + | ||
| 208 | + | impl Motion { | |
| 209 | + | /// The duration in whole milliseconds. | |
| 210 | + | #[must_use] | |
| 211 | + | pub const fn ms(self) -> u32 { | |
| 212 | + | match self { | |
| 213 | + | Self::Fade => 300, | |
| 214 | + | } | |
| 215 | + | } | |
| 216 | + | ||
| 217 | + | /// The duration as a [`Duration`]. | |
| 218 | + | #[must_use] | |
| 219 | + | pub const fn duration(self) -> Duration { | |
| 220 | + | Duration::from_millis(self.ms() as u64) | |
| 221 | + | } | |
| 222 | + | ||
| 223 | + | /// The CSS custom property name, without the leading dashes. | |
| 224 | + | #[must_use] | |
| 225 | + | pub const fn token(self) -> &'static str { | |
| 226 | + | match self { | |
| 227 | + | Self::Fade => "motion-fade", | |
| 228 | + | } | |
| 229 | + | } | |
| 230 | + | ||
| 231 | + | /// The CSS value, as a `ms` time. | |
| 232 | + | #[must_use] | |
| 233 | + | pub fn css(self) -> String { | |
| 234 | + | format!("{}ms", self.ms()) | |
| 235 | + | } | |
| 236 | + | ||
| 237 | + | /// Every motion, in the order they are emitted. | |
| 238 | + | #[must_use] | |
| 239 | + | pub const fn all() -> [Self; 1] { | |
| 240 | + | [Self::Fade] | |
| 241 | + | } | |
| 242 | + | } | |
| 243 | + | ||
| 244 | + | /// How long a notice lives, given whether the description calls it transient. | |
| 245 | + | /// | |
| 246 | + | /// The seam this crate was built for. `makeover-layout` documents | |
| 247 | + | /// `Notice::Toast` as "transient, stacked, dismisses itself" and | |
| 248 | + | /// `Notice::transient()` returns true for it — the description says a notice | |
| 249 | + | /// goes away on its own and deliberately says nothing about when. This is | |
| 250 | + | /// when, on the renderer's side of the line: | |
| 251 | + | /// | |
| 252 | + | /// ``` | |
| 253 | + | /// # use makeover_timing::{Intent, notice_lifetime}; | |
| 254 | + | /// // notice_lifetime(makeover_layout::Notice::Toast.transient()) | |
| 255 | + | /// assert_eq!(notice_lifetime(true), Some(Intent::Dismiss.duration())); | |
| 256 | + | /// assert_eq!(notice_lifetime(false), None); | |
| 257 | + | /// ``` | |
| 258 | + | /// | |
| 259 | + | /// Taking the bool rather than the enum is what keeps this crate off | |
| 260 | + | /// `makeover-layout`'s dependency graph, and the bool is exactly what the | |
| 261 | + | /// description asserts. A renderer already holds the `Notice`. | |
| 262 | + | /// | |
| 263 | + | /// `None` is not "the caller decides". It means the notice has no lifetime: | |
| 264 | + | /// a banner is dismissed by fixing the cause, and an error is a banner. See | |
| 265 | + | /// the crate header on why severity is not a fifth duration. | |
| 266 | + | #[must_use] | |
| 267 | + | pub const fn notice_lifetime(transient: bool) -> Option<Duration> { | |
| 268 | + | if transient { | |
| 269 | + | Some(Intent::Dismiss.duration()) | |
| 270 | + | } else { | |
| 271 | + | None | |
| 272 | + | } | |
| 273 | + | } | |
| 274 | + | ||
| 275 | + | /// Emit the time axis as CSS declarations, no selector. | |
| 276 | + | /// | |
| 277 | + | /// [`Intent::Debounce`] is emitted with the rest even though no stylesheet can | |
| 278 | + | /// use it. The point of the layer is that one document holds every duration in | |
| 279 | + | /// the system; a token that lives here for three of the four intents and in a | |
| 280 | + | /// JS constant for the fourth is the drift this crate exists to end, and a | |
| 281 | + | /// script can read the value back off the computed style. | |
| 282 | + | #[must_use] | |
| 283 | + | pub fn timing_css_declarations() -> String { | |
| 284 | + | let mut out = String::new(); | |
| 285 | + | out.push_str(" /* Time. Named for what is being waited on; the number\n"); | |
| 286 | + | out.push_str(" follows. One duration per intent on every renderer —\n"); | |
| 287 | + | out.push_str(" a divergence here is a bug report, not an axis. */\n"); | |
| 288 | + | for intent in Intent::all() { | |
| 289 | + | let _ = writeln!(out, " --{}: {};", intent.token(), intent.css()); | |
| 290 | + | } | |
| 291 | + | out.push_str("\n /* Motion: how long a change takes, not how long a state\n"); | |
| 292 | + | out.push_str(" lasts. A separate question, so a separate axis. */\n"); | |
| 293 | + | for motion in Motion::all() { | |
| 294 | + | let _ = writeln!(out, " --{}: {};", motion.token(), motion.css()); | |
| 295 | + | } | |
| 296 | + | out | |
| 297 | + | } | |
| 298 | + | ||
| 299 | + | /// Emit the whole time axis as a `:root { … }` block. | |
| 300 | + | /// | |
| 301 | + | /// Mirrors `makeover_geometry::geometry_css_vars`. Like geometry and unlike | |
| 302 | + | /// colour, none of this varies at runtime, so a web consumer bakes it in at | |
| 303 | + | /// build time rather than applying it from JS on load. | |
| 304 | + | #[must_use] | |
| 305 | + | pub fn timing_css_vars() -> String { | |
| 306 | + | format!(":root {{\n{}}}\n", timing_css_declarations()) | |
| 307 | + | } | |
| 308 | + | ||
| 309 | + | /// The time axis as a stylesheet, inside the family's cascade layer. | |
| 310 | + | /// | |
| 311 | + | /// The whole-file entry point, and the one a build script should call. | |
| 312 | + | /// Unlayered declarations outrank every named layer, so generated CSS that | |
| 313 | + | /// stays outside the layer beats the app's own overrides regardless of | |
| 314 | + | /// specificity — which is invisible until the app adopts layers, and then is a | |
| 315 | + | /// puzzle. `makeover_geometry::CSS_LAYER` is the one spelling of the name. | |
| 316 | + | #[must_use] | |
| 317 | + | pub fn timing_css() -> String { | |
| 318 | + | in_css_layer(&timing_css_vars()) | |
| 319 | + | } | |
| 320 | + | ||
| 321 | + | #[cfg(test)] | |
| 322 | + | mod tests { | |
| 323 | + | use super::*; | |
| 324 | + | ||
| 325 | + | #[test] | |
| 326 | + | fn every_intent_resolves_to_one_number_in_three_spellings() { | |
| 327 | + | // ms, Duration and CSS are three renderings of one value, so a rung | |
| 328 | + | // cannot drift between the browser and egui. | |
| 329 | + | for intent in Intent::all() { | |
| 330 | + | assert_eq!(intent.duration().as_millis() as u32, intent.ms()); | |
| 331 | + | assert_eq!(intent.css(), format!("{}ms", intent.ms())); | |
| 332 | + | } | |
| 333 | + | for motion in Motion::all() { | |
| 334 | + | assert_eq!(motion.duration().as_millis() as u32, motion.ms()); | |
| 335 | + | } | |
| 336 | + | } | |
| 337 | + | ||
| 338 | + | #[test] | |
| 339 | + | fn the_measured_values_are_the_ones_the_tree_had() { | |
| 340 | + | // Pinned against the 2026-08-18 count. Changing one of these is a | |
| 341 | + | // design decision about every consumer at once, which is the point of | |
| 342 | + | // the crate; a test failure is the argument happening out loud. | |
| 343 | + | assert_eq!(Intent::Revert.ms(), 1500); | |
| 344 | + | assert_eq!(Intent::Clear.ms(), 2000); | |
| 345 | + | assert_eq!(Intent::Dismiss.ms(), 3000); | |
| 346 | + | assert_eq!(Intent::Debounce.ms(), 150); | |
| 347 | + | assert_eq!(Motion::Fade.ms(), 300); | |
| 348 | + | } | |
| 349 | + | ||
| 350 | + | #[test] | |
| 351 | + | fn a_notice_leaves_after_its_lifetime_and_its_fade() { | |
| 352 | + | // The two numbers the toast class needs, and the reason they are on | |
| 353 | + | // different axes: a renderer that removes the node at Dismiss drops | |
| 354 | + | // the animation, and one that waits Dismiss + Fade is correct. | |
| 355 | + | assert_eq!(notice_lifetime(true), Some(Duration::from_secs(3))); | |
| 356 | + | assert!(Motion::Fade.duration() < Intent::Dismiss.duration()); | |
| 357 | + | } | |
| 358 | + | ||
| 359 | + | #[test] | |
| 360 | + | fn a_notice_that_is_not_transient_has_no_lifetime() { | |
| 361 | + | // Not "the caller decides" — a banner is dismissed by fixing the cause. | |
| 362 | + | // This is where an error toast's second number went. | |
| 363 | + | assert_eq!(notice_lifetime(false), None); | |
| 364 | + | } | |
| 365 | + | ||
| 366 | + | #[test] | |
| 367 | + | fn debounce_is_the_shortest_wait_and_a_notice_the_longest() { | |
| 368 | + | // The ordering is the sanity check on the set: input settling is the | |
| 369 | + | // one wait a user is inside rather than watching, so it is the only | |
| 370 | + | // sub-second rung, and nothing may quietly grow past a notice. | |
| 371 | + | assert!( | |
| 372 | + | Intent::all() | |
| 373 | + | .iter() | |
| 374 | + | .all(|i| i.ms() >= Intent::Debounce.ms()) | |
| 375 | + | ); | |
| 376 | + | assert!(Intent::all().iter().all(|i| i.ms() <= Intent::Dismiss.ms())); | |
| 377 | + | } | |
| 378 | + | ||
| 379 | + | #[test] | |
| 380 | + | fn no_intent_is_long_enough_to_be_a_poll_interval() { | |
| 381 | + | // A ceiling with an argument behind it: every rung here is a wait a | |
| 382 | + | // reader sits through, and past a few seconds that stops being true. | |
| 383 | + | // A backoff or a health check answers to what it talks to, not here. | |
| 384 | + | assert!(Intent::all().iter().all(|i| i.ms() <= 5_000)); | |
| 385 | + | } | |
| 386 | + | ||
| 387 | + | #[test] | |
| 388 | + | fn the_layer_is_emitted_inside_the_family_layer() { | |
| 389 | + | let css = timing_css(); | |
| 390 | + | assert!(css.starts_with("@layer makeover {\n")); | |
| 391 | + | assert!(css.contains(" :root {")); | |
| 392 | + | assert!(css.trim_end().ends_with('}')); | |
| 393 | + | } | |
| 394 | + | ||
| 395 | + | #[test] | |
| 396 | + | fn every_token_reaches_the_stylesheet_exactly_once() { | |
| 397 | + | let css = timing_css(); | |
| 398 | + | for intent in Intent::all() { | |
| 399 | + | let decl = format!("--{}: {}", intent.token(), intent.css()); | |
| 400 | + | assert_eq!(css.matches(&decl).count(), 1, "{}", intent.token()); | |
| 401 | + | } | |
| 402 | + | for motion in Motion::all() { | |
| 403 | + | let decl = format!("--{}: {}", motion.token(), motion.css()); | |
| 404 | + | assert_eq!(css.matches(&decl).count(), 1, "{}", motion.token()); | |
| 405 | + | } | |
| 406 | + | } | |
| 407 | + | ||
| 408 | + | #[test] | |
| 409 | + | fn tokens_are_prefixed_by_their_axis() { | |
| 410 | + | // `--timing-*` for a state's length, `--motion-*` for a change's. A | |
| 411 | + | // reader scanning the sheet can tell which question a var answers. | |
| 412 | + | assert!( | |
| 413 | + | Intent::all() | |
| 414 | + | .iter() | |
| 415 | + | .all(|i| i.token().starts_with("timing-")) | |
| 416 | + | ); | |
| 417 | + | assert!( | |
| 418 | + | Motion::all() | |
| 419 | + | .iter() | |
| 420 | + | .all(|m| m.token().starts_with("motion-")) | |
| 421 | + | ); | |
| 422 | + | } | |
| 423 | + | ||
| 424 | + | #[test] | |
| 425 | + | fn no_two_rungs_share_a_name_or_a_value() { | |
| 426 | + | // A duplicate name silently overwrites in the cascade; a duplicate | |
| 427 | + | // value is two names for one thing, which is a distinction nobody can | |
| 428 | + | // choose between. | |
| 429 | + | let mut tokens: Vec<&str> = Intent::all().iter().map(|i| i.token()).collect(); | |
| 430 | + | tokens.extend(Motion::all().iter().map(|m| m.token())); | |
| 431 | + | let mut sorted = tokens.clone(); | |
| 432 | + | sorted.sort_unstable(); | |
| 433 | + | sorted.dedup(); | |
| 434 | + | assert_eq!(sorted.len(), tokens.len(), "{tokens:?}"); | |
| 435 | + | ||
| 436 | + | let mut values: Vec<u32> = Intent::all().iter().map(|i| i.ms()).collect(); | |
| 437 | + | values.sort_unstable(); | |
| 438 | + | values.dedup(); | |
| 439 | + | assert_eq!(values.len(), Intent::all().len(), "two intents, one number"); | |
| 440 | + | } | |
| 441 | + | } |