diff --git a/.gitattributes b/.gitattributes
index 39bc8ae..d372ca0 100644
--- a/.gitattributes
+++ b/.gitattributes
@@ -1,5 +1,13 @@
-/.git* export-ignore
-/.php-cs-fixer.dist.php export-ignore
-/phpstan.dist.neon export-ignore
-/phpunit.dist.xml export-ignore
-/tests/ export-ignore
+* text=auto eol=lf
+
+/.gitattributes export-ignore
+/.github export-ignore
+/.gitignore export-ignore
+/.php-cs-fixer.dist.php export-ignore
+/docs export-ignore
+/phpstan.neon.dist export-ignore
+/phpunit.xml.dist export-ignore
+/tests export-ignore
+
+/docs/** linguist-documentation
+/tests/Fixtures/** -linguist-detectable
diff --git a/.github/workflows/CI.yml b/.github/workflows/CI.yml
index e36ea4f..21c8d6d 100644
--- a/.github/workflows/CI.yml
+++ b/.github/workflows/CI.yml
@@ -2,9 +2,9 @@ name: CI
on:
push:
- branches: [ "*" ]
+ branches: ["*"]
pull_request:
- branches: [ "*" ]
+ branches: ["*"]
workflow_dispatch:
permissions:
@@ -15,26 +15,17 @@ concurrency:
cancel-in-progress: true
jobs:
-
cs:
uses: altophp/.github/.github/workflows/CS.yml@main
- # with:
- # php-version: '8.5'
- # composer-validate: true
- # php-cs-fixer-args: '--diff --dry-run'
sa:
uses: altophp/.github/.github/workflows/SA.yml@main
- # with:
- # php-version: '8.5'
- # phpstan-args: 'analyse --no-progress --memory-limit=-1'
tests:
strategy:
fail-fast: false
matrix:
- php: ['8.4', '8.5']
+ php: ["8.4", "8.5"]
uses: altophp/.github/.github/workflows/tests.yml@main
with:
php-version: ${{ matrix.php }}
- # phpunit-args: '--colors=never'
diff --git a/.gitignore b/.gitignore
index d8135fb..8346dbb 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,6 +1,8 @@
-/.phpunit.cache/
/vendor/
-/.php-cs-fixer.cache
/composer.lock
-/phpstan.neon
-/phpunit.xml
+/.phpunit.cache/
+/.phpunit.result.cache
+/.php-cs-fixer.cache
+/coverage/
+/coverage.xml
+/clover.xml
diff --git a/.php-cs-fixer.dist.php b/.php-cs-fixer.dist.php
index 39c7b23..1bcabeb 100644
--- a/.php-cs-fixer.dist.php
+++ b/.php-cs-fixer.dist.php
@@ -1,29 +1,28 @@
in(__DIR__)
- ->exclude([
- 'var/',
- ])
-;
+$finder = PhpCsFixer\Finder::create()
+ ->in([__DIR__.'/src', __DIR__.'/tests'])
+ ->exclude('Fixtures');
return (new PhpCsFixer\Config())
- ->setParallelConfig(PhpCsFixer\Runner\Parallel\ParallelConfigFactory::detect())
- ->setFinder($finder)
->setRiskyAllowed(true)
->setRules([
- '@PER-CS' => true,
- '@Symfony' => true,
+ '@PER-CS2.0' => true,
'declare_strict_types' => true,
'header_comment' => ['header' => $licence],
+ 'no_unused_imports' => true,
+ 'ordered_imports' => ['imports_order' => ['class', 'function', 'const']],
+ 'phpdoc_line_span' => ['const' => 'multi', 'property' => 'multi', 'method' => 'multi'],
])
-;
+ ->setFinder($finder);
diff --git a/CHANGELOG.md b/CHANGELOG.md
index f10b7a3..fd837d5 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,5 +1,9 @@
# CHANGELOG
+## [Unreleased]
+
+- Add documentation.
+
## [1.0.0] - 2026-01-17
-* Initial release
+- Initial release.
diff --git a/LICENSE b/LICENSE
index 9310ffc..dc9b29f 100644
--- a/LICENSE
+++ b/LICENSE
@@ -1,6 +1,6 @@
MIT License
-Copyright (c) 2026 Simon André
+Copyright (c) 2026-present Simon André
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
diff --git a/README.md b/README.md
index 4c07f5f..96d5f50 100644
--- a/README.md
+++ b/README.md
@@ -1,204 +1,136 @@
-# Alto \ Scale
+# ALTO Scale
-A strict, mathematical engine for **Modular Scales**.
-It handles the arithmetic of progressions, designed for generative design, typography engines, and rhythmic computation.
+Strict mathematical scales for typography, spacing, grids, and rhythmic design systems.
----
+ 
+ 
+ [](https://packagist.org/packages/alto/scale)
+ 
+ [](https://github.com/sponsors/smnandre)
- [](https://github.com/altophp/scale)
- [](https://packagist.org/packages/alto/scale)
- [](https://github.com/altophp/scale/actions)
- [](https://github.com/altophp/scale)
- [](https://github.com/altophp/scale)
- [](./LICENSE)
+ALTO Scale turns a mathematical progression into predictable design values. Build modular,
+linear, Fibonacci, or multi-strand scales through one small API, then generate ranges, snap
+arbitrary values, infer an existing scale, or lint a collection.
+```php
+use Alto\Scale\Scale;
+
+$type = Scale::majorThird(16);
+
+$type->range(-1, 2);
+// [-1 => 12.8, 0 => 16.0, 1 => 20.0, 2 => 25.0]
+```
+
+The package has no runtime dependencies. Every scale implements the same typed interface and the
+codebase is checked at PHPStan's maximum level.
## Installation
+Install ALTO Scale with Composer:
+
```bash
composer require alto/scale
```
-## Core Concepts
+ALTO Scale requires PHP 8.4 or later.
-### 1. The Scale Facade
+## Quick Start
-Use the `Scale` facade for fluent instantiation of any scale type.
+Create a scale through the `Scale` facade and request the values needed by your design system:
```php
use Alto\Scale\Scale;
-use Alto\Scale\Ratio;
-
-// Modular (Geometric): Base 16px, Ratio 1.25 (Major Third)
-$type = Scale::majorThird(16.0);
-// Linear (Arithmetic): Base 0, Increment 8
-$grid = Scale::linear(0, 8);
+$spacing = Scale::linear(base: 0, increment: 8);
-// Fibonacci: Using Binet's formula
-$fib = Scale::fibonacci();
-
-// Multi-Strand: Interleaved scales (e.g., 16px and 12px strands)
-$strands = Scale::strands([16, 12], Ratio::PerfectFifth);
+echo $spacing->get(3); // 24
+echo $spacing->snap(19); // 16
```
-### 2. Computing Values
+All scales expose `get()`, `stepOf()`, `snap()`, and `range()` and can be iterated over steps zero
+through ten.
-All scales implement the `ScaleInterface` and are `IteratorAggregate`.
+## Scale Types
-```php
-echo $type->get(0); // 16.0
-echo $type->get(1); // 20.0
-echo $type->get(-1); // 12.8
+| Scale | Progression | Typical use |
+| --- | --- | --- |
+| Modular | Multiply by one ratio | Type sizes and proportional spacing |
+| Linear | Add one increment | Baseline grids and fixed spacing |
+| Fibonacci | Follow Fibonacci numbers | Integer rhythms and counts |
+| Multi-strand | Interleave modular scales | Multiple coordinated bases |
-// ModularScale is callable
-echo $type(2); // 25.0
-```
-
-#### Snapping (Normalization)
-
-Find the nearest mathematical step for any arbitrary value.
+Named constructors provide common ratios:
```php
-// Returns 20.0 (The nearest step on a Major Third scale starting at 16)
-$value = $type->snap(19.8);
+$type = Scale::majorThird(16);
+$display = Scale::perfectFifth(48);
+$golden = Scale::golden(1);
```
-#### Iteration & Ranges
+Custom ratios are available through `Scale::modular()`. See the
+[scale guide](docs/scales/index.md) for every progression and its constraints.
-```php
-// Scales are iterable (defaults to steps 0-10)
-foreach ($type as $step => $value) {
- // ...
-}
-
-// Custom ranges
-$values = $type->range(-2, 2);
-// Returns: [-2 => 10.24, -1 => 12.8, 0 => 16.0, 1 => 20.0, 2 => 25.0]
-```
+## Guessing
-### 3. The Guesser (Reverse Engineering)
-
-Analyze messy datasets to find their underlying mathematical scale.
+Infer a modular scale from existing positive values and align the originals to it:
```php
-use Alto\Scale\Scale;
use Alto\Scale\ScaleGuesser;
-$messyValues = [15.9, 20.1, 24.8, 31.5];
-
-// Via the facade
-$scale = Scale::guess($messyValues);
-
-// Or directly with custom tolerance
+$values = [15.9, 20.1, 24.8, 31.5];
$guesser = new ScaleGuesser(tolerance: 0.05);
-$scale = $guesser->guess($messyValues);
-// Align values to the guessed (or provided) scale
-$aligned = $guesser->align($messyValues);
+$scale = $guesser->guess($values);
+$aligned = $guesser->align($values, $scale);
```
-### 4. The Linter (Audit & Fix)
+Read [Guessing a scale](docs/guessing.md) for the estimation rules and limitations.
-Audit datasets for scale compliance and automatically harmonize them.
+## Linting
+
+Audit values against a known scale and normalize deviations:
```php
-use Alto\Scale\ScaleLinter;
use Alto\Scale\Scale;
+use Alto\Scale\ScaleLinter;
$linter = new ScaleLinter(Scale::linear(0, 8));
-// Get a detailed compliance report
$report = $linter->lint([8, 15, 24]);
-// Each entry: ['original', 'suggested', 'step', 'deviation', 'isValid']
-
-// Returns [8.0, 16.0, 24.0]
-$fixed = $linter->fix([8, 15, 24]);
+$fixed = $linter->fix([8, 15, 24]); // [8.0, 16.0, 24.0]
```
-## API Reference
-
-### `Alto\Scale\Scale` (Facade)
-
-| Method | Returns |
-|----------------------------------------------|--------------------|
-| `modular(float $base, float\|Ratio $ratio)` | `ModularScale` |
-| `linear(float $base, float $increment)` | `LinearScale` |
-| `fibonacci(float $multiplier)` | `FibonacciScale` |
-| `strands(array $bases, float\|Ratio $ratio)` | `MultiStrandScale` |
-| `guess(array $values)` | `ModularScale` |
-| `majorThird(float $base)` | `ModularScale` |
-| `perfectFifth(float $base)` | `ModularScale` |
-| `golden(float $base)` | `ModularScale` |
-
-### `Alto\Scale\ScaleInterface`
-
-All scale classes (`ModularScale`, `LinearScale`, `FibonacciScale`, `MultiStrandScale`) implement:
-
-| Method | Description |
-|------------------------------------|-----------------------------------------|
-| `get(int $step): float` | Get the value at a specific step |
-| `stepOf(float $value): int` | Find the closest step index for a value |
-| `snap(float $value): float` | Snap a value to the nearest step |
-| `range(int $min, int $max): array` | Generate values between steps |
-
-### `Alto\Scale\ModularScale`
+Read [Linting values](docs/linting.md) for the report format and inferred-scale behavior. The
+[complete documentation](docs/index.md) also covers installation, the shared API, and each scale
+type.
-Geometric progression: `value = base * (ratio ^ step)`
+## Contributing
-| Method | Description |
-|---------------------------------------------------------|----------------------------------------------|
-| `areHarmonic(float $a, float $b, float $epsilon): bool` | Check if two values are harmonically related |
-| `withBase(float $base): self` | Create a new scale with a different base |
-| `withRatio(float\|Ratio $ratio): self` | Create a new scale with a different ratio |
-| `shift(int $steps): self` | Create a new scale shifted by N steps |
+Contributions of all kinds are welcome. Visit the
+[project on GitHub](https://github.com/altophp/scale) to
+[report a bug](https://github.com/altophp/scale/issues/new),
+[suggest a feature](https://github.com/altophp/scale/issues/new), or
+[open a pull request](https://github.com/altophp/scale/pulls).
-### `Alto\Scale\LinearScale`
+Before submitting code, run:
-Arithmetic progression: `value = base + (step * increment)`
-
-### `Alto\Scale\FibonacciScale`
-
-Fibonacci sequence using Binet's formula, with optional multiplier.
-
-### `Alto\Scale\MultiStrandScale`
-
-Interleaved modular scales sharing a common ratio, useful for multi-strand typographic scales.
-
-### `Alto\Scale\ScaleGuesser`
-
-| Method | Description |
-|-------------------------------------------------------|-----------------------------------|
-| `guess(array $values): ModularScale` | Infer a modular scale from values |
-| `align(array $values, ?ScaleInterface $scale): array` | Snap all values to a scale |
-
-### `Alto\Scale\ScaleLinter`
+```bash
+# Runs PHP CS Fixer, PHPStan, and PHPUnit
+composer qa
+```
-| Method | Description |
-|------------------------------|-------------------------------|
-| `lint(array $values): array` | Audit values against a scale |
-| `fix(array $values): array` | Harmonize values to the scale |
+Changes to public behavior should include tests and documentation.
-### `Alto\Scale\Ratio` (Enum)
+## Support
-Standard musical/typographic ratios:
+ALTO Scale is open source. You can support its continued development through
+[GitHub Sponsors](https://github.com/sponsors/smnandre).
-| Ratio | Value |
-|-------------------|-------|
-| `MinorSecond` | 1.067 |
-| `MajorSecond` | 1.125 |
-| `MinorThird` | 1.200 |
-| `MajorThird` | 1.250 |
-| `PerfectFourth` | 1.333 |
-| `AugmentedFourth` | 1.414 |
-| `PerfectFifth` | 1.500 |
-| `MinorSixth` | 1.600 |
-| `GoldenRatio` | 1.618 |
-| `MajorSixth` | 1.667 |
-| `MinorSeventh` | 1.778 |
-| `MajorSeventh` | 1.875 |
-| `Octave` | 2.000 |
+Sharing this package with others or
+[starring it on GitHub](https://github.com/altophp/scale) is also much
+appreciated.
## License
-This project is licensed under the [MIT License](./LICENSE).
+ALTO Scale is released by [ALTO PHP](https://altophp.com) under the
+[MIT License](LICENSE).
diff --git a/composer.json b/composer.json
index 0679fb0..267985f 100644
--- a/composer.json
+++ b/composer.json
@@ -21,10 +21,11 @@
"email": "smn.andre@gmail.com"
}
],
- "homepage": "https://github.com/altophp/scale",
+ "homepage": "https://altophp.com/scale",
"support": {
"issues": "https://github.com/altophp/scale/issues",
- "docs": "https://github.com/altophp/scale#readme"
+ "source": "https://github.com/altophp/scale",
+ "docs": "https://altophp.com/scale"
},
"funding": [
{
@@ -36,11 +37,10 @@
"php": "^8.4"
},
"require-dev": {
- "friendsofphp/php-cs-fixer": "^3.92.4",
+ "friendsofphp/php-cs-fixer": "^3.94",
"phpstan/phpstan": "^2.1",
"phpunit/phpunit": "^12.5"
},
- "minimum-stability": "stable",
"autoload": {
"psr-4": {
"Alto\\Scale\\": "src/"
@@ -52,19 +52,17 @@
}
},
"config": {
- "allow-plugins": {
- "php-http/discovery": true
- },
"sort-packages": true
},
"scripts": {
- "cs": "vendor/bin/php-cs-fixer fix",
+ "cs": "vendor/bin/php-cs-fixer fix --dry-run --diff --sequential",
+ "cs:fix": "vendor/bin/php-cs-fixer fix --sequential",
"qa": [
"@cs",
"@sa",
"@test"
],
- "sa": "vendor/bin/phpstan analyse",
+ "sa": "vendor/bin/phpstan analyse --memory-limit=-1",
"test": "vendor/bin/phpunit"
}
}
diff --git a/docs/getting-started.md b/docs/getting-started.md
new file mode 100644
index 0000000..5b6555d
--- /dev/null
+++ b/docs/getting-started.md
@@ -0,0 +1,33 @@
+# Getting started
+
+Create a scale through the `Scale` facade, then ask it for the values required
+by the current design system.
+
+```php
+use Alto\Scale\Scale;
+
+$spacing = Scale::linear(base: 0, increment: 8);
+
+echo $spacing->get(1); // 8
+echo $spacing->get(3); // 24
+```
+
+All scales share four operations:
+
+```php
+$spacing->get(4); // 32.0: value at step 4
+$spacing->stepOf(30); // 4: nearest step to 30
+$spacing->snap(30); // 32.0: nearest scale value
+$spacing->range(1, 4); // [1 => 8.0, 2 => 16.0, 3 => 24.0, 4 => 32.0]
+```
+
+Scales are also iterable. Iteration yields steps 0 through 10:
+
+```php
+foreach ($spacing as $step => $value) {
+ printf("%d: %g\n", $step, $value);
+}
+```
+
+Choose a progression from [All scales](scales/index.md). If values already
+exist, use [Guessing](guessing.md) or [Linting](linting.md).
diff --git a/docs/guessing.md b/docs/guessing.md
new file mode 100644
index 0000000..e0b71d5
--- /dev/null
+++ b/docs/guessing.md
@@ -0,0 +1,35 @@
+# Guessing a scale
+
+`ScaleGuesser` estimates a modular scale from at least two positive values.
+It averages consecutive ratios, chooses a nearby named `Ratio` when one falls
+within tolerance, and estimates a shared base.
+
+```php
+use Alto\Scale\ScaleGuesser;
+
+$guesser = new ScaleGuesser(tolerance: 0.05);
+$scale = $guesser->guess([15.9, 20.1, 24.8, 31.5]);
+
+echo $scale->base;
+echo $scale->multiplier;
+```
+
+Use the facade for the default tolerance:
+
+```php
+use Alto\Scale\Scale;
+
+$scale = Scale::guess([16, 20, 25, 31.25]);
+```
+
+## Align the original values
+
+```php
+$aligned = $guesser->align([15.9, 20.1, 24.8, 31.5], $scale);
+```
+
+`align()` snaps positive values and preserves zero or negative values. Omit
+the second argument to infer the target scale from the same dataset.
+
+Guessing is an estimate, not statistical model selection. Check the returned
+base and ratio before making it part of a design-system contract.
diff --git a/docs/index.md b/docs/index.md
new file mode 100644
index 0000000..042719f
--- /dev/null
+++ b/docs/index.md
@@ -0,0 +1,34 @@
+# ALTO Scale
+
+ALTO Scale computes mathematical progressions for typography, spacing, grids,
+and other design values. Every scale can return a step, find the nearest step,
+snap an arbitrary value, and generate a range.
+
+```php
+use Alto\Scale\Scale;
+
+$type = Scale::majorThird(16);
+
+$type->get(-1); // 12.8
+$type->get(0); // 16.0
+$type->get(1); // 20.0
+Scale::majorThird(16)->snap(19.8); // 20.0
+```
+
+## Introduction
+
+- [Installation](installation.md): install the package and verify the runtime.
+- [Getting started](getting-started.md): create and use a first scale.
+
+## Scales
+
+- [All scales](scales/index.md): choose a progression for the values you need.
+- [Modular](scales/modular.md): generate a geometric progression from a base and ratio.
+- [Linear](scales/linear.md): generate values separated by a constant increment.
+- [Fibonacci](scales/fibonacci.md): generate a scaled Fibonacci sequence.
+- [Multi-strand](scales/multi-strand.md): interleave several modular progressions.
+
+## Analysis
+
+- [Guessing](guessing.md): infer a modular scale from existing positive values.
+- [Linting](linting.md): audit values and align them to a scale.
diff --git a/docs/installation.md b/docs/installation.md
new file mode 100644
index 0000000..e3d2703
--- /dev/null
+++ b/docs/installation.md
@@ -0,0 +1,26 @@
+# Installation
+
+ALTO Scale requires PHP 8.4 or later and has no runtime dependencies.
+
+```bash
+composer require alto/scale
+```
+
+## Verify the installation
+
+```php
+get(3);
+```
+
+The script prints `24`.
+
+Invalid bases, ratios, increments, ranges, or datasets raise
+`Alto\Scale\Exception\ScaleException`.
diff --git a/docs/linting.md b/docs/linting.md
new file mode 100644
index 0000000..8d3c6f6
--- /dev/null
+++ b/docs/linting.md
@@ -0,0 +1,41 @@
+# Linting values
+
+`ScaleLinter` compares positive values with a supplied scale and reports the
+nearest step, suggested value, absolute deviation, and validity.
+
+```php
+use Alto\Scale\Scale;
+use Alto\Scale\ScaleLinter;
+
+$linter = new ScaleLinter(
+ scale: Scale::linear(0, 8),
+ tolerance: 0.5,
+);
+
+$report = $linter->lint([8.0, 15.0, 24.0]);
+```
+
+Each report entry contains:
+
+```php
+[
+ 'original' => 15.0,
+ 'suggested' => 16.0,
+ 'step' => 2,
+ 'deviation' => 1.0,
+ 'isValid' => false,
+];
+```
+
+Non-positive values are omitted from the report.
+
+## Fix a collection
+
+```php
+$fixed = $linter->fix([8.0, 15.0, 24.0]);
+// [8.0, 16.0, 24.0]
+```
+
+`fix()` preserves non-positive values and snaps every positive value. If no
+scale is supplied to the constructor, the linter first uses `ScaleGuesser`
+with the same tolerance.
diff --git a/docs/scales/fibonacci.md b/docs/scales/fibonacci.md
new file mode 100644
index 0000000..ec6380b
--- /dev/null
+++ b/docs/scales/fibonacci.md
@@ -0,0 +1,29 @@
+# Fibonacci scales
+
+A Fibonacci scale generates the familiar integer sequence and applies an
+optional positive multiplier.
+
+```php
+use Alto\Scale\Scale;
+
+$sequence = Scale::fibonacci();
+
+$values = Scale::fibonacci()->range(0, 8);
+// [0.0, 1.0, 1.0, 2.0, 3.0, 5.0, 8.0, 13.0, 21.0]
+```
+
+Use a multiplier when the sequence represents a larger unit:
+
+```php
+$rhythm = Scale::fibonacci(multiplier: 4);
+
+$rhythm->get(5); // 20.0
+$rhythm->stepOf(19); // 5
+Scale::fibonacci(multiplier: 4)->snap(19); // 20.0
+```
+
+Negative steps follow the negafibonacci sequence. Lookup of a non-positive
+value returns step zero.
+
+The implementation uses Binet's formula and rounds to Fibonacci integers
+before applying the multiplier. The multiplier must be positive.
diff --git a/docs/scales/index.md b/docs/scales/index.md
new file mode 100644
index 0000000..ee7791f
--- /dev/null
+++ b/docs/scales/index.md
@@ -0,0 +1,27 @@
+# Scale types
+
+Choose the progression according to the relationship between consecutive
+values.
+
+| Scale | Relationship | Typical use |
+| --- | --- | --- |
+| [Modular](modular.md) | Multiply by one ratio | Type sizes, proportional spacing |
+| [Linear](linear.md) | Add one increment | Baseline grids, fixed spacing |
+| [Fibonacci](fibonacci.md) | Follow Fibonacci numbers | Integer rhythms and counts |
+| [Multi-strand](multi-strand.md) | Interleave several modular scales | Multiple coordinated bases |
+
+Every type implements `ScaleInterface`, so application code can accept any
+scale while using `get()`, `stepOf()`, `snap()`, and `range()`.
+
+```php
+use Alto\Scale\ScaleInterface;
+
+function tokens(ScaleInterface $scale): array
+{
+ return $scale->range(-2, 5);
+}
+```
+
+The shared contract does not imply identical domains. Modular and multi-strand
+scales require positive values for logarithmic lookup. Linear scales can cross
+zero. Fibonacci lookup maps non-positive values to step zero.
diff --git a/docs/scales/linear.md b/docs/scales/linear.md
new file mode 100644
index 0000000..4a28ed2
--- /dev/null
+++ b/docs/scales/linear.md
@@ -0,0 +1,32 @@
+# Linear scales
+
+A linear scale adds the same increment at every step:
+
+```text
+value = base + step × increment
+```
+
+Use it for baseline grids, fixed spacing systems, and other arithmetic
+progressions.
+
+```php
+use Alto\Scale\Scale;
+
+$grid = Scale::linear(base: 0, increment: 8);
+
+$values = Scale::linear(base: 0, increment: 8)->range(-1, 4);
+// [-1 => -8.0, 0 => 0.0, 1 => 8.0, 2 => 16.0, 3 => 24.0, 4 => 32.0]
+```
+
+Unlike logarithmic scales, a linear scale can contain zero and negative
+values.
+
+```php
+$grid->stepOf(19); // 2
+$grid->snap(19); // 16.0
+```
+
+Nearest-step lookup uses normal rounding. Values exactly between two steps
+follow PHP's default half-up rounding.
+
+The increment must be positive. An invalid increment raises `ScaleException`.
diff --git a/docs/scales/modular.md b/docs/scales/modular.md
new file mode 100644
index 0000000..309563a
--- /dev/null
+++ b/docs/scales/modular.md
@@ -0,0 +1,55 @@
+# Modular scales
+
+A modular scale is a geometric progression:
+
+```text
+value = base × ratio ^ step
+```
+
+Use it when each step should be proportionally larger or smaller than the
+previous one.
+
+```php
+use Alto\Scale\Ratio;
+use Alto\Scale\Scale;
+
+$type = Scale::modular(base: 16, ratio: Ratio::MajorThird);
+
+$values = Scale::modular(base: 16, ratio: Ratio::MajorThird)->range(-2, 3);
+// [10.24, 12.8, 16.0, 20.0, 25.0, 31.25], keyed from -2 to 3
+```
+
+Named constructors cover common ratios:
+
+```php
+$majorThird = Scale::majorThird(16);
+$perfectFifth = Scale::perfectFifth(16);
+$golden = Scale::golden(16);
+```
+
+`Ratio` includes thirteen established musical and typographic ratios from
+`MinorSecond` (`1.067`) through `Octave` (`2.0`). A positive custom float is
+also accepted.
+
+## Derive another scale
+
+Modular scales are immutable:
+
+```php
+$compact = $type->withBase(14);
+$wider = $type->withRatio(Ratio::PerfectFifth);
+$shifted = $type->shift(2);
+```
+
+`shift(2)` returns an equivalent scale whose base is the original step two.
+The object is callable, so `$type(2)` is equivalent to `$type->get(2)`.
+
+## Compare values
+
+```php
+$type->areHarmonic(16, 25); // true
+$type->areHarmonic(16, 24); // false
+```
+
+The method checks whether the logarithmic distance between two positive values
+is a whole number of scale steps within the supplied epsilon.
diff --git a/docs/scales/multi-strand.md b/docs/scales/multi-strand.md
new file mode 100644
index 0000000..5b5cec3
--- /dev/null
+++ b/docs/scales/multi-strand.md
@@ -0,0 +1,29 @@
+# Multi-strand scales
+
+A multi-strand scale interleaves several positive bases that share one modular
+ratio. Use it when two or more families of values must grow together without
+collapsing into one progression.
+
+```php
+use Alto\Scale\Ratio;
+use Alto\Scale\Scale;
+
+$scale = Scale::strands(
+ bases: [12, 16],
+ ratio: Ratio::PerfectFifth,
+);
+
+$scale->range(0, 5);
+// [12.0, 16.0, 18.0, 24.0, 27.0, 36.0]
+```
+
+The bases are filtered to positive values, deduplicated, and sorted before
+steps are assigned. Their original array order is therefore not significant.
+
+```php
+$scale->stepOf(25); // 3
+$scale->snap(25); // 24.0
+```
+
+The ratio must be greater than `1.0`, and at least one positive base must
+remain. Invalid input raises `ScaleException`.
diff --git a/phpstan.dist.neon b/phpstan.dist.neon
deleted file mode 100644
index cb0c143..0000000
--- a/phpstan.dist.neon
+++ /dev/null
@@ -1,5 +0,0 @@
-parameters:
- level: 10
- paths:
- - src/
- treatPhpDocTypesAsCertain: false
diff --git a/phpstan.neon.dist b/phpstan.neon.dist
new file mode 100644
index 0000000..e412539
--- /dev/null
+++ b/phpstan.neon.dist
@@ -0,0 +1,7 @@
+parameters:
+ level: max
+ paths:
+ - src
+ - tests
+ excludePaths:
+ - tests/Fixtures/*
diff --git a/phpunit.dist.xml b/phpunit.xml.dist
similarity index 54%
rename from phpunit.dist.xml
rename to phpunit.xml.dist
index 85047cd..7d96410 100644
--- a/phpunit.dist.xml
+++ b/phpunit.xml.dist
@@ -2,29 +2,20 @@
-
+ failOnWarning="true"
+ failOnNotice="true"
+ failOnDeprecation="true"
+ failOnRisky="true">
-
+
tests
-
-
+
src
-
-
-
-
-
-
-
diff --git a/src/AbstractScale.php b/src/AbstractScale.php
index 5b6d8d7..6e2be59 100644
--- a/src/AbstractScale.php
+++ b/src/AbstractScale.php
@@ -5,7 +5,7 @@
/*
* This file is part of the ALTO library.
*
- * © 2026–present Simon André
+ * © 2026-present Simon André
*
* For full copyright and license information, please see
* the LICENSE file distributed with this source code.
diff --git a/src/Exception/ScaleException.php b/src/Exception/ScaleException.php
index 780516b..68f47df 100644
--- a/src/Exception/ScaleException.php
+++ b/src/Exception/ScaleException.php
@@ -5,7 +5,7 @@
/*
* This file is part of the ALTO library.
*
- * © 2026–present Simon André
+ * © 2026-present Simon André
*
* For full copyright and license information, please see
* the LICENSE file distributed with this source code.
@@ -16,6 +16,4 @@
/**
* @author Simon André
*/
-final class ScaleException extends \RuntimeException
-{
-}
+final class ScaleException extends \RuntimeException {}
diff --git a/src/FibonacciScale.php b/src/FibonacciScale.php
index 0faa36d..43fa38e 100644
--- a/src/FibonacciScale.php
+++ b/src/FibonacciScale.php
@@ -5,7 +5,7 @@
/*
* This file is part of the ALTO library.
*
- * © 2026–present Simon André
+ * © 2026-present Simon André
*
* For full copyright and license information, please see
* the LICENSE file distributed with this source code.
diff --git a/src/LinearScale.php b/src/LinearScale.php
index f359ab5..2e591ac 100644
--- a/src/LinearScale.php
+++ b/src/LinearScale.php
@@ -5,7 +5,7 @@
/*
* This file is part of the ALTO library.
*
- * © 2026–present Simon André
+ * © 2026-present Simon André
*
* For full copyright and license information, please see
* the LICENSE file distributed with this source code.
diff --git a/src/ModularScale.php b/src/ModularScale.php
index b3c9485..0d52328 100644
--- a/src/ModularScale.php
+++ b/src/ModularScale.php
@@ -5,7 +5,7 @@
/*
* This file is part of the ALTO library.
*
- * © 2026–present Simon André
+ * © 2026-present Simon André
*
* For full copyright and license information, please see
* the LICENSE file distributed with this source code.
diff --git a/src/MultiStrandScale.php b/src/MultiStrandScale.php
index 61ce47d..6e48aaf 100644
--- a/src/MultiStrandScale.php
+++ b/src/MultiStrandScale.php
@@ -5,7 +5,7 @@
/*
* This file is part of the ALTO library.
*
- * © 2026–present Simon André
+ * © 2026-present Simon André
*
* For full copyright and license information, please see
* the LICENSE file distributed with this source code.
@@ -22,7 +22,9 @@
*/
final readonly class MultiStrandScale extends AbstractScale
{
- /** @var list */
+ /**
+ * @var list
+ */
private array $sortedBases;
public float $ratio;
@@ -36,7 +38,7 @@ public function __construct(array $bases, float|Ratio $ratio = Ratio::MajorThird
throw new ScaleException('Ratio must be > 1.0');
}
- $validBases = array_filter($bases, fn ($b) => $b > 0);
+ $validBases = array_filter($bases, fn($b) => $b > 0);
if (empty($validBases)) {
throw new ScaleException('At least one positive base required.');
}
diff --git a/src/Ratio.php b/src/Ratio.php
index 0b8751e..aaed071 100644
--- a/src/Ratio.php
+++ b/src/Ratio.php
@@ -5,7 +5,7 @@
/*
* This file is part of the ALTO library.
*
- * © 2026–present Simon André
+ * © 2026-present Simon André
*
* For full copyright and license information, please see
* the LICENSE file distributed with this source code.
diff --git a/src/Scale.php b/src/Scale.php
index c6875ae..ac4de20 100644
--- a/src/Scale.php
+++ b/src/Scale.php
@@ -5,7 +5,7 @@
/*
* This file is part of the ALTO library.
*
- * © 2026–present Simon André
+ * © 2026-present Simon André
*
* For full copyright and license information, please see
* the LICENSE file distributed with this source code.
diff --git a/src/ScaleGuesser.php b/src/ScaleGuesser.php
index 3db929c..2d99f9d 100644
--- a/src/ScaleGuesser.php
+++ b/src/ScaleGuesser.php
@@ -5,7 +5,7 @@
/*
* This file is part of the ALTO library.
*
- * © 2026–present Simon André
+ * © 2026-present Simon André
*
* For full copyright and license information, please see
* the LICENSE file distributed with this source code.
@@ -20,16 +20,14 @@
*/
readonly class ScaleGuesser
{
- public function __construct(public float $tolerance = 0.05)
- {
- }
+ public function __construct(public float $tolerance = 0.05) {}
/**
* @param array $values
*/
public function guess(array $values): ModularScale
{
- $clean = array_values(array_unique(array_filter($values, fn ($v) => $v > 0)));
+ $clean = array_values(array_unique(array_filter($values, fn($v) => $v > 0)));
sort($clean);
if (count($clean) < 2) {
throw new ScaleException('Need 2+ values to guess.');
@@ -82,6 +80,6 @@ public function align(array $values, ?ScaleInterface $scale = null): array
{
$target = $scale ?? $this->guess($values);
- return array_map(fn (float $v) => $v > 0 ? $target->snap($v) : $v, $values);
+ return array_map(fn(float $v) => $v > 0 ? $target->snap($v) : $v, $values);
}
}
diff --git a/src/ScaleInterface.php b/src/ScaleInterface.php
index c0fd838..71bb8e3 100644
--- a/src/ScaleInterface.php
+++ b/src/ScaleInterface.php
@@ -5,7 +5,7 @@
/*
* This file is part of the ALTO library.
*
- * © 2026–present Simon André
+ * © 2026-present Simon André
*
* For full copyright and license information, please see
* the LICENSE file distributed with this source code.
diff --git a/src/ScaleLinter.php b/src/ScaleLinter.php
index 8921bbd..b15892f 100644
--- a/src/ScaleLinter.php
+++ b/src/ScaleLinter.php
@@ -5,7 +5,7 @@
/*
* This file is part of the ALTO library.
*
- * © 2026–present Simon André
+ * © 2026-present Simon André
*
* For full copyright and license information, please see
* the LICENSE file distributed with this source code.
@@ -18,9 +18,7 @@
*/
readonly class ScaleLinter
{
- public function __construct(public ?ScaleInterface $scale = null, public float $tolerance = 0.05)
- {
- }
+ public function __construct(public ?ScaleInterface $scale = null, public float $tolerance = 0.05) {}
/**
* @param array $values
@@ -56,6 +54,6 @@ public function fix(array $values): array
{
$scale = $this->scale ?? (new ScaleGuesser($this->tolerance))->guess($values);
- return array_map(fn (float $v) => $v > 0 ? $scale->snap($v) : $v, $values);
+ return array_map(fn(float $v) => $v > 0 ? $scale->snap($v) : $v, $values);
}
}
diff --git a/tests/FibonacciScaleTest.php b/tests/FibonacciScaleTest.php
index 0efba56..b7d25d3 100644
--- a/tests/FibonacciScaleTest.php
+++ b/tests/FibonacciScaleTest.php
@@ -5,7 +5,7 @@
/*
* This file is part of the ALTO library.
*
- * © 2026–present Simon André
+ * © 2026-present Simon André
*
* For full copyright and license information, please see
* the LICENSE file distributed with this source code.
diff --git a/tests/LinearScaleTest.php b/tests/LinearScaleTest.php
index f205f4b..d12fb67 100644
--- a/tests/LinearScaleTest.php
+++ b/tests/LinearScaleTest.php
@@ -5,7 +5,7 @@
/*
* This file is part of the ALTO library.
*
- * © 2026–present Simon André
+ * © 2026-present Simon André
*
* For full copyright and license information, please see
* the LICENSE file distributed with this source code.
diff --git a/tests/ModularScaleTest.php b/tests/ModularScaleTest.php
index 8abd258..2a0e259 100644
--- a/tests/ModularScaleTest.php
+++ b/tests/ModularScaleTest.php
@@ -5,7 +5,7 @@
/*
* This file is part of the ALTO library.
*
- * © 2026–present Simon André
+ * © 2026-present Simon André
*
* For full copyright and license information, please see
* the LICENSE file distributed with this source code.
diff --git a/tests/MultiStrandScaleTest.php b/tests/MultiStrandScaleTest.php
index efff211..247a333 100644
--- a/tests/MultiStrandScaleTest.php
+++ b/tests/MultiStrandScaleTest.php
@@ -5,7 +5,7 @@
/*
* This file is part of the ALTO library.
*
- * © 2026–present Simon André
+ * © 2026-present Simon André
*
* For full copyright and license information, please see
* the LICENSE file distributed with this source code.
diff --git a/tests/RatioTest.php b/tests/RatioTest.php
index 554d8cd..3aa4bc1 100644
--- a/tests/RatioTest.php
+++ b/tests/RatioTest.php
@@ -5,7 +5,7 @@
/*
* This file is part of the ALTO library.
*
- * © 2026–present Simon André
+ * © 2026-present Simon André
*
* For full copyright and license information, please see
* the LICENSE file distributed with this source code.
diff --git a/tests/ScaleFacadeTest.php b/tests/ScaleFacadeTest.php
index 94db170..93f2a92 100644
--- a/tests/ScaleFacadeTest.php
+++ b/tests/ScaleFacadeTest.php
@@ -5,7 +5,7 @@
/*
* This file is part of the ALTO library.
*
- * © 2026–present Simon André
+ * © 2026-present Simon André
*
* For full copyright and license information, please see
* the LICENSE file distributed with this source code.
diff --git a/tests/ScaleGuesserTest.php b/tests/ScaleGuesserTest.php
index 03be9d8..3bbcbc5 100644
--- a/tests/ScaleGuesserTest.php
+++ b/tests/ScaleGuesserTest.php
@@ -5,7 +5,7 @@
/*
* This file is part of the ALTO library.
*
- * © 2026–present Simon André
+ * © 2026-present Simon André
*
* For full copyright and license information, please see
* the LICENSE file distributed with this source code.
diff --git a/tests/ScaleLinterTest.php b/tests/ScaleLinterTest.php
index 388d226..f776f0e 100644
--- a/tests/ScaleLinterTest.php
+++ b/tests/ScaleLinterTest.php
@@ -5,7 +5,7 @@
/*
* This file is part of the ALTO library.
*
- * © 2026–present Simon André
+ * © 2026-present Simon André
*
* For full copyright and license information, please see
* the LICENSE file distributed with this source code.