From 6c52a9fe6c452e1d957f19c10649b997601e0306 Mon Sep 17 00:00:00 2001 From: Kevin Chappell Date: Thu, 24 Sep 2026 01:53:08 +0100 Subject: [PATCH 01/19] test: add resolved-css baseline characterization test --- src/lib/sass/__fixtures__/formeo-baseline.css | 2419 +++++++++++++++++ src/lib/sass/css-properties.test.mjs | 13 + tools/compile-scss.mjs | 9 + tools/resolve-formeo-properties.mjs | 32 + 4 files changed, 2473 insertions(+) create mode 100644 src/lib/sass/__fixtures__/formeo-baseline.css create mode 100644 src/lib/sass/css-properties.test.mjs create mode 100644 tools/compile-scss.mjs create mode 100644 tools/resolve-formeo-properties.mjs diff --git a/src/lib/sass/__fixtures__/formeo-baseline.css b/src/lib/sass/__fixtures__/formeo-baseline.css new file mode 100644 index 00000000..b6d8ba86 --- /dev/null +++ b/src/lib/sass/__fixtures__/formeo-baseline.css @@ -0,0 +1,2419 @@ +/* + Mixins +*/ +.svg-icon { + display: inline-block; + width: 24px; + height: 24px; + pointer-events: none; +} + +.f-i-remove:hover { + fill: rgb(217, 83, 79); +} + +button[class*=-move]:hover { + background-color: rgb(153, 84, 187) !important; +} +button[class*=-move]:hover .svg-icon { + fill: #fff; +} + +button[class*=-remove]:hover { + background-color: rgb(217, 83, 79) !important; +} +button[class*=-remove]:hover .svg-icon { + fill: #fff; +} + +button[class*=-clone]:hover { + background-color: rgb(147, 197, 75) !important; +} +button[class*=-clone]:hover .svg-icon { + fill: #fff; +} + +.edit-toggle:hover { + background-color: rgb(50, 93, 136) !important; +} +.edit-toggle:hover .svg-icon { + fill: #fff; +} + +.f-autocomplete { + position: relative; +} + +.f-autocomplete-display-field { + width: 100%; +} + +.f-autocomplete-list { + background-color: #fff; + display: none; + list-style: none; + padding: 0; + position: absolute; + z-index: 2; + width: 100%; + margin: 0 -1px; + border: 1px solid #999999; + border-width: 1px 1px 0; + box-shadow: 0 1px 4px 0 rgba(0, 0, 0, 0.5); +} +.f-autocomplete-list li { + position: relative; + border: 1px solid #999999; + display: none; + cursor: default; + padding: 4px; + margin: -1px 0 0; + border-width: 1px 0; + transition: background-color 133ms ease-in-out; + will-change: background-color; + font-size: 0.85em; +} +.f-autocomplete-list li.component-type-row { + padding-left: 4px; +} +.f-autocomplete-list li.component-type-column { + padding-left: 8px; +} +.f-autocomplete-list li.component-type-field { + padding-left: 16px; +} +.f-autocomplete-list li.active-option.component-type-stage { + background-color: #4F46E5; + color: #f9fafb; +} +.f-autocomplete-list li.active-option.component-type-row { + background-color: rgb(229, 70, 188.2); + color: #f9fafb; +} +.f-autocomplete-list li.active-option.component-type-column { + background-color: rgb(229, 142.6, 70); + color: #111827; +} +.f-autocomplete-list li.active-option.component-type-field { + background-color: rgb(124.6, 229, 70); + color: #111827; +} +.f-autocomplete-list li.active-option.component-type-option { + background-color: rgb(70, 229, 206.2); + color: #111827; +} +.f-autocomplete-list li:hover.component-type-stage { + background-color: rgb(185.3507109005, 181.7890995261, 244.7109004739); + color: #111827; +} +.f-autocomplete-list li:hover.component-type-row { + background-color: rgb(244.7109004739, 181.7890995261, 228.56492891); + color: #111827; +} +.f-autocomplete-list li:hover.component-type-column { + background-color: rgb(244.7109004739, 210.5194312796, 181.7890995261); + color: #111827; +} +.f-autocomplete-list li:hover.component-type-field { + background-color: rgb(203.3962085308, 244.7109004739, 181.7890995261); + color: #111827; +} +.f-autocomplete-list li:hover.component-type-option { + background-color: rgb(181.7890995261, 244.7109004739, 235.6881516588); + color: #111827; +} +.f-autocomplete-list li:has(.options-list):hover .options-list { + display: block; +} +.f-autocomplete-list .component-type { + color: #666666; + font-style: italic; + font-size: 0.75em; +} +.f-autocomplete-list.options-list { + left: 100%; + top: 0; + width: 75%; + min-height: 100%; +} +.f-autocomplete-list.options-list li { + display: block; +} + +.formeo-panels-wrap h5 { + margin: 0; + padding: 0.55em 0; + color: #666666; + font-weight: normal; + display: inline-block; + width: 100%; +} +.formeo-panels-wrap nav { + position: relative; + padding: 0; + overflow: hidden; +} +.formeo-panels-wrap nav button { + position: absolute; + width: 24px; + color: #000; + height: 100%; + padding: 0; + line-height: 0; + z-index: 1; +} +.formeo-panels-wrap nav button:focus { + outline: none; + border: 1px solid rgb(102, 175, 233); + box-shadow: none; +} +.formeo-panels-wrap nav button .svg-icon { + width: 20px; + height: 20px; +} +.formeo-panels-wrap nav button.next-group { + right: 0; + top: 0; + border-top-left-radius: 0; + border-bottom-left-radius: 0; +} +.formeo-panels-wrap nav button.prev-group { + left: 0; + top: 0; + border-top-right-radius: 0; + border-bottom-right-radius: 0; +} +.formeo-panels-wrap ul { + margin: 0; + padding: 0; + list-style: none; +} +.formeo-panels-wrap .f-panel { + height: 0; + vertical-align: top; + display: inline-block; + width: 100%; + flex-direction: column; + flex: 1 0 100%; +} +.formeo-panels-wrap .f-panel > li:last-child { + border-radius: 0 0 4px 4px; +} +.formeo-panels-wrap .panels { + white-space: nowrap; + transition-property: height; + transition-duration: 150ms; + transition-timing-function: ease-in-out; + will-change: transform; + flex-direction: row; +} +.formeo-panels-wrap .panel-labels { + height: 100%; + background: #fff; + overflow: hidden; + text-align: center; + white-space: nowrap; +} +.formeo-panels-wrap .active-panel { + height: auto; +} + +.tabbed-panels .panel-nav { + height: auto; +} +.tabbed-panels .panel-nav button { + display: none; +} +.tabbed-panels .panel-labels div { + flex-direction: row; + justify-content: flex-start; + flex-wrap: nowrap; + align-content: stretch; + align-items: stretch; + display: flex; +} +.tabbed-panels .panel-labels h5 { + flex-direction: column; + flex: 1; + cursor: pointer; + background-color: #cccccc; + box-shadow: 0 -1px 8px #999999 inset; +} +.tabbed-panels .panel-labels h5.active-tab { + color: #000; + box-shadow: none; + background-color: #fff; +} + +.formeo-dialog { + border: none; + border-radius: 8px; + padding: 0; + max-width: min(90vw, 500px); + box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -2px rgba(0, 0, 0, 0.1); + position: absolute; + margin: 0; +} +.formeo-dialog.dialog-position-top { + left: 50%; + top: 20vh; + transform: translateX(-50%); +} +.formeo-dialog.dialog-position-center { + left: 50%; + top: 50%; + transform: translate(-50%, -50%); +} +.formeo-dialog.dialog-position-trigger { + left: -9999px; + top: -9999px; +} +.formeo-dialog::backdrop { + background-color: rgba(0, 0, 0, 0.5); + backdrop-filter: blur(2px); + animation: backdrop-fade-in 266ms ease-out; +} +.formeo-dialog .dialog-form { + display: flex; + flex-direction: column; + gap: 8px; + padding: 16px; + min-width: 300px; +} +.formeo-dialog .dialog-title { + margin: 0; + font-size: 1.25rem; + font-weight: 600; + color: rgb(25.5, 25.5, 25.5); +} +.formeo-dialog .dialog-body { + display: flex; + flex-direction: column; + gap: 8px; +} +.formeo-dialog .dialog-body label { + display: flex; + flex-direction: column; + gap: 4px; + font-weight: 500; + font-size: 0.9rem; + color: #333333; +} +.formeo-dialog .dialog-body label input, +.formeo-dialog .dialog-body label textarea, +.formeo-dialog .dialog-body label select { + padding: 0.3em 0.6em; + border: 1px solid #cccccc; + border-radius: 4px; + font-size: 1rem; +} +.formeo-dialog .dialog-body label input:focus, +.formeo-dialog .dialog-body label textarea:focus, +.formeo-dialog .dialog-body label select:focus { + outline: 2px solid rgb(50, 93, 136); + outline-offset: 0; +} +.formeo-dialog .dialog-message { + margin: 0; + line-height: 1.6; + color: rgb(25.5, 25.5, 25.5); +} +.formeo-dialog .dialog-prompt-label .dialog-message { + margin-bottom: 4px; +} +.formeo-dialog .dialog-prompt-input { + width: 100%; +} +.formeo-dialog .dialog-actions { + display: flex; + gap: 8px; + justify-content: flex-end; + margin-top: 4px; +} +.formeo-dialog .dialog-actions button { + min-width: 80px; +} +.formeo-dialog[open] { + animation: dialog-fade-in 266ms ease-out; +} +.formeo-dialog.config-item-dialog label { + display: flex; + flex-direction: row; + margin-bottom: 8px; +} + +@keyframes dialog-fade-in { + from { + opacity: 0; + transform: scale(0.95); + } + to { + opacity: 1; + transform: scale(1); + } +} +@keyframes dialog-fade-in-top { + from { + opacity: 0; + transform: translateX(-50%) scale(0.95); + } + to { + opacity: 1; + transform: translateX(-50%) scale(1); + } +} +@keyframes dialog-fade-in-center { + from { + opacity: 0; + transform: translate(-50%, -50%) scale(0.95); + } + to { + opacity: 1; + transform: translate(-50%, -50%) scale(1); + } +} +.dialog-position-top[open] { + animation: dialog-fade-in-top 266ms ease-out; +} + +.dialog-position-center[open] { + animation: dialog-fade-in-center 266ms ease-out; +} + +@keyframes backdrop-fade-in { + from { + opacity: 0; + } + to { + opacity: 1; + } +} +#formeo-sprite { + display: none !important; + visibility: hidden !important; +} + +.formeo * { + box-sizing: inherit; + font-family: inherit; +} +.formeo { + /* + Mixins + */ +} +.formeo .pill-buttons > button { + border-radius: 50px; +} +.formeo hr { + margin-top: 1rem; + margin-bottom: 1rem; + border: 0; + border-top: 1px solid #cccccc; +} +.formeo .f-field-group { + flex-wrap: wrap; + margin-bottom: 12px; +} +.formeo .f-field-group:last-child { + margin-bottom: 0; +} +.formeo .f-field-group label + .badge { + margin-left: 8px; +} +.formeo .f-field-group > label { + display: inline-block; + margin-bottom: 4px; +} +.formeo .f-field-group button { + margin-right: 4px; +} +.formeo select { + font-size: 100%; + font-family: inherit; + height: 2.1em; + line-height: 1.5; + margin: 0; + border: 1px solid #cccccc; + border-radius: 4px; + background-color: #fff; + width: 100%; + padding: 0.3em 0.6em; + box-sizing: border-box; +} +.formeo select:focus { + border: 1px solid rgb(102, 175, 233); + outline: none; +} +.formeo select[multiple] { + height: auto; +} +.formeo input { + font-size: 100%; + font-family: inherit; + height: 2.1em; + line-height: 1.5; + margin: 0; + border: 1px solid #cccccc; + border-radius: 4px; + background-color: #fff; + width: 100%; + padding: 0.3em 0.6em; + box-sizing: border-box; +} +.formeo input:focus { + border: 1px solid rgb(102, 175, 233); + outline: none; +} +.formeo input[type=checkbox], .formeo input[type=radio] { + width: auto; + height: auto; + margin-right: 4px; +} +.formeo input[type=date] { + max-width: 280px; + display: block; +} +.formeo textarea { + font-size: 100%; + font-family: inherit; + height: 2.1em; + line-height: 1.5; + margin: 0; + border: 1px solid #cccccc; + border-radius: 4px; + background-color: #fff; + width: 100%; + padding: 0.3em 0.6em; + box-sizing: border-box; +} +.formeo textarea:focus { + border: 1px solid rgb(102, 175, 233); + outline: none; +} +.formeo textarea { + height: auto; +} +.formeo button { + border-radius: 4px; + border: 1px solid #666666; + color: #333333; + background-color: #fff; + padding: 4px 8px; +} +.formeo button:active { + transform: scale(0.97); +} +.formeo button:hover { + filter: brightness(0.9); +} +.formeo button { + line-height: 1.5em; +} +.formeo button.primary, .formeo button.success, .formeo button.warning, .formeo button.error { + color: #fff; +} +.formeo button.primary { + background-color: rgb(50, 93, 136); + border-color: rgb(36.2903225806, 67.5, 98.7096774194); +} +.formeo button.success { + background-color: rgb(147, 197, 75); + border-color: rgb(120.7142857143, 167.1428571429, 53.8571428571); +} +.formeo button.warning { + background-color: rgb(244, 124, 60); + border-color: rgb(239.4902912621, 92.1116504854, 13.5097087379); +} +.formeo button.error { + background-color: rgb(217, 83, 79); + border-color: rgb(201.4953271028, 48.0841121495, 43.5046728972); +} +.formeo button[disabled] { + background-color: #cccccc; + color: #fff; +} +.formeo button:focus { + border: 1px solid rgb(102, 175, 233); +} +.formeo button:hover, .formeo button:focus { + outline: 0 none; +} +.formeo .f-addon { + font-size: 100%; + font-family: inherit; + height: 2.1em; + line-height: 1.5; + margin: 0; + border: 1px solid #cccccc; + border-radius: 4px; + background-color: #fff; + width: 100%; + padding: 0.3em 0.6em; + box-sizing: border-box; +} +.formeo .f-addon:focus { + border: 1px solid rgb(102, 175, 233); + outline: none; +} +.formeo .f-addon { + width: auto; +} +.formeo .f-addon label { + margin: 1px 0 0 3px; +} +.formeo .f-addon:last-child { + margin-left: -1px; +} +.formeo .f-btn-group { + display: inline-flex; + vertical-align: middle; +} +.formeo .f-btn-group > button { + flex: 0 1 auto; +} +.formeo .f-btn-group > button:not(:first-child):not(:last-child):not(.dropdown-toggle) { + border-radius: 0; +} +.formeo .f-btn-group > button:last-child:not(:first-child):not(.dropdown-toggle) { + border-bottom-left-radius: 0; + border-top-left-radius: 0; +} +.formeo .f-btn-group > button:first-child { + margin-left: 0; +} +.formeo .f-btn-group > button:first-child:not(:last-child):not(.dropdown-toggle) { + border-bottom-right-radius: 0; + border-top-right-radius: 0; +} +.formeo .f-btn-group button + button, +.formeo .f-btn-group button + .f-btn-group, +.formeo .f-btn-group .f-btn-group + button, +.formeo .f-btn-group .f-btn-group + .f-btn-group, +.formeo .f-btn-group .f-btn-group-vertical button + button, +.formeo .f-btn-group .f-btn-group-vertical button + .f-btn-group, +.formeo .f-btn-group .f-btn-group-vertical .f-btn-group + button, +.formeo .f-btn-group .f-btn-group-vertical .f-btn-group + .f-btn-group { + margin-left: -1px; +} +.formeo .f-input-group { + display: flex; + flex-direction: row; + justify-content: flex-start; + flex-wrap: nowrap; + align-content: stretch; + align-items: stretch; + display: inline-flex; + vertical-align: bottom; +} +.formeo .f-input-group select { + appearance: none; +} +.formeo .f-input-group select::-ms-expand { + display: none; +} +.formeo .f-input-group select { + background-image: url("data:image/svg+xml;base64,PD94bWwgdmVyc2lvbj0iMS4wIiBlbmNvZGluZz0idXRmLTgiPz4KPCEtLSBHZW5lcmF0ZWQgYnkgSWNvTW9vbi5pbyAtLT4KPCFET0NUWVBFIHN2ZyBQVUJMSUMgIi0vL1czQy8vRFREIFNWRyAxLjEvL0VOIiAiaHR0cDovL3d3dy53My5vcmcvR3JhcGhpY3MvU1ZHLzEuMS9EVEQvc3ZnMTEuZHRkIj4KPHN2ZyB2ZXJzaW9uPSIxLjEiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyIgeG1sbnM6eGxpbms9Imh0dHA6Ly93d3cudzMub3JnLzE5OTkveGxpbmsiIHdpZHRoPSIyNCIgaGVpZ2h0PSIzMiIgdmlld0JveD0iMCAwIDI0IDMyIj4KPHBhdGggZmlsbD0iIzQ0NCIgZD0iTTAgMTJsMTEuOTkyIDExLjk5MiAxMS45OTItMTEuOTkyaC0yMy45ODR6Ij48L3BhdGg+Cjwvc3ZnPgo="); + background-position: right 10px top 2px; + background-repeat: no-repeat; + background-size: 16px; +} +.formeo .f-input-group .f-addon, +.formeo .f-input-group input, +.formeo .f-input-group select { + flex: 0 1 auto; + border-radius: 0; +} +.formeo .f-input-group .f-addon:last-child, +.formeo .f-input-group input:last-child, +.formeo .f-input-group select:last-child { + border-right-width: 1px; + border-radius: 0 4px 4px 0; +} +.formeo .f-input-group .f-addon:first-child, +.formeo .f-input-group input:first-child, +.formeo .f-input-group select:first-child { + border-radius: 4px 0 0 4px; + border-left-width: 1px; +} +.formeo .f-input-group .f-addon:first-child:last-child, +.formeo .f-input-group input:first-child:last-child, +.formeo .f-input-group select:first-child:last-child { + border-radius: 4px; +} +.formeo .f-input-group .f-addon:focus + input, +.formeo .f-input-group .f-addon:focus + select, +.formeo .f-input-group input:focus + input, +.formeo .f-input-group input:focus + select, +.formeo .f-input-group select:focus + input, +.formeo .f-input-group select:focus + select { + border-left: 1px solid rgb(102, 175, 233); +} +.formeo .text-primary { + color: rgb(50, 93, 136); +} +.formeo .text-success { + color: rgb(147, 197, 75); +} +.formeo .text-warning { + color: rgb(244, 124, 60); +} +.formeo .text-error { + color: rgb(217, 83, 79); +} +.formeo small.f-help-text { + display: block; +} +.formeo label .f-tooltip, .formeo .prev-label .f-tooltip { + margin: 0 4px; +} +.formeo label .f-tooltip svg, .formeo .prev-label .f-tooltip svg { + width: 16px; + height: 16px; +} +.formeo.formeo-editor { + display: flex; + flex-direction: row; + text-align: left; + gap: 16px; + /* + Mixins + */ + /* + Animations + */ +} +@keyframes PLACEHOLDER { + 0% { + height: 1px; + } + 100% { + height: 15px; + } +} +@keyframes DRAG_GHOST { + 0% { + box-shadow: 0 0 0 0 #999999; + } + 100% { + box-shadow: 0 0 30px 0 #999999; + } +} +@keyframes EDIT_PULSE { + from, to { + border-color: rgb(102, 175, 233); + } + 50% { + border-color: rgb(191.1771428571, 221.6285714286, 245.8228571429); + } +} +@keyframes COMPONENT_HIGHLIGHT_PULSE { + 0% { + box-shadow: 0 0 1px 8px rgb(153, 84, 187); + } + 100% { + box-shadow: 0 0 0 0 rgb(153, 84, 187); + } +} +@keyframes SLIDE_UP { + 0% { + transform: translateY(100%); + clip-path: inset(0 0 100% -20%); + } + 100% { + transform: translateY(0); + clip-path: inset(0 0 0 -20%); + } +} +.formeo.formeo-editor .component-tag { + display: none; + height: 24px; + flex-direction: row; + gap: 4px; + align-items: center; + position: absolute; + font-size: 0.8em; + padding: 0 8px; + left: 50%; + top: -24px; + border-top-left-radius: 8px; + border-top-right-radius: 8px; + background-color: #fff; + border-color: #cccccc; + border-style: solid; + border-width: 1px 1px 0 1px; +} +.formeo.formeo-editor .component-tag [class*=-handle-] { + width: 12px; + height: 12px; +} +.formeo.formeo-editor .children { + margin: 0; + padding: 0; + list-style: none; +} +.formeo.formeo-editor .group-actions { + display: flex; + transition: opacity 0.3s ease-in-out allow-discrete; + position: absolute; + top: 0; + line-height: 0; + z-index: 1; + align-items: center; + justify-content: center; + flex-direction: row; + border-radius: 8px; +} +.formeo.formeo-editor .group-actions .action-btn-wrap { + display: none; + align-items: center; + justify-content: center; + border-top-right-radius: 8px; + border-bottom-left-radius: 8px; + border-bottom-right-radius: 8px; + transition: opacity 1s ease-in-out allow-discrete; +} +.formeo.formeo-editor .group-actions .action-btn-wrap .component-handle { + opacity: 0.65; +} +.formeo.formeo-editor .group-actions .action-btn-wrap button { + background-color: #fff; +} +.formeo.formeo-editor .group-actions button { + background-color: transparent; + width: 24px; + height: 24px; + padding: 6px; + border: 0 none; + line-height: 0; +} +.formeo.formeo-editor .group-actions button:focus { + border: 0 none; + outline: 0 none; + box-shadow: none; +} +.formeo.formeo-editor .group-actions .svg-icon { + width: 12px; + height: 12px; +} +.formeo.formeo-editor .last-field .group-actions button:last-child { + border-radius: 0; +} +.formeo.formeo-editor .column-editing-field .column-actions { + display: none; +} +.formeo.formeo-editor .group-config { + display: none; + padding: 0.5rem; +} +.formeo.formeo-editor .editing-row .column-actions { + display: none; +} +.formeo.formeo-editor .hovering > .children, +.formeo.formeo-editor .hovering > .field-preview, +.formeo.formeo-editor .hovering > .prev-label { + opacity: 0.65; +} +.formeo.formeo-editor .hovering .group-actions .f-i-handle { + display: none; +} +.formeo.formeo-editor { + /* + Mixins + */ +} +.formeo.formeo-editor .formeo-row > .children { + display: flex; + flex-direction: row; + justify-content: flex-start; + flex-wrap: nowrap; + align-content: stretch; + align-items: stretch; + gap: 16px; + min-height: 32px; +} +.formeo.formeo-editor .formeo-row { + transition: background-color 125ms ease-in-out; + position: relative; + clear: both; + background-color: #fff; + box-shadow: 0 0 0 1px #cccccc; + padding: 16px; +} +.formeo.formeo-editor .formeo-row::after { + clear: both; +} +.formeo.formeo-editor .formeo-row.control-ghost { + padding: 10px; +} +.formeo.formeo-editor .formeo-row:first-child:not(.editing-row, .hovering-row) { + border-top-left-radius: 8px; +} +.formeo.formeo-editor .formeo-row:first-child { + border-top-right-radius: 8px; +} +.formeo.formeo-editor .formeo-row:last-child { + border-bottom-left-radius: 8px; + border-bottom-right-radius: 8px; +} +.formeo.formeo-editor .formeo-row:only-child { + border-radius: 8px; +} +.formeo.formeo-editor .formeo-row.resizing-columns .formeo-column { + transition: none; +} +.formeo.formeo-editor .formeo-row.empty::after { + left: 0; + transform: translate(8px, -50%); +} +.formeo.formeo-editor .formeo-row .layout-row-control { + display: none; +} +.formeo.formeo-editor .formeo-row .row-tag { + left: -1px; + border-color: rgb(229, 70, 188.2); +} +.formeo.formeo-editor .formeo-row:has(.hovering-column) .row-actions, +.formeo.formeo-editor .formeo-row:has(.hovering-column) .field-actions { + display: none; +} +.formeo.formeo-editor .formeo-row.sortable-ghost { + background-color: rgb(244.7109004739, 181.7890995261, 228.56492891); + box-shadow: 0 0 0 1px rgb(229, 70, 188.2); +} +.formeo.formeo-editor .formeo-row.sortable-ghost * { + opacity: 0; +} +.formeo.formeo-editor .formeo-row:has(.editing-field) .row-actions { + display: none; +} +.formeo.formeo-editor .formeo-row.highlight-component { + box-shadow: 0 0 4px 2px rgb(229, 70, 188.2); +} +.formeo.formeo-editor .row-moving { + background-color: rgb(244.7109004739, 181.7890995261, 228.56492891) !important; +} +.formeo.formeo-editor .row-moving .action-btn-wrap button { + background-color: rgb(244.7109004739, 181.7890995261, 228.56492891); +} +.formeo.formeo-editor .row-moving .row-tag { + background-color: rgb(244.7109004739, 181.7890995261, 228.56492891); +} +.formeo.formeo-editor .row-actions { + left: 0; +} +.formeo.formeo-editor .hovering-row .column-actions, +.formeo.formeo-editor .hovering-row .field-actions { + display: none; +} +.formeo.formeo-editor .hovering-row.editing-row::before { + border-right-width: 0; +} +.formeo.formeo-editor .editing-row { + display: block; +} +.formeo.formeo-editor .editing-row .row-edit { + display: block; +} +.formeo.formeo-editor .editing-row.hovering-row .formeo-column { + opacity: 1; +} +.formeo.formeo-editor .hovering-row, +.formeo.formeo-editor .editing-row { + box-shadow: 0 0 0 1px rgb(229, 70, 188.2); +} +.formeo.formeo-editor .hovering-row:first-child, +.formeo.formeo-editor .editing-row:first-child { + border-top-left-radius: 0; +} +.formeo.formeo-editor .hovering-row .row-handle, +.formeo.formeo-editor .editing-row .row-handle { + display: none; +} +.formeo.formeo-editor .hovering-row .row-tag, +.formeo.formeo-editor .editing-row .row-tag { + display: flex; +} +.formeo.formeo-editor .hovering-row .row-action-btn-wrap, +.formeo.formeo-editor .editing-row .row-action-btn-wrap { + display: flex; +} +.formeo.formeo-editor .hovering-row .row-action-btn-wrap button:last-child, +.formeo.formeo-editor .editing-row .row-action-btn-wrap button:last-child { + border-bottom-right-radius: 8px; +} +.formeo.formeo-editor .row-edit { + padding-top: 2rem; +} +.formeo.formeo-editor .input-group-addon label { + margin-bottom: 0; +} +.formeo.formeo-editor .formeo-column { + margin: 0; + padding: 0; + list-style: none; + transition: width 266ms; + position: relative; + flex-direction: column; + will-change: width; + max-width: 100%; +} +.formeo.formeo-editor .formeo-column > .children { + display: flex; + flex-direction: column; + justify-content: flex-start; + gap: 16px; +} +.formeo.formeo-editor .formeo-column .column-tag { + border-color: rgb(229, 142.6, 70); +} +.formeo.formeo-editor .formeo-column .column-tag, +.formeo.formeo-editor .formeo-column .column-actions { + transform: translateX(-50%); +} +.formeo.formeo-editor .formeo-column[class*=col-] { + padding: 0; +} +.formeo.formeo-editor .formeo-column:first-child { + border-bottom-left-radius: 8px; +} +.formeo.formeo-editor .formeo-column:last-child { + border-bottom-right-radius: 8px; +} +.formeo.formeo-editor .formeo-column:last-child .resize-x-handle { + display: none !important; +} +.formeo.formeo-editor .formeo-column:only-child { + border-bottom-right-radius: 8px; + border-bottom-left-radius: 8px; +} +.formeo.formeo-editor .formeo-column:only-child .resize-x-handle { + display: none !important; +} +.formeo.formeo-editor .formeo-column .resize-x-handle { + display: none; + position: absolute; + right: -16px; + top: 0; + bottom: 0; + width: 16px; + z-index: 2; + cursor: ew-resize; +} +.formeo.formeo-editor .formeo-column .resize-x-handle::before { + width: 0; + right: 6px; + border: 1px dashed rgb(238.4265402844, 183.3516587678, 137.0734597156); + border-width: 0 2px; + display: block; + top: 0; + position: absolute; + height: 100%; + content: ""; +} +.formeo.formeo-editor .formeo-column .resize-x-handle svg { + fill: rgb(238.4265402844, 183.3516587678, 137.0734597156); + position: absolute; + right: 1px; + width: 14px; +} +.formeo.formeo-editor .formeo-column .resize-x-handle svg.f-i-triangle-down { + top: -14px; +} +.formeo.formeo-editor .formeo-column .resize-x-handle svg.f-i-triangle-up { + bottom: -14px; +} +.formeo.formeo-editor .formeo-column .resize-x-handle:hover::before { + border-color: rgb(229, 142.6, 70); +} +.formeo.formeo-editor .formeo-column .resize-x-handle:hover svg { + fill: rgb(229, 142.6, 70); +} +.formeo.formeo-editor .formeo-column.editing-column { + overflow: hidden; +} +.formeo.formeo-editor .formeo-column.editing-column .column-edit { + display: block; +} +.formeo.formeo-editor .formeo-column:has(.hovering-field) .column-actions { + display: none; +} +.formeo.formeo-editor .formeo-column.sortable-ghost { + background-color: rgb(244.7109004739, 210.5194312796, 181.7890995261); + box-shadow: 0 0 0 1px rgb(229, 142.6, 70); +} +.formeo.formeo-editor .formeo-column.sortable-ghost * { + opacity: 0; +} +.formeo.formeo-editor .formeo-column:has(.editing-field) .column-actions { + display: none; +} +.formeo.formeo-editor .formeo-column.highlight-component { + box-shadow: 0 0 4px 2px rgb(229, 142.6, 70); +} +.formeo.formeo-editor .editing-row .formeo-column, +.formeo.formeo-editor .editing-row .empty { + border-radius: 8px; + height: 60px; + background-color: rgb(229.5, 229.5, 229.5); +} +.formeo.formeo-editor .editing-row .formeo-column.empty, +.formeo.formeo-editor .editing-row .empty.empty { + min-height: 0; +} +.formeo.formeo-editor .editing-row .formeo-column .formeo-field, +.formeo.formeo-editor .editing-row .empty .formeo-field { + display: none; +} +.formeo.formeo-editor .editing-row .formeo-column .resize-x-handle, +.formeo.formeo-editor .editing-row .empty .resize-x-handle { + display: block; +} +.formeo.formeo-editor .editing-row .formeo-column::after, +.formeo.formeo-editor .editing-row .empty::after { + color: #333333 !important; + line-height: 1em; + opacity: 1; + font-size: 1.1em; + content: attr(data-col-width) !important; + display: block; + width: 100%; + text-align: center; + position: absolute; + left: 50%; + margin-top: 0; + top: 50%; + transform: translate(-50%, -50%); +} +.formeo.formeo-editor .editing-field-preview .column-actions { + display: none; +} +.formeo.formeo-editor .column-moving { + background-color: rgb(244.7109004739, 210.5194312796, 181.7890995261); +} +.formeo.formeo-editor .column-moving .action-btn-wrap button { + background-color: rgb(244.7109004739, 210.5194312796, 181.7890995261); +} +.formeo.formeo-editor .column-moving .column-tag { + background-color: rgb(244.7109004739, 210.5194312796, 181.7890995261); +} +.formeo.formeo-editor button.column-handle { + background-color: rgba(255, 255, 255, 0.9); +} +.formeo.formeo-editor .hovering-column .column-tag { + display: flex; +} +.formeo.formeo-editor .hovering-column .column-handle { + display: none; +} +.formeo.formeo-editor .hovering-column .column-action-btn-wrap { + display: flex; +} +.formeo.formeo-editor .hovering-column:first-child { + border-top-left-radius: 0; +} +.formeo.formeo-editor .hovering-column::after { + opacity: 0; +} +.formeo.formeo-editor .hovering-column, +.formeo.formeo-editor .editing-column { + box-shadow: 0 0 0 1px rgb(229, 142.6, 70); +} +.formeo.formeo-editor .column-actions { + padding: 0; + left: 50%; + transition: width 0.15s; +} +.formeo.formeo-editor .column-actions .f-i-handle { + transform: rotate(90deg); +} +.formeo.formeo-editor .formeo-field { + min-height: 26px; + position: relative; + list-style: none; + margin: 0; + will-change: box-shadow; +} +.formeo.formeo-editor .formeo-field.highlight-component { + box-shadow: 0 0 4px 2px rgb(124.6, 229, 70); +} +.formeo.formeo-editor .formeo-field:last-child { + border-bottom-right-radius: 4px; + border-bottom-left-radius: 4px; +} +.formeo.formeo-editor .formeo-field.first-field { + border-top-right-radius: 4px; +} +.formeo.formeo-editor .formeo-field.first-field .field-actions { + border-top-right-radius: 4px; +} +.formeo.formeo-editor .formeo-field .prev-label { + min-height: 24px; + max-width: calc(100% - 24px); + margin-bottom: 4px; +} +.formeo.formeo-editor .formeo-field .prev-label input { + width: auto; +} +.formeo.formeo-editor .formeo-field [contenteditable] { + padding: 1px 2px; + user-select: text; + display: inline-block; + position: relative; + min-width: 24px; +} +.formeo.formeo-editor .formeo-field [contenteditable]::after { + content: ""; + width: 100%; + position: absolute; + bottom: 0; + left: 0; + border-bottom: 1px dashed #cccccc; +} +.formeo.formeo-editor .formeo-field [contenteditable]:focus { + border-radius: 4px; + border-bottom-color: transparent; + outline: none; + box-shadow: 0 0 0 1px rgb(102, 175, 233) inset; +} +.formeo.formeo-editor .formeo-field [contenteditable]:focus::after { + display: none; +} +.formeo.formeo-editor .formeo-field .form-check { + margin-left: 1.25em; +} +.formeo.formeo-editor .formeo-field .form-check-input:only-child { + position: absolute; +} +.formeo.formeo-editor .formeo-field.field-type-hidden { + border: 1px dashed #cccccc; +} +.formeo.formeo-editor .formeo-field.sortable-ghost { + background-color: rgb(203.3962085308, 244.7109004739, 181.7890995261); + box-shadow: 0 0 0 1px rgb(124.6, 229, 70); +} +.formeo.formeo-editor .formeo-field.sortable-ghost * { + opacity: 0; +} +.formeo.formeo-editor .field-tag { + right: -1px; + left: auto; + top: -24px; + border-color: rgb(124.6, 229, 70); + background-color: #fff; +} +.formeo.formeo-editor .editing-field, +.formeo.formeo-editor .hovering-field { + box-shadow: 0 0 0 1px rgb(124.6, 229, 70); +} +.formeo.formeo-editor .editing-field .field-tag, +.formeo.formeo-editor .hovering-field .field-tag { + display: flex; +} +.formeo.formeo-editor .editing-field .field-action-btn-wrap, +.formeo.formeo-editor .hovering-field .field-action-btn-wrap { + display: flex; +} +.formeo.formeo-editor .editing-field .field-handle, +.formeo.formeo-editor .hovering-field .field-handle { + display: none; +} +.formeo.formeo-editor .field-actions { + border-color: transparent; + border-width: 1px 1px 0 0; + border-style: solid; + right: 0; + text-align: right; + border-bottom-left-radius: 4px; + border-bottom-right-radius: 0; +} +.formeo.formeo-editor .field-actions .action-btn-wrap { + flex-direction: row-reverse; +} +.formeo.formeo-editor .field-moving { + background-color: rgb(203.3962085308, 244.7109004739, 181.7890995261); +} +.formeo.formeo-editor .field-moving .action-btn-wrap button { + background-color: rgb(203.3962085308, 244.7109004739, 181.7890995261); +} +.formeo.formeo-editor .field-moving .field-tag { + background-color: rgb(203.3962085308, 244.7109004739, 181.7890995261); +} +.formeo.formeo-editor .editing-field { + z-index: 1; +} +.formeo.formeo-editor .editing-field-preview .field-actions { + display: none; +} +.formeo.formeo-editor .field-preview p { + white-space: normal; +} +.formeo.formeo-editor .field-preview .f-checkbox.highlight-component, +.formeo.formeo-editor .field-preview .f-radio.highlight-component { + box-shadow: 0 0 2px 1px rgb(70, 229, 206.2) inset; +} +.formeo.formeo-editor .component-edit { + display: none; + overflow: hidden; +} +.formeo.formeo-editor .component-edit label { + font-size: 0.825em; +} +.formeo.formeo-editor .component-edit .panel-nav { + margin-bottom: 0; + padding: 0; + overflow: hidden; +} +.formeo.formeo-editor .component-edit .panel-nav button { + border-bottom-left-radius: 0; + border-bottom-right-radius: 0; +} +.formeo.formeo-editor .component-edit .component-prop { + display: flex; + gap: 8px; +} +.formeo.formeo-editor .component-edit .prop-controls { + flex-shrink: 0; + align-items: center; + display: flex; +} +.formeo.formeo-editor .component-edit .prop-controls .prop-remove { + z-index: 3; +} +.formeo.formeo-editor .component-edit .prop-controls.conditions-prop-controls { + display: flex; + align-items: flex-start; + margin-left: 16px; + min-width: 16px; +} +.formeo.formeo-editor .component-edit .prop-controls.conditions-prop-controls .prop-remove { + margin-top: -1px; +} +.formeo.formeo-editor .component-edit .prop-controls button { + position: relative; +} +.formeo.formeo-editor .component-edit .prop-controls .svg-icon { + width: 10px; + height: 10px; + left: 50%; + position: absolute; + top: 50%; + transform: translate(-50%, -50%); +} +.formeo.formeo-editor .component-edit .prop-control { + width: 16px; + height: 16px; + right: 0; + bottom: 0; + position: absolute; + padding: 0; +} +.formeo.formeo-editor .component-edit .prop-control:nth-of-type(1) { + right: 0px; +} +.formeo.formeo-editor .component-edit .prop-control:nth-of-type(2) { + right: 24px; +} +.formeo.formeo-editor .component-edit .prop-control:nth-of-type(3) { + right: 48px; +} +.formeo.formeo-editor .component-edit .prop-control:nth-of-type(4) { + right: 72px; +} +.formeo.formeo-editor .component-edit .prop-control:first-child { + right: 0; +} +.formeo.formeo-editor :not(.control-count-1) .component-edit .prop-control:last-child { + border-radius: 4px 0 0 4px; + display: none; +} +.formeo.formeo-editor :not(.control-count-1) .component-edit .prop-control:first-child { + border-radius: 4px; +} +.formeo.formeo-editor .component-edit .prop-control:hover:first-child { + border-radius: 0 4px 4px 0; +} +.formeo.formeo-editor .component-edit .prop-control:hover:first-child:last-child { + border-radius: 4px; +} +.formeo.formeo-editor .component-edit .prop-control:hover:last-child { + display: inline-block; +} +.formeo.formeo-editor .component-edit .prop-control:last-child:first-child { + display: inline-block; + border-radius: 4px; + margin-right: 0; +} +.formeo.formeo-editor .component-edit .to-remove { + position: relative; +} +.formeo.formeo-editor .component-edit .to-remove::before { + content: ""; + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + background-color: rgba(217, 83, 79, 0.1); + z-index: 2; +} +.formeo.formeo-editor .component-edit .prop-wrap { + position: relative; + margin-bottom: 16px; + list-style: none; + margin-left: 0; +} +.formeo.formeo-editor .component-edit .prop-wrap:first-child .conditions-prop-controls .prop-remove { + display: none; +} +.formeo.formeo-editor .component-edit .field-edit-group { + padding: 8px 8px 0; +} +.formeo.formeo-editor .component-edit [contenteditable] { + display: flex; + flex-direction: row; + justify-content: flex-start; + flex-wrap: nowrap; + align-content: stretch; + align-items: stretch; +} +.formeo.formeo-editor .component-edit .prop-inputs, +.formeo.formeo-editor .component-edit .prop-controls { + transition: width 150ms, display 150ms; + will-change: width; +} +.formeo.formeo-editor .component-edit .prop-inputs .f-addon, +.formeo.formeo-editor .component-edit .prop-inputs input:not([type=checkbox]):not([type=radio]), +.formeo.formeo-editor .component-edit .prop-inputs select, +.formeo.formeo-editor .component-edit .prop-inputs textarea, +.formeo.formeo-editor .component-edit .prop-inputs [contenteditable], +.formeo.formeo-editor .component-edit .prop-controls .f-addon, +.formeo.formeo-editor .component-edit .prop-controls input:not([type=checkbox]):not([type=radio]), +.formeo.formeo-editor .component-edit .prop-controls select, +.formeo.formeo-editor .component-edit .prop-controls textarea, +.formeo.formeo-editor .component-edit .prop-controls [contenteditable] { + font-size: 0.825em; +} +.formeo.formeo-editor .component-edit .prop-inputs .f-addon:focus, +.formeo.formeo-editor .component-edit .prop-inputs input:not([type=checkbox]):not([type=radio]):focus, +.formeo.formeo-editor .component-edit .prop-inputs select:focus, +.formeo.formeo-editor .component-edit .prop-inputs textarea:focus, +.formeo.formeo-editor .component-edit .prop-inputs [contenteditable]:focus, +.formeo.formeo-editor .component-edit .prop-controls .f-addon:focus, +.formeo.formeo-editor .component-edit .prop-controls input:not([type=checkbox]):not([type=radio]):focus, +.formeo.formeo-editor .component-edit .prop-controls select:focus, +.formeo.formeo-editor .component-edit .prop-controls textarea:focus, +.formeo.formeo-editor .component-edit .prop-controls [contenteditable]:focus { + z-index: 1; +} +.formeo.formeo-editor .f-input-wrap .component-edit [disabled] { + display: none; +} +.formeo.formeo-editor .component-edit .f-input-wrap { + display: flex; + align-items: center; + flex: 1; +} +.formeo.formeo-editor .component-edit .f-input-wrap.disabled-prop { + display: none; +} +.formeo.formeo-editor .component-edit .f-input-wrap.locked-prop { + pointer-events: none; + opacity: 0.7; +} +.formeo.formeo-editor .component-edit .f-input-wrap.locked-prop select { + background-image: none; +} +.formeo.formeo-editor .component-edit .control-count-2 .prop-controls:hover { + width: 48px; +} +.formeo.formeo-editor .component-edit .control-count-2 .prop-controls:hover + .prop-inputs { + width: calc(100% - 24px); +} +.formeo.formeo-editor .component-edit.panel-count-1 .panel-nav { + border-bottom: 1px solid #999999; +} +.formeo.formeo-editor .component-edit.panel-count-1 .panel-nav button { + display: none; +} +.formeo.formeo-editor .component-edit.panel-count-1 .panel-labels { + background-color: transparent; +} +.formeo.formeo-editor .component-edit[popover] { + display: block; + width: 66.7%; + height: 66.7%; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + box-shadow: 0 0 24px 1px #000; + border: 1px solid #000; + border-radius: 8px; + z-index: 100; +} +.formeo.formeo-editor .f-condition-row { + display: flex; + width: 100%; + position: relative; + border-bottom: 1px solid #cccccc; +} +.formeo.formeo-editor .f-condition-row > [class^=condition-] { + border-left: 1px solid #cccccc; +} +.formeo.formeo-editor .f-condition-row .manage-condition-type { + display: flex; + align-items: center; + justify-content: center; + width: 16px; + height: 16px; + padding: 0; + right: -16px; + position: absolute; + box-shadow: none; + border-top-left-radius: 0; + border-bottom-left-radius: 0; + z-index: 1; +} +.formeo.formeo-editor .f-condition-row .remove-condition-type { + top: 0; +} +.formeo.formeo-editor .f-condition-row .add-condition-type { + bottom: 0; + display: none; +} +.formeo.formeo-editor .f-condition-row:last-child .remove-condition-type { + border-bottom-right-radius: 0; +} +.formeo.formeo-editor .f-condition-row:last-child .add-condition-type { + display: flex; +} +.formeo.formeo-editor .f-condition-row:last-child:not(:first-child) .add-condition-type { + border-top-right-radius: 0; +} +.formeo.formeo-editor .f-condition-row input, +.formeo.formeo-editor .f-condition-row select, +.formeo.formeo-editor .f-condition-row [contenteditable] { + background-color: #fff; + border: 0 none; + border-radius: 0; +} +.formeo.formeo-editor .f-condition-row input:focus, +.formeo.formeo-editor .f-condition-row select:focus, +.formeo.formeo-editor .f-condition-row [contenteditable]:focus { + box-shadow: 0 0 0 1px rgb(102, 175, 233) inset; +} +.formeo.formeo-editor .f-condition-row div.condition-source, +.formeo.formeo-editor .f-condition-row div.condition-target { + position: relative; + border-left: 1px solid #cccccc; +} +.formeo.formeo-editor .f-condition-row .condition-logical, +.formeo.formeo-editor .f-condition-row .condition-source, +.formeo.formeo-editor .f-condition-row .condition-target, +.formeo.formeo-editor .f-condition-row .condition-sourceProperty, +.formeo.formeo-editor .f-condition-row .condition-targetProperty, +.formeo.formeo-editor .f-condition-row .condition-comparison, +.formeo.formeo-editor .f-condition-row .condition-assignment, +.formeo.formeo-editor .f-condition-row .condition-value { + flex: 1 !important; + max-width: 100%; + transition: max-width 266ms; + will-change: max-width; + min-height: 31px; +} +.formeo.formeo-editor .f-condition-row .condition-logical { + max-width: 64px; + text-transform: uppercase; +} +.formeo.formeo-editor .f-condition-row .condition-source input[type=text], +.formeo.formeo-editor .f-condition-row .condition-target input[type=text] { + min-height: 31px; +} +.formeo.formeo-editor .f-condition-row .hidden-property { + max-width: 0% !important; + padding: 0; + border: 0 none; + overflow: hidden; + box-shadow: none; + margin: 0; +} +.formeo.formeo-editor .f-condition-row.display-none { + display: none; +} +.formeo.formeo-editor .f-condition-row .hidden-option { + display: none; +} +.formeo.formeo-editor .type-conditions-wrap { + width: 100%; +} +.formeo.formeo-editor .then-condition-row:first-of-type .remove-condition-type { + display: none; +} +.formeo.formeo-editor .if-condition-row:first-of-type .remove-condition-type { + display: none; +} +.formeo.formeo-editor .if-condition-row .condition-target input { + border-top-right-radius: 4px; + border-bottom-right-radius: 0; +} +.formeo.formeo-editor .condition-target input.f-autocomplete-display-field:placeholder-shown + span.clear-button, +.formeo.formeo-editor .condition-source input.f-autocomplete-display-field:placeholder-shown + span.clear-button { + display: none; +} +.formeo.formeo-editor .condition-target .clear-button, +.formeo.formeo-editor .condition-source .clear-button { + position: absolute; + right: 2px; + top: 50%; + transform: translateY(-50%); + cursor: pointer; + padding: 4px; + display: flex; + justify-content: center; + align-items: center; +} +.formeo.formeo-editor .condition-target .clear-button .svg-icon, +.formeo.formeo-editor .condition-source .clear-button .svg-icon { + width: 12px; + height: 12px; + border-radius: 50%; + padding: 1px; + border: 1px solid #333333; + fill: #333333; +} +.formeo.formeo-editor .condition-target .clear-button:hover .svg-icon, +.formeo.formeo-editor .condition-source .clear-button:hover .svg-icon { + border: 1px solid rgb(217, 83, 79); + fill: rgb(217, 83, 79); +} +.formeo.formeo-editor .condition-target .clear-button:active .svg-icon, +.formeo.formeo-editor .condition-source .clear-button:active .svg-icon { + background-color: rgb(217, 83, 79); + border: 1px solid rgb(201.4953271028, 48.0841121495, 43.5046728972); + fill: #fff; +} +.formeo.formeo-editor .conditions-prop-inputs { + border-top: 1px solid #cccccc; + border-right: 1px solid #cccccc; +} +.formeo.formeo-editor .conditions-prop-inputs label.condition-label { + display: inline-flex; + flex: 0 0 auto; + align-items: center; + padding: 4px; + border-right: 0; + text-transform: uppercase; + color: #333333; + background-color: #fff; +} +.formeo.formeo-editor .conditions-prop-inputs label.condition-label.then-condition-label { + background-color: #666666; + color: #fff; +} +.formeo.formeo-editor .field-conditions-0 .conditions-prop-inputs { + border-top: 0 none; +} +.formeo.formeo-editor .edit-group { + margin: 0; + padding: 0; + list-style: none; +} +.formeo.formeo-editor .options-panel .prop-wrap { + margin-bottom: 8px; +} +.formeo.formeo-editor .options-panel .input-group-addon { + line-height: 0; +} +.formeo.formeo-editor .options-panel .prop-labels { + padding: 8px 34px 8px 8px; +} +.formeo.formeo-editor .options-panel .prop-labels .input-group-addon { + font-size: 12px; +} +.formeo.formeo-editor .options-panel .prop-labels label { + font-size: 12px; + width: 50%; + position: relative; + display: table-cell; +} +.formeo.formeo-editor .options-panel .prop-label-selected, +.formeo.formeo-editor .options-panel .prop-label-disabled { + width: 1% !important; + white-space: nowrap; + vertical-align: middle; + border: 0 none; + background-color: transparent; +} +.formeo.formeo-editor .panel-action-buttons { + display: flex; + justify-content: end; + gap: 8px; + padding: 0 8px 8px; +} +.formeo.formeo-editor .panel-action-buttons button { + display: flex; + align-items: center; + justify-content: center; + line-height: 24px; + gap: 4px; +} +.formeo.formeo-editor .panel-action-buttons .svg-icon { + height: 16px; +} +.formeo.formeo-editor .panel-action-buttons [class^=add-] { + float: right; +} +.formeo.formeo-editor .prop-inputs { + width: 100%; + align-items: center; + vertical-align: bottom; + display: flex; + flex-direction: row; + flex-grow: 1; +} +.formeo.formeo-editor .prop-inputs.conditions-prop-inputs { + flex-direction: column; +} +.formeo.formeo-editor .prop-inputs .f-field-group { + width: 100%; + margin-bottom: 0; + display: inline-flex; + align-items: center; +} +.formeo.formeo-editor .prop-inputs .f-addon { + display: flex; + align-items: center; + flex: 0; +} +.formeo.formeo-editor .prop-inputs .f-addon input { + margin: 0; +} +.formeo.formeo-editor .prop-inputs label { + padding-right: 1em; + margin-bottom: 0; +} +.formeo.formeo-editor .formeo-stage { + width: 73%; + box-sizing: border-box; + transition: width 250ms; + margin-right: 4px; + flex: 1 1 auto; + position: relative; + transition-property: background-color, border; + transition-duration: 266ms, 266ms; + border-width: 1px; + border-style: dashed; + border-color: transparent; + background-color: rgba(255, 255, 255, 0); + padding-left: 23px; + padding-bottom: 8px; + overflow: visible; + border-radius: 8px 0 8px 8px; +} +.formeo.formeo-editor .formeo-stage > .children { + display: flex; + flex-direction: column; + justify-content: flex-start; + gap: 16px; + min-height: 100%; +} +.formeo.formeo-editor .formeo-stage.removing-all-fields .formeo-row { + transition: margin-top 250ms ease-in; +} +.formeo.formeo-editor .formeo-stage > .formeo-field { + background-color: #fff; +} +.formeo.formeo-editor .formeo-stage { + margin: 0; + padding: 0; + list-style: none; +} +@media (max-width: 481px) { + .formeo.formeo-editor .formeo-stage { + width: calc(100% - 50px); + } +} +.formeo.formeo-editor .formeo-stage.highlight-component { + box-shadow: 0 0 4px 2px #4F46E5; +} +.formeo.formeo-editor .formeo-stage .hidden { + display: none; +} +.formeo.formeo-editor .formeo-stage.empty { + border-color: #999999; + background-color: rgba(255, 255, 255, 0.25); +} +.formeo.formeo-editor .formeo-stage.editing-stage, .formeo.formeo-editor .formeo-stage.hovering-stage, .formeo.formeo-editor .formeo-stage.hovering-stage.empty { + border-color: #4F46E5; + border-style: solid; +} +.formeo.formeo-editor .formeo-stage.editing-stage .stage-tag, .formeo.formeo-editor .formeo-stage.hovering-stage .stage-tag, .formeo.formeo-editor .formeo-stage.hovering-stage.empty .stage-tag { + display: flex; +} +.formeo.formeo-editor .formeo-stage.editing-stage .stage-action-btn-wrap, .formeo.formeo-editor .formeo-stage.hovering-stage .stage-action-btn-wrap, .formeo.formeo-editor .formeo-stage.hovering-stage.empty .stage-action-btn-wrap { + display: flex; + border-top-right-radius: 8px; +} +.formeo.formeo-editor .formeo-stage.editing-stage .stage-handle, .formeo.formeo-editor .formeo-stage.hovering-stage .stage-handle, .formeo.formeo-editor .formeo-stage.hovering-stage.empty .stage-handle { + display: none; +} +.formeo.formeo-editor .formeo-stage.editing-stage .formeo-row, .formeo.formeo-editor .formeo-stage.hovering-stage .formeo-row, .formeo.formeo-editor .formeo-stage.hovering-stage.empty .formeo-row { + box-shadow: none; +} +.formeo.formeo-editor .stage-tag { + border: 0 none; + border: inherit; + display: none; + left: auto; + overflow: hidden; + padding: 0 32px 2px 8px; + right: -1px; + transition: width 266ms ease-in-out; + width: auto; + border-bottom-width: 0; +} +.formeo.formeo-editor .stage-edit { + padding: 16px; + background-color: #fff; + margin-top: 0; + margin-bottom: 16px; + border-top-left-radius: 8px; +} +.formeo.formeo-editor .stage-edit .panel-nav { + border-bottom: 0 none !important; +} +.formeo.formeo-editor .stage-edit .conditions-prop-inputs { + border-top: 1px solid #cccccc; +} +.formeo.formeo-editor .stage-actions { + right: -1px; + top: -24px; + text-align: right; + border-bottom-left-radius: 4px; + border-bottom-right-radius: 0; + border-width: 0; +} +.formeo.formeo-editor .stage-actions .edit-toggle, +.formeo.formeo-editor .stage-actions .edit-toggle:focus { + border-bottom-left-radius: 0; + border-bottom-right-radius: 0; + border-right: 1px solid #4F46E5; + border-top-left-radius: 0; + border-top-right-radius: 8px; + border-top: 1px solid #4F46E5; +} +.formeo.formeo-editor .editing-stage .formeo-settings { + display: block; +} +.formeo.formeo-editor .editing-stage .formeo-stage { + display: none; +} +.formeo.formeo-editor .f-field-group { + margin-bottom: 0; +} +.formeo.formeo-editor [class$=empty][class*=editing-]::after { + opacity: 0; +} +.formeo.formeo-editor [class$=empty]::after { + opacity: 1; + font-size: 24px; + position: absolute; + top: 50%; + left: 50%; + color: #999999; + transition: opacity 200ms ease-in-out; + will-change: opacity; + text-align: center; + transform: translate(-50%, -50%); + content: attr(data-hover-tag); +} +.formeo.formeo-editor .formeo-settings { + display: none; +} +.formeo.formeo-editor [dir=rtl] .f-btn-group { + display: inline-flex; + vertical-align: middle; +} +.formeo.formeo-editor [dir=rtl] .f-btn-group > button { + flex: 0 1 auto; +} +.formeo.formeo-editor [dir=rtl] .f-btn-group > button:not(:first-child):not(:last-child):not(.dropdown-toggle) { + border-radius: 0; +} +.formeo.formeo-editor [dir=rtl] .f-btn-group > button:last-child:not(:first-child):not(.dropdown-toggle) { + border-bottom-right-radius: 0; + border-top-right-radius: 0; + border-bottom-left-radius: 4px; + border-top-left-radius: 4px; +} +.formeo.formeo-editor [dir=rtl] .f-btn-group > button:first-child { + margin-left: 0; +} +.formeo.formeo-editor [dir=rtl] .f-btn-group > button:first-child:not(:last-child):not(.dropdown-toggle) { + border-bottom-left-radius: 0; + border-top-left-radius: 0; + border-bottom-right-radius: 4px; + border-top-right-radius: 4px; +} +.formeo.formeo-editor [dir=rtl] .f-btn-group button + button, +.formeo.formeo-editor [dir=rtl] .f-btn-group button + .f-btn-group, +.formeo.formeo-editor [dir=rtl] .f-btn-group .f-btn-group + button, +.formeo.formeo-editor [dir=rtl] .f-btn-group .f-btn-group + .f-btn-group, +.formeo.formeo-editor [dir=rtl] .f-btn-group .f-btn-group-vertical button + button, +.formeo.formeo-editor [dir=rtl] .f-btn-group .f-btn-group-vertical button + .f-btn-group, +.formeo.formeo-editor [dir=rtl] .f-btn-group .f-btn-group-vertical .f-btn-group + button, +.formeo.formeo-editor [dir=rtl] .f-btn-group .f-btn-group-vertical .f-btn-group + .f-btn-group { + margin-right: -1px; +} +.formeo.formeo-editor [dir=rtl] .formeo-controls { + float: left; +} +.formeo.formeo-editor [dir=rtl] .formeo-controls .form-actions { + float: left; +} +.formeo.formeo-editor [dir=rtl] { + /* + Mixins + */ +} +.formeo.formeo-editor [dir=rtl] .formeo-stage-wrap { + float: right; +} +.formeo.formeo-editor [dir=rtl] .formeo-stage-wrap .formeo-stage { + padding-left: 4px; + padding-right: 23px; +} +.formeo.formeo-editor [dir=rtl] { + /* + Mixins + */ +} +.formeo.formeo-editor [dir=rtl] .formeo-row::before { + border-bottom-left-radius: 8px; + border-bottom-right-radius: 0; + right: 0; + left: auto; +} +.formeo.formeo-editor [dir=rtl] .formeo-row:first-child { + border-top-left-radius: 8px; + border-top-right-radius: 0; +} +.formeo.formeo-editor [dir=rtl] .formeo-row:last-child { + border-bottom-left-radius: 8px; + border-bottom-right-radius: 8px; +} +.formeo.formeo-editor [dir=rtl] .formeo-row.hovering-row:first-child { + border-top-left-radius: 0; +} +.formeo.formeo-editor [dir=rtl] .formeo-row.hovering-row::before { + border-left-width: 1px; +} +.formeo.formeo-editor [dir=rtl] .formeo-row.empty::after { + left: 0; + right: auto; +} +.formeo.formeo-render { + /* + Mixins + */ +} +.formeo.formeo-render .formeo-row, +.formeo.formeo-render .formeo-column, +.formeo.formeo-render .formeo-field { + position: relative; +} +.formeo.formeo-render .f-input-group-wrap .formeo-row:first-of-type .remove-input-group { + display: none; +} +.formeo.formeo-render .f-input-group-wrap > fieldset { + position: relative; +} +.formeo.formeo-render .f-input-group-wrap > fieldset .remove-input-group { + top: 8px; +} +.formeo.formeo-render .formeo-row { + margin-bottom: 1em; + flex-direction: row; + justify-content: flex-start; + flex-wrap: nowrap; + align-content: stretch; + align-items: stretch; + display: flex; + border-radius: 8px; + outline: 0 solid rgba(217, 83, 79, 0.25); + transition: background-color 133ms, outline 133ms; + padding: 4px 0; +} +.formeo.formeo-render .formeo-row.will-remove { + border-radius: 2px; + background-color: rgba(217, 83, 79, 0.25); + outline: 6px solid rgba(217, 83, 79, 0.25); +} +.formeo.formeo-render .formeo-row:last-of-type { + margin-bottom: 0; +} +.formeo.formeo-render fieldset.formeo-row-wrap { + padding: 16px; +} +.formeo.formeo-render .formeo-row-wrap { + margin-bottom: 1em; + border-radius: 8px; +} +.formeo.formeo-render .formeo-row-wrap legend { + padding: 4px 8px; + border-style: groove; + border-width: 2px; + border-color: threedface; + border-radius: 8px; + font-size: 1.15em; +} +.formeo.formeo-render .formeo-row-wrap:last-child { + margin-bottom: 0; +} +.formeo.formeo-render .f-input-group { + position: relative; +} +.formeo.formeo-render .f-input-group:first-child .remove-input-group { + display: none; +} +.formeo.formeo-render .add-input-group { + float: right; + margin-top: 10px; +} +.formeo.formeo-render .remove-input-group { + position: absolute; + right: 0; + top: 0; + width: 24px; + height: 24px; + border: 0 none; + background: transparent; + outline: 0 none; + line-height: 0; + padding: 4px; +} +.formeo.formeo-render .remove-input-group:hover { + background-color: #fff; +} +.formeo.formeo-render .remove-input-group:hover .svg-icon { + fill: rgb(217, 83, 79); +} +.formeo.formeo-render .remove-input-group .svg-icon { + pointer-events: none; +} +.formeo.formeo-render .form-check-input:only-child { + position: absolute; +} +.formeo.formeo-render .svg-icon { + max-width: 100%; + max-height: 100%; +} +.formeo.formeo-render .formeo-column { + padding: 0 4px; + float: left; + max-width: none; + flex-direction: column; +} +.formeo.formeo-render .formeo-column:first-of-type { + padding-left: 0; +} +.formeo.formeo-render .formeo-column:last-of-type { + padding-right: 0; +} +.formeo.formeo-render .f-checkbox { + display: flex; + align-items: center; + gap: 4px; +} +.formeo button .svg-icon { + max-width: 100%; + max-height: 100%; +} + +/* + Mixins +*/ +.formeo-controls { + width: 26%; + max-width: 224px; + overflow: hidden; + container-type: inline-size; + container-name: controls; +} + +.formeo-controls.formeo-sticky { + position: sticky; + top: 0; + align-self: flex-start; +} + +.formeo-controls.pull-left .form-actions { + float: left; +} + +.formeo-controls .filtered-term { + background-color: #fff; + text-align: center; + border-radius: 4px 4px 0 0; + border: 1px solid #cccccc; + border-bottom: 0 none; + width: calc(100% - 2px); +} + +.formeo-controls .tabbed-panels nav { + padding: 0; +} + +.formeo-controls nav { + position: relative; + padding: 0 24px; + overflow: hidden; +} + +.formeo-controls nav h5 { + font-size: 13px; + line-height: 22px; +} + +.formeo-controls nav button { + position: absolute; + width: 24px; + color: #000; + height: calc(100% + 1px); + padding: 0; + line-height: 0; + margin: 0; + border-color: #cccccc; +} + +.formeo-controls nav button.next-group { + right: 0; + top: 0; + border-top-left-radius: 0; + border-top-right-radius: 8px; + border-bottom-left-radius: 0; + border-bottom-right-radius: 0; +} + +.formeo-controls nav button.prev-group { + border-top-left-radius: 8px; + border-bottom-left-radius: 0; + left: 0; +} + +.formeo-controls .formeo-panels-wrap { + font-size: 0.85em; + line-height: 1.8em; +} + +.formeo-controls .panel-labels { + border-top: 1px solid #cccccc; +} + +.formeo-controls .panel-count-1 { + border-bottom-left-radius: 0; +} + +.formeo-controls .panel-count-1 .panel-nav { + display: none; +} + +.formeo-controls .panel-count-1 .control-group li:first-child { + border-radius: 8px 8px 0 0; +} + +.formeo-controls ul { + margin: 0; + padding: 0; + list-style: none; +} + +.formeo-controls .control-group { + vertical-align: top; + display: inline-block; + width: 100%; + border-top: 1px solid #cccccc; +} + +.formeo-controls .control-group > li:first-child { + border-top-right-radius: 0; +} + +.formeo-controls .control-group > li:last-child { + border-radius: 0 0 8px 8px; +} + +.formeo-controls.filtered .panel-nav { + display: none; +} + +.formeo-controls.filtered .control-group { + display: block; +} + +.formeo-controls.filtered .control-group > li { + border-radius: 0; +} + +.formeo-controls.filtered .control-group:last-child > li:last-child { + border-radius: 0 0 0 4px; +} + +.formeo-controls .control-groups { + white-space: nowrap; +} + +.formeo-controls .control-group-labels { + height: 100%; + background: #fff; + overflow: hidden; +} + +.formeo-controls .control-group-labels div { + white-space: nowrap; +} + +.formeo-controls .control-group-labels h4 { + display: inline-block; + width: 100%; +} + +.formeo-controls .form-actions { + float: right; + margin-top: 4px; +} + +.formeo-controls .form-actions .svg-icon { + fill: #666666; + display: none; +} + +.formeo-controls .form-actions .svg-icon:hover { + fill: #000; +} + +.formeo-controls .form-actions .save-form:hover, +.formeo-controls .form-actions .clear-form:hover { + color: #fff; +} + +.formeo-controls .form-actions .save-form:hover { + background-color: rgb(50, 93, 136); +} + +.formeo-controls .form-actions .clear-form:hover { + background-color: rgb(217, 83, 79); +} + +@media (max-width: 481px) { + .formeo-controls { + width: 45px; + } + .formeo-controls .control-group { + text-indent: -9999px; + } + .formeo-controls .form-actions { + display: inline-block; + width: 100%; + position: relative; + vertical-align: middle; + float: none; + margin-top: 10px; + } + .formeo-controls .form-actions > button + button { + margin-top: -1px; + margin-left: 0; + } + .formeo-controls .form-actions > button { + max-width: 100%; + width: 100%; + float: none; + position: relative; + display: block; + } + .formeo-controls .form-actions > button:not(:first-child):not(:last-child) { + border-radius: 0; + } + .formeo-controls .form-actions > button:first-child:not(:last-child) { + border-top-right-radius: 4px; + border-top-left-radius: 4px; + border-bottom-right-radius: 0; + border-bottom-left-radius: 0; + } + .formeo-controls .form-actions > button:last-child:not(:first-child) { + border-bottom-left-radius: 4px; + border-bottom-right-radius: 4px; + border-top-right-radius: 0; + border-top-left-radius: 0; + } + .formeo-controls .form-actions .control-icon { + display: inline-block; + } +} +.formeo-controls .field-control { + cursor: move; + list-style: none; + margin: -1px 0 0; + border: 1px solid #cccccc; + text-align: left; + background: #fff; + user-select: none; + overflow: hidden; +} + +.formeo-controls .field-control.control-moving { + border-radius: 8px; + animation: DRAG_GHOST 500ms forwards; +} + +.formeo-controls .field-control::before { + margin-right: 8px; + font-size: 16px; +} + +.formeo-controls .field-control:hover { + background-color: rgb(242.25, 242.25, 242.25); +} + +.formeo-controls .field-control button { + box-sizing: border-box; + font-size: 1em; + line-height: 1.8em; + display: flex; + height: 100%; + width: 100%; + background: transparent; + border: 0 none; + text-align: left; + padding: 8px; + border-radius: 0; +} + +.formeo-controls .field-control button:focus { + outline: 0 none; + background-color: rgb(242.25, 242.25, 242.25); + box-shadow: 0 0 0 1px rgb(102, 175, 233) inset; + border-radius: 0 !important; +} + +.formeo-controls .field-control button:active { + transform: none; +} + +.formeo-controls .field-control button:hover { + filter: none; +} + +.formeo-controls .field-control .control-icon { + margin-right: 8px; + text-align: center; + width: 24px; + height: 24px; + display: flex; + align-items: center; + justify-content: center; +} + +[dir=rtl] .formeo-controls .field-control button { + text-align: right !important; +} + +[dir=rtl] .formeo-controls .field-control svg { + float: right !important; + margin: 0 0 0 8px !important; +} + +.formeo-controls .field-control .control-icon { + pointer-events: none; +} + +.formeo-controls .field-control .control-icon .f-i-hash { + padding: 2px; +} + +@container controls (max-width: 46px) { + .control-label { + display: none; + } + .form-actions button { + padding: 2px 0; + text-align: center; + } + .form-actions button:first-child { + border-top-right-radius: 4px !important; + border-top-left-radius: 4px !important; + border-bottom-right-radius: 0 !important; + border-bottom-left-radius: 0 !important; + } + .form-actions button:last-child { + border-bottom-left-radius: 4px !important; + border-bottom-right-radius: 4px !important; + border-top-right-radius: 0 !important; + border-top-left-radius: 0 !important; + } +} +.field-control { + cursor: move; + list-style: none; + margin: -1px 0 0; + border: 1px solid #cccccc; + text-align: left; + background: #fff; + user-select: none; + overflow: hidden; +} +.field-control.control-moving { + border-radius: 8px; + animation: DRAG_GHOST 500ms forwards; +} +.field-control::before { + margin-right: 8px; + font-size: 16px; +} +.field-control:hover { + background-color: rgb(242.25, 242.25, 242.25); +} +.field-control button { + box-sizing: border-box; + font-size: 1em; + line-height: 1.8em; + display: flex; + height: 100%; + width: 100%; + background: transparent; + border: 0 none; + text-align: left; + padding: 8px; + border-radius: 0; +} +.field-control button:focus { + outline: 0 none; + background-color: rgb(242.25, 242.25, 242.25); + box-shadow: 0 0 0 1px rgb(102, 175, 233) inset; + border-radius: 0 !important; +} +.field-control button:active { + transform: none; +} +.field-control button:hover { + filter: none; +} +.field-control .control-icon { + margin-right: 8px; + text-align: center; + width: 24px; + height: 24px; + display: flex; + align-items: center; + justify-content: center; +} +[dir=rtl] .field-control button { + text-align: right !important; +} +[dir=rtl] .field-control svg { + float: right !important; + margin: 0 0 0 8px !important; +} +.field-control .svg-icon { + pointer-events: none; +} \ No newline at end of file diff --git a/src/lib/sass/css-properties.test.mjs b/src/lib/sass/css-properties.test.mjs new file mode 100644 index 00000000..fc112587 --- /dev/null +++ b/src/lib/sass/css-properties.test.mjs @@ -0,0 +1,13 @@ +import { readFileSync } from 'node:fs' +import { suite, test } from 'node:test' +import { compileFormeoCss } from '../../../tools/compile-scss.mjs' +import { resolveFormeoProperties } from '../../../tools/resolve-formeo-properties.mjs' + +const baseline = readFileSync(new URL('./__fixtures__/formeo-baseline.css', import.meta.url), 'utf8') + +suite('formeo CSS custom properties', () => { + test('resolved CSS is identical to the pre-change baseline', t => { + const { css } = resolveFormeoProperties(compileFormeoCss()) + t.assert.strictEqual(css.trim(), baseline.trim()) + }) +}) diff --git a/tools/compile-scss.mjs b/tools/compile-scss.mjs new file mode 100644 index 00000000..55e3a24e --- /dev/null +++ b/tools/compile-scss.mjs @@ -0,0 +1,9 @@ +import { fileURLToPath } from 'node:url' +import * as sass from 'sass-embedded' + +const entry = fileURLToPath(new URL('../src/lib/sass/formeo.scss', import.meta.url)) + +/** Compile formeo.scss exactly as the lib build does (modern compiler, expanded output). */ +export function compileFormeoCss() { + return sass.compile(entry, { style: 'expanded', silenceDeprecations: ['import'] }).css +} diff --git a/tools/resolve-formeo-properties.mjs b/tools/resolve-formeo-properties.mjs new file mode 100644 index 00000000..9931c1e2 --- /dev/null +++ b/tools/resolve-formeo-properties.mjs @@ -0,0 +1,32 @@ +const block = selector => new RegExp(`${selector.replace(/[().]/g, m => `\\${m}`)}\\s*\\{([^}]*)\\}\\s*`) + +function readDeclarations(body = '') { + const map = new Map() + for (const decl of body.split(';')) { + const match = decl.match(/^\s*(--formeo-[\w-]+)\s*:\s*([\s\S]+?)\s*$/) + if (match) map.set(match[1], match[2]) + } + return map +} + +/** Rules intentionally added by this change; removed before comparing to baseline. */ +export const ALLOWED_ADDITIONS = [/:where\(\.svg-icon\)\s*\{[^}]*\}\s*/g] + +/** + * Replace every var(--formeo-*) with its :where(:root) default and drop the property blocks, + * producing CSS comparable to the pre-change baseline. + */ +export function resolveFormeoProperties(css) { + const rootRe = block(':where(:root)') + const darkRe = block(':where(.formeo-dark)') + const defaults = readDeclarations(css.match(rootRe)?.[1]) + const dark = readDeclarations(css.match(darkRe)?.[1]) + + let out = css.replace(rootRe, '').replace(darkRe, '') + for (const re of ALLOWED_ADDITIONS) out = out.replace(re, '') + out = out.replace(/var\((--formeo-[\w-]+)\)/g, (_, name) => { + if (!defaults.has(name)) throw new Error(`No default declared for ${name}`) + return defaults.get(name) + }) + return { css: out, defaults, dark } +} From fe06eb5795a57953b255833527e072ba8f5700a2 Mon Sep 17 00:00:00 2001 From: Kevin Chappell Date: Thu, 24 Sep 2026 02:00:24 +0100 Subject: [PATCH 02/19] feat: declare formeo color custom properties with current defaults --- src/lib/sass/base/_properties.scss | 52 +++++++++++++++++++++++++++ src/lib/sass/base/_tokens.scss | 53 ++++++++++++++++++++++++++++ src/lib/sass/css-properties.test.mjs | 42 ++++++++++++++++++++++ src/lib/sass/formeo.scss | 1 + 4 files changed, 148 insertions(+) create mode 100644 src/lib/sass/base/_properties.scss create mode 100644 src/lib/sass/base/_tokens.scss diff --git a/src/lib/sass/base/_properties.scss b/src/lib/sass/base/_properties.scss new file mode 100644 index 00000000..79964155 --- /dev/null +++ b/src/lib/sass/base/_properties.scss @@ -0,0 +1,52 @@ +@use "sass:color" as sass-color; +@use "sass:list"; +@use "colors" as c; + +$_structural: ( + stage: (c.$stage-outline-color, c.$stage-outline-color-text, c.$stage-highlight-color, c.$stage-highlight-color-text), + row: (c.$row-outline-color, c.$row-outline-color-text, c.$row-highlight-color, c.$row-highlight-color-text), + column: (c.$column-outline-color, c.$column-outline-color-text, c.$column-highlight-color, c.$column-highlight-color-text), + field: (c.$field-outline-color, c.$field-outline-color-text, c.$field-highlight-color, c.$field-highlight-color-text), + option: (c.$option-outline-color, c.$option-outline-color-text, c.$option-highlight-color, c.$option-highlight-color-text), +); + +:where(:root) { + --formeo-bg: #{c.$white}; + --formeo-bg-hover: #{sass-color.adjust(c.$white, $lightness: -5%)}; + --formeo-surface-muted: #{c.$gray-lightest}; + --formeo-stage-bg: #{c.$stage-background}; + --formeo-stage-shadow: #{c.$shadow}; + --formeo-overlay: #{rgba(255, 255, 255, 0.9)}; + + --formeo-text: #{c.$black}; + --formeo-text-strong: #{c.$gray-darker}; + --formeo-text-secondary: #{c.$gray-dark}; + --formeo-text-muted: #{c.$gray}; + --formeo-text-subtle: #{c.$gray-light}; + --formeo-on-accent: #{c.$white}; + --formeo-icon: #{c.$black}; + + --formeo-border: #{c.$gray-lighter}; + --formeo-border-strong: #{c.$gray-light}; + --formeo-focus: #{c.$input-focus}; + + --formeo-primary: #{c.$brand-primary}; + --formeo-primary-dark: #{c.$brand-primary-dark}; + --formeo-success: #{c.$brand-success}; + --formeo-success-dark: #{c.$brand-success-dark}; + --formeo-warning: #{c.$brand-warning}; + --formeo-warning-dark: #{c.$brand-warning-dark}; + --formeo-danger: #{c.$brand-error}; + --formeo-danger-dark: #{c.$brand-error-dark}; + --formeo-danger-subtle: #{rgba(c.$brand-error, 0.1)}; + --formeo-info: #{c.$brand-info}; + --formeo-remove-bg: #{c.$remove-bg}; + --formeo-column-outline-soft: #{sass-color.adjust(c.$column-outline-color, $lightness: 15%)}; + + @each $name, $values in $_structural { + --formeo-#{$name}-outline: #{list.nth($values, 1)}; + --formeo-#{$name}-outline-text: #{list.nth($values, 2)}; + --formeo-#{$name}-highlight: #{list.nth($values, 3)}; + --formeo-#{$name}-highlight-text: #{list.nth($values, 4)}; + } +} diff --git a/src/lib/sass/base/_tokens.scss b/src/lib/sass/base/_tokens.scss new file mode 100644 index 00000000..9c6e570c --- /dev/null +++ b/src/lib/sass/base/_tokens.scss @@ -0,0 +1,53 @@ +// SCSS aliases for formeo's CSS custom properties. Component styles use these instead of colors.$*. +$bg: var(--formeo-bg); +$bg-hover: var(--formeo-bg-hover); +$surface-muted: var(--formeo-surface-muted); +$stage-bg: var(--formeo-stage-bg); +$stage-shadow: var(--formeo-stage-shadow); +$overlay: var(--formeo-overlay); + +$text: var(--formeo-text); +$text-strong: var(--formeo-text-strong); +$text-secondary: var(--formeo-text-secondary); +$text-muted: var(--formeo-text-muted); +$text-subtle: var(--formeo-text-subtle); +$on-accent: var(--formeo-on-accent); +$icon: var(--formeo-icon); + +$border: var(--formeo-border); +$border-strong: var(--formeo-border-strong); +$focus: var(--formeo-focus); + +$primary: var(--formeo-primary); +$primary-dark: var(--formeo-primary-dark); +$success: var(--formeo-success); +$success-dark: var(--formeo-success-dark); +$warning: var(--formeo-warning); +$warning-dark: var(--formeo-warning-dark); +$danger: var(--formeo-danger); +$danger-dark: var(--formeo-danger-dark); +$danger-subtle: var(--formeo-danger-subtle); +$info: var(--formeo-info); +$remove-bg: var(--formeo-remove-bg); +$column-outline-soft: var(--formeo-column-outline-soft); + +$stage-outline: var(--formeo-stage-outline); +$stage-outline-text: var(--formeo-stage-outline-text); +$stage-highlight: var(--formeo-stage-highlight); +$stage-highlight-text: var(--formeo-stage-highlight-text); +$row-outline: var(--formeo-row-outline); +$row-outline-text: var(--formeo-row-outline-text); +$row-highlight: var(--formeo-row-highlight); +$row-highlight-text: var(--formeo-row-highlight-text); +$column-outline: var(--formeo-column-outline); +$column-outline-text: var(--formeo-column-outline-text); +$column-highlight: var(--formeo-column-highlight); +$column-highlight-text: var(--formeo-column-highlight-text); +$field-outline: var(--formeo-field-outline); +$field-outline-text: var(--formeo-field-outline-text); +$field-highlight: var(--formeo-field-highlight); +$field-highlight-text: var(--formeo-field-highlight-text); +$option-outline: var(--formeo-option-outline); +$option-outline-text: var(--formeo-option-outline-text); +$option-highlight: var(--formeo-option-highlight); +$option-highlight-text: var(--formeo-option-highlight-text); diff --git a/src/lib/sass/css-properties.test.mjs b/src/lib/sass/css-properties.test.mjs index fc112587..e6a9d15e 100644 --- a/src/lib/sass/css-properties.test.mjs +++ b/src/lib/sass/css-properties.test.mjs @@ -5,9 +5,51 @@ import { resolveFormeoProperties } from '../../../tools/resolve-formeo-propertie const baseline = readFileSync(new URL('./__fixtures__/formeo-baseline.css', import.meta.url), 'utf8') +const EXPECTED = [ + 'bg', + 'bg-hover', + 'surface-muted', + 'stage-bg', + 'stage-shadow', + 'overlay', + 'text', + 'text-strong', + 'text-secondary', + 'text-muted', + 'text-subtle', + 'on-accent', + 'icon', + 'border', + 'border-strong', + 'focus', + 'primary', + 'primary-dark', + 'success', + 'success-dark', + 'warning', + 'warning-dark', + 'danger', + 'danger-dark', + 'danger-subtle', + 'info', + 'remove-bg', + 'column-outline-soft', + ...['stage', 'row', 'column', 'field', 'option'].flatMap(s => [ + `${s}-outline`, + `${s}-outline-text`, + `${s}-highlight`, + `${s}-highlight-text`, + ]), +].map(n => `--formeo-${n}`) + suite('formeo CSS custom properties', () => { test('resolved CSS is identical to the pre-change baseline', t => { const { css } = resolveFormeoProperties(compileFormeoCss()) t.assert.strictEqual(css.trim(), baseline.trim()) }) + + test('declares every expected property on :where(:root)', t => { + const { defaults } = resolveFormeoProperties(compileFormeoCss()) + t.assert.deepStrictEqual([...defaults.keys()].sort(), [...EXPECTED].sort()) + }) }) diff --git a/src/lib/sass/formeo.scss b/src/lib/sass/formeo.scss index 1681bec3..c194fd6c 100644 --- a/src/lib/sass/formeo.scss +++ b/src/lib/sass/formeo.scss @@ -1,3 +1,4 @@ +@use "base/properties"; @use "sass:meta"; @use "base/variables"; @use "base/mixins"; From fdce0e5792f75b1b39e2377ada702489088e46cc Mon Sep 17 00:00:00 2001 From: Kevin Chappell Date: Thu, 24 Sep 2026 02:12:43 +0100 Subject: [PATCH 03/19] refactor: consume color custom properties in component styles --- package.json | 2 +- src/lib/sass/_render.scss | 12 ++--- src/lib/sass/base/_animation.scss | 13 +++--- src/lib/sass/base/_bs.scss | 14 +++--- src/lib/sass/base/_icons.scss | 20 ++++---- src/lib/sass/base/_mixins.scss | 47 +++++++++---------- src/lib/sass/base/_variables.scss | 4 +- src/lib/sass/components/_autocomplete.scss | 52 ++++++++++----------- src/lib/sass/components/_column.scss | 33 +++++++------ src/lib/sass/components/_controls.scss | 26 +++++------ src/lib/sass/components/_dialog.scss | 14 +++--- src/lib/sass/components/_field-edit.scss | 46 +++++++++--------- src/lib/sass/components/_field.scss | 28 +++++------ src/lib/sass/components/_group-actions.scss | 4 +- src/lib/sass/components/_panels.scss | 18 +++---- src/lib/sass/components/_row.scss | 22 ++++----- src/lib/sass/components/_stage.scss | 24 +++++----- src/lib/sass/components/component.scss | 6 +-- tools/check-color-literals.mjs | 29 ++++++++++++ 19 files changed, 221 insertions(+), 193 deletions(-) create mode 100644 tools/check-color-literals.mjs diff --git a/package.json b/package.json index b7c73e64..2878a761 100644 --- a/package.json +++ b/package.json @@ -80,7 +80,7 @@ "postbuild:demo": "node --no-warnings tools/copy-assets.mjs", "build:demo:watch": "vite build --mode demo --watch", "build:icons": "node ./tools/generate-sprite", - "lint": "biome check ./src", + "lint": "biome check ./src && node tools/check-color-literals.mjs", "lint:fix": "biome check --write ./src", "format": "biome format --write .", "test": "node --loader=./tools/svg-loader.mjs --import=./tools/__mocks__/sprite-init.mjs --experimental-test-snapshots --require ./tools/test-setup.cjs --test --no-warnings src/**/*.test.{js,mjs}", diff --git a/src/lib/sass/_render.scss b/src/lib/sass/_render.scss index fff9e7f0..43e79429 100644 --- a/src/lib/sass/_render.scss +++ b/src/lib/sass/_render.scss @@ -1,6 +1,6 @@ @use "base/mixins"; -@use "base/colors" as color; +@use "base/tokens" as t; @use "base/variables" as var; .formeo-row, @@ -33,7 +33,7 @@ align-items: stretch; display: flex; border-radius: var.$border-radius; - outline: 0 solid color.$remove-bg; + outline: 0 solid t.$remove-bg; transition: background-color var.$animation-speed-fast, outline var.$animation-speed-fast; @@ -41,8 +41,8 @@ &.will-remove { border-radius: 2px; - background-color: color.$remove-bg; - outline: 6px solid color.$remove-bg; + background-color: t.$remove-bg; + outline: 6px solid t.$remove-bg; } &:last-of-type { @@ -99,9 +99,9 @@ fieldset.formeo-row-wrap { padding: mixins.space(); &:hover { - background-color: color.$white; + background-color: t.$bg; .svg-icon { - fill: color.$brand-error; + fill: t.$danger; } } diff --git a/src/lib/sass/base/_animation.scss b/src/lib/sass/base/_animation.scss index f33b697c..e679145d 100644 --- a/src/lib/sass/base/_animation.scss +++ b/src/lib/sass/base/_animation.scss @@ -2,6 +2,7 @@ @use 'mixins'; @use 'colors' as color; +@use 'tokens' as t; @use 'variables' as var; /* @@ -20,31 +21,31 @@ @keyframes DRAG_GHOST { 0% { - box-shadow: 0 0 0 0 color.$gray-light; + box-shadow: 0 0 0 0 t.$border-strong; } 100% { - box-shadow: 0 0 30px 0 color.$gray-light; + box-shadow: 0 0 30px 0 t.$border-strong; } } @keyframes EDIT_PULSE { from, to { - border-color: color.$input-focus; + border-color: t.$focus; } 50% { - border-color: sass-color.adjust(color.$input-focus, $lightness: 20%); + border-color: sass-color.adjust(color.$input-focus, $lightness: 20%); // formeo-color-literal-ok: derived } } @keyframes COMPONENT_HIGHLIGHT_PULSE { 0% { - box-shadow: 0 0 1px mixins.space(1) color.$brand-info; + box-shadow: 0 0 1px mixins.space(1) t.$info; } 100% { - box-shadow: 0 0 0 0 color.$brand-info; + box-shadow: 0 0 0 0 t.$info; } } diff --git a/src/lib/sass/base/_bs.scss b/src/lib/sass/base/_bs.scss index c2bafc4a..ca1cc1e2 100644 --- a/src/lib/sass/base/_bs.scss +++ b/src/lib/sass/base/_bs.scss @@ -1,6 +1,6 @@ @use 'mixins'; -@use 'colors' as color; +@use 'tokens' as t; @use 'variables' as var; .pill-buttons>button { @@ -11,7 +11,7 @@ hr { margin-top: 1rem; margin-bottom: 1rem; border: 0; - border-top: 1px solid color.$gray-lighter; + border-top: 1px solid t.$border; } .f-field-group { @@ -169,7 +169,7 @@ button { +input, +select { - border-left: 1px solid color.$input-focus; + border-left: 1px solid t.$focus; } } } @@ -177,19 +177,19 @@ button { .text- { &primary { - color: color.$brand-primary; + color: t.$primary; } &success { - color: color.$brand-success; + color: t.$success; } &warning { - color: color.$brand-warning; + color: t.$warning; } &error { - color: color.$brand-error; + color: t.$danger; } } diff --git a/src/lib/sass/base/_icons.scss b/src/lib/sass/base/_icons.scss index 7f663ce1..4548be08 100644 --- a/src/lib/sass/base/_icons.scss +++ b/src/lib/sass/base/_icons.scss @@ -1,4 +1,4 @@ -@use 'colors' as color; +@use 'tokens' as t; @use 'variables' as var; @@ -11,45 +11,45 @@ .f-i-remove { &:hover { - fill: color.$brand-error; + fill: t.$danger; } } button[class*='-move'] { &:hover { - background-color: color.$brand-info !important; + background-color: t.$info !important; .svg-icon { - fill: color.$white; + fill: t.$on-accent; } } } button[class*='-remove'] { &:hover { - background-color: color.$brand-error !important; + background-color: t.$danger !important; .svg-icon { - fill: color.$white; + fill: t.$on-accent; } } } button[class*='-clone'] { &:hover { - background-color: color.$brand-success !important; + background-color: t.$success !important; .svg-icon { - fill: color.$white; + fill: t.$on-accent; } } } .edit-toggle { &:hover { - background-color: color.$brand-primary !important; + background-color: t.$primary !important; .svg-icon { - fill: color.$white; + fill: t.$on-accent; } } } diff --git a/src/lib/sass/base/_mixins.scss b/src/lib/sass/base/_mixins.scss index b4056549..f4f8f258 100644 --- a/src/lib/sass/base/_mixins.scss +++ b/src/lib/sass/base/_mixins.scss @@ -1,5 +1,4 @@ -@use "sass:color" as sass-color; -@use "colors" as color; +@use "tokens" as t; @use "variables" as var; /* @@ -95,13 +94,13 @@ margin: 0; border: var.$input-border; border-radius: var.$input-border-radius; - background-color: color.$white; + background-color: t.$bg; width: 100%; padding: var.$input-padding; box-sizing: border-box; &:focus { - border: 1px solid color.$input-focus; + border: 1px solid t.$focus; outline: none; } } @@ -116,9 +115,9 @@ cursor: move; list-style: none; margin: -1px 0 0; - border: 1px solid color.$gray-lighter; + border: 1px solid t.$border; text-align: left; - background: color.$white; + background: t.$bg; user-select: none; overflow: hidden; @@ -133,7 +132,7 @@ } &:hover { - background-color: sass-color.adjust(color.$white, $lightness: -5%); + background-color: t.$bg-hover; } button { @@ -151,8 +150,8 @@ &:focus { outline: 0 none; - background-color: sass-color.adjust(color.$white, $lightness: -5%); - box-shadow: 0 0 0 1px color.$input-focus inset; + background-color: t.$bg-hover; + box-shadow: 0 0 0 1px t.$focus inset; border-radius: 0 !important; } @@ -189,9 +188,9 @@ @mixin button-style { border-radius: var.$input-border-radius; - border: 1px solid color.$gray; - color: color.$gray-dark; - background-color: color.$white; + border: 1px solid t.$text-muted; + color: t.$text-secondary; + background-color: t.$bg; padding: space() space(1); &:active { @@ -208,36 +207,36 @@ &.success, &.warning, &.error { - color: color.$white; + color: t.$on-accent; } &.primary { - background-color: color.$brand-primary; - border-color: color.$brand-primary-dark; + background-color: t.$primary; + border-color: t.$primary-dark; } &.success { - background-color: color.$brand-success; - border-color: color.$brand-success-dark; + background-color: t.$success; + border-color: t.$success-dark; } &.warning { - background-color: color.$brand-warning; - border-color: color.$brand-warning-dark; + background-color: t.$warning; + border-color: t.$warning-dark; } &.error { - background-color: color.$brand-error; - border-color: color.$brand-error-dark; + background-color: t.$danger; + border-color: t.$danger-dark; } &[disabled] { - background-color: color.$gray-lighter; - color: color.$white; + background-color: t.$border; + color: t.$on-accent; } &:focus { - border: 1px solid color.$input-focus; + border: 1px solid t.$focus; } &:hover, diff --git a/src/lib/sass/base/_variables.scss b/src/lib/sass/base/_variables.scss index dedf8451..90459dd2 100644 --- a/src/lib/sass/base/_variables.scss +++ b/src/lib/sass/base/_variables.scss @@ -1,4 +1,4 @@ -@use "colors" as color; +@use "tokens" as t; // Sizing $base-space: 8px; @@ -11,7 +11,7 @@ $border-radius: $base-space; $input-border-radius: $half-space; $input-btn-border-width: 1px; $input-padding: 0.3em 0.6em; -$input-border: $input-btn-border-width solid color.$gray-lighter; +$input-border: $input-btn-border-width solid t.$border; $checkbox-character: "✔" !default; $animation-speed-base: 266ms; $animation-speed-slow: calc($animation-speed-base * 2); diff --git a/src/lib/sass/components/_autocomplete.scss b/src/lib/sass/components/_autocomplete.scss index 1fec35fc..8c22f4aa 100644 --- a/src/lib/sass/components/_autocomplete.scss +++ b/src/lib/sass/components/_autocomplete.scss @@ -1,7 +1,7 @@ @use 'sass:color' as sass-color; @use '../base/mixins'; -@use '../base/colors' as color; +@use '../base/tokens' as t; @use '../base/variables' as var; .f-autocomplete { @@ -13,7 +13,7 @@ } .f-autocomplete-list { - background-color: color.$white; + background-color: t.$bg; display: none; list-style: none; padding: 0; @@ -21,13 +21,13 @@ z-index: 2; width: 100%; margin: 0 -1px; - border: 1px solid color.$gray-light; + border: 1px solid t.$border-strong; border-width: 1px 1px 0; - box-shadow: 0 1px mixins.space() 0 rgba(0, 0, 0, 0.5); + box-shadow: 0 1px mixins.space() 0 rgba(0, 0, 0, 0.5); // formeo-color-literal-ok: shadow li { position: relative; - border: 1px solid color.$gray-light; + border: 1px solid t.$border-strong; display: none; cursor: default; padding: mixins.space(); @@ -51,55 +51,55 @@ &.active-option { &.component-type-stage { - background-color: color.$stage-outline-color; - color: color.$stage-outline-color-text; + background-color: t.$stage-outline; + color: t.$stage-outline-text; } &.component-type-row { - background-color: color.$row-outline-color; - color: color.$row-outline-color-text; + background-color: t.$row-outline; + color: t.$row-outline-text; } &.component-type-column { - background-color: color.$column-outline-color; - color: color.$column-outline-color-text; + background-color: t.$column-outline; + color: t.$column-outline-text; } &.component-type-field { - background-color: color.$field-outline-color; - color: color.$field-outline-color-text; + background-color: t.$field-outline; + color: t.$field-outline-text; } &.component-type-option { - background-color: color.$option-outline-color; - color: color.$option-outline-color-text; + background-color: t.$option-outline; + color: t.$option-outline-text; } } &:hover { &.component-type-stage { - background-color: color.$stage-highlight-color; - color: color.$stage-highlight-color-text; + background-color: t.$stage-highlight; + color: t.$stage-highlight-text; } &.component-type-row { - background-color: color.$row-highlight-color; - color: color.$row-highlight-color-text; + background-color: t.$row-highlight; + color: t.$row-highlight-text; } &.component-type-column { - background-color: color.$column-highlight-color; - color: color.$column-highlight-color-text; + background-color: t.$column-highlight; + color: t.$column-highlight-text; } &.component-type-field { - background-color: color.$field-highlight-color; - color: color.$field-highlight-color-text; + background-color: t.$field-highlight; + color: t.$field-highlight-text; } &.component-type-option { - background-color: color.$option-highlight-color; - color: color.$option-highlight-color-text; + background-color: t.$option-highlight; + color: t.$option-highlight-text; } } @@ -109,7 +109,7 @@ } .component-type { - color: color.$gray; + color: t.$text-muted; font-style: italic; font-size: 0.75em; } diff --git a/src/lib/sass/components/_column.scss b/src/lib/sass/components/_column.scss index 4b5a2e75..67b89e0b 100644 --- a/src/lib/sass/components/_column.scss +++ b/src/lib/sass/components/_column.scss @@ -1,7 +1,6 @@ -@use "sass:color" as sass-color; @use "../base/mixins"; -@use "../base/colors" as color; +@use "../base/tokens" as t; @use "../base/variables" as var; .formeo-column { @@ -19,7 +18,7 @@ } .column-tag { - border-color: color.$column-outline-color; + border-color: t.$column-outline; } .column-tag, @@ -65,7 +64,7 @@ &::before { width: 0; right: 6px; - border: 1px dashed sass-color.adjust(color.$column-outline-color, $lightness: 15%); + border: 1px dashed t.$column-outline-soft; border-width: 0 2px; display: block; top: 0; @@ -75,7 +74,7 @@ } svg { - fill: sass-color.adjust(color.$column-outline-color, $lightness: 15%); + fill: t.$column-outline-soft; position: absolute; right: 1px; width: 14px; @@ -91,11 +90,11 @@ &:hover { &::before { - border-color: color.$column-outline-color; + border-color: t.$column-outline; } svg { - fill: color.$column-outline-color; + fill: t.$column-outline; } } } @@ -115,8 +114,8 @@ } &.sortable-ghost { - background-color: color.$column-highlight-color; - box-shadow: 0 0 0 1px color.$column-outline-color; + background-color: t.$column-highlight; + box-shadow: 0 0 0 1px t.$column-outline; * { opacity: 0; @@ -130,7 +129,7 @@ } &.highlight-component { - box-shadow: 0 0 mixins.space() 2px color.$column-outline-color; + box-shadow: 0 0 mixins.space() 2px t.$column-outline; } } @@ -141,7 +140,7 @@ .empty { border-radius: var.$border-radius; height: 60px; - background-color: color.$gray-lightest; + background-color: t.$surface-muted; &.empty { min-height: 0; @@ -156,7 +155,7 @@ } &::after { - color: color.$gray-dark !important; + color: t.$text-secondary !important; line-height: 1em; opacity: 1; font-size: 1.1em; @@ -180,21 +179,21 @@ } .column-moving { - background-color: color.$column-highlight-color; + background-color: t.$column-highlight; .action-btn-wrap { button { - background-color: color.$column-highlight-color; + background-color: t.$column-highlight; } } .column-tag { - background-color: color.$column-highlight-color; + background-color: t.$column-highlight; } } button.column-handle { - background-color: rgba(255, 255, 255, 0.9); + background-color: t.$overlay; } .hovering-column { @@ -221,7 +220,7 @@ button.column-handle { .hovering-column, .editing-column { - box-shadow: 0 0 0 1px color.$column-outline-color; + box-shadow: 0 0 0 1px t.$column-outline; } .column-actions { diff --git a/src/lib/sass/components/_controls.scss b/src/lib/sass/components/_controls.scss index d90df12b..f6064b26 100644 --- a/src/lib/sass/components/_controls.scss +++ b/src/lib/sass/components/_controls.scss @@ -1,6 +1,6 @@ @use "../base/mixins"; -@use "../base/colors" as color; +@use "../base/tokens" as t; @use "../base/variables" as var; .formeo-controls { @@ -23,10 +23,10 @@ } .filtered-term { - background-color: color.$white; + background-color: t.$bg; text-align: center; border-radius: var.$input-border-radius var.$input-border-radius 0 0; - border: 1px solid color.$gray-lighter; + border: 1px solid t.$border; border-bottom: 0 none; width: calc(100% - 2px); } @@ -50,12 +50,12 @@ button { position: absolute; width: var.$action-btn-width; - color: color.$black; + color: t.$text; height: calc(100% + 1px); padding: 0; line-height: 0; margin: 0; - border-color: color.$gray-lighter; + border-color: t.$border; &.next-group { right: 0; @@ -80,7 +80,7 @@ } .panel-labels { - border-top: 1px solid color.$gray-lighter; + border-top: 1px solid t.$border; // border-radius: $border-radius; } @@ -107,7 +107,7 @@ vertical-align: top; display: inline-block; width: 100%; - border-top: 1px solid color.$gray-lighter; + border-top: 1px solid t.$border; > li { &:first-child { @@ -148,7 +148,7 @@ .control-group-labels { height: 100%; - background: color.$white; + background: t.$bg; overflow: hidden; div { @@ -166,30 +166,30 @@ margin-top: mixins.space(); .svg-icon { - fill: color.$gray; + fill: t.$text-muted; display: none; &:hover { - fill: color.$black; + fill: t.$text; } } .save-form, .clear-form { &:hover { - color: color.$white; + color: t.$on-accent; } } .save-form { &:hover { - background-color: color.$brand-primary; + background-color: t.$primary; } } .clear-form { &:hover { - background-color: color.$brand-error; + background-color: t.$danger; } } } diff --git a/src/lib/sass/components/_dialog.scss b/src/lib/sass/components/_dialog.scss index 57c5a018..cf02570a 100644 --- a/src/lib/sass/components/_dialog.scss +++ b/src/lib/sass/components/_dialog.scss @@ -1,5 +1,5 @@ @use "../base/variables" as vars; -@use "../base/colors" as color; +@use "../base/tokens" as t; @use "../base/mixins" as mixins; .formeo-dialog { @@ -7,7 +7,7 @@ border-radius: vars.$border-radius; padding: 0; max-width: min(90vw, 500px); - box-shadow: 0 4px 6px -1px rgb(0 0 0 / 0.1), 0 2px 4px -2px rgb(0 0 0 / 0.1); + box-shadow: 0 4px 6px -1px rgb(0 0 0 / 0.1), 0 2px 4px -2px rgb(0 0 0 / 0.1); // formeo-color-literal-ok: shadow position: absolute; margin: 0; @@ -34,7 +34,7 @@ } &::backdrop { - background-color: rgb(0 0 0 / 0.5); + background-color: rgb(0 0 0 / 0.5); // formeo-color-literal-ok: shadow backdrop-filter: blur(2px); animation: backdrop-fade-in vars.$animation-speed-base ease-out; } @@ -51,7 +51,7 @@ margin: 0; font-size: 1.25rem; font-weight: 600; - color: color.$gray-darker; + color: t.$text-strong; } .dialog-body { @@ -65,7 +65,7 @@ gap: vars.$half-space; font-weight: 500; font-size: 0.9rem; - color: color.$gray-dark; + color: t.$text-secondary; input, textarea, @@ -76,7 +76,7 @@ font-size: 1rem; &:focus { - outline: 2px solid color.$brand-primary; + outline: 2px solid t.$primary; outline-offset: 0; } } @@ -86,7 +86,7 @@ .dialog-message { margin: 0; line-height: 1.6; - color: color.$gray-darker; + color: t.$text-strong; } .dialog-prompt-label { diff --git a/src/lib/sass/components/_field-edit.scss b/src/lib/sass/components/_field-edit.scss index 7f34ff6f..f6990998 100644 --- a/src/lib/sass/components/_field-edit.scss +++ b/src/lib/sass/components/_field-edit.scss @@ -1,6 +1,6 @@ @use '../base/mixins'; -@use '../base/colors' as color; +@use '../base/tokens' as t; @use '../base/variables' as var; .component-edit { @@ -122,7 +122,7 @@ left: 0; width: 100%; height: 100%; - background-color: rgba(color.$brand-error, 0.1); + background-color: t.$danger-subtle; z-index: 2; } } @@ -215,7 +215,7 @@ &.panel-count-1 { .panel-nav { - border-bottom: 1px solid color.$gray-light; + border-bottom: 1px solid t.$border-strong; button { display: none; @@ -234,8 +234,8 @@ top: 50%; left: 50%; transform: translate(-50%, -50%); - box-shadow: 0 0 mixins.space(3) 1px #000; - border: 1px solid #000; + box-shadow: 0 0 mixins.space(3) 1px #000; // formeo-color-literal-ok: shadow + border: 1px solid t.$text; border-radius: mixins.space(1); z-index: 100; } @@ -245,10 +245,10 @@ display: flex; width: 100%; position: relative; - border-bottom: 1px solid color.$gray-lighter; + border-bottom: 1px solid t.$border; >[class^='condition-'] { - border-left: 1px solid color.$gray-lighter; + border-left: 1px solid t.$border; } .manage-condition-type { @@ -294,19 +294,19 @@ input, select, [contenteditable] { - background-color: color.$white; + background-color: t.$bg; border: 0 none; border-radius: 0; &:focus { - box-shadow: 0 0 0 1px color.$input-focus inset; + box-shadow: 0 0 0 1px t.$focus inset; } } div.condition-source, div.condition-target { position: relative; - border-left: 1px solid color.$gray-lighter; + border-left: 1px solid t.$border; } .condition-logical, @@ -407,30 +407,30 @@ height: mixins.space(1) + mixins.space(); border-radius: 50%; padding: 1px; - border: 1px solid color.$gray-dark; - fill: color.$gray-dark; + border: 1px solid t.$text-secondary; + fill: t.$text-secondary; } &:hover { .svg-icon { - border: 1px solid color.$brand-error; - fill: color.$brand-error; + border: 1px solid t.$danger; + fill: t.$danger; } } &:active { .svg-icon { - background-color: color.$brand-error; - border: 1px solid color.$brand-error-dark; - fill: color.$white; + background-color: t.$danger; + border: 1px solid t.$danger-dark; + fill: t.$on-accent; } } } } .conditions-prop-inputs { - border-top: 1px solid color.$gray-lighter; - border-right: 1px solid color.$gray-lighter; + border-top: 1px solid t.$border; + border-right: 1px solid t.$border; label.condition-label { display: inline-flex; @@ -439,12 +439,12 @@ padding: mixins.space(); border-right: 0; text-transform: uppercase; - color: color.$gray-dark; - background-color: color.$white; + color: t.$text-secondary; + background-color: t.$bg; &.then-condition-label { - background-color: color.$gray; - color: color.$white; + background-color: t.$text-muted; + color: t.$on-accent; } } } diff --git a/src/lib/sass/components/_field.scss b/src/lib/sass/components/_field.scss index 7a89baf1..a5e4406f 100644 --- a/src/lib/sass/components/_field.scss +++ b/src/lib/sass/components/_field.scss @@ -1,5 +1,5 @@ @use "../base/mixins"; -@use "../base/colors" as color; +@use "../base/tokens" as t; @use "../base/variables" as var; .formeo-field { @@ -10,7 +10,7 @@ will-change: box-shadow; &.highlight-component { - box-shadow: 0 0 mixins.space() 2px color.$field-outline-color; + box-shadow: 0 0 mixins.space() 2px t.$field-outline; } &:last-child { @@ -49,14 +49,14 @@ position: absolute; bottom: 0; left: 0; - border-bottom: 1px dashed color.$gray-lighter; + border-bottom: 1px dashed t.$border; } &:focus { border-radius: var.$input-border-radius; border-bottom-color: transparent; outline: none; - box-shadow: 0 0 0 1px color.$input-focus inset; + box-shadow: 0 0 0 1px t.$focus inset; &::after { display: none; @@ -73,12 +73,12 @@ } &.field-type-hidden { - border: 1px dashed color.$gray-lighter; + border: 1px dashed t.$border; } &.sortable-ghost { - background-color: color.$field-highlight-color; - box-shadow: 0 0 0 1px color.$field-outline-color; + background-color: t.$field-highlight; + box-shadow: 0 0 0 1px t.$field-outline; * { opacity: 0; @@ -91,15 +91,15 @@ left: auto; top: -(var.$icon-size); - border-color: color.$field-outline-color; - background-color: color.$white; + border-color: t.$field-outline; + background-color: t.$bg; } .editing-field, .hovering-field { - box-shadow: 0 0 0 1px color.$field-outline-color; + box-shadow: 0 0 0 1px t.$field-outline; .field-tag { display: flex; @@ -130,16 +130,16 @@ } .field-moving { - background-color: color.$field-highlight-color; + background-color: t.$field-highlight; .action-btn-wrap { button { - background-color: color.$field-highlight-color; + background-color: t.$field-highlight; } } .field-tag { - background-color: color.$field-highlight-color; + background-color: t.$field-highlight; } @@ -163,7 +163,7 @@ .f-checkbox, .f-radio { &.highlight-component { - box-shadow: 0 0 2px 1px color.$option-outline-color inset; + box-shadow: 0 0 2px 1px t.$option-outline inset; } } } diff --git a/src/lib/sass/components/_group-actions.scss b/src/lib/sass/components/_group-actions.scss index 7a57d59e..693c4333 100644 --- a/src/lib/sass/components/_group-actions.scss +++ b/src/lib/sass/components/_group-actions.scss @@ -1,4 +1,4 @@ -@use "../base/colors" as color; +@use "../base/tokens" as t; @use "../base/variables" as var; .group-actions { @@ -31,7 +31,7 @@ } button { - background-color: color.$white; + background-color: t.$bg; } } diff --git a/src/lib/sass/components/_panels.scss b/src/lib/sass/components/_panels.scss index 4fac5050..03e2686b 100644 --- a/src/lib/sass/components/_panels.scss +++ b/src/lib/sass/components/_panels.scss @@ -1,13 +1,13 @@ @use '../base/mixins'; -@use '../base/colors' as color; +@use '../base/tokens' as t; @use '../base/variables' as var; .formeo-panels-wrap { h5 { margin: 0; padding: 0.55em 0; - color: color.$gray; + color: t.$text-muted; font-weight: normal; display: inline-block; width: 100%; @@ -21,7 +21,7 @@ button { position: absolute; width: 24px; - color: color.$black; + color: t.$text; height: 100%; padding: 0; line-height: 0; @@ -29,7 +29,7 @@ &:focus { outline: none; - border: 1px solid color.$input-focus; + border: 1px solid t.$focus; box-shadow: none; } @@ -85,7 +85,7 @@ .panel-labels { height: 100%; - background: color.$white; + background: t.$bg; overflow: hidden; text-align: center; white-space: nowrap; @@ -119,13 +119,13 @@ flex-direction: column; flex: 1; cursor: pointer; - background-color: color.$gray-lighter; - box-shadow: 0 -1px mixins.space(1) color.$gray-light inset; + background-color: t.$border; + box-shadow: 0 -1px mixins.space(1) t.$border-strong inset; &.active-tab { - color: color.$black; + color: t.$text; box-shadow: none; - background-color: color.$white; + background-color: t.$bg; } } } diff --git a/src/lib/sass/components/_row.scss b/src/lib/sass/components/_row.scss index 0923d617..7f2bec9d 100644 --- a/src/lib/sass/components/_row.scss +++ b/src/lib/sass/components/_row.scss @@ -1,6 +1,6 @@ @use "../base/mixins"; -@use "../base/colors" as color; +@use "../base/tokens" as t; @use "../base/variables" as var; .formeo-row { @@ -13,8 +13,8 @@ transition: background-color 125ms ease-in-out; position: relative; clear: both; - background-color: color.$white; - box-shadow: 0 0 0 1px color.$gray-lighter; + background-color: t.$bg; + box-shadow: 0 0 0 1px t.$border; padding: mixins.space(2); &::after { @@ -61,7 +61,7 @@ .row-tag { left: -1px; - border-color: color.$row-outline-color; + border-color: t.$row-outline; } @@ -75,8 +75,8 @@ } &.sortable-ghost { - background-color: color.$row-highlight-color; - box-shadow: 0 0 0 1px color.$row-outline-color; + background-color: t.$row-highlight; + box-shadow: 0 0 0 1px t.$row-outline; * { opacity: 0; @@ -91,21 +91,21 @@ &.highlight-component { - box-shadow: 0 0 mixins.space() 2px color.$row-outline-color; + box-shadow: 0 0 mixins.space() 2px t.$row-outline; } } .row-moving { - background-color: color.$row-highlight-color !important; + background-color: t.$row-highlight !important; .action-btn-wrap { button { - background-color: color.$row-highlight-color; + background-color: t.$row-highlight; } } .row-tag { - background-color: color.$row-highlight-color; + background-color: t.$row-highlight; } @@ -145,7 +145,7 @@ .hovering-row, .editing-row { - box-shadow: 0 0 0 1px color.$row-outline-color; + box-shadow: 0 0 0 1px t.$row-outline; &:first-child { border-top-left-radius: 0; diff --git a/src/lib/sass/components/_stage.scss b/src/lib/sass/components/_stage.scss index 3e2ab6ff..d351e131 100644 --- a/src/lib/sass/components/_stage.scss +++ b/src/lib/sass/components/_stage.scss @@ -1,4 +1,4 @@ -@use '../base/colors' as color; +@use '../base/tokens' as t; @use '../base/variables' as var; @use 'group-actions'; @@ -21,7 +21,7 @@ border-width: 1px; border-style: dashed; border-color: transparent; - background-color: color.$stage-background; + background-color: t.$stage-bg; padding-left: var.$action-btn-width - 1; padding-bottom: mixins.space(1); overflow: visible; @@ -40,7 +40,7 @@ } &>.formeo-field { - background-color: color.$white; + background-color: t.$bg; } @include mixins.no-list-style; @@ -50,7 +50,7 @@ } &.highlight-component { - box-shadow: 0 0 mixins.space() 2px color.$stage-outline-color; + box-shadow: 0 0 mixins.space() 2px t.$stage-outline; } .hidden { @@ -58,14 +58,14 @@ } &.empty { - border-color: color.$gray-light; - background-color: color.$shadow; + border-color: t.$border-strong; + background-color: t.$stage-shadow; } &.editing-stage, &.hovering-stage, &.hovering-stage.empty { - border-color: color.$stage-outline-color; + border-color: t.$stage-outline; border-style: solid; .stage-tag { @@ -103,7 +103,7 @@ .stage-edit { padding: mixins.space(2); - background-color: color.$white; + background-color: t.$bg; margin-top: 0; margin-bottom: mixins.space(2); border-top-left-radius: mixins.space(1); @@ -113,7 +113,7 @@ } .conditions-prop-inputs { - border-top: 1px solid color.$gray-lighter; + border-top: 1px solid t.$border; } } @@ -129,10 +129,10 @@ .edit-toggle:focus { border-bottom-left-radius: 0; border-bottom-right-radius: 0; - border-right: 1px solid color.$stage-outline-color; + border-right: 1px solid t.$stage-outline; border-top-left-radius: 0; border-top-right-radius: mixins.space(1); - border-top: 1px solid color.$stage-outline-color; + border-top: 1px solid t.$stage-outline; } } @@ -163,7 +163,7 @@ position: absolute; top: 50%; left: 50%; - color: color.$gray-light; + color: t.$text-subtle; transition: opacity 200ms ease-in-out; will-change: opacity; text-align: center; diff --git a/src/lib/sass/components/component.scss b/src/lib/sass/components/component.scss index a3eaa1e2..ce1b21ac 100644 --- a/src/lib/sass/components/component.scss +++ b/src/lib/sass/components/component.scss @@ -1,5 +1,5 @@ @use "../base/mixins"; -@use "../base/colors" as color; +@use "../base/tokens" as t; @use "../base/variables" as var; @use "../base/animation"; @@ -18,8 +18,8 @@ top: -(var.$icon-size); border-top-left-radius: var.$border-radius; border-top-right-radius: var.$border-radius; - background-color: color.$white; - border-color: color.$gray-lighter; + background-color: t.$bg; + border-color: t.$border; border-style: solid; border-width: 1px 1px 0 1px; diff --git a/tools/check-color-literals.mjs b/tools/check-color-literals.mjs new file mode 100644 index 00000000..c3d768c6 --- /dev/null +++ b/tools/check-color-literals.mjs @@ -0,0 +1,29 @@ +import { globSync, readFileSync } from 'node:fs' + +const OK = '// formeo-color-literal-ok' +const files = [ + ...globSync('src/lib/sass/components/**/*.scss'), + 'src/lib/sass/_render.scss', + 'src/lib/sass/base/_bs.scss', + 'src/lib/sass/base/_icons.scss', + 'src/lib/sass/base/_mixins.scss', + 'src/lib/sass/base/_variables.scss', + 'src/lib/sass/base/_animation.scss', +] +const LITERAL = /#[0-9a-fA-F]{3,8}\b|\brgba?\(|\bcolor\.\$|:\s*(white|black)\b/ +const problems = [] +for (const file of files) { + readFileSync(file, 'utf8') + .split('\n') + .forEach((line, i) => { + if (line.includes(OK)) return + const code = line.replace(/\/\/.*$/, '') // ignore comments + if (LITERAL.test(code)) { + problems.push(`${file}:${i + 1}: ${line.trim()}`) + } + }) +} +if (problems.length) { + console.error(`Color literals / colors.$* found (use tokens.$*):\n${problems.join('\n')}`) + process.exit(1) +} From 0bdfe1bd796a1be046c8f03030b535053a8f2624 Mon Sep 17 00:00:00 2001 From: Kevin Chappell Date: Thu, 24 Sep 2026 02:20:34 +0100 Subject: [PATCH 04/19] chore: exclude css baseline fixture from biome --- biome.json | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/biome.json b/biome.json index 6dddbffa..bad32509 100644 --- a/biome.json +++ b/biome.json @@ -7,7 +7,8 @@ "!**/dist", "!**/test-results", "!**/playwright-report", - "!**/src/demo/index.html" + "!**/src/demo/index.html", + "!**/src/lib/sass/__fixtures__" ] }, "assist": { "actions": { "source": { "organizeImports": "on" } } }, From 8ee536e3edd120928f632c4469cdd22039c3458d Mon Sep 17 00:00:00 2001 From: Kevin Chappell Date: Thu, 24 Sep 2026 02:23:39 +0100 Subject: [PATCH 05/19] fix: let icons inherit the formeo icon color --- src/lib/icons/formeo-sprite.svg | 2 +- src/lib/icons/icon-email.svg | 2 +- src/lib/icons/icon-hash.svg | 2 +- src/lib/icons/icon-header.svg | 2 +- src/lib/icons/icon-paragraph.svg | 2 +- src/lib/icons/icon-triangle-down.svg | 2 +- src/lib/icons/icon-triangle-left.svg | 2 +- src/lib/icons/icon-triangle-right.svg | 2 +- src/lib/icons/icon-triangle-up.svg | 2 +- src/lib/icons/icons.test.mjs | 13 +++++++++++++ src/lib/sass/base/_icons.scss | 4 ++++ 11 files changed, 26 insertions(+), 9 deletions(-) create mode 100644 src/lib/icons/icons.test.mjs diff --git a/src/lib/icons/formeo-sprite.svg b/src/lib/icons/formeo-sprite.svg index 2f73f02d..b3cb0298 100644 --- a/src/lib/icons/formeo-sprite.svg +++ b/src/lib/icons/formeo-sprite.svg @@ -1 +1 @@ -image/svg+xmlimage/svg+xmlimage/svg+xmlimage/svg+xmlimage/svg+xmlimage/svg+xml \ No newline at end of file +image/svg+xmlimage/svg+xmlimage/svg+xmlimage/svg+xmlimage/svg+xmlimage/svg+xml \ No newline at end of file diff --git a/src/lib/icons/icon-email.svg b/src/lib/icons/icon-email.svg index aa3c9e01..a3954865 100644 --- a/src/lib/icons/icon-email.svg +++ b/src/lib/icons/icon-email.svg @@ -1,4 +1,4 @@ - + \ No newline at end of file diff --git a/src/lib/icons/icon-hash.svg b/src/lib/icons/icon-hash.svg index d475e2d4..2d6956bc 100644 --- a/src/lib/icons/icon-hash.svg +++ b/src/lib/icons/icon-hash.svg @@ -2,5 +2,5 @@ - + diff --git a/src/lib/icons/icon-header.svg b/src/lib/icons/icon-header.svg index 7bbe2293..d75a469c 100644 --- a/src/lib/icons/icon-header.svg +++ b/src/lib/icons/icon-header.svg @@ -2,5 +2,5 @@ - + diff --git a/src/lib/icons/icon-paragraph.svg b/src/lib/icons/icon-paragraph.svg index a69c4a79..3cd8ee71 100644 --- a/src/lib/icons/icon-paragraph.svg +++ b/src/lib/icons/icon-paragraph.svg @@ -2,5 +2,5 @@ - + diff --git a/src/lib/icons/icon-triangle-down.svg b/src/lib/icons/icon-triangle-down.svg index 2bc7f59f..3180ae09 100644 --- a/src/lib/icons/icon-triangle-down.svg +++ b/src/lib/icons/icon-triangle-down.svg @@ -2,5 +2,5 @@ - + diff --git a/src/lib/icons/icon-triangle-left.svg b/src/lib/icons/icon-triangle-left.svg index acda4c4c..a469df7c 100644 --- a/src/lib/icons/icon-triangle-left.svg +++ b/src/lib/icons/icon-triangle-left.svg @@ -2,5 +2,5 @@ - + diff --git a/src/lib/icons/icon-triangle-right.svg b/src/lib/icons/icon-triangle-right.svg index 41eaf6ee..e63b2ec5 100644 --- a/src/lib/icons/icon-triangle-right.svg +++ b/src/lib/icons/icon-triangle-right.svg @@ -2,5 +2,5 @@ - + diff --git a/src/lib/icons/icon-triangle-up.svg b/src/lib/icons/icon-triangle-up.svg index c8c67da5..57a0825f 100644 --- a/src/lib/icons/icon-triangle-up.svg +++ b/src/lib/icons/icon-triangle-up.svg @@ -2,5 +2,5 @@ - + diff --git a/src/lib/icons/icons.test.mjs b/src/lib/icons/icons.test.mjs new file mode 100644 index 00000000..c530a298 --- /dev/null +++ b/src/lib/icons/icons.test.mjs @@ -0,0 +1,13 @@ +import { readdirSync, readFileSync } from 'node:fs' +import { suite, test } from 'node:test' + +const dir = new URL('./', import.meta.url) + +suite('icons', () => { + test('no icon hard-codes a fill color', t => { + const offenders = readdirSync(dir) + .filter(f => f.endsWith('.svg')) + .filter(f => /fill="#[0-9a-fA-F]+"/.test(readFileSync(new URL(f, dir), 'utf8'))) + t.assert.deepStrictEqual(offenders, []) + }) +}) diff --git a/src/lib/sass/base/_icons.scss b/src/lib/sass/base/_icons.scss index 4548be08..660c9d4c 100644 --- a/src/lib/sass/base/_icons.scss +++ b/src/lib/sass/base/_icons.scss @@ -9,6 +9,10 @@ pointer-events: none; } +:where(.svg-icon) { + fill: t.$icon; +} + .f-i-remove { &:hover { fill: t.$danger; From 75ff3239f19f037f099adc36d428cce8713266a6 Mon Sep 17 00:00:00 2001 From: Kevin Chappell Date: Thu, 24 Sep 2026 02:33:22 +0100 Subject: [PATCH 06/19] feat: expose theme colors as CSS custom properties Adds --formeo-* properties for every editor and renderer color (defaults unchanged) and an opt-in .formeo-dark preset. --- README.md | 17 +++++++++++++++++ src/demo/js/actionButtons.js | 1 + src/lib/sass/base/_properties.scss | 28 ++++++++++++++++++++++++++++ src/lib/sass/css-properties.test.mjs | 10 ++++++++++ 4 files changed, 56 insertions(+) diff --git a/README.md b/README.md index 1157cfec..b5830cb1 100644 --- a/README.md +++ b/README.md @@ -14,6 +14,7 @@ A highly configurable drag & drop form building module for creating dynamic, res - [Installation](#installation) - [Usage](#usage) - [Framework Integration](#framework-integration) +- [Theming](#theming) - [Documentation](#documentation) - [Development](#development) - [Contributing](#contributing) @@ -161,6 +162,22 @@ Formeo can be integrated with popular frontend frameworks: - **[React Integration Guide](https://github.com/Draggable/formeo/blob/main/docs/react-integration.md)** - Custom hooks, functional components, and Context API patterns - **[Angular Integration Guide](https://github.com/Draggable/formeo/blob/main/docs/angular-integration.md)** - Services, components, and standalone patterns +## Theming + +Every color formeo uses is a CSS custom property declared on `:root` with zero specificity, so you can override any of them from your own stylesheet: + +```css +.my-app { + --formeo-bg: #fafafa; + --formeo-text: #111; + --formeo-border: #ddd; +} +``` + +For a built-in dark palette, add the `formeo-dark` class to any ancestor of the editor (for example ``). + +The full list of properties is in `src/lib/sass/base/_properties.scss`. Icons now inherit `--formeo-icon` (default `#000`). A few icons that previously used a hard-coded `#444` are now slightly darker by default. + ## Documentation Comprehensive documentation is available in the [docs](https://github.com/Draggable/formeo/blob/main/docs/README.md) directory: diff --git a/src/demo/js/actionButtons.js b/src/demo/js/actionButtons.js index 9054bd22..e8294f40 100644 --- a/src/demo/js/actionButtons.js +++ b/src/demo/js/actionButtons.js @@ -48,6 +48,7 @@ const editorActions = (editor, renderer) => { editData: () => { jsonEditor.setValue(JSON.stringify(editor.formData, null, 2), 1) }, + toggleDark: () => document.body.classList.toggle('formeo-dark'), } } diff --git a/src/lib/sass/base/_properties.scss b/src/lib/sass/base/_properties.scss index 79964155..30d72b7e 100644 --- a/src/lib/sass/base/_properties.scss +++ b/src/lib/sass/base/_properties.scss @@ -50,3 +50,31 @@ $_structural: ( --formeo-#{$name}-highlight-text: #{list.nth($values, 4)}; } } + +:where(.formeo-dark) { + color-scheme: dark; + --formeo-bg: #1f1f23; + --formeo-bg-hover: #2a2a2f; + --formeo-surface-muted: #26262b; + --formeo-stage-bg: rgba(0, 0, 0, 0); + --formeo-stage-shadow: rgba(0, 0, 0, 0.25); + --formeo-overlay: rgba(31, 31, 35, 0.9); + + --formeo-text: #f4f4f5; + --formeo-text-strong: #e4e4e7; + --formeo-text-secondary: #d4d4d8; + --formeo-text-muted: #a1a1aa; + --formeo-text-subtle: #71717a; + --formeo-icon: #e4e4e7; + + --formeo-border: #3f3f46; + --formeo-border-strong: #52525b; + + --formeo-danger-subtle: #{rgba(c.$brand-error, 0.2)}; + --formeo-remove-bg: #{rgba(c.$brand-error, 0.35)}; + + @each $name, $values in $_structural { + --formeo-#{$name}-highlight: #{rgba(list.nth($values, 1), 0.25)}; + --formeo-#{$name}-highlight-text: #f4f4f5; + } +} diff --git a/src/lib/sass/css-properties.test.mjs b/src/lib/sass/css-properties.test.mjs index e6a9d15e..7135ea08 100644 --- a/src/lib/sass/css-properties.test.mjs +++ b/src/lib/sass/css-properties.test.mjs @@ -52,4 +52,14 @@ suite('formeo CSS custom properties', () => { const { defaults } = resolveFormeoProperties(compileFormeoCss()) t.assert.deepStrictEqual([...defaults.keys()].sort(), [...EXPECTED].sort()) }) + + test('.formeo-dark preset overrides surfaces and text but keeps on-accent light', t => { + const { dark, defaults } = resolveFormeoProperties(compileFormeoCss()) + for (const name of ['--formeo-bg', '--formeo-text', '--formeo-border', '--formeo-icon']) { + t.assert.ok(dark.has(name), `${name} missing from dark preset`) + t.assert.notStrictEqual(dark.get(name), defaults.get(name)) + } + t.assert.ok(!dark.has('--formeo-on-accent'), 'on-accent must not be overridden') + for (const key of dark.keys()) t.assert.ok(defaults.has(key), `${key} has no default`) + }) }) From 36e07be7a88499622d0b0c83782ecaa2246ac8d8 Mon Sep 17 00:00:00 2001 From: Kevin Chappell Date: Thu, 24 Sep 2026 09:13:08 +0100 Subject: [PATCH 07/19] feat: set container text color in the .formeo-dark preset Formeo containers otherwise inherit the host page's text color, which can read dark-on-dark under .formeo-dark. Explicitly sets --formeo-text as the color for .formeo, .formeo-controls, and .formeo-dialog under the preset. --- src/lib/sass/base/_properties.scss | 5 +++++ src/lib/sass/css-properties.test.mjs | 10 ++++++++++ tools/resolve-formeo-properties.mjs | 5 ++++- 3 files changed, 19 insertions(+), 1 deletion(-) diff --git a/src/lib/sass/base/_properties.scss b/src/lib/sass/base/_properties.scss index 30d72b7e..f35e5366 100644 --- a/src/lib/sass/base/_properties.scss +++ b/src/lib/sass/base/_properties.scss @@ -78,3 +78,8 @@ $_structural: ( --formeo-#{$name}-highlight-text: #f4f4f5; } } + +// Formeo containers inherit the host's text color by default; the dark preset must set its own. +:where(.formeo-dark) :where(.formeo, .formeo-controls, .formeo-dialog) { + color: var(--formeo-text); +} diff --git a/src/lib/sass/css-properties.test.mjs b/src/lib/sass/css-properties.test.mjs index 7135ea08..a4bd78fb 100644 --- a/src/lib/sass/css-properties.test.mjs +++ b/src/lib/sass/css-properties.test.mjs @@ -62,4 +62,14 @@ suite('formeo CSS custom properties', () => { t.assert.ok(!dark.has('--formeo-on-accent'), 'on-accent must not be overridden') for (const key of dark.keys()) t.assert.ok(defaults.has(key), `${key} has no default`) }) + + test('.formeo-dark preset sets text color on formeo containers', t => { + const compiled = compileFormeoCss() + t.assert.match( + compiled, + /:where\(\.formeo-dark\) :where\(\.formeo, \.formeo-controls, \.formeo-dialog\)\s*\{\s*color: var\(--formeo-text\);\s*\}/ + ) + const { css } = resolveFormeoProperties(compiled) + t.assert.ok(!css.includes('formeo-dark'), 'formeo-dark rule must be stripped before baseline comparison') + }) }) diff --git a/tools/resolve-formeo-properties.mjs b/tools/resolve-formeo-properties.mjs index 9931c1e2..7560bc46 100644 --- a/tools/resolve-formeo-properties.mjs +++ b/tools/resolve-formeo-properties.mjs @@ -10,7 +10,10 @@ function readDeclarations(body = '') { } /** Rules intentionally added by this change; removed before comparing to baseline. */ -export const ALLOWED_ADDITIONS = [/:where\(\.svg-icon\)\s*\{[^}]*\}\s*/g] +export const ALLOWED_ADDITIONS = [ + /:where\(\.svg-icon\)\s*\{[^}]*\}\s*/g, + /:where\(\.formeo-dark\) :where\(\.formeo, \.formeo-controls, \.formeo-dialog\)\s*\{[^}]*\}\s*/g, +] /** * Replace every var(--formeo-*) with its :where(:root) default and drop the property blocks, From ecd0626f43769883267a6804135fab3acbfaa13a Mon Sep 17 00:00:00 2001 From: Kevin Chappell Date: Thu, 24 Sep 2026 09:53:23 +0100 Subject: [PATCH 08/19] fix: let the plus and minus icons follow the icon color The plus/minus condition icons hard-coded a black stroke, so they stayed black under a dark theme. They now stroke with currentColor, and :where(.svg-icon) sets color as well as fill from --formeo-icon, which defaults to black, so nothing changes by default. The icon test now also rejects hex stroke attributes in any icon and hex fill/stroke values inside style attributes in the generated sprite. --- src/lib/icons/formeo-sprite.svg | 2 +- src/lib/icons/icon-minus.svg | 2 +- src/lib/icons/icon-plus.svg | 2 +- src/lib/icons/icons.test.mjs | 14 ++++++++++---- src/lib/sass/base/_icons.scss | 1 + 5 files changed, 14 insertions(+), 7 deletions(-) diff --git a/src/lib/icons/formeo-sprite.svg b/src/lib/icons/formeo-sprite.svg index b3cb0298..1544c41c 100644 --- a/src/lib/icons/formeo-sprite.svg +++ b/src/lib/icons/formeo-sprite.svg @@ -1 +1 @@ -image/svg+xmlimage/svg+xmlimage/svg+xmlimage/svg+xmlimage/svg+xmlimage/svg+xml \ No newline at end of file +image/svg+xmlimage/svg+xmlimage/svg+xmlimage/svg+xmlimage/svg+xmlimage/svg+xml \ No newline at end of file diff --git a/src/lib/icons/icon-minus.svg b/src/lib/icons/icon-minus.svg index bc96d610..aa646f0a 100644 --- a/src/lib/icons/icon-minus.svg +++ b/src/lib/icons/icon-minus.svg @@ -1,4 +1,4 @@ - + \ No newline at end of file diff --git a/src/lib/icons/icon-plus.svg b/src/lib/icons/icon-plus.svg index 637f5d9f..35cf3cdd 100644 --- a/src/lib/icons/icon-plus.svg +++ b/src/lib/icons/icon-plus.svg @@ -1,4 +1,4 @@ - + \ No newline at end of file diff --git a/src/lib/icons/icons.test.mjs b/src/lib/icons/icons.test.mjs index c530a298..919da8fb 100644 --- a/src/lib/icons/icons.test.mjs +++ b/src/lib/icons/icons.test.mjs @@ -2,12 +2,18 @@ import { readdirSync, readFileSync } from 'node:fs' import { suite, test } from 'node:test' const dir = new URL('./', import.meta.url) +const svgFiles = readdirSync(dir).filter(f => f.endsWith('.svg')) suite('icons', () => { - test('no icon hard-codes a fill color', t => { - const offenders = readdirSync(dir) - .filter(f => f.endsWith('.svg')) - .filter(f => /fill="#[0-9a-fA-F]+"/.test(readFileSync(new URL(f, dir), 'utf8'))) + test('no icon hard-codes a fill or stroke color attribute', t => { + const offenders = svgFiles.filter(f => /(fill|stroke)="#[0-9a-fA-F]+"/.test(readFileSync(new URL(f, dir), 'utf8'))) + t.assert.deepStrictEqual(offenders, []) + }) + + test('the sprite has no fill or stroke color in a style attribute', t => { + const sprite = readFileSync(new URL('formeo-sprite.svg', dir), 'utf8') + const styles = sprite.match(/style="[^"]*"/g) ?? [] + const offenders = styles.filter(style => /(fill|stroke)\s*:\s*#/.test(style)) t.assert.deepStrictEqual(offenders, []) }) }) diff --git a/src/lib/sass/base/_icons.scss b/src/lib/sass/base/_icons.scss index 660c9d4c..f3843496 100644 --- a/src/lib/sass/base/_icons.scss +++ b/src/lib/sass/base/_icons.scss @@ -11,6 +11,7 @@ :where(.svg-icon) { fill: t.$icon; + color: t.$icon; } .f-i-remove { From b04cf08fdd81287a0ea4525d51544ce01d2e14e9 Mon Sep 17 00:00:00 2001 From: Kevin Chappell Date: Thu, 24 Sep 2026 09:54:03 +0100 Subject: [PATCH 09/19] feat: theme formeo dialogs and raise dark preset contrast - :where(.formeo-dialog, .component-edit[popover]) takes its background and text from --formeo-bg/--formeo-text instead of the UA Canvas colors. Zero specificity, and the defaults render the same as the UA colors. - The dark container text rule also matches when .formeo-dark is on the container itself. - Under .formeo-dark the THEN condition label uses --formeo-border-strong behind its white text; --formeo-text-muted was about 2.5:1. Each new rule has an ALLOWED_ADDITIONS entry and a test that pins its compiled text, and every entry must match exactly one compiled rule. --- src/lib/sass/base/_properties.scss | 15 +++++- src/lib/sass/css-properties.test.mjs | 70 +++++++++++++++++++++++++--- tools/resolve-formeo-properties.mjs | 37 +++++++++++---- 3 files changed, 104 insertions(+), 18 deletions(-) diff --git a/src/lib/sass/base/_properties.scss b/src/lib/sass/base/_properties.scss index f35e5366..9390abfa 100644 --- a/src/lib/sass/base/_properties.scss +++ b/src/lib/sass/base/_properties.scss @@ -80,6 +80,19 @@ $_structural: ( } // Formeo containers inherit the host's text color by default; the dark preset must set its own. -:where(.formeo-dark) :where(.formeo, .formeo-controls, .formeo-dialog) { +:where(.formeo-dark) :where(.formeo, .formeo-controls, .formeo-dialog), +:where(.formeo-dark):where(.formeo, .formeo-controls, .formeo-dialog) { + color: var(--formeo-text); +} + +// White on --formeo-text-muted is too faint in dark; outranks the .formeo.formeo-editor rule. +.formeo-dark .formeo.formeo-editor .conditions-prop-inputs label.condition-label.then-condition-label, +.formeo-dark.formeo.formeo-editor .conditions-prop-inputs label.condition-label.then-condition-label { + background-color: var(--formeo-border-strong); +} + +// Dialogs and popovers otherwise keep the UA Canvas colors; the defaults render the same. +:where(.formeo-dialog, .component-edit[popover]) { + background-color: var(--formeo-bg); color: var(--formeo-text); } diff --git a/src/lib/sass/css-properties.test.mjs b/src/lib/sass/css-properties.test.mjs index a4bd78fb..72050574 100644 --- a/src/lib/sass/css-properties.test.mjs +++ b/src/lib/sass/css-properties.test.mjs @@ -1,7 +1,7 @@ import { readFileSync } from 'node:fs' import { suite, test } from 'node:test' import { compileFormeoCss } from '../../../tools/compile-scss.mjs' -import { resolveFormeoProperties } from '../../../tools/resolve-formeo-properties.mjs' +import { ALLOWED_ADDITIONS, resolveFormeoProperties } from '../../../tools/resolve-formeo-properties.mjs' const baseline = readFileSync(new URL('./__fixtures__/formeo-baseline.css', import.meta.url), 'utf8') @@ -63,13 +63,69 @@ suite('formeo CSS custom properties', () => { for (const key of dark.keys()) t.assert.ok(defaults.has(key), `${key} has no default`) }) - test('.formeo-dark preset sets text color on formeo containers', t => { + test('every allowed addition matches exactly one compiled rule', t => { const compiled = compileFormeoCss() - t.assert.match( - compiled, - /:where\(\.formeo-dark\) :where\(\.formeo, \.formeo-controls, \.formeo-dialog\)\s*\{\s*color: var\(--formeo-text\);\s*\}/ + for (const re of ALLOWED_ADDITIONS) { + t.assert.strictEqual([...compiled.matchAll(re)].length, 1, `${re} must match exactly one rule`) + } + }) + + test(':where(.svg-icon) sets the icon fill and color', t => { + t.assert.strictEqual( + compiledRule(':where(.svg-icon) {'), + ':where(.svg-icon) {\n fill: var(--formeo-icon);\n color: var(--formeo-icon);\n}' + ) + }) + + test('.formeo-dark preset sets text color on formeo containers, nested or on the container itself', t => { + t.assert.strictEqual( + compiledRule(':where(.formeo-dark) :where('), + [ + ':where(.formeo-dark) :where(.formeo, .formeo-controls, .formeo-dialog),', + ':where(.formeo-dark):where(.formeo, .formeo-controls, .formeo-dialog) {', + ' color: var(--formeo-text);', + '}', + ].join('\n') + ) + }) + + test('.formeo-dark gives the THEN condition label a higher-contrast background', t => { + const label = '.formeo.formeo-editor .conditions-prop-inputs label.condition-label.then-condition-label' + t.assert.strictEqual( + compiledRule(`.formeo-dark ${label},`), + [ + `.formeo-dark ${label},`, + `.formeo-dark${label} {`, + ' background-color: var(--formeo-border-strong);', + '}', + ].join('\n') ) - const { css } = resolveFormeoProperties(compiled) - t.assert.ok(!css.includes('formeo-dark'), 'formeo-dark rule must be stripped before baseline comparison') + }) + + test('dialogs and the edit popover take their surface and text from the properties', t => { + t.assert.strictEqual( + compiledRule(':where(.formeo-dialog, '), + [ + ':where(.formeo-dialog, .component-edit[popover]) {', + ' background-color: var(--formeo-bg);', + ' color: var(--formeo-text);', + '}', + ].join('\n') + ) + }) + + test('allowed additions are stripped before the baseline comparison', t => { + const { css } = resolveFormeoProperties(compileFormeoCss()) + for (const fragment of ['formeo-dark', ':where(.svg-icon)', ':where(.formeo-dialog']) { + t.assert.ok(!css.includes(fragment), `${fragment} must be stripped before baseline comparison`) + } }) }) + +/** The single compiled rule starting with `start`, trimmed. */ +function compiledRule(start) { + const compiled = compileFormeoCss() + const at = compiled.indexOf(start) + if (at === -1 || compiled.indexOf(start, at + 1) !== -1) throw new Error(`expected one rule starting ${start}`) + return compiled.slice(at, compiled.indexOf('}', at) + 1) +} diff --git a/tools/resolve-formeo-properties.mjs b/tools/resolve-formeo-properties.mjs index 7560bc46..98b1ae4a 100644 --- a/tools/resolve-formeo-properties.mjs +++ b/tools/resolve-formeo-properties.mjs @@ -1,18 +1,35 @@ -const block = selector => new RegExp(`${selector.replace(/[().]/g, m => `\\${m}`)}\\s*\\{([^}]*)\\}\\s*`) +const escapeSelector = selector => selector.replace(/[()[\].]/g, m => `\\${m}`) + +/** A whole rule whose selector is exactly `selectorSource` (a regex source), starting where a rule may start. */ +const rule = selectorSource => new RegExp(String.raw`(?<=(?:^|[};]|\*/)\s*)${selectorSource}\s*\{([^}]*)\}\s*`, 'g') function readDeclarations(body = '') { const map = new Map() - for (const decl of body.split(';')) { - const match = decl.match(/^\s*(--formeo-[\w-]+)\s*:\s*([\s\S]+?)\s*$/) + for (const decl of body.split(';').map(d => d.trim())) { + const match = decl.match(/^(--formeo-[\w-]+)\s*:\s*([\s\S]+)$/) if (match) map.set(match[1], match[2]) } return map } +/** Remove the `selector { … }` property block and return its body. */ +function takeBlock(css, selector) { + const re = rule(escapeSelector(selector)) + return { body: re.exec(css)?.[1], css: css.replace(re, '') } +} + /** Rules intentionally added by this change; removed before comparing to baseline. */ export const ALLOWED_ADDITIONS = [ - /:where\(\.svg-icon\)\s*\{[^}]*\}\s*/g, - /:where\(\.formeo-dark\) :where\(\.formeo, \.formeo-controls, \.formeo-dialog\)\s*\{[^}]*\}\s*/g, + rule(String.raw`:where\(\.svg-icon\)`), + rule( + String.raw`:where\(\.formeo-dark\) :where\(\.formeo, \.formeo-controls, \.formeo-dialog\),\s*` + + String.raw`:where\(\.formeo-dark\):where\(\.formeo, \.formeo-controls, \.formeo-dialog\)` + ), + rule( + String.raw`\.formeo-dark \.formeo\.formeo-editor \.conditions-prop-inputs label\.condition-label\.then-condition-label,\s*` + + String.raw`\.formeo-dark\.formeo\.formeo-editor \.conditions-prop-inputs label\.condition-label\.then-condition-label` + ), + rule(String.raw`:where\(\.formeo-dialog, \.component-edit\[popover\]\)`), ] /** @@ -20,12 +37,12 @@ export const ALLOWED_ADDITIONS = [ * producing CSS comparable to the pre-change baseline. */ export function resolveFormeoProperties(css) { - const rootRe = block(':where(:root)') - const darkRe = block(':where(.formeo-dark)') - const defaults = readDeclarations(css.match(rootRe)?.[1]) - const dark = readDeclarations(css.match(darkRe)?.[1]) + const root = takeBlock(css, ':where(:root)') + const darkBlock = takeBlock(root.css, ':where(.formeo-dark)') + const defaults = readDeclarations(root.body) + const dark = readDeclarations(darkBlock.body) - let out = css.replace(rootRe, '').replace(darkRe, '') + let out = darkBlock.css for (const re of ALLOWED_ADDITIONS) out = out.replace(re, '') out = out.replace(/var\((--formeo-[\w-]+)\)/g, (_, name) => { if (!defaults.has(name)) throw new Error(`No default declared for ${name}`) From 63677a8a130681c95f7128dc49a7d580a3cb03bb Mon Sep 17 00:00:00 2001 From: Kevin Chappell Date: Thu, 24 Sep 2026 09:54:12 +0100 Subject: [PATCH 10/19] test: make the property resolver reject impure property blocks resolveFormeoProperties now throws when the compiled CSS has more than one :where(:root) or :where(.formeo-dark) block, when the root block declares anything but --formeo-* properties, or when the dark block declares anything but --formeo-* properties and color-scheme. Synthetic stylesheets cover each case. --- src/lib/sass/css-properties.test.mjs | 36 ++++++++++++++++++++++++++++ tools/resolve-formeo-properties.mjs | 30 +++++++++++++++++------ 2 files changed, 59 insertions(+), 7 deletions(-) diff --git a/src/lib/sass/css-properties.test.mjs b/src/lib/sass/css-properties.test.mjs index 72050574..813e1a81 100644 --- a/src/lib/sass/css-properties.test.mjs +++ b/src/lib/sass/css-properties.test.mjs @@ -122,6 +122,42 @@ suite('formeo CSS custom properties', () => { }) }) +suite('resolveFormeoProperties purity', () => { + const root = ':where(:root) {\n --formeo-bg: #fff;\n}\n' + const dark = ':where(.formeo-dark) {\n color-scheme: dark;\n --formeo-bg: #000;\n}\n' + const use = '.a {\n color: var(--formeo-bg);\n}\n' + + test('resolves a well-formed stylesheet', t => { + const result = resolveFormeoProperties(root + dark + use) + t.assert.strictEqual(result.css, '.a {\n color: #fff;\n}\n') + t.assert.strictEqual(result.dark.get('--formeo-bg'), '#000') + }) + + test('rejects more than one :where(:root) block', t => { + t.assert.throws(() => resolveFormeoProperties(root + root + use), /at most one :where\(:root\) block, found 2/) + }) + + test('rejects more than one :where(.formeo-dark) block', t => { + t.assert.throws( + () => resolveFormeoProperties(root + dark + dark), + /at most one :where\(\.formeo-dark\) block, found 2/ + ) + }) + + test('rejects non-property declarations in the root block', t => { + const impure = ':where(:root) {\n --formeo-bg: #fff;\n color: red;\n}\n' + t.assert.throws(() => resolveFormeoProperties(impure), /:where\(:root\) may only declare .*found: color: red/) + }) + + test('rejects anything but properties and color-scheme in the dark block', t => { + const impure = ':where(.formeo-dark) {\n color-scheme: dark;\n background: black;\n}\n' + t.assert.throws( + () => resolveFormeoProperties(root + impure), + /:where\(\.formeo-dark\) may only declare .*found: background: black/ + ) + }) +}) + /** The single compiled rule starting with `start`, trimmed. */ function compiledRule(start) { const compiled = compileFormeoCss() diff --git a/tools/resolve-formeo-properties.mjs b/tools/resolve-formeo-properties.mjs index 98b1ae4a..525cff3f 100644 --- a/tools/resolve-formeo-properties.mjs +++ b/tools/resolve-formeo-properties.mjs @@ -3,19 +3,35 @@ const escapeSelector = selector => selector.replace(/[()[\].]/g, m => `\\${m}`) /** A whole rule whose selector is exactly `selectorSource` (a regex source), starting where a rule may start. */ const rule = selectorSource => new RegExp(String.raw`(?<=(?:^|[};]|\*/)\s*)${selectorSource}\s*\{([^}]*)\}\s*`, 'g') -function readDeclarations(body = '') { +function splitDeclarations(body = '') { + return body + .split(';') + .map(decl => decl.trim()) + .filter(Boolean) +} + +function readDeclarations(body) { const map = new Map() - for (const decl of body.split(';').map(d => d.trim())) { + for (const decl of splitDeclarations(body)) { const match = decl.match(/^(--formeo-[\w-]+)\s*:\s*([\s\S]+)$/) if (match) map.set(match[1], match[2]) } return map } -/** Remove the `selector { … }` property block and return its body. */ -function takeBlock(css, selector) { +/** Remove the single `selector { … }` property block, throwing if it appears more than once or holds other rules. */ +function takeBlock(css, selector, allowed) { const re = rule(escapeSelector(selector)) - return { body: re.exec(css)?.[1], css: css.replace(re, '') } + const matches = [...css.matchAll(re)] + if (matches.length > 1) { + throw new Error(`Expected at most one ${selector} block, found ${matches.length}`) + } + const body = matches[0]?.[1] + const stray = splitDeclarations(body).filter(decl => !allowed.test(decl)) + if (stray.length) { + throw new Error(`${selector} may only declare ${allowed.source}; found: ${stray.join('; ')}`) + } + return { body, css: css.replace(re, '') } } /** Rules intentionally added by this change; removed before comparing to baseline. */ @@ -37,8 +53,8 @@ export const ALLOWED_ADDITIONS = [ * producing CSS comparable to the pre-change baseline. */ export function resolveFormeoProperties(css) { - const root = takeBlock(css, ':where(:root)') - const darkBlock = takeBlock(root.css, ':where(.formeo-dark)') + const root = takeBlock(css, ':where(:root)', /^--formeo-[\w-]+\s*:/) + const darkBlock = takeBlock(root.css, ':where(.formeo-dark)', /^(--formeo-[\w-]+|color-scheme)\s*:/) const defaults = readDeclarations(root.body) const dark = readDeclarations(darkBlock.body) From 6541b70826d9bbaea417b7ff9b8edd9f56fb8ecc Mon Sep 17 00:00:00 2001 From: Kevin Chappell Date: Thu, 24 Sep 2026 09:54:19 +0100 Subject: [PATCH 11/19] test: harden the color literal guard and run it in npm test tools/check-color-literals.mjs now exports findColorLiterals(text, file) and the guarded file list; run directly it still prints the problems and exits 1. It now also flags hsl/hsla/hwb/lab/lch/oklab/oklch, any @use of the colors module, its namespace (including colors.$), and CSS named colors in declaration values. It ignores block comments. The colors import in base/_animation.scss carries the OK marker, since its one derived literal still needs it. color-literals.test.mjs checks the guarded tree and unit-tests each rule, which puts the guard into npm test (CI and pre-push). --- src/lib/sass/base/_animation.scss | 2 +- src/lib/sass/color-literals.test.mjs | 65 ++++++++ tools/check-color-literals.mjs | 231 +++++++++++++++++++++++++-- 3 files changed, 280 insertions(+), 18 deletions(-) create mode 100644 src/lib/sass/color-literals.test.mjs diff --git a/src/lib/sass/base/_animation.scss b/src/lib/sass/base/_animation.scss index e679145d..98072e61 100644 --- a/src/lib/sass/base/_animation.scss +++ b/src/lib/sass/base/_animation.scss @@ -1,7 +1,7 @@ @use 'sass:color' as sass-color; @use 'mixins'; -@use 'colors' as color; +@use 'colors' as color; // formeo-color-literal-ok: derived @use 'tokens' as t; @use 'variables' as var; diff --git a/src/lib/sass/color-literals.test.mjs b/src/lib/sass/color-literals.test.mjs new file mode 100644 index 00000000..b719d52f --- /dev/null +++ b/src/lib/sass/color-literals.test.mjs @@ -0,0 +1,65 @@ +import { suite, test } from 'node:test' +import { checkGuardedFiles, findColorLiterals, GUARDED_FILES } from '../../../tools/check-color-literals.mjs' + +const flagged = text => findColorLiterals(text, 'sample.scss').length > 0 + +suite('color literal guard', () => { + test('guarded styles contain no color literals', t => { + t.assert.ok(GUARDED_FILES.some(file => file.startsWith('src/lib/sass/components/'))) + t.assert.deepStrictEqual(checkGuardedFiles(), []) + }) + + test('flags hex and every color function', t => { + for (const value of [ + '#fff', + '#a1a1aa80', + 'rgb(0 0 0)', + 'rgba(0, 0, 0, 0.5)', + 'hsl(0 0% 0%)', + 'hsla(0, 0%, 0%, 0.5)', + 'hwb(0 0% 0%)', + 'lab(50% 0 0)', + 'lch(50% 0 0)', + 'oklab(0.5 0 0)', + 'oklch(0.5 0 0)', + ]) { + t.assert.ok(flagged(` color: ${value};`), `${value} not flagged`) + } + }) + + test('flags the colors module however it is imported or referenced', t => { + t.assert.ok(flagged("@use '../base/colors' as c;")) + t.assert.ok(flagged('@use "colors" as palette;')) + t.assert.ok(flagged("@use 'colors';")) + t.assert.ok(flagged("@use 'colors' as *;")) + t.assert.ok(flagged(' border-color: color.$gray;')) + t.assert.ok(flagged(' border-color: colors.$gray;')) + const viaNamespace = findColorLiterals("@use 'colors' as c; // formeo-color-literal-ok: test\n fill: c.$x;", 's') + t.assert.deepStrictEqual(viaNamespace, ['s:2: fill: c.$x;']) + }) + + test('flags named colors in declaration values only', t => { + t.assert.ok(flagged(' border: 1px solid white;')) + t.assert.ok(flagged(' color: Black;')) + t.assert.ok(flagged(' background: rebeccapurple')) + t.assert.ok(flagged('$accent: tomato;')) + t.assert.ok(!flagged('.red, .white {')) + t.assert.ok(!flagged('li:first-child {')) + t.assert.ok(!flagged("@include mixins.theme('white');")) + t.assert.ok(!flagged(' white-space: nowrap;')) + t.assert.ok(!flagged(' border-color: t.$white;')) + t.assert.ok(!flagged(' background: transparent;')) + t.assert.ok(!flagged(' fill: currentcolor;')) + t.assert.ok(!flagged(' color: inherit;')) + }) + + test('allows marked lines, comments and token or variable references', t => { + t.assert.ok(!flagged(' box-shadow: 0 0 1px #000; // formeo-color-literal-ok: shadow')) + t.assert.ok(!flagged(' // border: 1px solid #000;')) + t.assert.ok(!flagged('/*\n color: white;\n*/')) + t.assert.ok(!flagged(' border-radius: var.$input-border-radius;')) + t.assert.ok(!flagged(' padding: mixins.space();')) + t.assert.ok(!flagged(' background-color: t.$bg;')) + t.assert.ok(!flagged("@use 'sass:color' as sass-color;")) + }) +}) diff --git a/tools/check-color-literals.mjs b/tools/check-color-literals.mjs index c3d768c6..a97c4f87 100644 --- a/tools/check-color-literals.mjs +++ b/tools/check-color-literals.mjs @@ -1,8 +1,12 @@ import { globSync, readFileSync } from 'node:fs' +import { fileURLToPath, pathToFileURL } from 'node:url' const OK = '// formeo-color-literal-ok' -const files = [ - ...globSync('src/lib/sass/components/**/*.scss'), +const root = fileURLToPath(new URL('../', import.meta.url)) + +/** Files that must take every color from tokens.$* (paths relative to the repo root). */ +export const GUARDED_FILES = [ + ...globSync('src/lib/sass/components/**/*.scss', { cwd: root }).sort(), 'src/lib/sass/_render.scss', 'src/lib/sass/base/_bs.scss', 'src/lib/sass/base/_icons.scss', @@ -10,20 +14,213 @@ const files = [ 'src/lib/sass/base/_variables.scss', 'src/lib/sass/base/_animation.scss', ] -const LITERAL = /#[0-9a-fA-F]{3,8}\b|\brgba?\(|\bcolor\.\$|:\s*(white|black)\b/ -const problems = [] -for (const file of files) { - readFileSync(file, 'utf8') - .split('\n') - .forEach((line, i) => { - if (line.includes(OK)) return - const code = line.replace(/\/\/.*$/, '') // ignore comments - if (LITERAL.test(code)) { - problems.push(`${file}:${i + 1}: ${line.trim()}`) - } - }) + +/** Every CSS named color (CSS Color 4). transparent, currentcolor and the global keywords are allowed. */ +export const NAMED_COLORS = [ + 'aliceblue', + 'antiquewhite', + 'aqua', + 'aquamarine', + 'azure', + 'beige', + 'bisque', + 'black', + 'blanchedalmond', + 'blue', + 'blueviolet', + 'brown', + 'burlywood', + 'cadetblue', + 'chartreuse', + 'chocolate', + 'coral', + 'cornflowerblue', + 'cornsilk', + 'crimson', + 'cyan', + 'darkblue', + 'darkcyan', + 'darkgoldenrod', + 'darkgray', + 'darkgreen', + 'darkgrey', + 'darkkhaki', + 'darkmagenta', + 'darkolivegreen', + 'darkorange', + 'darkorchid', + 'darkred', + 'darksalmon', + 'darkseagreen', + 'darkslateblue', + 'darkslategray', + 'darkslategrey', + 'darkturquoise', + 'darkviolet', + 'deeppink', + 'deepskyblue', + 'dimgray', + 'dimgrey', + 'dodgerblue', + 'firebrick', + 'floralwhite', + 'forestgreen', + 'fuchsia', + 'gainsboro', + 'ghostwhite', + 'gold', + 'goldenrod', + 'gray', + 'green', + 'greenyellow', + 'grey', + 'honeydew', + 'hotpink', + 'indianred', + 'indigo', + 'ivory', + 'khaki', + 'lavender', + 'lavenderblush', + 'lawngreen', + 'lemonchiffon', + 'lightblue', + 'lightcoral', + 'lightcyan', + 'lightgoldenrodyellow', + 'lightgray', + 'lightgreen', + 'lightgrey', + 'lightpink', + 'lightsalmon', + 'lightseagreen', + 'lightskyblue', + 'lightslategray', + 'lightslategrey', + 'lightsteelblue', + 'lightyellow', + 'lime', + 'limegreen', + 'linen', + 'magenta', + 'maroon', + 'mediumaquamarine', + 'mediumblue', + 'mediumorchid', + 'mediumpurple', + 'mediumseagreen', + 'mediumslateblue', + 'mediumspringgreen', + 'mediumturquoise', + 'mediumvioletred', + 'midnightblue', + 'mintcream', + 'mistyrose', + 'moccasin', + 'navajowhite', + 'navy', + 'oldlace', + 'olive', + 'olivedrab', + 'orange', + 'orangered', + 'orchid', + 'palegoldenrod', + 'palegreen', + 'paleturquoise', + 'palevioletred', + 'papayawhip', + 'peachpuff', + 'peru', + 'pink', + 'plum', + 'powderblue', + 'purple', + 'rebeccapurple', + 'red', + 'rosybrown', + 'royalblue', + 'saddlebrown', + 'salmon', + 'sandybrown', + 'seagreen', + 'seashell', + 'sienna', + 'silver', + 'skyblue', + 'slateblue', + 'slategray', + 'slategrey', + 'snow', + 'springgreen', + 'steelblue', + 'tan', + 'teal', + 'thistle', + 'tomato', + 'turquoise', + 'violet', + 'wheat', + 'white', + 'whitesmoke', + 'yellow', + 'yellowgreen', +] + +/** Hex, color functions and the colors module's `color.$*` namespace, anywhere in code. */ +const LITERAL = /#[0-9a-fA-F]{3,8}\b|\b(?:rgba?|hsla?|hwb|lab|lch|oklab|oklch)\(|\bcolors?\.\$/ +/** A named color as a whole identifier (not part of `$brand-red`, `white-space` or `t.$white`). */ +const NAMED = new RegExp(`(? comment.replace(/[^\n]/g, ' ')).replace(/\/\/.*$/gm, '') } -if (problems.length) { - console.error(`Color literals / colors.$* found (use tokens.$*):\n${problems.join('\n')}`) - process.exit(1) + +/** + * Lines of `text` (from `file`) that hard-code a color or reach into the colors module. + * Lines carrying `// formeo-color-literal-ok: ` are exempt. + * @returns {string[]} `file:line: source` for each problem line + */ +export function findColorLiterals(text, file) { + const raw = text.split('\n') + const code = stripComments(text).split('\n') + const namespaces = code.map(line => line.match(COLORS_USE)).filter(Boolean) + const namespaceUse = namespaces + .map(match => match[2] ?? 'colors') + .filter(ns => !['color', 'colors', '*'].includes(ns)) + .map(ns => new RegExp(`(? { + if (raw[i].includes(OK)) return + const trimmed = line.trim() + const value = !trimmed.startsWith('@') && !trimmed.endsWith('{') ? line.match(DECLARATION)?.[1] : undefined + const flagged = + LITERAL.test(line) || + COLORS_USE.test(line) || + namespaceUse.some(re => re.test(line)) || + (value !== undefined && NAMED.test(value)) + if (flagged) problems.push(`${file}:${i + 1}: ${raw[i].trim()}`) + }) + return problems +} + +/** Check every guarded file; returns the problem lines. */ +export function checkGuardedFiles() { + return GUARDED_FILES.flatMap(file => + findColorLiterals(readFileSync(new URL(file, pathToFileURL(root)), 'utf8'), file) + ) +} + +if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { + const problems = checkGuardedFiles() + if (problems.length) { + console.error(`Color literals / colors.$* found (use tokens.$*):\n${problems.join('\n')}`) + process.exit(1) + } } From d3c6324e2268c83f4c463bfecd203859c4cd6bf1 Mon Sep 17 00:00:00 2001 From: Kevin Chappell Date: Thu, 24 Sep 2026 10:04:04 +0100 Subject: [PATCH 12/19] chore: give the demo page a dark theme under formeo-dark "Toggle Dark" puts .formeo-dark on , but the demo page kept its light gradient, header and footer, so the stage and row handles and the header text were hard to see. body.formeo-dark now darkens the page, header, footer and formData popover using the formeo properties, and inverts the logo and GitHub images. Library styles are unchanged. --- src/demo/sass/demo.scss | 32 ++++++++++++++++++++++++++++++++ 1 file changed, 32 insertions(+) diff --git a/src/demo/sass/demo.scss b/src/demo/sass/demo.scss index d896e980..db6059a3 100644 --- a/src/demo/sass/demo.scss +++ b/src/demo/sass/demo.scss @@ -473,3 +473,35 @@ body { } } } + +// "Toggle Dark" puts .formeo-dark on , so the demo page itself goes dark too. +body.formeo-dark { + background: linear-gradient(135deg, #18181b 0%, #1e293b 100%); + background-attachment: fixed; + color: var(--formeo-text); + + #demo-header, + #demo-header nav, + #demo-footer nav { + background-color: var(--formeo-bg); + } + + #demo-header { + border-bottom: 1px solid var(--formeo-border); + } + + #demo-footer nav { + border-top: 1px solid var(--formeo-border); + } + + #formeo-logo, + #demo-footer img { + filter: invert(1); + } + + #formData-popover[popover] { + background-color: var(--formeo-bg); + border-color: var(--formeo-border); + color: var(--formeo-text); + } +} From 5493920d5728e5876991b60de79ace8cc19f7a5a Mon Sep 17 00:00:00 2001 From: Kevin Chappell Date: Thu, 24 Sep 2026 10:05:30 +0100 Subject: [PATCH 13/19] docs: rewrite the theming section of the readme Say where overrides and the formeo-dark class belong (dialogs are appended to document.body), what color-scheme does and why a custom dark theme needs it, that container text inherits the page color, and which derived properties don't follow their base color. List the property groups, link to _properties.scss on GitHub (it isn't in the npm package), and list the visual changes from 5.1.3, including the column resize-handle triangles. --- README.md | 29 ++++++++++++++++++++++------- 1 file changed, 22 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index b5830cb1..c77e15e0 100644 --- a/README.md +++ b/README.md @@ -164,19 +164,34 @@ Formeo can be integrated with popular frontend frameworks: ## Theming -Every color formeo uses is a CSS custom property declared on `:root` with zero specificity, so you can override any of them from your own stylesheet: +Every color formeo uses is a `--formeo-*` CSS custom property, declared on `:root` with zero specificity. Override them on `:root` or ``: formeo appends its dialogs to `document.body`, so a rule scoped to the editor's container misses them. A narrower selector works too if it also covers `.formeo-dialog` and, when you move the controls panel with `controls.container`, `.formeo-controls`. For example, to map your own dark theme: ```css -.my-app { - --formeo-bg: #fafafa; - --formeo-text: #111; - --formeo-border: #ddd; +:root { + color-scheme: dark; + color: #fafafa; + --formeo-bg: #0a0a0a; + --formeo-bg-hover: #1a1a1a; + --formeo-text: #fafafa; + --formeo-border: #262626; + --formeo-icon: #e5e5e5; } ``` -For a built-in dark palette, add the `formeo-dark` class to any ancestor of the editor (for example ``). +- **Dark preset:** add the `formeo-dark` class to `` (or `:root`). It sets `color-scheme: dark` on that subtree, so your own native controls inside it darken too. +- **Your own dark theme:** set `color-scheme: dark` as well. Formeo leaves the text color of inputs and selects, and the checkboxes themselves, to the browser, which only makes them light under a dark `color-scheme`. +- **Text color:** outside the dark preset, formeo's containers inherit the page's `color`. Setting `--formeo-text` alone only recolors text formeo colors explicitly, not inherited labels, so set `color` on your container too. +- **Derived properties:** some defaults are computed from another color at build time and don't follow when you override that color. If you change the base, override these too: `--formeo-bg-hover`, `--formeo-overlay`, `--formeo-danger-subtle`, `--formeo-column-outline-soft`, and the `--formeo-*-highlight` / `--formeo-*-highlight-text` properties. -The full list of properties is in `src/lib/sass/base/_properties.scss`. Icons now inherit `--formeo-icon` (default `#000`). A few icons that previously used a hard-coded `#444` are now slightly darker by default. +The full list, with defaults, is in [`_properties.scss`](https://github.com/Draggable/formeo/blob/main/src/lib/sass/base/_properties.scss). It isn't included in the npm package. The groups, without the `--formeo-` prefix, are: + +- **Surfaces:** `bg`, `bg-hover`, `surface-muted`, `stage-bg`, `stage-shadow`, `overlay` +- **Text and icons:** `text`, `text-strong`, `text-secondary`, `text-muted`, `text-subtle`, `on-accent`, `icon` +- **Borders and focus:** `border`, `border-strong`, `focus` +- **Accents:** `primary`, `success`, `warning` and `danger` (each with a `-dark` variant), `danger-subtle`, `info`, `remove-bg` +- **Component outlines:** `{stage,row,column,field,option}-outline`, `-outline-text`, `-highlight` and `-highlight-text`, plus `column-outline-soft` + +**Visual changes from 5.1.3:** icons that hard-coded `#444` (header, paragraph and the triangles) now use `--formeo-icon`, which defaults to `#000`. The column resize-handle triangles now use the column outline color (`--formeo-column-outline-soft`, or the darker `--formeo-column-outline` on hover) instead of `#444`. ## Documentation From 5e611a2dc90d61e747c7d92d0b42eb9239aa8d2e Mon Sep 17 00:00:00 2001 From: Kevin Chappell Date: Thu, 24 Sep 2026 10:06:25 +0100 Subject: [PATCH 14/19] test: count property blocks nested in at-rules The resolver's rule matcher now also treats an opening brace as a rule start, so a second :where(:root) block inside @media is counted (and rejected) instead of being silently left in the output. --- src/lib/sass/css-properties.test.mjs | 5 +++++ tools/resolve-formeo-properties.mjs | 2 +- 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/src/lib/sass/css-properties.test.mjs b/src/lib/sass/css-properties.test.mjs index 813e1a81..3923c1b9 100644 --- a/src/lib/sass/css-properties.test.mjs +++ b/src/lib/sass/css-properties.test.mjs @@ -137,6 +137,11 @@ suite('resolveFormeoProperties purity', () => { t.assert.throws(() => resolveFormeoProperties(root + root + use), /at most one :where\(:root\) block, found 2/) }) + test('counts a :where(:root) block nested in an at-rule', t => { + const nested = `@media print {\n ${root}}\n` + t.assert.throws(() => resolveFormeoProperties(root + nested + use), /at most one :where\(:root\) block, found 2/) + }) + test('rejects more than one :where(.formeo-dark) block', t => { t.assert.throws( () => resolveFormeoProperties(root + dark + dark), diff --git a/tools/resolve-formeo-properties.mjs b/tools/resolve-formeo-properties.mjs index 525cff3f..e737e260 100644 --- a/tools/resolve-formeo-properties.mjs +++ b/tools/resolve-formeo-properties.mjs @@ -1,7 +1,7 @@ const escapeSelector = selector => selector.replace(/[()[\].]/g, m => `\\${m}`) /** A whole rule whose selector is exactly `selectorSource` (a regex source), starting where a rule may start. */ -const rule = selectorSource => new RegExp(String.raw`(?<=(?:^|[};]|\*/)\s*)${selectorSource}\s*\{([^}]*)\}\s*`, 'g') +const rule = selectorSource => new RegExp(String.raw`(?<=(?:^|[{};]|\*/)\s*)${selectorSource}\s*\{([^}]*)\}\s*`, 'g') function splitDeclarations(body = '') { return body From 4b352e44d774d5ff1a2d865034290dc575066b6b Mon Sep 17 00:00:00 2001 From: Kevin Chappell Date: Thu, 24 Sep 2026 10:57:15 +0100 Subject: [PATCH 15/19] fix: scope the dialog surface rule to the dark preset The zero-specificity dialog rule changed the dialog from the UA Canvas colors to --formeo-bg for hosts that set color-scheme: dark without mapping formeo's properties. It now applies only under .formeo-dark, either on an ancestor or on the dialog itself. The unused .component-edit[popover] selector is gone. The README says dialogs follow the preset, and that hosts with their own mapping should set color-scheme: dark and include .formeo-dialog in its scope. --- README.md | 6 +++--- src/lib/sass/base/_properties.scss | 5 +++-- src/lib/sass/css-properties.test.mjs | 11 ++++++----- tools/resolve-formeo-properties.mjs | 4 +++- 4 files changed, 15 insertions(+), 11 deletions(-) diff --git a/README.md b/README.md index c77e15e0..28cee625 100644 --- a/README.md +++ b/README.md @@ -178,9 +178,9 @@ Every color formeo uses is a `--formeo-*` CSS custom property, declared on `:roo } ``` -- **Dark preset:** add the `formeo-dark` class to `` (or `:root`). It sets `color-scheme: dark` on that subtree, so your own native controls inside it darken too. -- **Your own dark theme:** set `color-scheme: dark` as well. Formeo leaves the text color of inputs and selects, and the checkboxes themselves, to the browser, which only makes them light under a dark `color-scheme`. -- **Text color:** outside the dark preset, formeo's containers inherit the page's `color`. Setting `--formeo-text` alone only recolors text formeo colors explicitly, not inherited labels, so set `color` on your container too. +- **Dark preset:** add the `formeo-dark` class to `` (or `:root`). It sets `color-scheme: dark` on that subtree, so your own native controls inside it darken too. Dialogs take the preset's background and text colors as well. +- **Your own dark theme:** set `color-scheme: dark` as well, and make sure your mapping's scope includes `.formeo-dialog`. Outside the preset, formeo leaves the dialog's own background and text color, the text color of inputs and selects, and the checkboxes to the browser, which only switches them to dark-mode colors under a dark `color-scheme`. +- **Text color:** outside the dark preset, the editor and controls containers inherit the page's `color`. Setting `--formeo-text` alone only recolors text formeo colors explicitly, not inherited labels, so set `color` on your container too. - **Derived properties:** some defaults are computed from another color at build time and don't follow when you override that color. If you change the base, override these too: `--formeo-bg-hover`, `--formeo-overlay`, `--formeo-danger-subtle`, `--formeo-column-outline-soft`, and the `--formeo-*-highlight` / `--formeo-*-highlight-text` properties. The full list, with defaults, is in [`_properties.scss`](https://github.com/Draggable/formeo/blob/main/src/lib/sass/base/_properties.scss). It isn't included in the npm package. The groups, without the `--formeo-` prefix, are: diff --git a/src/lib/sass/base/_properties.scss b/src/lib/sass/base/_properties.scss index 9390abfa..9770f878 100644 --- a/src/lib/sass/base/_properties.scss +++ b/src/lib/sass/base/_properties.scss @@ -91,8 +91,9 @@ $_structural: ( background-color: var(--formeo-border-strong); } -// Dialogs and popovers otherwise keep the UA Canvas colors; the defaults render the same. -:where(.formeo-dialog, .component-edit[popover]) { +// Dialogs otherwise keep the UA Canvas colors, which ignore the dark palette. +:where(.formeo-dark) :where(.formeo-dialog), +:where(.formeo-dark):where(.formeo-dialog) { background-color: var(--formeo-bg); color: var(--formeo-text); } diff --git a/src/lib/sass/css-properties.test.mjs b/src/lib/sass/css-properties.test.mjs index 3923c1b9..01c4c164 100644 --- a/src/lib/sass/css-properties.test.mjs +++ b/src/lib/sass/css-properties.test.mjs @@ -79,7 +79,7 @@ suite('formeo CSS custom properties', () => { test('.formeo-dark preset sets text color on formeo containers, nested or on the container itself', t => { t.assert.strictEqual( - compiledRule(':where(.formeo-dark) :where('), + compiledRule(':where(.formeo-dark) :where(.formeo,'), [ ':where(.formeo-dark) :where(.formeo, .formeo-controls, .formeo-dialog),', ':where(.formeo-dark):where(.formeo, .formeo-controls, .formeo-dialog) {', @@ -102,11 +102,12 @@ suite('formeo CSS custom properties', () => { ) }) - test('dialogs and the edit popover take their surface and text from the properties', t => { + test('.formeo-dark gives dialogs the dark surface and text, nested or on the dialog itself', t => { t.assert.strictEqual( - compiledRule(':where(.formeo-dialog, '), + compiledRule(':where(.formeo-dark) :where(.formeo-dialog)'), [ - ':where(.formeo-dialog, .component-edit[popover]) {', + ':where(.formeo-dark) :where(.formeo-dialog),', + ':where(.formeo-dark):where(.formeo-dialog) {', ' background-color: var(--formeo-bg);', ' color: var(--formeo-text);', '}', @@ -116,7 +117,7 @@ suite('formeo CSS custom properties', () => { test('allowed additions are stripped before the baseline comparison', t => { const { css } = resolveFormeoProperties(compileFormeoCss()) - for (const fragment of ['formeo-dark', ':where(.svg-icon)', ':where(.formeo-dialog']) { + for (const fragment of ['formeo-dark', ':where(.svg-icon)']) { t.assert.ok(!css.includes(fragment), `${fragment} must be stripped before baseline comparison`) } }) diff --git a/tools/resolve-formeo-properties.mjs b/tools/resolve-formeo-properties.mjs index e737e260..56a1a8d4 100644 --- a/tools/resolve-formeo-properties.mjs +++ b/tools/resolve-formeo-properties.mjs @@ -45,7 +45,9 @@ export const ALLOWED_ADDITIONS = [ String.raw`\.formeo-dark \.formeo\.formeo-editor \.conditions-prop-inputs label\.condition-label\.then-condition-label,\s*` + String.raw`\.formeo-dark\.formeo\.formeo-editor \.conditions-prop-inputs label\.condition-label\.then-condition-label` ), - rule(String.raw`:where\(\.formeo-dialog, \.component-edit\[popover\]\)`), + rule( + String.raw`:where\(\.formeo-dark\) :where\(\.formeo-dialog\),\s*:where\(\.formeo-dark\):where\(\.formeo-dialog\)` + ), ] /** From 68588aa1a235ec1ee7d297bfbf357e8bb90498fb Mon Sep 17 00:00:00 2001 From: Kevin Chappell Date: Thu, 24 Sep 2026 12:21:38 +0100 Subject: [PATCH 16/19] style: use bg token for editing field background --- src/lib/sass/components/_field.scss | 1 + 1 file changed, 1 insertion(+) diff --git a/src/lib/sass/components/_field.scss b/src/lib/sass/components/_field.scss index a5e4406f..6374469f 100644 --- a/src/lib/sass/components/_field.scss +++ b/src/lib/sass/components/_field.scss @@ -147,6 +147,7 @@ .editing-field { z-index: 1; + background-color: t.$bg; } .editing-field-preview { From 362cce0a2c95d04323622703718b07c64094c776 Mon Sep 17 00:00:00 2001 From: Kevin Chappell Date: Thu, 24 Sep 2026 12:35:02 +0100 Subject: [PATCH 17/19] fix: give the editing field its surface background at zero specificity Moves the .editing-field background into a top-level :where() rule so field and column highlights still win, and lists it as an allowed addition with a content test. --- src/lib/sass/base/_properties.scss | 5 +++++ src/lib/sass/components/_field.scss | 1 - src/lib/sass/css-properties.test.mjs | 9 ++++++++- tools/resolve-formeo-properties.mjs | 1 + 4 files changed, 14 insertions(+), 2 deletions(-) diff --git a/src/lib/sass/base/_properties.scss b/src/lib/sass/base/_properties.scss index 9770f878..dce7b69a 100644 --- a/src/lib/sass/base/_properties.scss +++ b/src/lib/sass/base/_properties.scss @@ -97,3 +97,8 @@ $_structural: ( background-color: var(--formeo-bg); color: var(--formeo-text); } + +// The field being edited sits above its neighbours; give it the surface color. Zero specificity so highlights still win. +:where(.editing-field) { + background-color: var(--formeo-bg); +} diff --git a/src/lib/sass/components/_field.scss b/src/lib/sass/components/_field.scss index 6374469f..a5e4406f 100644 --- a/src/lib/sass/components/_field.scss +++ b/src/lib/sass/components/_field.scss @@ -147,7 +147,6 @@ .editing-field { z-index: 1; - background-color: t.$bg; } .editing-field-preview { diff --git a/src/lib/sass/css-properties.test.mjs b/src/lib/sass/css-properties.test.mjs index 01c4c164..ef610433 100644 --- a/src/lib/sass/css-properties.test.mjs +++ b/src/lib/sass/css-properties.test.mjs @@ -115,9 +115,16 @@ suite('formeo CSS custom properties', () => { ) }) + test(':where(.editing-field) gives the edited field the surface background', t => { + t.assert.strictEqual( + compiledRule(':where(.editing-field) {'), + ':where(.editing-field) {\n background-color: var(--formeo-bg);\n}' + ) + }) + test('allowed additions are stripped before the baseline comparison', t => { const { css } = resolveFormeoProperties(compileFormeoCss()) - for (const fragment of ['formeo-dark', ':where(.svg-icon)']) { + for (const fragment of ['formeo-dark', ':where(.svg-icon)', ':where(.editing-field)']) { t.assert.ok(!css.includes(fragment), `${fragment} must be stripped before baseline comparison`) } }) diff --git a/tools/resolve-formeo-properties.mjs b/tools/resolve-formeo-properties.mjs index 56a1a8d4..57b383b9 100644 --- a/tools/resolve-formeo-properties.mjs +++ b/tools/resolve-formeo-properties.mjs @@ -37,6 +37,7 @@ function takeBlock(css, selector, allowed) { /** Rules intentionally added by this change; removed before comparing to baseline. */ export const ALLOWED_ADDITIONS = [ rule(String.raw`:where\(\.svg-icon\)`), + rule(String.raw`:where\(\.editing-field\)`), rule( String.raw`:where\(\.formeo-dark\) :where\(\.formeo, \.formeo-controls, \.formeo-dialog\),\s*` + String.raw`:where\(\.formeo-dark\):where\(\.formeo, \.formeo-controls, \.formeo-dialog\)` From 084e1bdc37e52b631cc1a2814b0375284e5de1fa Mon Sep 17 00:00:00 2001 From: Kevin Chappell Date: Thu, 24 Sep 2026 12:37:12 +0100 Subject: [PATCH 18/19] chore: npm audit fix --- package-lock.json | 78 +++++++++++++++++++++++------------------------ 1 file changed, 39 insertions(+), 39 deletions(-) diff --git a/package-lock.json b/package-lock.json index e5c27d40..b04217d6 100644 --- a/package-lock.json +++ b/package-lock.json @@ -3219,9 +3219,9 @@ "license": "MIT" }, "node_modules/@xmldom/xmldom": { - "version": "0.8.13", - "resolved": "https://registry.npmjs.org/@xmldom/xmldom/-/xmldom-0.8.13.tgz", - "integrity": "sha512-KRYzxepc14G/CEpEGc3Yn+JKaAeT63smlDr+vjB8jRfgTBBI9wRj/nkQEO+ucV8p8I9bfKLWp37uHgFrbntPvw==", + "version": "0.8.15", + "resolved": "https://registry.npmjs.org/@xmldom/xmldom/-/xmldom-0.8.15.tgz", + "integrity": "sha512-/5NV/vDALVFDXgLmfsy9TRCBlKwO2LNBFzpzvb9iIj+jR+eSc6DLYYvVOdivT/jm7MtU6TebYuRmzEOI7w40UA==", "dev": true, "license": "MIT", "engines": { @@ -5017,9 +5017,9 @@ } }, "node_modules/fast-uri": { - "version": "3.1.5", - "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.5.tgz", - "integrity": "sha512-gHwA1O9LDIcKunMKhObS/HimwtehO1nPUECKAu5TpKgaO19fcWEl4bliWe1jWxVFvIXztJjjQ4L8XQ1EU9f7Jw==", + "version": "3.1.8", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.8.tgz", + "integrity": "sha512-GZMtZUTNRpOVIECoXwLNZS5xUGE+mVNbTB8h/7Rwh2TFWcBQiPzTgyZi05BF9UMZKkLJv8XBRJTlU7zg8+ZfMg==", "dev": true, "funding": [ { @@ -6316,9 +6316,9 @@ "license": "MIT" }, "node_modules/js-yaml": { - "version": "4.3.1", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.1.tgz", - "integrity": "sha512-CY6crGq313MX8GkwvB7tzgp99vjQxY1++5y10/BKN/GUfHqWaOGQMNZkBvqSzsZKWk/ijwHlWzzkLulsGHhjWQ==", + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.2.tgz", + "integrity": "sha512-SFNOvSJ+Dgf/9An904Yx+CgSlIPCkIpao4qo51lpee25TIRejdH3rhR4EZMGoNx3/TP3O+wzWuiTFl4sqbltzA==", "dev": true, "funding": [ { @@ -7338,9 +7338,9 @@ } }, "node_modules/npm": { - "version": "11.19.0", - "resolved": "https://registry.npmjs.org/npm/-/npm-11.19.0.tgz", - "integrity": "sha512-SDd/hHg3KqHE5Ht2NHWxNYNtqCQ2pXAPLl6OtQhPyED5PHsRfrOtO199MZTIG2cQoQ1ZRI9t28shrD+2cr3AAw==", + "version": "11.20.0", + "resolved": "https://registry.npmjs.org/npm/-/npm-11.20.0.tgz", + "integrity": "sha512-dF3EDFwbYN+N5RUip+ZYDe0NeURK5BgqKOcvT1iNtUYhTMTl0FwWhBuXrS7KtXyduqyTMS5aaQaregnHDAxNgw==", "bundleDependencies": [ "@isaacs/string-locale-compare", "@npmcli/arborist", @@ -7419,8 +7419,8 @@ ], "dependencies": { "@isaacs/string-locale-compare": "^1.1.0", - "@npmcli/arborist": "^9.9.1", - "@npmcli/config": "^10.12.0", + "@npmcli/arborist": "^9.9.2", + "@npmcli/config": "^10.13.0", "@npmcli/fs": "^5.0.0", "@npmcli/map-workspaces": "^5.0.3", "@npmcli/metavuln-calculator": "^9.0.3", @@ -7444,12 +7444,12 @@ "is-cidr": "^6.0.4", "json-parse-even-better-errors": "^5.0.0", "libnpmaccess": "^10.0.3", - "libnpmdiff": "^8.1.12", - "libnpmexec": "^10.3.2", - "libnpmfund": "^7.0.26", + "libnpmdiff": "^8.1.13", + "libnpmexec": "^10.3.3", + "libnpmfund": "^7.0.27", "libnpmorg": "^8.0.1", - "libnpmpack": "^9.1.12", - "libnpmpublish": "^11.2.0", + "libnpmpack": "^9.1.14", + "libnpmpublish": "^11.2.1", "libnpmsearch": "^9.0.1", "libnpmteam": "^8.0.2", "libnpmversion": "^8.0.4", @@ -7477,7 +7477,7 @@ "spdx-expression-parse": "^4.0.0", "ssri": "^13.0.1", "supports-color": "^10.2.2", - "tar": "^7.5.19", + "tar": "^7.5.22", "text-table": "~0.2.0", "tiny-relative-date": "^2.0.2", "treeverse": "^3.0.0", @@ -7744,7 +7744,7 @@ } }, "node_modules/npm/node_modules/@npmcli/arborist": { - "version": "9.9.1", + "version": "9.9.2", "dev": true, "inBundle": true, "license": "ISC", @@ -7792,7 +7792,7 @@ } }, "node_modules/npm/node_modules/@npmcli/config": { - "version": "10.12.0", + "version": "10.13.0", "dev": true, "inBundle": true, "license": "ISC", @@ -8137,7 +8137,7 @@ } }, "node_modules/npm/node_modules/brace-expansion": { - "version": "5.0.7", + "version": "5.0.9", "dev": true, "inBundle": true, "license": "MIT", @@ -8145,7 +8145,7 @@ "balanced-match": "^4.0.2" }, "engines": { - "node": "18 || 20 || >=22" + "node": "20 || >=22" } }, "node_modules/npm/node_modules/cacache": { @@ -8429,7 +8429,7 @@ } }, "node_modules/npm/node_modules/ip-address": { - "version": "10.2.0", + "version": "10.5.0", "dev": true, "inBundle": true, "license": "MIT", @@ -8511,12 +8511,12 @@ } }, "node_modules/npm/node_modules/libnpmdiff": { - "version": "8.1.12", + "version": "8.1.13", "dev": true, "inBundle": true, "license": "ISC", "dependencies": { - "@npmcli/arborist": "^9.9.1", + "@npmcli/arborist": "^9.9.2", "@npmcli/installed-package-contents": "^4.0.0", "binary-extensions": "^3.0.0", "diff": "^8.0.2", @@ -8530,13 +8530,13 @@ } }, "node_modules/npm/node_modules/libnpmexec": { - "version": "10.3.2", + "version": "10.3.3", "dev": true, "inBundle": true, "license": "ISC", "dependencies": { "@gar/promise-retry": "^1.0.0", - "@npmcli/arborist": "^9.9.1", + "@npmcli/arborist": "^9.9.2", "@npmcli/package-json": "^7.0.0", "@npmcli/run-script": "^10.0.0", "ci-info": "^4.0.0", @@ -8553,12 +8553,12 @@ } }, "node_modules/npm/node_modules/libnpmfund": { - "version": "7.0.26", + "version": "7.0.27", "dev": true, "inBundle": true, "license": "ISC", "dependencies": { - "@npmcli/arborist": "^9.9.1" + "@npmcli/arborist": "^9.9.2" }, "engines": { "node": "^20.17.0 || >=22.9.0" @@ -8578,12 +8578,12 @@ } }, "node_modules/npm/node_modules/libnpmpack": { - "version": "9.1.12", + "version": "9.1.14", "dev": true, "inBundle": true, "license": "ISC", "dependencies": { - "@npmcli/arborist": "^9.9.1", + "@npmcli/arborist": "^9.9.2", "@npmcli/run-script": "^10.0.0", "npm-package-arg": "^13.0.0", "pacote": "^21.0.2" @@ -8593,7 +8593,7 @@ } }, "node_modules/npm/node_modules/libnpmpublish": { - "version": "11.2.0", + "version": "11.2.1", "dev": true, "inBundle": true, "license": "ISC", @@ -9288,7 +9288,7 @@ } }, "node_modules/npm/node_modules/tar": { - "version": "7.5.19", + "version": "7.5.22", "dev": true, "inBundle": true, "license": "BlueOak-1.0.0", @@ -9384,7 +9384,7 @@ } }, "node_modules/npm/node_modules/undici": { - "version": "6.27.0", + "version": "6.28.0", "dev": true, "inBundle": true, "license": "MIT", @@ -11766,9 +11766,9 @@ } }, "node_modules/svgo": { - "version": "2.8.3", - "resolved": "https://registry.npmjs.org/svgo/-/svgo-2.8.3.tgz", - "integrity": "sha512-5EZD0pafXX6PphdwOGCiVLDSaV1xyuQao2blHajHLsPxr07q4mmEjdtXEWgG07ae2mIz8Ex2CDXNCTiXhy3Khw==", + "version": "2.8.4", + "resolved": "https://registry.npmjs.org/svgo/-/svgo-2.8.4.tgz", + "integrity": "sha512-2GJ4h3rl13qYTdwllaK6QlL8tG+UrM8626V2Ylcd/yBUv2Y/EwLsYisVV6UDCRmlk+C74G8nMpxJCk0RWPdDCw==", "dev": true, "license": "MIT", "dependencies": { From 51e300a4506ac85688d5b897eb593b495c4afc45 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 24 Sep 2026 11:46:03 +0000 Subject: [PATCH 19/19] fix: derive edit pulse midpoint from runtime focus color Co-authored-by: kevinchappell <1457540+kevinchappell@users.noreply.github.com> --- src/lib/sass/__fixtures__/formeo-baseline.css | 2 +- src/lib/sass/base/_animation.scss | 4 +--- 2 files changed, 2 insertions(+), 4 deletions(-) diff --git a/src/lib/sass/__fixtures__/formeo-baseline.css b/src/lib/sass/__fixtures__/formeo-baseline.css index b6d8ba86..678fc8ed 100644 --- a/src/lib/sass/__fixtures__/formeo-baseline.css +++ b/src/lib/sass/__fixtures__/formeo-baseline.css @@ -704,7 +704,7 @@ button[class*=-clone]:hover .svg-icon { border-color: rgb(102, 175, 233); } 50% { - border-color: rgb(191.1771428571, 221.6285714286, 245.8228571429); + border-color: color-mix(in srgb, rgb(102, 175, 233) 80%, white); } } @keyframes COMPONENT_HIGHLIGHT_PULSE { diff --git a/src/lib/sass/base/_animation.scss b/src/lib/sass/base/_animation.scss index 98072e61..e36aef5e 100644 --- a/src/lib/sass/base/_animation.scss +++ b/src/lib/sass/base/_animation.scss @@ -1,7 +1,5 @@ -@use 'sass:color' as sass-color; @use 'mixins'; -@use 'colors' as color; // formeo-color-literal-ok: derived @use 'tokens' as t; @use 'variables' as var; @@ -36,7 +34,7 @@ } 50% { - border-color: sass-color.adjust(color.$input-focus, $lightness: 20%); // formeo-color-literal-ok: derived + border-color: color-mix(in srgb, t.$focus 80%, white); // formeo-color-literal-ok: derived } }