# core/stylelint/no-held-entrance-fill.stylelint.rule.ts

> 157 lines of code and 43 definitions.

Tree: GovLab Quality
Language: typescript
Canonical: https://banes-lab.com/anatomy/quality#file-quality-core-stylelint-no-held-entrance-fill-stylelint-rule-ts
Source text: https://banes-lab.com/source/quality/core/stylelint/no-held-entrance-fill.stylelint.rule.ts.txt

Listed in [core/stylelint](https://banes-lab.com/api/source/quality/core/stylelint.md), after [core/stylelint/no-custom-property-redefinition.stylelint.rule.ts](https://banes-lab.com/source/quality/core/stylelint/no-custom-property-redefinition.stylelint.rule.ts.md) and before [core/stylelint/no-large-viewport-unit.stylelint.rule.ts](https://banes-lab.com/source/quality/core/stylelint/no-large-viewport-unit.stylelint.rule.ts.md).

## Definitions

- `opaqueEndingsIn` (lexical_declaration, line 61)
- `endsOpaque` (lexical_declaration, line 48)
- `layersOf` (lexical_declaration, line 108)
- `isFinalFrame` (lexical_declaration, line 44)
- `layerBreak` (lexical_declaration, line 120)
- `opaqueEndingsOf` (lexical_declaration, line 91)
- `heldNames` (lexical_declaration, line 139)
- `{ createPlugin, utils }` (lexical_declaration, line 9)
- `ruleName` (lexical_declaration, line 10)
- `ruleId` (lexical_declaration, line 11)
- `RULE_META` (lexical_declaration, line 13, exported)
- `KEYFRAMES` (lexical_declaration, line 23)
- `FINAL_FRAMES` (lexical_declaration, line 24)
- `OPACITY` (lexical_declaration, line 25)
- `OPAQUE` (lexical_declaration, line 26)
- `HOLDING_FILLS` (lexical_declaration, line 27)
- `SHORTHAND` (lexical_declaration, line 28)
- `NAME_LONGHAND` (lexical_declaration, line 29)
- `FILL_LONGHAND` (lexical_declaration, line 30)
- `LAYER_SEPARATOR` (lexical_declaration, line 31)
- `PACKAGE_MANIFEST` (lexical_declaration, line 32)
- `STYLESHEET_EXTENSION` (lexical_declaration, line 33)
- `DEPENDENCY_FOLDER` (lexical_declaration, line 34)
- `messages` (lexical_declaration, line 36)
- `memberRootOf` (lexical_declaration, line 71)
- `folder` (lexical_declaration, line 72)
- `stylesheetsUnder` (lexical_declaration, line 82)
- `memberEndings` (lexical_declaration, line 89)
- `file` (lexical_declaration, line 92)
- `member` (lexical_declaration, line 93)
- `cached` (lexical_declaration, line 97)
- `layers` (lexical_declaration, line 109)
- `current` (lexical_declaration, line 112)
- `heldIn` (lexical_declaration, line 124)
- `holds` (lexical_declaration, line 125)
- `siblingValue` (lexical_declaration, line 129)
- `found` (lexical_declaration, line 130)
- `prop` (lexical_declaration, line 140)
- `names` (lexical_declaration, line 150)
- `words` (lexical_declaration, line 151)
- `name` (lexical_declaration, line 152)
- `rule` (lexical_declaration, line 156)
- `opaque` (lexical_declaration, line 162)

## Contained in

- [core/stylelint](https://banes-lab.com/anatomy/quality/folder-quality-core-stylelint.md)

## Linked from

- [core/stylelint](https://banes-lab.com/anatomy/quality/folder-quality-core-stylelint.md)

## Source

```typescript
import { dirname, join, sep } from "node:path";
import { existsSync, readFileSync, readdirSync } from "node:fs";
import postcss, { type AtRule, type Rule as CssRule, type Declaration, type Root } from "postcss";
import stylelint, { type PostcssResult, type Rule } from "stylelint";
import type { GovlabStylelintMeta } from "#types/stylelint.types";
import valueParser, { type Node } from "postcss-value-parser";
import { withRuleId } from "#core/formatters/stylelint.formatter";

const { createPlugin, utils } = stylelint;
const ruleName = "govlab/no-held-entrance-fill";
const ruleId = "no_held_entrance_fill";

export const RULE_META: GovlabStylelintMeta = {
    meta: {
        canonical: ["css-architecture"],
        description: "Forbid an animation that keeps holding a fully opaque final keyframe after it ends",
        fixable: false,
    },
    ruleId,
    ruleName,
};

const KEYFRAMES = "keyframes";
const FINAL_FRAMES = new Set(["to", "100%"]);
const OPACITY = "opacity";
const OPAQUE = new Set(["1", "100%"]);
const HOLDING_FILLS = new Set(["forwards", "both"]);
const SHORTHAND = "animation";
const NAME_LONGHAND = "animation-name";
const FILL_LONGHAND = "animation-fill-mode";
const LAYER_SEPARATOR = ",";
const PACKAGE_MANIFEST = "package.json";
const STYLESHEET_EXTENSION = ".css";
const DEPENDENCY_FOLDER = "node_modules";

const messages = utils.ruleMessages(ruleName, {
    heldFill: (name: string): string =>
        withRuleId(
            `The animation '${name}' ends fully opaque and its fill keeps holding that end state after it finishes, so the element stays a composited layer and becomes the backdrop root of every child. A child's backdrop-filter then blurs nothing. Put the end state in the element's own rule and fill the animation backwards.`,
            ruleId,
        ),
});

const isFinalFrame = function isFinalFrame(frame: CssRule): boolean {
    return frame.selector.split(LAYER_SEPARATOR).some((part) => FINAL_FRAMES.has(part.trim().toLowerCase()));
};

const endsOpaque = function endsOpaque(keyframes: AtRule): boolean {
    let opaque = false;
    keyframes.walkRules((frame) => {
        if (!isFinalFrame(frame)) {
            return;
        }
        frame.walkDecls(OPACITY, (decl) => {
            opaque = OPAQUE.has(decl.value.trim());
        });
    });
    return opaque;
};

const opaqueEndingsIn = function opaqueEndingsIn(root: Root): readonly string[] {
    const names: string[] = [];
    root.walkAtRules((atRule) => {
        if (atRule.name.toLowerCase().endsWith(KEYFRAMES) && endsOpaque(atRule)) {
            names.push(atRule.params.trim());
        }
    });
    return names;
};

const memberRootOf = function memberRootOf(file: string): string | null {
    for (let folder = dirname(file); ; folder = dirname(folder)) {
        if (existsSync(join(folder, PACKAGE_MANIFEST))) {
            return folder;
        }
        if (dirname(folder) === folder) {
            return null;
        }
    }
};

const stylesheetsUnder = function stylesheetsUnder(folder: string): readonly string[] {
    return readdirSync(folder, { recursive: true, withFileTypes: true })
        .filter((entry) => entry.isFile() && entry.name.endsWith(STYLESHEET_EXTENSION))
        .map((entry) => join(entry.parentPath, entry.name))
        .filter((path) => !path.split(sep).includes(DEPENDENCY_FOLDER));
};

const memberEndings = new Map<string, ReadonlySet<string>>();

const opaqueEndingsOf = function opaqueEndingsOf(root: Root): ReadonlySet<string> {
    const file = root.source?.input.file;
    const member = file === undefined ? null : memberRootOf(file);
    if (member === null) {
        return new Set(opaqueEndingsIn(root));
    }
    const cached = memberEndings.get(member);
    if (cached !== undefined) {
        return new Set([...cached, ...opaqueEndingsIn(root)]);
    }
    const names = new Set(
        stylesheetsUnder(member).flatMap((path) => opaqueEndingsIn(postcss.parse(readFileSync(path, "utf8")))),
    );
    memberEndings.set(member, names);
    return new Set([...names, ...opaqueEndingsIn(root)]);
};

const layersOf = function layersOf(value: string): readonly (readonly string[])[] {
    const layers: string[][] = [[]];
    for (const node of valueParser(value).nodes) {
        layers.push(...layerBreak(node));
        const current = layers.at(-1);
        if (node.type === "word" && current !== undefined) {
            current.push(node.value);
        }
    }
    return layers;
};

const layerBreak = function layerBreak(node: Node): string[][] {
    return node.type === "div" && node.value === LAYER_SEPARATOR ? [[]] : [];
};

const heldIn = function heldIn(words: readonly string[], opaque: ReadonlySet<string>): string | null {
    const holds = words.some((word) => HOLDING_FILLS.has(word.toLowerCase()));
    return holds ? (words.find((word) => opaque.has(word)) ?? null) : null;
};

const siblingValue = function siblingValue(decl: Declaration, prop: string): string | null {
    let found: string | null = null;
    decl.parent?.each((sibling) => {
        if (sibling.type === "decl" && sibling.prop.toLowerCase() === prop) {
            found = sibling.value;
        }
    });
    return found;
};

const heldNames = function heldNames(decl: Declaration, opaque: ReadonlySet<string>): readonly string[] {
    const prop = decl.prop.toLowerCase();
    if (prop === SHORTHAND) {
        return layersOf(decl.value).flatMap((words) => {
            const name = heldIn(words, opaque);
            return name === null ? [] : [name];
        });
    }
    if (prop !== FILL_LONGHAND) {
        return [];
    }
    const names = siblingValue(decl, NAME_LONGHAND);
    const words = [...layersOf(decl.value).flat(), ...(names === null ? [] : layersOf(names).flat())];
    const name = heldIn(words, opaque);
    return name === null ? [] : [name];
};

const rule: Rule = Object.assign(
    (primary: unknown) =>
        (root: Root, result: PostcssResult): void => {
            if (primary !== true) {
                return;
            }
            const opaque = opaqueEndingsOf(root);
            if (opaque.size === 0) {
                return;
            }
            root.walkDecls((decl: Declaration) => {
                for (const name of heldNames(decl, opaque)) {
                    utils.report({ message: messages.heldFill(name), node: decl, result, ruleName, word: name });
                }
            });
        },
    { messages, ruleName },
);

export default createPlugin(ruleName, rule);
```
