diff --git a/README.md b/README.md
index d4456f7..0c75fe8 100644
--- a/README.md
+++ b/README.md
@@ -23,7 +23,7 @@ always offer the same palette and sizes.
- Change a token in `app.css`, not in `theme.json`: the root `theme.json` is
only the base (layout, fonts, block styles), and a slug defined there wins
over `@theme`.
-- The font is the exception: Inter needs a `fontFace` declaration, which only
+- The font is the exception: Inter (the variable font, `InterVariable.woff2`) needs a `fontFace` declaration, which only
`theme.json` can hold, so fonts are declared there and `vite.config.js` turns
their generation off (`disableTailwindFonts`). The same option exists for
colours, font sizes and radii.
@@ -32,6 +32,27 @@ always offer the same palette and sizes.
See [Theme.json and Vite Build Integration](https://pollora.dev/theming/theme-structure/)
for the details.
+## Gutenberg design system
+
+Every core block is styled in `theme.json` (`styles`: root, elements, blocks),
+from the presets above only, so a paragraph, a quote, a table or a button look
+the same in the editor and on the page. Block-specific touches that
+`theme.json` has no property for (the quote's gradient border, the table's
+rules) sit in each block's `css` field, which the editor loads too.
+
+- Reference presets by the name WordPress prints: `5xl` becomes
+ `var(--wp--preset--font-size--5-xl)`. Never a Tailwind variable
+ (`var(--text-xl)`): it does not exist in the editor.
+- A block's `css` takes one selector per rule; WordPress wraps it in
+ `:root :where(...)` and breaks on `a, b`.
+- `app/Cms/StyleLayers.php` puts WordPress's CSS in cascade layers, declared at
+ the top of `app.css`: `theme, base, wp-core, wp, components, utilities`. The
+ block library and the global styles beat Tailwind's reset, and a Tailwind
+ class in a Blade template always beats them. A link in the templates that
+ should not look like a content link says so with `no-underline`.
+- `php bin/tests/run.php` checks that every preset and custom variable the
+ styles use exists.
+
## Contributing
### Development Setup
diff --git a/app/Cms/StyleLayers.php b/app/Cms/StyleLayers.php
new file mode 100644
index 0000000..a4d554d
--- /dev/null
+++ b/app/Cms/StyleLayers.php
@@ -0,0 +1,98 @@
+registered as $handle => $style) {
+ $layer = $this->layerFor((string) $handle);
+
+ if ($layer === null || empty($style->extra['after']) || ! empty($style->extra['theme_layered'])) {
+ continue;
+ }
+
+ $style->extra['after'] = [self::ORDER."\n@layer {$layer} {\n".implode("\n", (array) $style->extra['after'])."\n}"];
+ $style->extra['theme_layered'] = true;
+ }
+ }
+
+ /**
+ * Load a linked WordPress stylesheet into its layer instead of as a bare .
+ */
+ #[Filter('style_loader_tag')]
+ public function layerLinkedStyle(string $tag, string $handle, string $href, string $media): string
+ {
+ $layer = $this->layerFor($handle);
+
+ if ($layer === null || is_admin()) {
+ return $tag;
+ }
+
+ $mediaQuery = in_array($media, ['', 'all'], true) ? '' : ' '.$media;
+
+ return sprintf(
+ "\n",
+ esc_attr($handle),
+ self::ORDER,
+ esc_url($href),
+ $layer,
+ $mediaQuery
+ );
+ }
+
+ /**
+ * The layer a WordPress style handle belongs to, or null to leave it alone.
+ *
+ * `core-block-supports` (the per-block layout and colours chosen in the
+ * editor) stays unlayered on purpose: it must win over the global styles.
+ */
+ private function layerFor(string $handle): ?string
+ {
+ if ($handle === 'global-styles') {
+ return 'wp';
+ }
+
+ if ($handle === 'classic-theme-styles' || str_starts_with($handle, 'wp-block-')) {
+ return 'wp-core';
+ }
+
+ return null;
+ }
+}
diff --git a/bin/tests/contract.php b/bin/tests/contract.php
index 1a612d2..d80e8dc 100644
--- a/bin/tests/contract.php
+++ b/bin/tests/contract.php
@@ -316,3 +316,135 @@ function checkLoginConfig(): void
: $source.' is '.number_format($size).' bytes; the framework will not inline it';
});
}
+
+/**
+ * WordPress's slug to CSS-variable conversion (_wp_to_kebab_case): `5xl` → `5-xl`.
+ */
+function wpKebab(string $slug): string
+{
+ $slug = preg_replace('/([a-z])([A-Z])/', '$1-$2', $slug) ?? $slug;
+ $slug = preg_replace('/([0-9])([a-zA-Z])/', '$1-$2', $slug) ?? $slug;
+ $slug = preg_replace('/([a-zA-Z])([0-9])/', '$1-$2', $slug) ?? $slug;
+
+ return strtolower($slug);
+}
+
+/**
+ * @return array> preset kind => CSS-variable slugs WordPress will print
+ */
+function designSystemPresets(array $themeJson): array
+{
+ $css = (string) file_get_contents(themePath('resources/assets/css/app.css'));
+ $vite = (string) file_get_contents(themePath('vite.config.js'));
+ $presets = ['color' => [], 'font-size' => [], 'font-family' => [], 'border-radius' => [], 'spacing' => []];
+
+ if (preg_match('/@theme static\s*\{(.*?)\n\}/s', $css, $block)) {
+ preg_match_all('/--(color|text|radius|font)-([a-z0-9-]+?)\s*:/', $block[1], $tokens, PREG_SET_ORDER);
+
+ foreach ($tokens as [, $family, $slug]) {
+ if (str_contains($slug, '--')) {
+ continue;
+ }
+
+ $kind = ['color' => 'color', 'text' => 'font-size', 'radius' => 'border-radius', 'font' => 'font-family'][$family];
+
+ if ($kind === 'font-family' && str_contains($vite, 'disableTailwindFonts: true')) {
+ continue;
+ }
+
+ $presets[$kind][] = wpKebab($slug);
+ }
+ }
+
+ $settings = $themeJson['settings'] ?? [];
+ foreach ([
+ 'color' => $settings['color']['palette'] ?? [],
+ 'font-size' => $settings['typography']['fontSizes'] ?? [],
+ 'font-family' => $settings['typography']['fontFamilies'] ?? [],
+ 'border-radius' => $settings['border']['radiusSizes'] ?? [],
+ 'spacing' => $settings['spacing']['spacingSizes'] ?? [],
+ ] as $kind => $entries) {
+ foreach ($entries as $entry) {
+ $presets[$kind][] = wpKebab((string) ($entry['slug'] ?? ''));
+ }
+ }
+
+ return $presets;
+}
+
+/**
+ * @return list the --wp--custom-- variables settings.custom defines
+ */
+function designSystemCustomVariables(array $custom, string $prefix = ''): array
+{
+ $names = [];
+
+ foreach ($custom as $key => $value) {
+ $name = $prefix.'--'.wpKebab((string) $key);
+ $names = [...$names, ...(is_array($value) ? designSystemCustomVariables($value, $name) : [$name])];
+ }
+
+ return $names;
+}
+
+function checkDesignSystem(): void
+{
+ section('Design system — theme.json styles point at what exists');
+
+ $themeJson = json_decode((string) file_get_contents(themePath('theme.json')), true);
+
+ test('theme.json parses', fn () => is_array($themeJson) ?: 'theme.json is not valid JSON');
+
+ if (! is_array($themeJson)) {
+ return;
+ }
+
+ $styles = json_encode($themeJson['styles'] ?? [], JSON_UNESCAPED_SLASHES);
+ $presets = designSystemPresets($themeJson);
+
+ test('Every preset the styles use is generated', function () use ($styles, $presets) {
+ preg_match_all('/var\(--wp--preset--(color|font-size|font-family|border-radius|spacing)--([a-z0-9-]+)\)/', $styles, $refs, PREG_SET_ORDER);
+ $missing = [];
+
+ foreach ($refs as [, $kind, $slug]) {
+ if (! in_array($slug, $presets[$kind], true)) {
+ $missing[] = "--wp--preset--{$kind}--{$slug}";
+ }
+ }
+
+ // WordPress prints `5xl` as `5-xl`: a reference to the raw slug is silently ignored.
+ return $missing === [] ? true : 'no such preset, the value is dropped: '.implode(', ', array_unique($missing));
+ });
+
+ test('Every custom variable the styles use is defined', function () use ($styles, $themeJson) {
+ $defined = designSystemCustomVariables($themeJson['settings']['custom'] ?? []);
+ preg_match_all('/var\(--wp--custom(--[a-z0-9-]+)\)/', $styles, $refs);
+ $missing = array_diff(array_unique($refs[1]), $defined);
+
+ return $missing === [] ? true : 'not in settings.custom: '.implode(', ', $missing);
+ });
+
+ test('The styles use no Tailwind-only variable', function () use ($styles) {
+ // The editor loads theme.json, not app.css: var(--text-xl) is undefined there.
+ preg_match_all('/var\(--(?!wp--)[a-z][a-z0-9-]*/', $styles, $refs);
+
+ return $refs[0] === [] ? true : 'undefined in the editor: '.implode(', ', array_unique($refs[0]));
+ });
+
+ test('No block css uses a selector list', function () use ($themeJson) {
+ $broken = [];
+
+ foreach ($themeJson['styles']['blocks'] ?? [] as $block => $style) {
+ foreach (explode('}', (string) ($style['css'] ?? '')) as $rule) {
+ $selector = explode('{', $rule)[0];
+
+ // WordPress wraps each selector in :root :where(…) and breaks on `a, b`.
+ if (str_contains(preg_replace('/\([^)]*\)/', '', $selector) ?? $selector, ',')) {
+ $broken[] = $block.': '.trim($selector);
+ }
+ }
+ }
+
+ return $broken === [] ? true : 'one rule per selector: '.implode('; ', $broken);
+ });
+}
diff --git a/bin/tests/run.php b/bin/tests/run.php
index 623dbed..44e6e09 100644
--- a/bin/tests/run.php
+++ b/bin/tests/run.php
@@ -29,6 +29,7 @@
checkViewReferences();
checkNoLeakedCodeName();
checkLoginConfig();
+checkDesignSystem();
checkSyntax();
exit(summary());
diff --git a/resources/assets/css/app.css b/resources/assets/css/app.css
index 49a4c60..f78e1f7 100644
--- a/resources/assets/css/app.css
+++ b/resources/assets/css/app.css
@@ -1,3 +1,10 @@
+/*
+ * WordPress's block library and theme.json styles sit between Tailwind's reset
+ * and its utilities (app/Cms/StyleLayers.php): theme.json styles every block,
+ * a utility class still wins in the Blade templates.
+ */
+@layer theme, base, wp-core, wp, components, utilities;
+
@import "tailwindcss";
@source "../../views";
@@ -54,143 +61,3 @@
--color-border: var(--wp--preset--color--border, #e5e7eb);
}
}
-
-/* WordPress content blocks */
-.entry-content p {
- margin-block: 1.25em;
- line-height: 1.75;
-}
-
-.entry-content > *:first-child {
- margin-top: 0;
-}
-
-.entry-content > *:last-child {
- margin-bottom: 0;
-}
-
-.entry-content blockquote,
-.entry-content .wp-block-quote {
- position: relative;
- margin-block: 2.5em;
- padding: 2rem 2rem 2rem 5rem;
- border: none;
- border-radius: 1.25rem;
- background:
- linear-gradient(var(--color-surface), var(--color-surface)) padding-box,
- linear-gradient(135deg, var(--color-primary-vivid), var(--color-primary), var(--color-secondary)) border-box;
- border: 2px solid transparent;
- font-style: italic;
- color: var(--color-foreground);
- overflow: visible;
- box-shadow: 0 4px 24px -4px color-mix(in srgb, var(--color-primary-vivid) 12%, transparent);
-}
-
-.entry-content blockquote::before,
-.entry-content .wp-block-quote::before {
- content: "\201C";
- position: absolute;
- top: 0.25rem;
- left: 1.25rem;
- font-family: Georgia, serif;
- font-size: 7rem;
- font-style: normal;
- font-weight: 700;
- line-height: 1;
- background: linear-gradient(135deg, var(--color-primary-vivid), var(--color-secondary));
- -webkit-background-clip: text;
- -webkit-text-fill-color: transparent;
- background-clip: text;
- opacity: 0.2;
-}
-
-.entry-content blockquote::after,
-.entry-content .wp-block-quote::after {
- content: "";
- position: absolute;
- bottom: -6px;
- left: 2rem;
- width: 40%;
- height: 6px;
- border-radius: 3px;
- background: linear-gradient(90deg, var(--color-primary-vivid), var(--color-secondary), transparent);
- opacity: 0.5;
-}
-
-.entry-content blockquote p,
-.entry-content .wp-block-quote p {
- margin-block: 0.5em;
-}
-
-.entry-content blockquote p:first-child,
-.entry-content .wp-block-quote p:first-child {
- margin-top: 0;
-}
-
-.entry-content blockquote p:last-child,
-.entry-content .wp-block-quote p:last-child {
- margin-bottom: 0;
-}
-
-.entry-content blockquote cite,
-.entry-content .wp-block-quote cite {
- display: block;
- margin-top: 1em;
- font-size: 0.875em;
- font-style: normal;
- font-weight: 600;
- background: linear-gradient(90deg, var(--color-primary-vivid), var(--color-primary));
- -webkit-background-clip: text;
- -webkit-text-fill-color: transparent;
- background-clip: text;
-}
-
-.entry-content blockquote cite::before,
-.entry-content .wp-block-quote cite::before {
- content: "— ";
-}
-
-.entry-content a {
- color: var(--color-primary);
- text-decoration: underline;
- text-decoration-color: var(--color-primary) / 0.3;
- text-underline-offset: 2px;
- transition: text-decoration-color 0.15s;
-}
-
-.entry-content a:hover {
- text-decoration-color: var(--color-primary);
-}
-
-.entry-content h2 {
- margin-top: 2em;
- margin-bottom: 0.75em;
- font-size: 1.5rem;
- font-weight: 700;
- line-height: 1.3;
- color: var(--color-foreground);
-}
-
-.entry-content h3 {
- margin-top: 1.75em;
- margin-bottom: 0.5em;
- font-size: 1.25rem;
- font-weight: 600;
- line-height: 1.4;
- color: var(--color-foreground);
-}
-
-.entry-content ul,
-.entry-content ol {
- margin-block: 1.25em;
- padding-left: 1.5em;
-}
-
-.entry-content li {
- margin-block: 0.375em;
-}
-
-.entry-content img {
- border-radius: 0.75rem;
- margin-block: 1.5em;
-}
\ No newline at end of file
diff --git a/resources/assets/fonts/Inter-Italic.woff2 b/resources/assets/fonts/Inter-Italic.woff2
deleted file mode 100644
index 4c24ce2..0000000
Binary files a/resources/assets/fonts/Inter-Italic.woff2 and /dev/null differ
diff --git a/resources/assets/fonts/Inter-LICENSE.txt b/resources/assets/fonts/Inter-LICENSE.txt
new file mode 100644
index 0000000..9b2ca37
--- /dev/null
+++ b/resources/assets/fonts/Inter-LICENSE.txt
@@ -0,0 +1,92 @@
+Copyright (c) 2016 The Inter Project Authors (https://github.com/rsms/inter)
+
+This Font Software is licensed under the SIL Open Font License, Version 1.1.
+This license is copied below, and is also available with a FAQ at:
+http://scripts.sil.org/OFL
+
+-----------------------------------------------------------
+SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
+-----------------------------------------------------------
+
+PREAMBLE
+The goals of the Open Font License (OFL) are to stimulate worldwide
+development of collaborative font projects, to support the font creation
+efforts of academic and linguistic communities, and to provide a free and
+open framework in which fonts may be shared and improved in partnership
+with others.
+
+The OFL allows the licensed fonts to be used, studied, modified and
+redistributed freely as long as they are not sold by themselves. The
+fonts, including any derivative works, can be bundled, embedded,
+redistributed and/or sold with any software provided that any reserved
+names are not used by derivative works. The fonts and derivatives,
+however, cannot be released under any other type of license. The
+requirement for fonts to remain under this license does not apply
+to any document created using the fonts or their derivatives.
+
+DEFINITIONS
+"Font Software" refers to the set of files released by the Copyright
+Holder(s) under this license and clearly marked as such. This may
+include source files, build scripts and documentation.
+
+"Reserved Font Name" refers to any names specified as such after the
+copyright statement(s).
+
+"Original Version" refers to the collection of Font Software components as
+distributed by the Copyright Holder(s).
+
+"Modified Version" refers to any derivative made by adding to, deleting,
+or substituting -- in part or in whole -- any of the components of the
+Original Version, by changing formats or by porting the Font Software to a
+new environment.
+
+"Author" refers to any designer, engineer, programmer, technical
+writer or other person who contributed to the Font Software.
+
+PERMISSION AND CONDITIONS
+Permission is hereby granted, free of charge, to any person obtaining
+a copy of the Font Software, to use, study, copy, merge, embed, modify,
+redistribute, and sell modified and unmodified copies of the Font
+Software, subject to the following conditions:
+
+1) Neither the Font Software nor any of its individual components,
+in Original or Modified Versions, may be sold by itself.
+
+2) Original or Modified Versions of the Font Software may be bundled,
+redistributed and/or sold with any software, provided that each copy
+contains the above copyright notice and this license. These can be
+included either as stand-alone text files, human-readable headers or
+in the appropriate machine-readable metadata fields within text or
+binary files as long as those fields can be easily viewed by the user.
+
+3) No Modified Version of the Font Software may use the Reserved Font
+Name(s) unless explicit written permission is granted by the corresponding
+Copyright Holder. This restriction only applies to the primary font name as
+presented to the users.
+
+4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
+Software shall not be used to promote, endorse or advertise any
+Modified Version, except to acknowledge the contribution(s) of the
+Copyright Holder(s) and the Author(s) or with their explicit written
+permission.
+
+5) The Font Software, modified or unmodified, in part or in whole,
+must be distributed entirely under this license, and must not be
+distributed under any other license. The requirement for fonts to
+remain under this license does not apply to any document created
+using the Font Software.
+
+TERMINATION
+This license becomes null and void if any of the above conditions are
+not met.
+
+DISCLAIMER
+THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
+EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
+MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
+OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
+COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
+INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
+DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
+FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
+OTHER DEALINGS IN THE FONT SOFTWARE.
diff --git a/resources/assets/fonts/Inter-Regular.woff2 b/resources/assets/fonts/Inter-Regular.woff2
deleted file mode 100644
index b8699af..0000000
Binary files a/resources/assets/fonts/Inter-Regular.woff2 and /dev/null differ
diff --git a/resources/assets/fonts/InterVariable-Italic.woff2 b/resources/assets/fonts/InterVariable-Italic.woff2
new file mode 100644
index 0000000..b3530f3
Binary files /dev/null and b/resources/assets/fonts/InterVariable-Italic.woff2 differ
diff --git a/resources/assets/fonts/InterVariable.woff2 b/resources/assets/fonts/InterVariable.woff2
new file mode 100644
index 0000000..5a8d3e7
Binary files /dev/null and b/resources/assets/fonts/InterVariable.woff2 differ
diff --git a/resources/views/home.blade.php b/resources/views/home.blade.php
index 68c3584..139d50b 100644
--- a/resources/views/home.blade.php
+++ b/resources/views/home.blade.php
@@ -20,11 +20,11 @@
Ship faster, build smarter.
Read more
diff --git a/resources/views/index.blade.php b/resources/views/index.blade.php
index 42f7373..0a3092d 100644
--- a/resources/views/index.blade.php
+++ b/resources/views/index.blade.php
@@ -55,7 +55,7 @@
@include('parts.search')
-
+
Back to home
diff --git a/resources/views/layouts/app.blade.php b/resources/views/layouts/app.blade.php
index e5a091a..e19cc15 100644
--- a/resources/views/layouts/app.blade.php
+++ b/resources/views/layouts/app.blade.php
@@ -11,14 +11,14 @@