Let <x-icon> pick the optical size 20 cut

M3's optical size axis redraws a symbol so its strokes look equally heavy at
every size; drawing the 24 cut at 20px thins them by about a sixth (styles.md
§ Icons). `optical` picks the cut — 24 by default, 20 for an icon drawn at 20px
or smaller — and combines with `filled`; anything else falls back to 24, so no
caller can land on a folder that does not exist. SvgFile::symbol() takes the
size as a fourth argument and resolves the `-20` folders; the catalogue is the
same in both cuts, so symbolNames() and DesignGuard's icon-name check are
unchanged. The callers that size their own icons pass it in a later step.

Plan step 10; finding C16.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Qwx5USif3wFFmxtHg5U1g9
This commit is contained in:
Andreas Reinhold / reini
2026-09-14 04:04:32 +02:00
co-authored by Claude Fable 5.1
parent c22d693334
commit 699827d696
4 changed files with 89 additions and 15 deletions
@@ -179,15 +179,18 @@ Your export is ready.
### `<x-icon>`
A Material Symbol (Rounded, weight 400, grade 0, 24px), inline. Every symbol on fonts.google.com/icons exists, by Google's name with underscores. An unknown name throws.
A Material Symbol (Rounded, weight 400, grade 0), inline. Every symbol on fonts.google.com/icons exists, by Google's name with underscores. An unknown name throws.
| Prop | Default | |
|---|---|---|
| `name` | required | `calendar_month`, `cloud_upload`, `content_copy` |
| `filled` | `false` | the filled symbol — M3 uses it for active or selected |
| `optical` | `24` | the cut the glyph is drawn from, `24` or `20`; anything else falls back to 24 |
| `label` | `null` | names the icon for screen readers when it carries the meaning alone; otherwise it is `aria-hidden` |
24px (`size-6`) unless a `size-*`, `w-*` or `h-*` class is passed. Colour follows the text: `<x-icon name="lock" class="size-5 text-on-surface-variant" />`.
24px (`size-6`) unless a `size-*`, `w-*` or `h-*` class is passed. Colour follows the text: `<x-icon name="lock" class="size-5 text-on-surface-variant" optical="20" />`.
M3's optical size axis redraws a symbol so its strokes look equally heavy at every size, so **an icon drawn at 20px or smaller (`size-5` and below) takes `optical="20"`**; scaling the 24 cut down thins its strokes by about a sixth. The cut is not a size — pass both, and where a component sizes an icon for you, pass the cut alongside the size class.
### `<x-shape>`
+11 -2
View File
@@ -1,9 +1,17 @@
{{-- A Material Symbol (Rounded, weight 400, grade 0, optical size 24), inline.
{{-- A Material Symbol (Rounded, weight 400, grade 0), inline.
`name` is Google's name for it, underscores and all (`arrow_back`), as listed on
fonts.google.com/icons; every symbol Google publishes is available, and an unknown name
throws. `filled` draws the filled state, which in M3 means active or selected.
`optical` is the cut the glyph is drawn from, `24` (the default) or `20`; anything else
falls back to 24, and `filled` combines with either. M3's optical size axis redraws a
symbol so its strokes look equally heavy at every size, which means an icon drawn at
20px or smaller uses the 20 cut: scaling the 24 cut down to 20px instead makes its
strokes about a sixth too thin (docs/reference/m3/styles.md § Icons). The cut and the
size are separate decisions, so a caller that draws `size-5` passes `optical="20"`
with it; the components that size their own icons do that for you.
Decorative by default and hidden from screen readers, because nearly every icon sits
beside words that already say what it means. Pass `label` when the icon alone carries
the meaning.
@@ -16,6 +24,7 @@
'name',
'filled' => false,
'label' => null,
'optical' => 24,
])
@php
@@ -31,4 +40,4 @@
]));
@endphp
{{ \NoNameWeb\LivewireMaterial\Support\SvgFile::symbol($name, (bool) $filled, $attributes) }}
{{ \NoNameWeb\LivewireMaterial\Support\SvgFile::symbol($name, (bool) $filled, $attributes, (int) $optical) }}
+14 -4
View File
@@ -10,7 +10,7 @@ use InvalidArgumentException;
* Inline SVGs from the package's own folders: Material Symbols and M3 Expressive shapes.
*
* Not blade-icons: its view-factory hook registers one Blade component per icon on every
* request, which for the ~8,000 symbol files here would be ~8,000 registrations each time.
* request, which for the ~16,500 symbol files here would be ~16,500 registrations each time.
* A file is read the first time it is drawn and kept for the life of the worker; the
* contents never change while the application runs, so nothing request-specific is held.
*/
@@ -19,13 +19,22 @@ class SvgFile
/** @var array<string, string> */
protected static array $contents = [];
public static function symbol(string $name, bool $filled, ComponentAttributeBag $attributes): HtmlString
/**
* One symbol, from the cut drawn for the optical size it will be seen at.
*
* The package ships Google's 24 and 20 cuts; the 20 one lives in a `-20` folder under the
* same names, so a symbol that exists exists in both. Any optical size but 20 draws the
* standard 24 cut, which is also what a caller who says nothing gets.
*/
public static function symbol(string $name, bool $filled, ComponentAttributeBag $attributes, int $optical = 24): HtmlString
{
if (preg_match('/^[a-z0-9_]+$/', $name) !== 1) {
throw new InvalidArgumentException("[{$name}] is not a Material Symbol name. Symbols are named in lowercase with underscores, as on fonts.google.com/icons: `calendar_month`, `arrow_back`.");
}
$path = static::directory('svg/symbols/'.($filled ? 'filled' : 'outlined'))."/{$name}.svg";
$folder = ($filled ? 'filled' : 'outlined').($optical === 20 ? '-20' : '');
$path = static::directory('svg/symbols/'.$folder)."/{$name}.svg";
if (! is_file($path)) {
throw new InvalidArgumentException("There is no Material Symbol named [{$name}].");
@@ -54,7 +63,8 @@ class SvgFile
}
/**
* The names of every Material Symbol the package ships.
* The names of every Material Symbol the package ships, in either cut: Google draws the
* same catalogue at optical size 24 and at 20, so one folder answers for all four.
*
* @return list<string>
*/
+59 -7
View File
@@ -3,6 +3,17 @@
use Illuminate\View\ViewException;
use NoNameWeb\LivewireMaterial\Support\SvgFile;
/**
* What a symbol file draws, from its first shape on: the part that tells one cut from
* another, since every file opens with the same `<svg>` and viewBox.
*/
function symbolGeometry(string $file): string
{
$svg = trim((string) file_get_contents(__DIR__.'/../../../resources/svg/symbols/'.$file.'.svg'));
return substr($svg, (int) strpos($svg, '><') + 1);
}
it('draws a symbol inline, hidden from screen readers, 24px by default', function () {
$this->blade('<x-icon name="calendar_month" />')
->assertSee('class="shrink-0 size-6"', false)
@@ -32,6 +43,34 @@ it('draws the filled symbol when asked', function () {
->and($filled)->toContain('<path d=');
});
it('draws the optical size 20 cut when the caller asks for it', function () {
$standard = (string) $this->blade('<x-icon name="home" class="size-5" />');
$dense = (string) $this->blade('<x-icon name="home" class="size-5" optical="20" />');
expect($dense)->not->toBe($standard)
->and($dense)->toContain('class="shrink-0 size-5"')
->and($dense)->toContain('fill="currentColor"')
->and($dense)->toContain(symbolGeometry('outlined-20/home'));
});
it('combines the filled state with the 20 cut', function () {
$outlined = (string) $this->blade('<x-icon name="favorite" optical="20" />');
$filled = (string) $this->blade('<x-icon name="favorite" optical="20" filled />');
expect($filled)->not->toBe($outlined)
->and($outlined)->toContain(symbolGeometry('outlined-20/favorite'))
->and($filled)->toContain(symbolGeometry('filled-20/favorite'))
->and($filled)->not->toContain(symbolGeometry('filled/favorite'));
});
it('falls back to the 24 cut for an optical size it does not ship', function () {
$standard = (string) $this->blade('<x-icon name="home" />');
expect((string) $this->blade('<x-icon name="home" optical="40" />'))->toBe($standard)
->and((string) $this->blade('<x-icon name="home" optical="24" />'))->toBe($standard)
->and((string) $this->blade('<x-icon name="home" optical="" />'))->toBe($standard);
});
it('refuses a name that is not a symbol', function () {
expect(fn () => $this->blade('<x-icon name="not_a_symbol_at_all" />'))
->toThrow(ViewException::class, 'There is no Material Symbol named [not_a_symbol_at_all].');
@@ -42,16 +81,29 @@ it('points a Heroicon name at the symbol catalogue', function () {
->toThrow(ViewException::class, 'is not a Material Symbol name');
});
it('ships every symbol outlined and filled, painted in the text colour', function () {
it('ships every symbol outlined and filled in both cuts, painted in the text colour', function () {
$symbols = SvgFile::symbolNames();
$filled = array_map(fn (string $file): string => basename($file, '.svg'), glob(__DIR__.'/../../../resources/svg/symbols/filled/*.svg'));
expect(count($symbols))->toBeGreaterThan(4000)
->and($filled)->toEqual($symbols);
expect(count($symbols))->toBeGreaterThan(4000);
foreach (['filled', 'outlined-20', 'filled-20'] as $folder) {
$names = array_map(fn (string $file): string => basename($file, '.svg'), glob(__DIR__."/../../../resources/svg/symbols/{$folder}/*.svg"));
expect($names)->toEqual($symbols, "{$folder} holds a different catalogue than outlined");
}
foreach (['outlined', 'filled', 'outlined-20', 'filled-20'] as $folder) {
foreach (['home', 'search', 'cloud_upload', 'content_copy', 'delete'] as $name) {
expect(file_get_contents(__DIR__."/../../../resources/svg/symbols/{$folder}/{$name}.svg"))
->toStartWith('<svg fill="currentColor" xmlns="http://www.w3.org/2000/svg" viewBox="0 -960 960 960"><path')
->not->toContain('width=');
}
}
});
it('draws the two cuts from different geometry, not the same file scaled', function () {
foreach (['home', 'search', 'cloud_upload', 'content_copy', 'delete'] as $name) {
expect(file_get_contents(__DIR__."/../../../resources/svg/symbols/outlined/{$name}.svg"))
->toStartWith('<svg fill="currentColor" xmlns="http://www.w3.org/2000/svg" viewBox="0 -960 960 960"><path')
->not->toContain('width=');
expect(symbolGeometry("outlined-20/{$name}"))->not->toBe(symbolGeometry("outlined/{$name}"))
->and(symbolGeometry("filled-20/{$name}"))->not->toBe(symbolGeometry("filled/{$name}"));
}
});