Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion libs/.stylelintrc.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": [
Expand Down
117 changes: 117 additions & 0 deletions libs/core/lint-plugins/stylelint-plugin-pine-motion.cjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
/**
* 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`). The leading
// negative lookbehind prevents matching time-like substrings inside
// identifiers / custom-property names (e.g. `var(--app-delay-200ms)`).
const TIME_REGEX = /(?<![\w-])(\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) {
// Record position + length so the fix is applied by index (not by a
// first-match string replace, which could rewrite the wrong
// occurrence in a multi-segment shorthand).
replacements.push({ index: match.index, length: raw.length, replacement: tokenVar });
continue;
}

stylelint.utils.report({
ruleName,
result,
node: decl,
message: messages.rejected(message),
word: raw,
});
}

if (context && context.fix && replacements.length > 0) {
// Apply right-to-left so earlier indices stay valid as the string length changes.
let newValue = value;
for (const { index, length, replacement } of replacements.reverse()) {
newValue = newValue.slice(0, index) + replacement + newValue.slice(index + length);
}
decl.value = newValue;
}
});
};
});

module.exports.ruleName = ruleName;
module.exports.messages = messages;
Original file line number Diff line number Diff line change
Expand Up @@ -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"] {
Expand Down
2 changes: 2 additions & 0 deletions libs/core/src/components/pds-combobox/pds-combobox.scss
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -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;

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions libs/core/src/components/pds-table/pds-table.scss
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
Loading