From 58b6e6f7f69da5581751152e8ee7465ec2677c7f Mon Sep 17 00:00:00 2001 From: Quinton Jason Date: Mon, 1 Jun 2026 10:54:09 -0500 Subject: [PATCH 1/2] chore(lint): add pine-motion stylelint rule banning hardcoded transition timing --- libs/.stylelintrc.json | 4 +- .../stylelint-plugin-pine-motion.cjs | 111 ++++++++++++++++++ .../pds-combobox-dropdown-panel.scss | 1 + .../components/pds-combobox/pds-combobox.scss | 2 + .../pds-multiselect/pds-multiselect.scss | 1 + .../src/components/pds-table/pds-table.scss | 1 + 6 files changed, 119 insertions(+), 1 deletion(-) create mode 100644 libs/core/lint-plugins/stylelint-plugin-pine-motion.cjs diff --git a/libs/.stylelintrc.json b/libs/.stylelintrc.json index 886135fb1..5c9c2e1ff 100644 --- a/libs/.stylelintrc.json +++ b/libs/.stylelintrc.json @@ -6,11 +6,13 @@ ], "plugins": [ "./core/lint-plugins/stylelint-plugin-pine-colors.cjs", - "./core/lint-plugins/stylelint-plugin-pine-semantic-tokens.cjs" + "./core/lint-plugins/stylelint-plugin-pine-semantic-tokens.cjs", + "./core/lint-plugins/stylelint-plugin-pine-motion.cjs" ], "rules": { "pine-design-system/no-hardcoded-colors": true, "pine-design-system/prefer-semantic-tokens": true, + "pine-design-system/no-hardcoded-motion": true, "at-rule-no-unknown": null, "color-hex-length": "long", "length-zero-no-unit": [ diff --git a/libs/core/lint-plugins/stylelint-plugin-pine-motion.cjs b/libs/core/lint-plugins/stylelint-plugin-pine-motion.cjs new file mode 100644 index 000000000..f0839965b --- /dev/null +++ b/libs/core/lint-plugins/stylelint-plugin-pine-motion.cjs @@ -0,0 +1,111 @@ +/** + * Stylelint plugin to enforce Pine motion duration tokens. + * + * Flags hard-coded time values in `transition` / `transition-duration` + * declarations and points to the `--pine-motion-duration-*` tokens, so + * component transitions stay consistent and inherit the `prefers-reduced-motion` + * override. + * + * Scope (deliberate): + * - Only `transition` / `transition-duration`. Keyframe `animation` durations + * (spinners, progress bars, etc.) are bespoke ongoing motion, not interaction + * transitions, and are intentionally out of scope. + * - `0` / `0s` / `0ms` are always allowed. + * - Values that exactly match a token (120ms/200ms/300ms and their `s` forms) + * get an autofix to the token var. Off-grid values (e.g. 0.15s) have no exact + * token; they must use the nearest token or carry a justified + * `stylelint-disable-next-line` with a comment. + */ +const stylelint = require('stylelint'); + +const ruleName = 'pine-design-system/no-hardcoded-motion'; +const messages = stylelint.utils.ruleMessages(ruleName, { + rejected: (message) => message, +}); + +// Properties whose hard-coded timing should use a motion token. +const TIMING_PROPS = new Set(['transition', 'transition-duration']); + +// Exact literal → `--pine-motion-duration-*` token name (both ms and s spellings). +const DURATION_TOKENS = { + '120ms': 'fast', + '0.12s': 'fast', + '.12s': 'fast', + '200ms': 'base', + '0.2s': 'base', + '.2s': 'base', + '300ms': 'slow', + '0.3s': 'slow', + '.3s': 'slow', +}; + +// Matches a CSS time value (e.g. `200ms`, `0.2s`, `.15s`). +const TIME_REGEX = /(\d*\.?\d+)(ms|s)\b/g; + +function isZeroTime(raw) { + return /^0*\.?0+(ms|s)$/.test(raw); +} + +module.exports = stylelint.createPlugin(ruleName, (primaryOption, _secondaryOptions, context) => { + return (root, result) => { + const validOptions = stylelint.utils.validateOptions(result, ruleName, { + actual: primaryOption, + possible: [true, false], + }); + + if (!validOptions || primaryOption === false) { + return; + } + + root.walkDecls((decl) => { + if (!TIMING_PROPS.has(decl.prop.toLowerCase())) { + return; + } + + const value = decl.value; + const replacements = []; + let match; + + TIME_REGEX.lastIndex = 0; + while ((match = TIME_REGEX.exec(value)) !== null) { + const raw = match[0]; + if (isZeroTime(raw)) { + continue; + } + + const token = DURATION_TOKENS[raw]; + const tokenVar = token ? `var(--pine-motion-duration-${token})` : null; + + const message = tokenVar + ? `Hard-coded transition duration "${raw}" should use the motion token ${tokenVar}.` + : `Hard-coded transition duration "${raw}" has no exact Pine motion token ` + + `(fast 120ms / base 200ms / slow 300ms). Use the nearest token, or add a ` + + `justified \`stylelint-disable-next-line ${ruleName}\` when an off-grid value is intentional.`; + + if (tokenVar && context && context.fix) { + replacements.push({ original: raw, replacement: tokenVar }); + continue; + } + + stylelint.utils.report({ + ruleName, + result, + node: decl, + message: messages.rejected(message), + word: raw, + }); + } + + if (context && context.fix && replacements.length > 0) { + let newValue = value; + for (const { original, replacement } of replacements) { + newValue = newValue.replace(original, replacement); + } + decl.value = newValue; + } + }); + }; +}); + +module.exports.ruleName = ruleName; +module.exports.messages = messages; diff --git a/libs/core/src/components/pds-combobox/pds-combobox-dropdown-panel.scss b/libs/core/src/components/pds-combobox/pds-combobox-dropdown-panel.scss index 21e603510..8c754063c 100644 --- a/libs/core/src/components/pds-combobox/pds-combobox-dropdown-panel.scss +++ b/libs/core/src/components/pds-combobox/pds-combobox-dropdown-panel.scss @@ -24,6 +24,7 @@ justify-content: space-between; padding: var(--pine-dimension-xs) var(--pine-dimension-sm); position: relative; + /* stylelint-disable-next-line pine-design-system/no-hardcoded-motion -- 0.15s off-grid micro-transition; no exact motion token */ transition: background 0.15s; &[aria-selected="true"] { diff --git a/libs/core/src/components/pds-combobox/pds-combobox.scss b/libs/core/src/components/pds-combobox/pds-combobox.scss index de708eedb..15ff35bff 100644 --- a/libs/core/src/components/pds-combobox/pds-combobox.scss +++ b/libs/core/src/components/pds-combobox/pds-combobox.scss @@ -91,6 +91,7 @@ min-height: var(--pine-dimension-450); outline: none; padding: 0 var(--pine-dimension-sm); + /* stylelint-disable-next-line pine-design-system/no-hardcoded-motion -- 0.15s off-grid micro-transition; no exact motion token */ transition: border-color 0.15s, box-shadow 0.15s, background 0.15s; width: fit-content; @@ -226,6 +227,7 @@ outline: none; padding-block: var(--pine-dimension-025); padding-inline: var(--pine-dimension-150); + /* stylelint-disable-next-line pine-design-system/no-hardcoded-motion -- 0.15s off-grid micro-transition; no exact motion token */ transition: all 0.15s ease; width: fit-content; diff --git a/libs/core/src/components/pds-multiselect/pds-multiselect.scss b/libs/core/src/components/pds-multiselect/pds-multiselect.scss index 7fa999f18..5e1c534fa 100644 --- a/libs/core/src/components/pds-multiselect/pds-multiselect.scss +++ b/libs/core/src/components/pds-multiselect/pds-multiselect.scss @@ -271,6 +271,7 @@ cursor: pointer; display: flex; padding: var(--pine-dimension-2xs) var(--pine-dimension-xs); + /* stylelint-disable-next-line pine-design-system/no-hardcoded-motion -- 0.15s off-grid micro-transition; no exact motion token */ transition: background 0.15s; // Prevent checkbox label from triggering double events diff --git a/libs/core/src/components/pds-table/pds-table.scss b/libs/core/src/components/pds-table/pds-table.scss index 0a5bd0996..cb7e0b710 100644 --- a/libs/core/src/components/pds-table/pds-table.scss +++ b/libs/core/src/components/pds-table/pds-table.scss @@ -75,6 +75,7 @@ pointer-events: none; position: absolute; top: 0; + /* stylelint-disable-next-line pine-design-system/no-hardcoded-motion -- 0.1s off-grid micro-transition; no exact motion token */ transition: opacity 0.1s ease; width: 4px; z-index: var(--pine-z-index); From 616926f5c85878f07ab15a1bbaa0fc8f721e48c9 Mon Sep 17 00:00:00 2001 From: Quinton Jason Date: Mon, 1 Jun 2026 14:40:25 -0500 Subject: [PATCH 2/2] fix(lint): pine-motion ignores var() identifiers, autofix replaces by index --- .../stylelint-plugin-pine-motion.cjs | 16 +++++++++++----- 1 file changed, 11 insertions(+), 5 deletions(-) diff --git a/libs/core/lint-plugins/stylelint-plugin-pine-motion.cjs b/libs/core/lint-plugins/stylelint-plugin-pine-motion.cjs index f0839965b..762e30194 100644 --- a/libs/core/lint-plugins/stylelint-plugin-pine-motion.cjs +++ b/libs/core/lint-plugins/stylelint-plugin-pine-motion.cjs @@ -39,8 +39,10 @@ const DURATION_TOKENS = { '.3s': 'slow', }; -// Matches a CSS time value (e.g. `200ms`, `0.2s`, `.15s`). -const TIME_REGEX = /(\d*\.?\d+)(ms|s)\b/g; +// Matches a CSS time value (e.g. `200ms`, `0.2s`, `.15s`). The leading +// negative lookbehind prevents matching time-like substrings inside +// identifiers / custom-property names (e.g. `var(--app-delay-200ms)`). +const TIME_REGEX = /(? 0) { + // Apply right-to-left so earlier indices stay valid as the string length changes. let newValue = value; - for (const { original, replacement } of replacements) { - newValue = newValue.replace(original, replacement); + for (const { index, length, replacement } of replacements.reverse()) { + newValue = newValue.slice(0, index) + replacement + newValue.slice(index + length); } decl.value = newValue; }