@ekanos/sdk 0.5.0 → 0.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +99 -13
- package/api-report.md +51 -6
- package/dist/components/index.d.ts +3 -3
- package/dist/components/index.js +2 -2
- package/dist/components/index.js.map +1 -1
- package/dist/components/widgets/widget-context.d.ts +17 -0
- package/dist/components/widgets/widget-context.js.map +1 -1
- package/dist/components/widgets/widget-preview-provider.d.ts +4 -2
- package/dist/components/widgets/widget-preview-provider.js +3 -1
- package/dist/components/widgets/widget-preview-provider.js.map +1 -1
- package/dist/components/widgets/widget-state-components.d.ts +59 -3
- package/dist/components/widgets/widget-state-components.js +65 -7
- package/dist/components/widgets/widget-state-components.js.map +1 -1
- package/dist/components/widgets/widget.d.ts +124 -21
- package/dist/components/widgets/widget.js +159 -37
- package/dist/components/widgets/widget.js.map +1 -1
- package/dist/hooks/index.d.ts +2 -2
- package/dist/hooks/index.js +1 -1
- package/dist/hooks/index.js.map +1 -1
- package/dist/hooks/partner-oauth.js +1 -0
- package/dist/hooks/partner-oauth.js.map +1 -1
- package/dist/hooks/use-oauth-connection-status.d.ts +17 -1
- package/dist/hooks/use-oauth-connection-status.js +12 -17
- package/dist/hooks/use-oauth-connection-status.js.map +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.js.map +1 -1
- package/dist/integration/actor-role.d.ts +14 -0
- package/dist/integration/actor-role.js +21 -0
- package/dist/integration/actor-role.js.map +1 -0
- package/dist/integration/index.d.ts +3 -1
- package/dist/integration/index.js +4 -0
- package/dist/integration/index.js.map +1 -1
- package/dist/integration/types.d.ts +1 -1
- package/dist/integration/types.js.map +1 -1
- package/dist/integration/untrusted-text.d.ts +24 -0
- package/dist/integration/untrusted-text.js +36 -0
- package/dist/integration/untrusted-text.js.map +1 -0
- package/dist/layout/index.d.ts +8 -0
- package/dist/layout/index.js +9 -0
- package/dist/layout/index.js.map +1 -0
- package/dist/layout/widget-layout.d.ts +84 -0
- package/dist/layout/widget-layout.js +138 -0
- package/dist/layout/widget-layout.js.map +1 -0
- package/dist/testing/index.d.ts +4 -0
- package/dist/testing/index.js +5 -0
- package/dist/testing/index.js.map +1 -1
- package/dist/testing/mock-context.js +3 -1
- package/dist/testing/mock-context.js.map +1 -1
- package/dist/testing/verify-signature.d.ts +26 -0
- package/dist/testing/verify-signature.js +45 -0
- package/dist/testing/verify-signature.js.map +1 -0
- package/dist/types/index.d.ts +1 -0
- package/dist/types/index.js.map +1 -1
- package/dist/types/integration.d.ts +5 -0
- package/dist/types/integration.js.map +1 -1
- package/dist/types/widget-verdict.d.ts +6 -0
- package/dist/types/widget-verdict.js +2 -0
- package/dist/types/widget-verdict.js.map +1 -0
- package/eslint.design-preset.mjs +226 -0
- package/eslint.design-rules.mjs +437 -0
- package/package.json +16 -6
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @ekanos/sdk/eslint/design — the partner STYLE preset.
|
|
3
|
+
*
|
|
4
|
+
* This is a style preset, and it is deliberately a SEPARATE export from
|
|
5
|
+
* `@ekanos/sdk/eslint`. That one is a security control: it bans the escapes
|
|
6
|
+
* that route around the capability layer, it sets `noInlineConfig` so partner
|
|
7
|
+
* source cannot silence it, and every rule in it is core ESLint so it adds
|
|
8
|
+
* nothing to the SDK's build. Mixing a style rule into that file would make a
|
|
9
|
+
* load-bearing control look negotiable, and a partner who disabled the preset
|
|
10
|
+
* over a `<button>` complaint would have disabled the control with it.
|
|
11
|
+
*
|
|
12
|
+
* So the two ship apart, and a partner may adopt this one and not the other
|
|
13
|
+
* (never the reverse — the capability preset is not optional for a
|
|
14
|
+
* submission).
|
|
15
|
+
*
|
|
16
|
+
* ── What it enforces ─────────────────────────────────────────────────────
|
|
17
|
+
* Nine rules. One is local to this file: no raw interactive HTML element
|
|
18
|
+
* where `@ekanos/ui` publishes the host's component. A hand-rolled `<button>`
|
|
19
|
+
* is not a styling nit — it misses the kit's focus ring, its disabled and
|
|
20
|
+
* loading states, its size scale, and every per-Source retint that reaches a
|
|
21
|
+
* widget through the component rather than through a class. The same goes for
|
|
22
|
+
* inputs, selects, textareas and labels, each of which carries accessibility
|
|
23
|
+
* wiring a bare element does not.
|
|
24
|
+
*
|
|
25
|
+
* The other eight are the host's own `design/*` rules — palette-adjacent
|
|
26
|
+
* white/black utilities, Badge tone variants, overlay widths, rounded row
|
|
27
|
+
* hovers, faded muted text, viewport breakpoints in widgets, tinted chips,
|
|
28
|
+
* toolbar tabs — imported from `./eslint.design-rules.mjs`, the single
|
|
29
|
+
* implementation the host repo's oxlint plugin also loads (through the
|
|
30
|
+
* two-line re-export the host's lint config keeps). There is
|
|
31
|
+
* exactly one copy of each rule, so a partner is held to what the host holds
|
|
32
|
+
* itself to and the two cannot drift.
|
|
33
|
+
*
|
|
34
|
+
* `ekanos validate` still reports its own `design/*` findings from a
|
|
35
|
+
* parser-less text scan (`@ekanos/integration-schema`'s
|
|
36
|
+
* `collectDesignSourceFindings`). That is a different mechanism for a
|
|
37
|
+
* different place — the admission gate scans a submitted archive with no
|
|
38
|
+
* ESLint and no AST — not a second copy of these rules.
|
|
39
|
+
*
|
|
40
|
+
* ── Severity: warn, everywhere ───────────────────────────────────────────
|
|
41
|
+
* Every rule here reports `warn`, never `error`, for the same reason
|
|
42
|
+
* `collectDesignSourceFindings` does: off-system UI is a cohesion defect a
|
|
43
|
+
* reviewer weighs, not an unbuildable submission. The scaffold wires this
|
|
44
|
+
* preset up on day one, and a lint that fails a new partner's very first run
|
|
45
|
+
* teaches them to delete the config. The host lints itself at `error` — see
|
|
46
|
+
* `.oxlintrc.json` — which is the asymmetry we want.
|
|
47
|
+
*
|
|
48
|
+
* ── Dependency-free, like its sibling ────────────────────────────────────
|
|
49
|
+
* `react/forbid-elements` would need `eslint-plugin-react`, a runtime
|
|
50
|
+
* dependency this package does not have and should not grow for a lint
|
|
51
|
+
* preset. The rule below is the same check, the same option shape
|
|
52
|
+
* (`{ forbid: [{ element, message }] }`), implemented in ~20 lines against
|
|
53
|
+
* ESLint's own JSX AST.
|
|
54
|
+
*
|
|
55
|
+
* ── Usage (a partner's eslint.config.mjs) ────────────────────────────────
|
|
56
|
+
*
|
|
57
|
+
* import capabilityPreset from '@ekanos/sdk/eslint';
|
|
58
|
+
* import designPreset from '@ekanos/sdk/eslint/design';
|
|
59
|
+
*
|
|
60
|
+
* export default [
|
|
61
|
+
* ...someBaseConfig,
|
|
62
|
+
* ...capabilityPreset,
|
|
63
|
+
* ...designPreset,
|
|
64
|
+
* ];
|
|
65
|
+
*
|
|
66
|
+
* Inline disables DO work here, unlike the capability preset: a style rule
|
|
67
|
+
* with a real exception should be disabled at the line that needs it, where a
|
|
68
|
+
* reviewer reads the reason, rather than argued about.
|
|
69
|
+
*/
|
|
70
|
+
import designRulesPlugin from './eslint.design-rules.mjs';
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* `.ts` as well as `.tsx`. The JSX-shaped rules find nothing in a `.ts`
|
|
74
|
+
* module and cost nothing there, but the class-string rules must see it: a
|
|
75
|
+
* `.ts` module can build UI with `createElement` and a `className` string,
|
|
76
|
+
* which the starter scaffold itself does.
|
|
77
|
+
*/
|
|
78
|
+
const COMPONENT_GLOB = ['src/**/*.ts', 'src/**/*.tsx'];
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* The raw elements `@ekanos/ui` replaces, each with the module a partner
|
|
82
|
+
* imports instead. Kept equal to the host repo's own
|
|
83
|
+
* `react/forbid-elements` list for the example integrations by a parity test
|
|
84
|
+
* in `src/context/__tests__/design-preset-oxlint-parity.test.ts`.
|
|
85
|
+
*/
|
|
86
|
+
export const FORBIDDEN_ELEMENTS = [
|
|
87
|
+
{
|
|
88
|
+
element: 'button',
|
|
89
|
+
message:
|
|
90
|
+
'Use <Button> from @ekanos/ui/button. A bare <button> misses the focus ' +
|
|
91
|
+
'ring, the disabled and loading states, the size scale, and the ' +
|
|
92
|
+
'per-Source retint that only reaches a widget through the component.',
|
|
93
|
+
},
|
|
94
|
+
{
|
|
95
|
+
element: 'input',
|
|
96
|
+
message:
|
|
97
|
+
'Use <Input> from @ekanos/ui/input, inside a <FormField> from ' +
|
|
98
|
+
'@ekanos/ui/form when it is part of a form.',
|
|
99
|
+
},
|
|
100
|
+
{
|
|
101
|
+
element: 'select',
|
|
102
|
+
message: 'Use <Select> from @ekanos/ui/select.',
|
|
103
|
+
},
|
|
104
|
+
{
|
|
105
|
+
element: 'textarea',
|
|
106
|
+
message: 'Use <Textarea> from @ekanos/ui/textarea.',
|
|
107
|
+
},
|
|
108
|
+
{
|
|
109
|
+
element: 'label',
|
|
110
|
+
message:
|
|
111
|
+
'Use <Label> from @ekanos/ui/label, which wires itself to the control ' +
|
|
112
|
+
'it names.',
|
|
113
|
+
},
|
|
114
|
+
{
|
|
115
|
+
element: 'table',
|
|
116
|
+
message:
|
|
117
|
+
'Use <Table> from @ekanos/ui/table only for genuinely tabular data. ' +
|
|
118
|
+
'For a list, prefer edge-to-edge rows: a container with no horizontal ' +
|
|
119
|
+
'padding, each row `px-5 py-3` with a flat `hover:bg-accent/50`, ' +
|
|
120
|
+
'separated by <Separator> from @ekanos/ui/separator. See ' +
|
|
121
|
+
"the design system's widget rules.",
|
|
122
|
+
},
|
|
123
|
+
];
|
|
124
|
+
|
|
125
|
+
const FORBIDDEN_BY_ELEMENT = new Map(
|
|
126
|
+
FORBIDDEN_ELEMENTS.map((entry) => [entry.element, entry.message]),
|
|
127
|
+
);
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* ESLint's own `react/forbid-elements`, minus the plugin. A JSX element whose
|
|
131
|
+
* name is a lowercase HTML tag is a `JSXIdentifier`; a component reference is
|
|
132
|
+
* capitalised or a member expression, so neither can collide with this list.
|
|
133
|
+
*/
|
|
134
|
+
const designPlugin = {
|
|
135
|
+
meta: { name: 'ekanos-design' },
|
|
136
|
+
rules: {
|
|
137
|
+
'forbid-elements': {
|
|
138
|
+
meta: {
|
|
139
|
+
type: 'problem',
|
|
140
|
+
docs: {
|
|
141
|
+
description:
|
|
142
|
+
'Disallow raw interactive HTML elements that @ekanos/ui publishes a component for.',
|
|
143
|
+
},
|
|
144
|
+
schema: [
|
|
145
|
+
{
|
|
146
|
+
type: 'object',
|
|
147
|
+
properties: {
|
|
148
|
+
forbid: {
|
|
149
|
+
type: 'array',
|
|
150
|
+
items: {
|
|
151
|
+
type: 'object',
|
|
152
|
+
properties: {
|
|
153
|
+
element: { type: 'string' },
|
|
154
|
+
message: { type: 'string' },
|
|
155
|
+
},
|
|
156
|
+
required: ['element'],
|
|
157
|
+
additionalProperties: false,
|
|
158
|
+
},
|
|
159
|
+
},
|
|
160
|
+
},
|
|
161
|
+
additionalProperties: false,
|
|
162
|
+
},
|
|
163
|
+
],
|
|
164
|
+
},
|
|
165
|
+
/**
|
|
166
|
+
* Typed in JSDoc because a test imports this module, which pulls it
|
|
167
|
+
* into the package's TypeScript program (`eslint.preset.mjs` needs none
|
|
168
|
+
* — it declares no callbacks of its own).
|
|
169
|
+
*
|
|
170
|
+
* @param {{
|
|
171
|
+
* options?: Array<{ forbid?: Array<{ element: string; message?: string }> }>;
|
|
172
|
+
* report: (descriptor: { node: unknown; message: string }) => void;
|
|
173
|
+
* }} context
|
|
174
|
+
*/
|
|
175
|
+
create(context) {
|
|
176
|
+
const configured = context.options?.[0]?.forbid;
|
|
177
|
+
const forbidden = Array.isArray(configured)
|
|
178
|
+
? new Map(
|
|
179
|
+
configured.map((entry) => [
|
|
180
|
+
entry.element,
|
|
181
|
+
entry.message ?? `<${entry.element}> is forbidden.`,
|
|
182
|
+
]),
|
|
183
|
+
)
|
|
184
|
+
: FORBIDDEN_BY_ELEMENT;
|
|
185
|
+
|
|
186
|
+
return {
|
|
187
|
+
/** @param {{ name?: { type: string; name: string } }} node */
|
|
188
|
+
JSXOpeningElement(node) {
|
|
189
|
+
if (node.name?.type !== 'JSXIdentifier') return;
|
|
190
|
+
|
|
191
|
+
const message = forbidden.get(node.name.name);
|
|
192
|
+
if (message) context.report({ node, message });
|
|
193
|
+
},
|
|
194
|
+
};
|
|
195
|
+
},
|
|
196
|
+
},
|
|
197
|
+
},
|
|
198
|
+
};
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* Every rule the shared plugin publishes, at `warn`. Derived from the plugin
|
|
202
|
+
* rather than listed, so a rule added to `eslint.design-rules.mjs` for the
|
|
203
|
+
* host reaches partners in the same commit — the drift this preset's whole
|
|
204
|
+
* arrangement exists to prevent.
|
|
205
|
+
*/
|
|
206
|
+
const sharedDesignRules = Object.fromEntries(
|
|
207
|
+
Object.keys(designRulesPlugin.rules).map((rule) => [
|
|
208
|
+
`${designRulesPlugin.meta.name}/${rule}`,
|
|
209
|
+
'warn',
|
|
210
|
+
]),
|
|
211
|
+
);
|
|
212
|
+
|
|
213
|
+
export default [
|
|
214
|
+
{
|
|
215
|
+
name: '@ekanos/sdk/eslint/design',
|
|
216
|
+
files: COMPONENT_GLOB,
|
|
217
|
+
plugins: {
|
|
218
|
+
'ekanos-design': designPlugin,
|
|
219
|
+
[designRulesPlugin.meta.name]: designRulesPlugin,
|
|
220
|
+
},
|
|
221
|
+
rules: {
|
|
222
|
+
...sharedDesignRules,
|
|
223
|
+
'ekanos-design/forbid-elements': ['warn', { forbid: FORBIDDEN_ELEMENTS }],
|
|
224
|
+
},
|
|
225
|
+
},
|
|
226
|
+
];
|
|
@@ -0,0 +1,437 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The design-system rules the shadcn linter does not cover — eight of them,
|
|
3
|
+
* as one lint plugin object. The rules and their reasoning live in the design system's
|
|
4
|
+
* rules; each message names the rule.
|
|
5
|
+
*
|
|
6
|
+
* ── Why this file sits in a published package ────────────────────────────
|
|
7
|
+
* Two consumers need the same eight rules and neither may fork them:
|
|
8
|
+
*
|
|
9
|
+
* - the host repo's own lint, as an oxlint JS plugin. `.oxlintrc.json`
|
|
10
|
+
* still loads this plugin through a
|
|
11
|
+
* two-line re-export of this file, so the plugin name, the rule names
|
|
12
|
+
* and their severities are untouched.
|
|
13
|
+
* - a partner's own lint, as an ESLint plugin, through
|
|
14
|
+
* `@ekanos/sdk/eslint/design` (`eslint.design-preset.mjs`, its sibling
|
|
15
|
+
* here), which a scaffolded project wires up by default.
|
|
16
|
+
*
|
|
17
|
+
* The host's tooling is not published, so the import can only run this
|
|
18
|
+
* direction: the rules live in the package a partner installs and the host
|
|
19
|
+
* repo's tooling reaches in. The host's capability lint already reads its
|
|
20
|
+
* preset out of `eslint.preset.mjs` the same way, only for the plugin rather
|
|
21
|
+
* than the config.
|
|
22
|
+
*
|
|
23
|
+
* ── What it may depend on ────────────────────────────────────────────────
|
|
24
|
+
* Nothing. Every rule below is plain JS over the ESTree/JSX AST with
|
|
25
|
+
* `context.report`, no oxlint-only API and no import of any kind, which is
|
|
26
|
+
* what lets one implementation satisfy both linters. Keep it that way: a
|
|
27
|
+
* dependency here would either break oxlint's plugin loader or grow the
|
|
28
|
+
* SDK's install for a lint plugin.
|
|
29
|
+
*/
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* The AST these rules read, typed in JSDoc because `eslint.design-preset.mjs`
|
|
33
|
+
* imports this file and that pulls it into the package's TypeScript program.
|
|
34
|
+
* Deliberately loose: a lint rule is handed whatever the parser produced and
|
|
35
|
+
* narrows on `type` itself, so one node type stands in for several families
|
|
36
|
+
* and every field is optional.
|
|
37
|
+
*
|
|
38
|
+
* @typedef {{ type?: string, name?: string }} AstName
|
|
39
|
+
*
|
|
40
|
+
* @typedef {object} Expression
|
|
41
|
+
* @property {string} [type]
|
|
42
|
+
* @property {string | number | boolean | null} [value]
|
|
43
|
+
* @property {Expression} [expression]
|
|
44
|
+
* @property {Expression[]} [arguments]
|
|
45
|
+
* @property {TemplateChunk[]} [quasis]
|
|
46
|
+
* @property {Expression} [left]
|
|
47
|
+
* @property {Expression} [right]
|
|
48
|
+
* @property {Expression} [consequent]
|
|
49
|
+
* @property {Expression} [alternate]
|
|
50
|
+
*
|
|
51
|
+
* @typedef {{ value: { cooked?: string | null } }} TemplateChunk
|
|
52
|
+
* @typedef {{ value?: string | number | boolean | null }} StringLiteral
|
|
53
|
+
*
|
|
54
|
+
* @typedef {object} JsxAttribute
|
|
55
|
+
* @property {string} [type]
|
|
56
|
+
* @property {AstName} [name]
|
|
57
|
+
* @property {Expression | null} [value]
|
|
58
|
+
*
|
|
59
|
+
* @typedef {object} JsxElement
|
|
60
|
+
* @property {AstName} [name]
|
|
61
|
+
* @property {JsxAttribute[]} [attributes]
|
|
62
|
+
* @property {JsxElement} [openingElement]
|
|
63
|
+
* @property {JsxElement} [parent]
|
|
64
|
+
* @property {string} [type]
|
|
65
|
+
*
|
|
66
|
+
* @typedef {{ report: (descriptor: { node: unknown, message: string }) => void }} RuleContext
|
|
67
|
+
*/
|
|
68
|
+
|
|
69
|
+
const FADED_MUTED = /(?:^|[\s:'"`])(?:[\w-]+:)*text-muted-foreground\/\d+/;
|
|
70
|
+
|
|
71
|
+
/** `bg-white`, `text-black/50`, `dark:border-white`… — @shadcn/lint's
|
|
72
|
+
* no-raw-colors covers the palette scales but not these two. */
|
|
73
|
+
const ABSOLUTE_COLOR =
|
|
74
|
+
/(?:^|[\s'"`])(?:[\w-]+:)*(?:bg|text|border|ring|outline|divide|fill|stroke|from|via|to|decoration|placeholder|caret|accent|shadow)-(?:white|black)(?:\/\d+)?(?=$|[\s'"`])/;
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Visits every string and template chunk, so class names in `cn()`, cva
|
|
78
|
+
* maps and plain constants are all checked, not only `className=`.
|
|
79
|
+
*
|
|
80
|
+
* @param {RegExp} test
|
|
81
|
+
* @param {string} message
|
|
82
|
+
*/
|
|
83
|
+
function stringVisitors(test, message) {
|
|
84
|
+
/** @param {RuleContext} context */
|
|
85
|
+
return (context) => {
|
|
86
|
+
/** @param {unknown} node @param {unknown} value */
|
|
87
|
+
const check = (node, value) => {
|
|
88
|
+
if (typeof value === 'string' && test.test(value)) {
|
|
89
|
+
context.report({ node, message });
|
|
90
|
+
}
|
|
91
|
+
};
|
|
92
|
+
|
|
93
|
+
return {
|
|
94
|
+
/** @param {StringLiteral} node */
|
|
95
|
+
Literal: (node) => check(node, node.value),
|
|
96
|
+
/** @param {TemplateChunk} node */
|
|
97
|
+
TemplateElement: (node) => check(node, node.value.cooked),
|
|
98
|
+
};
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
const BADGE_TONES = new Set(['success', 'warning', 'info']);
|
|
103
|
+
|
|
104
|
+
const TONE_BADGE_MESSAGE =
|
|
105
|
+
'A tone is not a Badge variant. A record\u2019s state is <StatusBadge tone="…"> from @kit/ui/status-badge, whose dot carries the tone so meaning never rides on hue alone; Badge is for counts, categories and short labels (design-system status-badge rule).';
|
|
106
|
+
|
|
107
|
+
/** @param {Expression | null | undefined} value */
|
|
108
|
+
function literalValue(value) {
|
|
109
|
+
if (!value) return null;
|
|
110
|
+
if (value.type === 'Literal') return value.value;
|
|
111
|
+
if (
|
|
112
|
+
value.type === 'JSXExpressionContainer' &&
|
|
113
|
+
value.expression?.type === 'Literal'
|
|
114
|
+
) {
|
|
115
|
+
return value.expression.value;
|
|
116
|
+
}
|
|
117
|
+
return null;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/** @param {AstName | undefined} name */
|
|
121
|
+
function jsxName(name) {
|
|
122
|
+
return name?.type === 'JSXIdentifier' ? name.name : null;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
const OVERLAY_CONTENT = new Set([
|
|
126
|
+
'DialogContent',
|
|
127
|
+
'SheetContent',
|
|
128
|
+
'AlertDialogContent',
|
|
129
|
+
'DrawerContent',
|
|
130
|
+
]);
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* `max-w-md`, `sm:w-[560px]`… but not the mobile-width `w-full` / `w-auto`.
|
|
134
|
+
*
|
|
135
|
+
* A variant prefix may itself contain brackets (`data-[swipe-direction=right]:`),
|
|
136
|
+
* so the prefix group has to allow them — `[\w-]+:` alone stopped at the `[`
|
|
137
|
+
* and let `data-[swipe-direction=right]:sm:max-w-xl` through on two drawer
|
|
138
|
+
* shells for as long as the rule has existed.
|
|
139
|
+
*/
|
|
140
|
+
const OVERLAY_WIDTH =
|
|
141
|
+
/(?:^|\s)(?:(?:[\w-]+|\[[^\]]*\]|[\w-]+\[[^\]]*\])+:)*(?:max-w|min-w|w)-(?!full\b|auto\b)\S+/;
|
|
142
|
+
|
|
143
|
+
const OVERLAY_WIDTH_MESSAGE =
|
|
144
|
+
'Never set an overlay’s width in className. Dialog, Sheet and Drawer take size="sm|md|lg|xl|2xl|3xl|full" from @kit/ui/overlay-size; AlertDialog takes size="sm" (design-system overlay rule).';
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* Every string literal in a className value, including inside `cn(...)`.
|
|
148
|
+
*
|
|
149
|
+
* @param {Expression | null | undefined} value
|
|
150
|
+
* @returns {StringLiteral[]}
|
|
151
|
+
*/
|
|
152
|
+
function classNameStrings(value) {
|
|
153
|
+
if (!value) return [];
|
|
154
|
+
if (value.type === 'Literal') return [value];
|
|
155
|
+
if (value.type === 'JSXExpressionContainer') {
|
|
156
|
+
return classNameStrings(value.expression);
|
|
157
|
+
}
|
|
158
|
+
if (value.type === 'CallExpression') {
|
|
159
|
+
return (value.arguments ?? []).flatMap(classNameStrings);
|
|
160
|
+
}
|
|
161
|
+
if (value.type === 'TemplateLiteral') {
|
|
162
|
+
return (value.quasis ?? []).map((quasi) => ({
|
|
163
|
+
...quasi,
|
|
164
|
+
value: quasi.value.cooked,
|
|
165
|
+
}));
|
|
166
|
+
}
|
|
167
|
+
if (value.type === 'LogicalExpression' || value.type === 'BinaryExpression') {
|
|
168
|
+
return [...classNameStrings(value.left), ...classNameStrings(value.right)];
|
|
169
|
+
}
|
|
170
|
+
if (value.type === 'ConditionalExpression') {
|
|
171
|
+
return [
|
|
172
|
+
...classNameStrings(value.consequent),
|
|
173
|
+
...classNameStrings(value.alternate),
|
|
174
|
+
];
|
|
175
|
+
}
|
|
176
|
+
return [];
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/** A rounded hover band with side padding: a row drawn as a pill. Square
|
|
180
|
+
* icon buttons (`size-8`, `h-6 w-6`) are a different shape and pass, and so
|
|
181
|
+
* does a bordered or filled tile, which is a card rather than a row. */
|
|
182
|
+
/** @param {string} value */
|
|
183
|
+
function isRoundedRowHover(value) {
|
|
184
|
+
if (/(?:^|\s)border(?=$|\s)/.test(value) || /(?:^|\s)bg-/.test(value)) {
|
|
185
|
+
return false;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
if (
|
|
189
|
+
!/\brounded-(?:md|lg|xl)\b/.test(value) ||
|
|
190
|
+
!/(?:^|\s)hover:bg-/.test(value) ||
|
|
191
|
+
!/(?:^|\s)(?:[\w-]+:)*px-/.test(value) ||
|
|
192
|
+
!/(?:^|\s)(?:w-full|justify-between)(?=$|\s)/.test(value)
|
|
193
|
+
) {
|
|
194
|
+
return false;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
if (/(?:^|\s)(?:[\w-]+:)*size-/.test(value)) return false;
|
|
198
|
+
|
|
199
|
+
const height = /(?:^|\s)h-([\w.[\]]+)(?=$|\s)/.exec(value);
|
|
200
|
+
const width = /(?:^|\s)w-([\w.[\]]+)(?=$|\s)/.exec(value);
|
|
201
|
+
|
|
202
|
+
return !(height && width && height[1] === width[1]);
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
const CHIP_TONES = ['success', 'primary', 'warning', 'destructive', 'info'];
|
|
206
|
+
|
|
207
|
+
const CHIP_SMALL_TEXT = /(?:^|\s)(?:[\w-]+:)*text-(?:xs|sm)(?=$|\s)/;
|
|
208
|
+
|
|
209
|
+
/** Base-tone small text on a tint of the same tone. */
|
|
210
|
+
/** @param {string} value */
|
|
211
|
+
function isWeakTintedChip(value) {
|
|
212
|
+
if (!CHIP_SMALL_TEXT.test(value)) return false;
|
|
213
|
+
|
|
214
|
+
return CHIP_TONES.some(
|
|
215
|
+
(tone) =>
|
|
216
|
+
new RegExp(`(?:^|\\s)(?:[\\w-]+:)*bg-${tone}/\\d+`).test(value) &&
|
|
217
|
+
new RegExp(`(?:^|\\s)(?:[\\w-]+:)*text-${tone}(?![\\w-])`).test(value),
|
|
218
|
+
);
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/**
|
|
222
|
+
* @param {(value: string) => boolean} predicate
|
|
223
|
+
* @param {string} message
|
|
224
|
+
*/
|
|
225
|
+
function predicateVisitors(predicate, message) {
|
|
226
|
+
/** @param {RuleContext} context */
|
|
227
|
+
return (context) => {
|
|
228
|
+
/** @param {unknown} node @param {unknown} value */
|
|
229
|
+
const check = (node, value) => {
|
|
230
|
+
if (typeof value === 'string' && predicate(value)) {
|
|
231
|
+
context.report({ node, message });
|
|
232
|
+
}
|
|
233
|
+
};
|
|
234
|
+
|
|
235
|
+
return {
|
|
236
|
+
/** @param {StringLiteral} node */
|
|
237
|
+
Literal: (node) => check(node, node.value),
|
|
238
|
+
/** @param {TemplateChunk} node */
|
|
239
|
+
TemplateElement: (node) => check(node, node.value.cooked),
|
|
240
|
+
};
|
|
241
|
+
};
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/**
|
|
245
|
+
* A viewport breakpoint variant anywhere in a class token's variant chain:
|
|
246
|
+
* `sm:grid-cols-2`, `hover:md:flex`, `max-lg:hidden`. Container variants
|
|
247
|
+
* (`@sm/widget:`) start with `@` and pass.
|
|
248
|
+
*/
|
|
249
|
+
const VIEWPORT_BREAKPOINT =
|
|
250
|
+
/(?:^|\s)(?:[\w-]+:)*(?:max-)?(?:sm|md|lg|xl|2xl):[^\s:]/;
|
|
251
|
+
|
|
252
|
+
const TOOLBAR_TAB_ELEMENTS = new Set(['TabsList', 'ScopePills', 'ScopeLinks']);
|
|
253
|
+
const TOOLBARS = new Set(['PageToolbar', 'PageToolbarActions']);
|
|
254
|
+
|
|
255
|
+
export default {
|
|
256
|
+
meta: { name: 'design' },
|
|
257
|
+
rules: {
|
|
258
|
+
'no-tone-badge-variant': {
|
|
259
|
+
meta: {
|
|
260
|
+
type: 'problem',
|
|
261
|
+
docs: {
|
|
262
|
+
description:
|
|
263
|
+
'Disallow a status tone as a Badge variant; a record state is StatusBadge.',
|
|
264
|
+
},
|
|
265
|
+
},
|
|
266
|
+
/** @param {RuleContext} context */
|
|
267
|
+
create(context) {
|
|
268
|
+
return {
|
|
269
|
+
/** @param {JsxElement} node */
|
|
270
|
+
JSXOpeningElement(node) {
|
|
271
|
+
if (
|
|
272
|
+
node.name?.type !== 'JSXIdentifier' ||
|
|
273
|
+
node.name.name !== 'Badge'
|
|
274
|
+
) {
|
|
275
|
+
return;
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
for (const attribute of node.attributes ?? []) {
|
|
279
|
+
if (
|
|
280
|
+
attribute.type !== 'JSXAttribute' ||
|
|
281
|
+
attribute.name?.name !== 'variant'
|
|
282
|
+
) {
|
|
283
|
+
continue;
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
const value = literalValue(attribute.value);
|
|
287
|
+
|
|
288
|
+
if (typeof value === 'string' && BADGE_TONES.has(value)) {
|
|
289
|
+
context.report({
|
|
290
|
+
node: attribute,
|
|
291
|
+
message: TONE_BADGE_MESSAGE,
|
|
292
|
+
});
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
},
|
|
296
|
+
};
|
|
297
|
+
},
|
|
298
|
+
},
|
|
299
|
+
'no-faded-muted-text': {
|
|
300
|
+
meta: {
|
|
301
|
+
type: 'problem',
|
|
302
|
+
docs: {
|
|
303
|
+
description:
|
|
304
|
+
'Disallow an opacity modifier on text-muted-foreground; every step below 100 fails contrast somewhere.',
|
|
305
|
+
},
|
|
306
|
+
},
|
|
307
|
+
create: stringVisitors(
|
|
308
|
+
FADED_MUTED,
|
|
309
|
+
'Opacity on text-muted-foreground fails WCAG AA: even /90 measures 4.18:1 on a dark card. Use plain text-muted-foreground, a smaller size if it must recede, or text-border for a decorative separator (design-system opacity-on-muted rule).',
|
|
310
|
+
),
|
|
311
|
+
},
|
|
312
|
+
'no-absolute-colors': {
|
|
313
|
+
meta: {
|
|
314
|
+
type: 'problem',
|
|
315
|
+
docs: {
|
|
316
|
+
description:
|
|
317
|
+
'Disallow white/black color utilities; they ignore the theme, dark mode and Source branding.',
|
|
318
|
+
},
|
|
319
|
+
},
|
|
320
|
+
create: stringVisitors(
|
|
321
|
+
ABSOLUTE_COLOR,
|
|
322
|
+
'white/black color utilities ignore the theme, dark mode and Source branding. Use a token: bg-card or bg-background for surfaces, text-foreground / text-primary-foreground for text. A modal scrim may stay black with a disable comment saying so (design-system theming rule).',
|
|
323
|
+
),
|
|
324
|
+
},
|
|
325
|
+
'no-overlay-width-class': {
|
|
326
|
+
meta: {
|
|
327
|
+
type: 'problem',
|
|
328
|
+
docs: {
|
|
329
|
+
description:
|
|
330
|
+
'Disallow width classes on overlay content; overlays take a size from the overlay-size scale.',
|
|
331
|
+
},
|
|
332
|
+
},
|
|
333
|
+
/** @param {RuleContext} context */
|
|
334
|
+
create(context) {
|
|
335
|
+
return {
|
|
336
|
+
/** @param {JsxElement} node */
|
|
337
|
+
JSXOpeningElement(node) {
|
|
338
|
+
if (!OVERLAY_CONTENT.has(jsxName(node.name) ?? '')) return;
|
|
339
|
+
|
|
340
|
+
for (const attribute of node.attributes ?? []) {
|
|
341
|
+
if (
|
|
342
|
+
attribute.type !== 'JSXAttribute' ||
|
|
343
|
+
attribute.name?.name !== 'className'
|
|
344
|
+
) {
|
|
345
|
+
continue;
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
const flagged = classNameStrings(attribute.value).some(
|
|
349
|
+
(literal) =>
|
|
350
|
+
typeof literal.value === 'string' &&
|
|
351
|
+
OVERLAY_WIDTH.test(literal.value),
|
|
352
|
+
);
|
|
353
|
+
|
|
354
|
+
if (flagged) {
|
|
355
|
+
context.report({
|
|
356
|
+
node: attribute,
|
|
357
|
+
message: OVERLAY_WIDTH_MESSAGE,
|
|
358
|
+
});
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
},
|
|
362
|
+
};
|
|
363
|
+
},
|
|
364
|
+
},
|
|
365
|
+
'no-rounded-row-hover': {
|
|
366
|
+
meta: {
|
|
367
|
+
type: 'problem',
|
|
368
|
+
docs: {
|
|
369
|
+
description:
|
|
370
|
+
'Disallow a rounded hover pill on a padded row; rows are flat edge-to-edge bands.',
|
|
371
|
+
},
|
|
372
|
+
},
|
|
373
|
+
create: predicateVisitors(
|
|
374
|
+
isRoundedRowHover,
|
|
375
|
+
'Rows are flat edge-to-edge bands: padding on the row (px-5 py-3), a flat hover:bg-accent/50, never a rounded pill with side padding (design-system edge-to-edge rows rule).',
|
|
376
|
+
),
|
|
377
|
+
},
|
|
378
|
+
'tinted-chip-strong': {
|
|
379
|
+
meta: {
|
|
380
|
+
type: 'problem',
|
|
381
|
+
docs: {
|
|
382
|
+
description:
|
|
383
|
+
'Require the -strong text step for small text on a tint of its own colour.',
|
|
384
|
+
},
|
|
385
|
+
},
|
|
386
|
+
create: predicateVisitors(
|
|
387
|
+
isWeakTintedChip,
|
|
388
|
+
'Small text on a tint of its own colour takes the -strong step: text-success-strong on bg-success/10, text-primary-strong on bg-primary/10. The base token fails AA once it sits on its own wash; it stays right for the fill and for icons (design-system tinted-chip rule).',
|
|
389
|
+
),
|
|
390
|
+
},
|
|
391
|
+
'no-viewport-breakpoints-in-widgets': {
|
|
392
|
+
meta: {
|
|
393
|
+
type: 'problem',
|
|
394
|
+
docs: {
|
|
395
|
+
description:
|
|
396
|
+
'Disallow viewport breakpoints in widget code; a widget sizes to its own container.',
|
|
397
|
+
},
|
|
398
|
+
},
|
|
399
|
+
create: stringVisitors(
|
|
400
|
+
VIEWPORT_BREAKPOINT,
|
|
401
|
+
'A widget sits in a half-width column, a full row or a tile, whatever the viewport, so sm:/md:/lg: misfire. Size to the card with container queries: Widget.Card is @container/widget, so write @sm/widget:grid-cols-2 (design-system widget rules).',
|
|
402
|
+
),
|
|
403
|
+
},
|
|
404
|
+
'no-toolbar-tabs': {
|
|
405
|
+
meta: {
|
|
406
|
+
type: 'problem',
|
|
407
|
+
docs: {
|
|
408
|
+
description: 'Disallow tabs or scope pills inside a PageToolbar.',
|
|
409
|
+
},
|
|
410
|
+
},
|
|
411
|
+
/** @param {RuleContext} context */
|
|
412
|
+
create(context) {
|
|
413
|
+
return {
|
|
414
|
+
/** @param {JsxElement} node */
|
|
415
|
+
JSXOpeningElement(node) {
|
|
416
|
+
if (!TOOLBAR_TAB_ELEMENTS.has(jsxName(node.name) ?? '')) return;
|
|
417
|
+
|
|
418
|
+
for (let parent = node.parent; parent; parent = parent.parent) {
|
|
419
|
+
if (
|
|
420
|
+
parent.type === 'JSXElement' &&
|
|
421
|
+
parent !== node.parent &&
|
|
422
|
+
TOOLBARS.has(jsxName(parent.openingElement?.name) ?? '')
|
|
423
|
+
) {
|
|
424
|
+
context.report({
|
|
425
|
+
node,
|
|
426
|
+
message:
|
|
427
|
+
'No tabs inside a PageToolbar. View tabs go in PageTitleSection’s `tabs` prop; a filter over one list goes in that card’s ListMasthead (page-layout view-tabs rule).',
|
|
428
|
+
});
|
|
429
|
+
return;
|
|
430
|
+
}
|
|
431
|
+
}
|
|
432
|
+
},
|
|
433
|
+
};
|
|
434
|
+
},
|
|
435
|
+
},
|
|
436
|
+
},
|
|
437
|
+
};
|