From d8f8d18f539cb29ad31311164e30287f5280a3f9 Mon Sep 17 00:00:00 2001 From: Foscat Date: Sat, 8 Aug 2026 03:16:21 -0500 Subject: [PATCH 01/22] test: verify removed bridge documentation contract --- test/release-docs-contract.test.mjs | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/test/release-docs-contract.test.mjs b/test/release-docs-contract.test.mjs index 177a06f..d447ea3 100644 --- a/test/release-docs-contract.test.mjs +++ b/test/release-docs-contract.test.mjs @@ -74,6 +74,11 @@ const currentGuidanceCorpus = [ ].map((file) => read("docs", "wiki", file)) ].join("\n"); +assert( + !currentGuidanceCorpus.includes("layout-style-css/bridge.css"), + "Current v3 setup documentation must not require the removed bridge.css export." +); + for (const requiredText of [ "3.0.0", "Node.js 20", From 80a052680f337f503c764097357b9315b54ab129 Mon Sep 17 00:00:00 2001 From: Foscat Date: Sat, 8 Aug 2026 13:04:07 -0500 Subject: [PATCH 02/22] docs: clarify removed v3 bridge export --- docs/wiki/Migrating-To-3.0.md | 1 + test/release-docs-contract.test.mjs | 6 +++++- 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/docs/wiki/Migrating-To-3.0.md b/docs/wiki/Migrating-To-3.0.md index ac98c61..7b93bf1 100644 --- a/docs/wiki/Migrating-To-3.0.md +++ b/docs/wiki/Migrating-To-3.0.md @@ -8,6 +8,7 @@ | --- | --- | | `layout-style-css/legacy.css` | Canonical v3 hooks; `legacy.css` is not shipped | | `layout-style-css/integrations/ui-style-kit.css` | Import UI Style Kit paint explicitly; `integrations/ui-style-kit.css` is not shipped | +| `layout-style-css/bridge.css` | Removed; compose UI Style Kit paint explicitly because `bridge.css` is not shipped | | `layout-style-css/css` (`./css`) | `layout-style-css` | | `layout-style-css/css.css` (`./css.css`) | `layout-style-css` | | `layout-style-css/min` (`./min`) | `layout-style-css/min.css` | diff --git a/test/release-docs-contract.test.mjs b/test/release-docs-contract.test.mjs index d447ea3..b2d3284 100644 --- a/test/release-docs-contract.test.mjs +++ b/test/release-docs-contract.test.mjs @@ -67,7 +67,6 @@ const currentGuidanceCorpus = [ "Layout-Primitives.md", "Layout-Recipes.md", "Layout-Styles.md", - "Migrating-To-3.0.md", "Release-And-Publishing.md", "Security-And-Support.md", "UI-Style-Kit-Compatibility.md" @@ -78,6 +77,11 @@ assert( !currentGuidanceCorpus.includes("layout-style-css/bridge.css"), "Current v3 setup documentation must not require the removed bridge.css export." ); +assert( + migration.includes("layout-style-css/bridge.css") && + /bridge\.css[^\n]*(?:removed|not shipped)/i.test(migration), + "Migration documentation must identify layout-style-css/bridge.css as removed from v3." +); for (const requiredText of [ "3.0.0", From c18ed9987463f7cdacb363e7dcb1cdb18189ffd2 Mon Sep 17 00:00:00 2001 From: Foscat Date: Sat, 8 Aug 2026 14:17:57 -0500 Subject: [PATCH 03/22] feat: publish layout style manifest --- manifest.json | 43 ++++++++++++ package.json | 6 +- test/layout-css-contract.test.mjs | 5 +- test/manifest-contract.test.mjs | 105 ++++++++++++++++++++++++++++++ 4 files changed, 156 insertions(+), 3 deletions(-) create mode 100644 manifest.json create mode 100644 test/manifest-contract.test.mjs diff --git a/manifest.json b/manifest.json new file mode 100644 index 0000000..b526e85 --- /dev/null +++ b/manifest.json @@ -0,0 +1,43 @@ +{ + "schemaVersion": 1, + "schemaPolicy": { + "compatibility": "additive-within-major", + "breakingChange": "increment-schemaVersion-before-removing-or-renaming-fields" + }, + "name": "layout-style-css", + "version": "3.0.0", + "entrypoints": { + "default": "./dist/layout-style-css.css", + "minified": "./dist/layout-style-css.min.css", + "foundation": "./dist/foundation.css", + "wrappers": "./dist/wrappers.css", + "primitives": "./dist/primitives.css", + "recipes": "./dist/recipes.css", + "utilities": "./dist/utilities.css", + "personalities": "./dist/personalities.css" + }, + "selectors": { + "stable": [".ly-root", ".ly-wrapper"], + "deprecated": [] + }, + "wrappers": ["compact", "prose", "content", "wide", "full", "breakout"], + "primitives": ["page", "header", "footer", "main", "section", "surface", "readable", "stack", "cluster", "center", "cover", "switcher", "sidebar", "grid", "split", "panes", "media", "reel", "frame", "scroll"], + "recipes": ["app-shell", "dashboard", "docs", "list-detail", "split-hero", "gallery", "card-grid"], + "areas": ["header", "nav", "sidebar", "main", "aside", "footer", "content", "media", "actions", "primary", "secondary"], + "personalities": ["minimal-saas", "bento", "maximalist", "bauhaus", "tactile", "neumorphism", "retrofuturism", "brutalism", "cyberpunk", "y2k", "retro-glass", "f-pattern", "z-pattern", "split-screen", "mondrian", "synthwave"], + "containers": { + "names": ["ly-scope"], + "type": "inline-size" + }, + "thresholds": { + "containerMinWidths": ["42rem", "44rem", "48rem", "52rem", "72rem"], + "viewportMaxHeights": ["44rem", "30rem"] + }, + "tokens": { + "geometry": ["--ly-wrapper-compact", "--ly-wrapper-prose", "--ly-wrapper-content", "--ly-wrapper-wide", "--ly-gap", "--ly-grid-gap", "--ly-stack-gap", "--ly-cluster-gap", "--ly-switcher-threshold", "--ly-sidebar-size", "--ly-sidebar-content-min", "--ly-grid-min", "--ly-split-min", "--ly-pane-min", "--ly-pane-size", "--ly-media-min", "--ly-media-size", "--ly-reel-item-min", "--ly-reel-item-max", "--ly-frame-ratio", "--ly-recipe-rail", "--ly-recipe-aside", "--ly-gallery-min", "--ly-card-grid-min"] + }, + "companions": { + "ui-style-kit-css": ">=2.1.0 <3.0.0", + "interactive-surface-css": ">=1.5.0 <2.0.0" + } +} diff --git a/package.json b/package.json index ef69f25..534edbd 100644 --- a/package.json +++ b/package.json @@ -32,7 +32,7 @@ "pages:build": "node scripts/build-pages.mjs", "lint": "stylelint \"styles/**/*.css\" \"demo/**/*.css\"", "check:demo-js": "node --check demo/demo.js", - "test:static": "node test/layout-css-contract.test.mjs && node test/release-docs-contract.test.mjs", + "test:static": "node --test test/manifest-contract.test.mjs && node test/layout-css-contract.test.mjs && node test/release-docs-contract.test.mjs", "test:demo:quick": "node test/demo-smoke.test.mjs --quick --browser=chromium", "test:demo:chromium": "node test/demo-smoke.test.mjs --browser=chromium", "test:demo:firefox": "node test/demo-smoke.test.mjs --browser=firefox", @@ -69,6 +69,7 @@ "./utilities.css": "./dist/utilities.css", "./personalities.css": "./dist/personalities.css", "./personalities/*.css": "./dist/personalities/*.css", + "./manifest.json": "./manifest.json", "./package.json": "./package.json" }, "files": [ @@ -81,6 +82,7 @@ "dist/recipes.css", "dist/utilities.css", "dist/personalities.css", - "dist/personalities/*.css" + "dist/personalities/*.css", + "manifest.json" ] } diff --git a/test/layout-css-contract.test.mjs b/test/layout-css-contract.test.mjs index 2891cf3..3f9ffe4 100644 --- a/test/layout-css-contract.test.mjs +++ b/test/layout-css-contract.test.mjs @@ -70,6 +70,7 @@ const expectedExports = { "./utilities.css": "./dist/utilities.css", "./personalities.css": "./dist/personalities.css", "./personalities/*.css": "./dist/personalities/*.css", + "./manifest.json": "./manifest.json", "./package.json": "./package.json" }; const expectedPublishedFiles = [ @@ -82,7 +83,8 @@ const expectedPublishedFiles = [ "dist/recipes.css", "dist/utilities.css", "dist/personalities.css", - "dist/personalities/*.css" + "dist/personalities/*.css", + "manifest.json" ]; const flattenedSourceFiles = [ "foundation.css", @@ -140,6 +142,7 @@ const [packReport] = JSON.parse(packResult.stdout); const expectedTarballFiles = [ "LICENSE", "README.md", + "manifest.json", "package.json", "dist/layout-style-css.css", "dist/layout-style-css.min.css", diff --git a/test/manifest-contract.test.mjs b/test/manifest-contract.test.mjs new file mode 100644 index 0000000..378f12c --- /dev/null +++ b/test/manifest-contract.test.mjs @@ -0,0 +1,105 @@ +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import { join } from "node:path"; +import { fileURLToPath } from "node:url"; +import test from "node:test"; + +const root = fileURLToPath(new URL("..", import.meta.url)); +const packageJson = JSON.parse(readFileSync(join(root, "package.json"), "utf8")); +const manifest = JSON.parse(readFileSync(join(root, "manifest.json"), "utf8")); + +const entrypoints = { + default: "./dist/layout-style-css.css", + minified: "./dist/layout-style-css.min.css", + foundation: "./dist/foundation.css", + wrappers: "./dist/wrappers.css", + primitives: "./dist/primitives.css", + recipes: "./dist/recipes.css", + utilities: "./dist/utilities.css", + personalities: "./dist/personalities.css" +}; +const entrypointExports = { + default: ".", + minified: "./min.css", + foundation: "./foundation.css", + wrappers: "./wrappers.css", + primitives: "./primitives.css", + recipes: "./recipes.css", + utilities: "./utilities.css", + personalities: "./personalities.css" +}; +const personalities = [ + "minimal-saas", "bento", "maximalist", "bauhaus", "tactile", "neumorphism", + "retrofuturism", "brutalism", "cyberpunk", "y2k", "retro-glass", "f-pattern", + "z-pattern", "split-screen", "mondrian", "synthwave" +]; +const geometryTokens = [ + "--ly-wrapper-compact", "--ly-wrapper-prose", "--ly-wrapper-content", "--ly-wrapper-wide", + "--ly-gap", "--ly-grid-gap", "--ly-stack-gap", "--ly-cluster-gap", "--ly-switcher-threshold", + "--ly-sidebar-size", "--ly-sidebar-content-min", "--ly-grid-min", "--ly-split-min", + "--ly-pane-min", "--ly-pane-size", "--ly-media-min", "--ly-media-size", "--ly-reel-item-min", + "--ly-reel-item-max", "--ly-frame-ratio", "--ly-recipe-rail", "--ly-recipe-aside", + "--ly-gallery-min", "--ly-card-grid-min" +]; + +test("ecosystem manifest publishes the structural API and package export", () => { + assert.equal(manifest.schemaVersion, 1); + assert.equal(manifest.name, packageJson.name); + assert.equal(manifest.version, packageJson.version); + assert.equal(manifest.schemaPolicy.compatibility, "additive-within-major"); + assert.equal( + manifest.schemaPolicy.breakingChange, + "increment-schemaVersion-before-removing-or-renaming-fields" + ); + assert.deepEqual(manifest.entrypoints, entrypoints); + + for (const [name, entrypoint] of Object.entries(entrypoints)) { + assert.equal( + packageJson.exports[entrypointExports[name]], + entrypoint, + `${entrypoint} must resolve through package exports` + ); + } + assert.equal(packageJson.exports["./manifest.json"], "./manifest.json"); + assert(packageJson.files.includes("manifest.json")); +}); + +test("ecosystem manifest describes real structural selectors, thresholds, and tokens", () => { + assert.deepEqual(manifest.selectors.stable, [".ly-root", ".ly-wrapper"]); + assert.deepEqual(manifest.selectors.deprecated, []); + assert.deepEqual(manifest.wrappers, ["compact", "prose", "content", "wide", "full", "breakout"]); + assert.deepEqual(manifest.primitives, [ + "page", "header", "footer", "main", "section", "surface", "readable", "stack", "cluster", + "center", "cover", "switcher", "sidebar", "grid", "split", "panes", "media", "reel", "frame", "scroll" + ]); + assert.deepEqual(manifest.recipes, [ + "app-shell", "dashboard", "docs", "list-detail", "split-hero", "gallery", "card-grid" + ]); + assert.deepEqual(manifest.areas, [ + "header", "nav", "sidebar", "main", "aside", "footer", "content", "media", "actions", "primary", "secondary" + ]); + assert.deepEqual(manifest.personalities, personalities); + assert.deepEqual(manifest.containers, { names: ["ly-scope"], type: "inline-size" }); + assert.deepEqual(manifest.thresholds, { + containerMinWidths: ["42rem", "44rem", "48rem", "52rem", "72rem"], + viewportMaxHeights: ["44rem", "30rem"] + }); + assert.deepEqual(manifest.tokens.geometry, geometryTokens); + assert.deepEqual(manifest.companions, { + "ui-style-kit-css": ">=2.1.0 <3.0.0", + "interactive-surface-css": ">=1.5.0 <2.0.0" + }); + + const css = Object.values(entrypoints) + .map((entrypoint) => readFileSync(join(root, entrypoint), "utf8")) + .join("\n"); + for (const selector of manifest.selectors.stable) { + assert(css.includes(selector), `${selector} must remain in public CSS`); + } + for (const recipe of manifest.recipes) { + assert(css.includes(`[data-ly-recipe=\"${recipe}\"]`), `${recipe} must remain a recipe hook`); + } + for (const token of manifest.tokens.geometry) { + assert(css.includes(token), `${token} must remain a public geometry token`); + } +}); From 0abd21357f464c2e3b034f7cd42a6c890538148e Mon Sep 17 00:00:00 2001 From: Foscat Date: Sat, 8 Aug 2026 14:33:30 -0500 Subject: [PATCH 04/22] fix: complete layout manifest entrypoints --- manifest.json | 4 +++- test/manifest-contract.test.mjs | 12 ++++++++++-- 2 files changed, 13 insertions(+), 3 deletions(-) diff --git a/manifest.json b/manifest.json index b526e85..fb6e090 100644 --- a/manifest.json +++ b/manifest.json @@ -9,12 +9,14 @@ "entrypoints": { "default": "./dist/layout-style-css.css", "minified": "./dist/layout-style-css.min.css", + "core": "./dist/core.css", "foundation": "./dist/foundation.css", "wrappers": "./dist/wrappers.css", "primitives": "./dist/primitives.css", "recipes": "./dist/recipes.css", "utilities": "./dist/utilities.css", - "personalities": "./dist/personalities.css" + "personalities": "./dist/personalities.css", + "personalityModules": "./dist/personalities/*.css" }, "selectors": { "stable": [".ly-root", ".ly-wrapper"], diff --git a/test/manifest-contract.test.mjs b/test/manifest-contract.test.mjs index 378f12c..87c3d96 100644 --- a/test/manifest-contract.test.mjs +++ b/test/manifest-contract.test.mjs @@ -11,22 +11,26 @@ const manifest = JSON.parse(readFileSync(join(root, "manifest.json"), "utf8")); const entrypoints = { default: "./dist/layout-style-css.css", minified: "./dist/layout-style-css.min.css", + core: "./dist/core.css", foundation: "./dist/foundation.css", wrappers: "./dist/wrappers.css", primitives: "./dist/primitives.css", recipes: "./dist/recipes.css", utilities: "./dist/utilities.css", - personalities: "./dist/personalities.css" + personalities: "./dist/personalities.css", + personalityModules: "./dist/personalities/*.css" }; const entrypointExports = { default: ".", minified: "./min.css", + core: "./core.css", foundation: "./foundation.css", wrappers: "./wrappers.css", primitives: "./primitives.css", recipes: "./recipes.css", utilities: "./utilities.css", - personalities: "./personalities.css" + personalities: "./personalities.css", + personalityModules: "./personalities/*.css" }; const personalities = [ "minimal-saas", "bento", "maximalist", "bauhaus", "tactile", "neumorphism", @@ -91,6 +95,10 @@ test("ecosystem manifest describes real structural selectors, thresholds, and to }); const css = Object.values(entrypoints) + .flatMap((entrypoint) => { + if (!entrypoint.includes("*")) return [entrypoint]; + return personalities.map((personality) => entrypoint.replace("*", personality)); + }) .map((entrypoint) => readFileSync(join(root, entrypoint), "utf8")) .join("\n"); for (const selector of manifest.selectors.stable) { From a5c5897f11915b61481460fcf2f13e3a4c81cd1a Mon Sep 17 00:00:00 2001 From: Foscat Date: Sat, 8 Aug 2026 15:44:31 -0500 Subject: [PATCH 05/22] feat: publish layout personality metadata --- README.md | 3 ++ demo/demo.js | 81 ++++++++++++++++++++++++------- demo/index.html | 21 ++------ docs/wiki/Layout-Styles.md | 23 +++++++++ manifest.json | 3 +- package.json | 2 + personalities.json | 33 +++++++++++++ scripts/build-pages.mjs | 4 +- scripts/build.mjs | 23 ++------- test/demo-smoke.test.mjs | 74 ++++++++++++++++++++++++++++ test/layout-css-contract.test.mjs | 3 ++ test/manifest-contract.test.mjs | 39 ++++++++++++++- test/pages-artifact.test.mjs | 4 +- 13 files changed, 254 insertions(+), 59 deletions(-) create mode 100644 personalities.json diff --git a/README.md b/README.md index e533a72..094a23c 100644 --- a/README.md +++ b/README.md @@ -151,6 +151,7 @@ Each personality is a token/topology profile consumed by the shared recipe engin | `layout-style-css/utilities.css` | Small structural utility set | | `layout-style-css/personalities.css` | All sixteen profiles | | `layout-style-css/personalities/minimal-saas.css` | One profile; substitute any public personality name | +| `layout-style-css/personalities.json` | Public layout-to-visual pairing recommendations | | `layout-style-css/package.json` | Package metadata | The cascade order is `ly.reset`, `ly.tokens`, `ly.wrappers`, `ly.primitives`, `ly.recipes`, `ly.utilities`, and `ly.personalities`. @@ -168,6 +169,8 @@ import "layout-style-css"; This order lets UI Style Kit establish paint and theme roles, Interactive Surface add interaction states, and Layout apply structure. +`data-ly-layout`, `data-ui`, `data-theme`, and `data-mode` are independently selectable. See [Layout Styles](docs/wiki/Layout-Styles.md#visual-pairing-guidance) for the full recommendation matrix; pairings are never dependencies. + ## CDN ```html diff --git a/demo/demo.js b/demo/demo.js index 7cf42be..46b4e00 100644 --- a/demo/demo.js +++ b/demo/demo.js @@ -1,5 +1,6 @@ const UI_STYLE_KIT_VERSION = "2.1.0"; const UI_STYLE_KIT_MANIFEST_URL = `https://unpkg.com/ui-style-kit-css@${UI_STYLE_KIT_VERSION}/manifest.json`; +const PERSONALITY_METADATA_URL = document.querySelector("script[data-personalities-url]")?.dataset.personalitiesUrl; const UI_STYLE_KIT_MANIFEST_FALLBACK = Object.freeze({ version: UI_STYLE_KIT_VERSION, presets: Object.freeze([ @@ -77,6 +78,46 @@ async function loadUiStyleKitManifest() { } const UI_STYLE_KIT_MANIFEST = await loadUiStyleKitManifest(); +async function loadPersonalityMetadata() { + if (!PERSONALITY_METADATA_URL) { + throw new Error("The demo requires a local layout personality metadata URL."); + } + + const response = await fetch(PERSONALITY_METADATA_URL, { cache: "force-cache" }); + + if (!response.ok) { + throw new Error(`Layout personality metadata request failed with HTTP ${response.status}.`); + } + + const metadata = await response.json(); + const personalities = Array.isArray(metadata?.personalities) ? metadata.personalities : []; + const validCompatibility = new Set(["native", "any", "recommended"]); + const normalized = personalities.map((personality) => ({ + id: String(personality?.id ?? ""), + label: String(personality?.label ?? personality?.id ?? ""), + visualCompatibility: String(personality?.visualCompatibility ?? ""), + recommendedVisualPresets: Array.isArray(personality?.recommendedVisualPresets) + ? personality.recommendedVisualPresets.map(String) + : [] + })); + + if ( + metadata?.schemaVersion !== 1 || + normalized.length === 0 || + normalized.some(({ id, label, visualCompatibility }) => + !id || !label || !validCompatibility.has(visualCompatibility) + ) + ) { + throw new Error("Layout personality metadata is missing a valid public pairing contract."); + } + + return Object.freeze({ + schemaVersion: metadata.schemaVersion, + personalities: Object.freeze(normalized.map((personality) => Object.freeze(personality))) + }); +} + +const PERSONALITY_METADATA = await loadPersonalityMetadata(); const ALLOWLISTS = Object.freeze({ device: Object.freeze([ "custom", @@ -97,24 +138,7 @@ const ALLOWLISTS = Object.freeze({ "gallery", "card-grid" ]), - personality: Object.freeze([ - "minimal-saas", - "bauhaus", - "tactile", - "cyberpunk", - "f-pattern", - "brutalism", - "neumorphism", - "y2k", - "retro-glass", - "z-pattern", - "retrofuturism", - "mondrian", - "synthwave", - "bento", - "maximalist", - "split-screen" - ]), + personality: Object.freeze(PERSONALITY_METADATA.personalities.map(({ id }) => id)), container: Object.freeze([ "auto", "20rem", @@ -255,6 +279,8 @@ const stateToggle = document.querySelector("#stateToggle"); const mobileControlsQuery = window.matchMedia("(max-width: 63.999rem)"); body.dataset.uiManifestVersion = UI_STYLE_KIT_MANIFEST.version; +body.dataset.personalityMetadataVersion = String(PERSONALITY_METADATA.schemaVersion); +syncPersonalityMetadataSelectOptions(); syncUiManifestSelectOptions(); let state = readStateFromQuery(); @@ -439,6 +465,25 @@ function syncUiManifestSelectOptions() { } } +function syncPersonalityMetadataSelectOptions() { + const select = controls.personality; + + if (!select) { + return; + } + + select.replaceChildren( + ...PERSONALITY_METADATA.personalities.map(({ id, label, visualCompatibility }) => { + const option = document.createElement("option"); + option.value = id; + option.textContent = label; + option.dataset.visualCompatibility = visualCompatibility; + return option; + }) + ); + select.setAttribute("aria-busy", "false"); +} + function syncUiKitClasses() { const prefix = UI_CLASS_PREFIXES[state.ui]; diff --git a/demo/index.html b/demo/index.html index 3b0bf72..a51d70b 100644 --- a/demo/index.html +++ b/demo/index.html @@ -91,7 +91,7 @@ "publisher": { "@type": "Organization", "name": "Foscat" } } - + Skip to the layout lab @@ -161,23 +161,8 @@

Layout controls

diff --git a/docs/wiki/Layout-Styles.md b/docs/wiki/Layout-Styles.md index 50d68e8..7f0c366 100644 --- a/docs/wiki/Layout-Styles.md +++ b/docs/wiki/Layout-Styles.md @@ -29,6 +29,29 @@ Layout owns structure. UI Style Kit owns paint. Interactive Surface owns interac The personality name does not promise colors, typography, borders, shadows, or interaction states. Pair the same name across libraries when desired, but import each library explicitly and let it own its layer. +## Visual Pairing Guidance + +[`layout-style-css/personalities.json`](../../personalities.json) is the public, machine-readable pairing source used by the demo. Its entries are recommendations, never dependencies: `data-ly-layout`, `data-ui`, `data-theme`, and `data-mode` are independently selectable on the same document. + +| Layout personality | Visual pairing guidance | +| --- | --- | +| Minimal SaaS | Native match: `minimal-saas` | +| Bento | Native match: `bento` | +| Maximalist | Native match: `maximalist` | +| Bauhaus | Native match: `bauhaus` | +| Tactile | Native match: `tactile` | +| Neumorphism | Native match: `neumorphism` | +| Retrofuturism | Native match: `retrofuturism` | +| Brutalism | Native match: `brutalism` | +| Cyberpunk | Native match: `cyberpunk` | +| Y2K | Native match: `y2k` | +| Retro Glass | Native match: `retro-glass` | +| F-pattern | Any visual preset; this is structure only | +| Z-pattern | Any visual preset; this is structure only | +| Split Screen | Any visual preset; this is structure only | +| Mondrian | Any visual preset; this is structure only | +| Synthwave | Recommended: `cyberpunk` or `retrofuturism`; the demo verifies each with a rendered native visual token while retaining `data-ly-layout="synthwave"` | + ## Customization Override public tokens at `.ly-root` or a narrower scope: diff --git a/manifest.json b/manifest.json index fb6e090..65ddbf6 100644 --- a/manifest.json +++ b/manifest.json @@ -16,7 +16,8 @@ "recipes": "./dist/recipes.css", "utilities": "./dist/utilities.css", "personalities": "./dist/personalities.css", - "personalityModules": "./dist/personalities/*.css" + "personalityModules": "./dist/personalities/*.css", + "personalityMetadata": "./personalities.json" }, "selectors": { "stable": [".ly-root", ".ly-wrapper"], diff --git a/package.json b/package.json index 534edbd..72374ff 100644 --- a/package.json +++ b/package.json @@ -69,6 +69,7 @@ "./utilities.css": "./dist/utilities.css", "./personalities.css": "./dist/personalities.css", "./personalities/*.css": "./dist/personalities/*.css", + "./personalities.json": "./personalities.json", "./manifest.json": "./manifest.json", "./package.json": "./package.json" }, @@ -83,6 +84,7 @@ "dist/utilities.css", "dist/personalities.css", "dist/personalities/*.css", + "personalities.json", "manifest.json" ] } diff --git a/personalities.json b/personalities.json new file mode 100644 index 0000000..acda7d3 --- /dev/null +++ b/personalities.json @@ -0,0 +1,33 @@ +{ + "schemaVersion": 1, + "selector": "data-ly-layout", + "independentSelectors": ["data-ly-layout", "data-ui", "data-theme", "data-mode"], + "personalities": [ + { "id": "minimal-saas", "label": "Minimal SaaS", "visualCompatibility": "native", "recommendedVisualPresets": ["minimal-saas"] }, + { "id": "bento", "label": "Bento", "visualCompatibility": "native", "recommendedVisualPresets": ["bento"] }, + { "id": "maximalist", "label": "Maximalist", "visualCompatibility": "native", "recommendedVisualPresets": ["maximalist"] }, + { "id": "bauhaus", "label": "Bauhaus", "visualCompatibility": "native", "recommendedVisualPresets": ["bauhaus"] }, + { "id": "tactile", "label": "Tactile", "visualCompatibility": "native", "recommendedVisualPresets": ["tactile"] }, + { "id": "neumorphism", "label": "Neumorphism", "visualCompatibility": "native", "recommendedVisualPresets": ["neumorphism"] }, + { "id": "retrofuturism", "label": "Retrofuturism", "visualCompatibility": "native", "recommendedVisualPresets": ["retrofuturism"] }, + { "id": "brutalism", "label": "Brutalism", "visualCompatibility": "native", "recommendedVisualPresets": ["brutalism"] }, + { "id": "cyberpunk", "label": "Cyberpunk", "visualCompatibility": "native", "recommendedVisualPresets": ["cyberpunk"] }, + { "id": "y2k", "label": "Y2K", "visualCompatibility": "native", "recommendedVisualPresets": ["y2k"] }, + { "id": "retro-glass", "label": "Retro Glass", "visualCompatibility": "native", "recommendedVisualPresets": ["retro-glass"] }, + { "id": "f-pattern", "label": "F-pattern", "visualCompatibility": "any", "recommendedVisualPresets": [] }, + { "id": "z-pattern", "label": "Z-pattern", "visualCompatibility": "any", "recommendedVisualPresets": [] }, + { "id": "split-screen", "label": "Split Screen", "visualCompatibility": "any", "recommendedVisualPresets": [] }, + { "id": "mondrian", "label": "Mondrian", "visualCompatibility": "any", "recommendedVisualPresets": [] }, + { + "id": "synthwave", + "label": "Synthwave", + "visualCompatibility": "recommended", + "recommendedVisualPresets": ["cyberpunk", "retrofuturism"], + "visualVerification": { + "method": "rendered-computed-style", + "test": "test/demo-smoke.test.mjs", + "assertion": "The independent data-ui selector applies non-empty native visual tokens while data-ly-layout remains synthwave." + } + } + ] +} diff --git a/scripts/build-pages.mjs b/scripts/build-pages.mjs index 22bd99e..45febfb 100644 --- a/scripts/build-pages.mjs +++ b/scripts/build-pages.mjs @@ -43,6 +43,7 @@ await rm(pagesDir, { recursive: true, force: true }); await mkdir(pagesDir, { recursive: true }); await cp(demoDir, pagesDir, { recursive: true }); await cp(distDir, join(pagesDir, "dist"), { recursive: true }); +await cp(join(root, "personalities.json"), join(pagesDir, "personalities.json")); const indexPath = join(pagesDir, "index.html"); const index = await readFile(indexPath, "utf8"); @@ -50,12 +51,13 @@ let pagesIndex = index.replaceAll( "../dist/layout-style-css.css", "./dist/layout-style-css.css" ); +pagesIndex = pagesIndex.replaceAll("../personalities.json", "./personalities.json"); /* * Pages receives immutable asset URLs derived from deployed content, so a demo * hotfix cannot be hidden by a browser cache that still holds the same version. */ -for (const assetPath of ["demo.css", "demo.js", "dist/layout-style-css.css"]) { +for (const assetPath of ["demo.css", "demo.js", "dist/layout-style-css.css", "personalities.json"]) { const assetContents = await readFile(join(pagesDir, ...assetPath.split("/"))); pagesIndex = fingerprintVersionedAsset(pagesIndex, assetPath, assetContents); } diff --git a/scripts/build.mjs b/scripts/build.mjs index 479cfa6..edccad1 100644 --- a/scripts/build.mjs +++ b/scripts/build.mjs @@ -5,6 +5,9 @@ import { fileURLToPath } from "node:url"; const root = fileURLToPath(new URL("..", import.meta.url)); const sourceDir = join(root, "styles"); const distDir = join(root, "dist"); +const personalityMetadata = JSON.parse( + await readFile(join(root, "personalities.json"), "utf8") +); const cascadeLayerPrelude = "@layer ly.reset, ly.tokens, ly.wrappers, ly.primitives, ly.recipes, ly.utilities, ly.personalities;"; @@ -15,24 +18,8 @@ const coreModuleFiles = [ "recipes.css", "utilities.css" ]; -const personalityNames = [ - "minimal-saas", - "bento", - "maximalist", - "bauhaus", - "tactile", - "neumorphism", - "retrofuturism", - "brutalism", - "cyberpunk", - "y2k", - "retro-glass", - "f-pattern", - "z-pattern", - "split-screen", - "mondrian", - "synthwave" -]; +/* Public pairing metadata drives every profile asset built into the package. */ +const personalityNames = personalityMetadata.personalities.map(({ id }) => id); const personalityFiles = personalityNames.map((name) => `personalities/${name}.css`); const authoredEntryFiles = [ "core.css", diff --git a/test/demo-smoke.test.mjs b/test/demo-smoke.test.mjs index d3d9f7e..64e25ed 100644 --- a/test/demo-smoke.test.mjs +++ b/test/demo-smoke.test.mjs @@ -12,6 +12,7 @@ const demoJs = readFileSync(join(root, "demo", "demo.js"), "utf8"); const uiManifest = JSON.parse( readFileSync(join(root, "node_modules", "ui-style-kit-css", "manifest.json"), "utf8") ); +const personalityMetadata = JSON.parse(readFileSync(join(root, "personalities.json"), "utf8")); const companionCssFixtures = Object.freeze({ "ui-style-kit.visual.min.css": readFileSync( join(root, "node_modules", "ui-style-kit-css", "dist", "ui-style-kit.visual.min.css") @@ -460,6 +461,77 @@ const verifyIdentityAndControls = async (page, baseUrl) => { assert.equal(await page.locator("[data-ly-recipe]").getAttribute("data-ly-responsive"), "manual"); }; +const verifyPersonalityOptionsUsePairingMetadata = async (page, baseUrl) => { + const metadataUrl = new URL("../personalities.json?v=3.0.0", baseUrl).toString(); + const pairingFixture = { + schemaVersion: 1, + personalities: [ + { + id: "minimal-saas", + label: "Minimal SaaS", + visualCompatibility: "native", + recommendedVisualPresets: ["minimal-saas"] + }, + { + id: "synthwave", + label: "Synthwave", + visualCompatibility: "recommended", + recommendedVisualPresets: ["cyberpunk", "retrofuturism"] + } + ] + }; + + await page.route(metadataUrl, (route) => + route.fulfill({ + body: JSON.stringify(pairingFixture), + contentType: "application/json", + status: 200 + }) + ); + await page.goto(`${baseUrl}?ecosystem=layout-only`, { waitUntil: "domcontentloaded" }); + await page.waitForFunction(() => document.body.dataset.demoReady === "true"); + + assert.deepEqual( + await page.locator("#personalitySelect option").evaluateAll((options) => + options.map((option) => ({ + value: option.value, + label: option.textContent, + compatibility: option.dataset.visualCompatibility + })) + ), + [ + { value: "minimal-saas", label: "Minimal SaaS", compatibility: "native" }, + { value: "synthwave", label: "Synthwave", compatibility: "recommended" } + ], + "The personality switcher must render the layout pairing metadata it loads." + ); + + await page.unroute(metadataUrl); +}; + +const verifySynthwaveVisualRecommendations = async (page, baseUrl) => { + const synthwave = personalityMetadata.personalities.find(({ id }) => id === "synthwave"); + + assert.deepEqual(synthwave?.recommendedVisualPresets, ["cyberpunk", "retrofuturism"]); + for (const ui of synthwave.recommendedVisualPresets) { + await page.goto( + `${baseUrl}?ecosystem=layout-ui&personality=synthwave&ui=${ui}&theme=cyber-lime&mode=dark`, + { waitUntil: "domcontentloaded" } + ); + await page.waitForFunction(() => document.body.dataset.demoReady === "true"); + + const rendered = await page.evaluate(() => ({ + layout: document.querySelector("#previewRoot")?.dataset.lyLayout, + ui: document.body.dataset.ui, + nativePrimary: getComputedStyle(document.body).getPropertyValue("--usk-native-primary").trim() + })); + + assert.equal(rendered.layout, "synthwave", `${ui} must not override the independent layout selector.`); + assert.equal(rendered.ui, ui); + assert.notEqual(rendered.nativePrimary, "", `${ui} must provide a rendered native visual token.`); + } +}; + const verifyTopologyEdges = async (page, baseUrl) => { await page.setViewportSize({ width: 1920, height: 1080 }); await page.goto(`${baseUrl}?ecosystem=layout-only&wrapper=full`, { @@ -993,6 +1065,8 @@ page.on("console", (message) => { try { await installExternalFixtures(page); + await verifyPersonalityOptionsUsePairingMetadata(page, server.baseUrl); + await verifySynthwaveVisualRecommendations(page, server.baseUrl); await verifyIdentityAndControls(page, server.baseUrl); await verifyTopologyEdges(page, server.baseUrl); await verifyManualAndNearestContainer(page, server.baseUrl); diff --git a/test/layout-css-contract.test.mjs b/test/layout-css-contract.test.mjs index 3f9ffe4..fcdca2e 100644 --- a/test/layout-css-contract.test.mjs +++ b/test/layout-css-contract.test.mjs @@ -70,6 +70,7 @@ const expectedExports = { "./utilities.css": "./dist/utilities.css", "./personalities.css": "./dist/personalities.css", "./personalities/*.css": "./dist/personalities/*.css", + "./personalities.json": "./personalities.json", "./manifest.json": "./manifest.json", "./package.json": "./package.json" }; @@ -84,6 +85,7 @@ const expectedPublishedFiles = [ "dist/utilities.css", "dist/personalities.css", "dist/personalities/*.css", + "personalities.json", "manifest.json" ]; const flattenedSourceFiles = [ @@ -142,6 +144,7 @@ const [packReport] = JSON.parse(packResult.stdout); const expectedTarballFiles = [ "LICENSE", "README.md", + "personalities.json", "manifest.json", "package.json", "dist/layout-style-css.css", diff --git a/test/manifest-contract.test.mjs b/test/manifest-contract.test.mjs index 87c3d96..a47f271 100644 --- a/test/manifest-contract.test.mjs +++ b/test/manifest-contract.test.mjs @@ -7,6 +7,7 @@ import test from "node:test"; const root = fileURLToPath(new URL("..", import.meta.url)); const packageJson = JSON.parse(readFileSync(join(root, "package.json"), "utf8")); const manifest = JSON.parse(readFileSync(join(root, "manifest.json"), "utf8")); +const personalityMetadata = JSON.parse(readFileSync(join(root, "personalities.json"), "utf8")); const entrypoints = { default: "./dist/layout-style-css.css", @@ -18,7 +19,8 @@ const entrypoints = { recipes: "./dist/recipes.css", utilities: "./dist/utilities.css", personalities: "./dist/personalities.css", - personalityModules: "./dist/personalities/*.css" + personalityModules: "./dist/personalities/*.css", + personalityMetadata: "./personalities.json" }; const entrypointExports = { default: ".", @@ -30,7 +32,8 @@ const entrypointExports = { recipes: "./recipes.css", utilities: "./utilities.css", personalities: "./personalities.css", - personalityModules: "./personalities/*.css" + personalityModules: "./personalities/*.css", + personalityMetadata: "./personalities.json" }; const personalities = [ "minimal-saas", "bento", "maximalist", "bauhaus", "tactile", "neumorphism", @@ -66,6 +69,38 @@ test("ecosystem manifest publishes the structural API and package export", () => } assert.equal(packageJson.exports["./manifest.json"], "./manifest.json"); assert(packageJson.files.includes("manifest.json")); + assert.equal(packageJson.exports["./personalities.json"], "./personalities.json"); + assert(packageJson.files.includes("personalities.json")); +}); + +test("public personality pairings inventory every exported layout profile without coupling selectors", () => { + assert.equal(personalityMetadata.schemaVersion, 1); + assert.equal(personalityMetadata.selector, "data-ly-layout"); + assert.deepEqual( + personalityMetadata.independentSelectors, + ["data-ly-layout", "data-ui", "data-theme", "data-mode"] + ); + assert.deepEqual( + personalityMetadata.personalities.map(({ id }) => id), + personalities, + "Every source personality must publish one explicit pairing record." + ); + + const pairings = new Map(personalityMetadata.personalities.map((pairing) => [pairing.id, pairing])); + for (const id of [ + "minimal-saas", "bento", "maximalist", "bauhaus", "tactile", "neumorphism", + "retrofuturism", "brutalism", "cyberpunk", "y2k", "retro-glass" + ]) { + assert.equal(pairings.get(id)?.visualCompatibility, "native", `${id} needs its verified native visual match.`); + assert.deepEqual(pairings.get(id)?.recommendedVisualPresets, [id]); + } + for (const id of ["f-pattern", "z-pattern", "split-screen", "mondrian"]) { + assert.equal(pairings.get(id)?.visualCompatibility, "any", `${id} must remain visual-preset agnostic.`); + assert.deepEqual(pairings.get(id)?.recommendedVisualPresets, []); + } + assert.deepEqual(pairings.get("synthwave")?.recommendedVisualPresets, ["cyberpunk", "retrofuturism"]); + assert.equal(pairings.get("synthwave")?.visualCompatibility, "recommended"); + assert.equal(pairings.get("synthwave")?.visualVerification?.method, "rendered-computed-style"); }); test("ecosystem manifest describes real structural selectors, thresholds, and tokens", () => { diff --git a/test/pages-artifact.test.mjs b/test/pages-artifact.test.mjs index 90fba13..7a83186 100644 --- a/test/pages-artifact.test.mjs +++ b/test/pages-artifact.test.mjs @@ -31,6 +31,7 @@ const requiredArtifactFiles = [ "robots.txt", "sitemap.xml", "site.webmanifest", + "personalities.json", "assets/favicon.svg", "assets/apple-touch-icon.svg", "assets/social-card.png", @@ -72,7 +73,8 @@ assert( for (const { attribute, path } of [ { attribute: "href", path: "demo.css" }, { attribute: "src", path: "demo.js" }, - { attribute: "href", path: "dist/layout-style-css.css" } + { attribute: "href", path: "dist/layout-style-css.css" }, + { attribute: "data-personalities-url", path: "personalities.json" } ]) { const contents = readFileSync(join(outputDir, ...path.split("/"))); const fingerprint = createHash("sha256").update(contents).digest("hex").slice(0, 12); From 6bc94bb394dc2c1571d856ecd9a85862e058852b Mon Sep 17 00:00:00 2001 From: Foscat Date: Sat, 8 Aug 2026 16:16:51 -0500 Subject: [PATCH 06/22] fix: generate layout pairing metadata --- demo/demo.js | 63 ++++++++++--- demo/index.html | 2 + demo/personality-metadata.js | 2 + docs/wiki/Layout-Styles.md | 2 +- manifest.json | 32 +++++++ personalities.json | 150 ++++++++++++++++++++++++++---- scripts/build-pages.mjs | 2 +- scripts/build.mjs | 41 +++++++- test/demo-smoke.test.mjs | 79 +++++++++++----- test/layout-css-contract.test.mjs | 20 +--- test/manifest-contract.test.mjs | 48 +++++++--- test/pages-artifact.test.mjs | 2 + 12 files changed, 353 insertions(+), 90 deletions(-) create mode 100644 demo/personality-metadata.js diff --git a/demo/demo.js b/demo/demo.js index 46b4e00..4f3d3a4 100644 --- a/demo/demo.js +++ b/demo/demo.js @@ -78,18 +78,7 @@ async function loadUiStyleKitManifest() { } const UI_STYLE_KIT_MANIFEST = await loadUiStyleKitManifest(); -async function loadPersonalityMetadata() { - if (!PERSONALITY_METADATA_URL) { - throw new Error("The demo requires a local layout personality metadata URL."); - } - - const response = await fetch(PERSONALITY_METADATA_URL, { cache: "force-cache" }); - - if (!response.ok) { - throw new Error(`Layout personality metadata request failed with HTTP ${response.status}.`); - } - - const metadata = await response.json(); +function normalizePersonalityMetadata(metadata) { const personalities = Array.isArray(metadata?.personalities) ? metadata.personalities : []; const validCompatibility = new Set(["native", "any", "recommended"]); const normalized = personalities.map((personality) => ({ @@ -117,7 +106,51 @@ async function loadPersonalityMetadata() { }); } -const PERSONALITY_METADATA = await loadPersonalityMetadata(); +function minimalPersonalityFallback() { + return Object.freeze({ + schemaVersion: 1, + personalities: Object.freeze([ + Object.freeze({ + id: "minimal-saas", + label: "Minimal SaaS", + visualCompatibility: "any", + recommendedVisualPresets: [] + }) + ]) + }); +} + +async function loadPersonalityMetadata() { + try { + if (!PERSONALITY_METADATA_URL) { + throw new Error("The demo requires a local layout personality metadata URL."); + } + + const response = await fetch(PERSONALITY_METADATA_URL, { cache: "force-cache" }); + + if (!response.ok) { + throw new Error(`Layout personality metadata request failed with HTTP ${response.status}.`); + } + + return { metadata: normalizePersonalityMetadata(await response.json()), status: "" }; + } catch (error) { + try { + return { + metadata: normalizePersonalityMetadata(window.LAYOUT_STYLE_PERSONALITY_METADATA), + status: "Layout pairing metadata is unavailable; using packaged fallback." + }; + } catch { + console.error("Layout personality metadata and packaged fallback are unavailable.", error); + return { + metadata: minimalPersonalityFallback(), + status: "Layout pairing metadata is unavailable; Minimal SaaS remains available." + }; + } + } +} + +const PERSONALITY_METADATA_LOAD = await loadPersonalityMetadata(); +const PERSONALITY_METADATA = PERSONALITY_METADATA_LOAD.metadata; const ALLOWLISTS = Object.freeze({ device: Object.freeze([ "custom", @@ -264,6 +297,7 @@ const recipePreview = document.querySelector("#recipePreview"); const importsSnippet = document.querySelector("#importsSnippet"); const markupSnippet = document.querySelector("#markupSnippet"); const copyStatus = document.querySelector("#copyStatus"); +const personalityMetadataStatus = document.querySelector("#personalityMetadataStatus"); const ecosystemStatus = document.querySelector("#ecosystemStatus"); const containerReadout = document.querySelector("#containerReadout"); const topologyReadout = document.querySelector("#topologyReadout"); @@ -281,6 +315,9 @@ const mobileControlsQuery = window.matchMedia("(max-width: 63.999rem)"); body.dataset.uiManifestVersion = UI_STYLE_KIT_MANIFEST.version; body.dataset.personalityMetadataVersion = String(PERSONALITY_METADATA.schemaVersion); syncPersonalityMetadataSelectOptions(); +if (personalityMetadataStatus) { + personalityMetadataStatus.textContent = PERSONALITY_METADATA_LOAD.status; +} syncUiManifestSelectOptions(); let state = readStateFromQuery(); diff --git a/demo/index.html b/demo/index.html index a51d70b..0274a72 100644 --- a/demo/index.html +++ b/demo/index.html @@ -91,6 +91,7 @@ "publisher": { "@type": "Organization", "name": "Foscat" } } + @@ -165,6 +166,7 @@

Layout controls

+