@dillingerstaffing/strand-ui 0.1.1 → 0.2.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.
Files changed (33) hide show
  1. package/dist/css/strand-ui.css +239 -76
  2. package/dist/index.d.ts +1 -1
  3. package/package.json +15 -2
  4. package/src/__tests__/build-output.test.ts +123 -0
  5. package/src/__tests__/design-language.test.ts +137 -0
  6. package/src/__tests__/static.test.tsx +60 -0
  7. package/src/components/Alert/Alert.css +11 -3
  8. package/src/components/Badge/Badge.css +1 -1
  9. package/src/components/Breadcrumb/Breadcrumb.css +6 -1
  10. package/src/components/Button/Button.css +15 -8
  11. package/src/components/Card/Card.css +12 -3
  12. package/src/components/Checkbox/Checkbox.css +4 -4
  13. package/src/components/Dialog/Dialog.css +7 -6
  14. package/src/components/FormField/FormField.css +1 -1
  15. package/src/components/Grid/Grid.css +21 -0
  16. package/src/components/Input/Input.css +11 -4
  17. package/src/components/Link/Link.css +6 -0
  18. package/src/components/Nav/Nav.css +13 -3
  19. package/src/components/Progress/Progress.css +3 -3
  20. package/src/components/Radio/Radio.css +4 -4
  21. package/src/components/Select/Select.css +11 -4
  22. package/src/components/Skeleton/Skeleton.css +2 -2
  23. package/src/components/Slider/Slider.css +5 -5
  24. package/src/components/Stack/Stack.css +15 -0
  25. package/src/components/Switch/Switch.css +4 -4
  26. package/src/components/Table/Table.css +6 -1
  27. package/src/components/Tabs/Tabs.css +7 -2
  28. package/src/components/Tag/Tag.css +7 -7
  29. package/src/components/Textarea/Textarea.css +11 -4
  30. package/src/components/Toast/Toast.css +3 -3
  31. package/src/components/Tooltip/Tooltip.css +2 -2
  32. package/src/index.ts +1 -1
  33. package/src/static.css +47 -0
package/dist/index.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- /*! Strand UI v0.1.0 | MIT License | dillingerstaffing.com */
1
+ /*! Strand UI v0.2.0 | MIT License | dillingerstaffing.com */
2
2
  export { Button } from "./components/Button/index.js";
3
3
  export type { ButtonProps } from "./components/Button/index.js";
4
4
  export { Input } from "./components/Input/index.js";
package/package.json CHANGED
@@ -1,9 +1,21 @@
1
1
  {
2
2
  "name": "@dillingerstaffing/strand-ui",
3
- "version": "0.1.1",
3
+ "version": "0.2.0",
4
4
  "description": "Strand UI - Preact/React component library built on the Strand Design Language",
5
5
  "author": "Dillinger Staffing <engineering@dillingerstaffing.com> (https://dillingerstaffing.com)",
6
6
  "license": "MIT",
7
+ "keywords": [
8
+ "design-system",
9
+ "ui-components",
10
+ "preact",
11
+ "react",
12
+ "css-custom-properties",
13
+ "design-tokens",
14
+ "accessibility",
15
+ "wcag",
16
+ "aria",
17
+ "component-library"
18
+ ],
7
19
  "homepage": "https://dillingerstaffing.com/labs/strand",
8
20
  "repository": {
9
21
  "type": "git",
@@ -24,6 +36,7 @@
24
36
  },
25
37
  "./css/strand-ui.css": "./dist/css/strand-ui.css"
26
38
  },
39
+ "style": "./dist/css/strand-ui.css",
27
40
  "files": [
28
41
  "dist/",
29
42
  "src/"
@@ -40,7 +53,7 @@
40
53
  }
41
54
  },
42
55
  "dependencies": {
43
- "@dillingerstaffing/strand": "0.1.1"
56
+ "@dillingerstaffing/strand": "0.2.0"
44
57
  },
45
58
  "devDependencies": {
46
59
  "@testing-library/preact": "^3.2.0",
@@ -74,4 +74,127 @@ describe("Build output", () => {
74
74
  expect(existsSync(resolve(__dirname, "../components/Button/Button.css"))).toBe(true);
75
75
  expect(existsSync(resolve(__dirname, "../components/Dialog/Dialog.tsx"))).toBe(true);
76
76
  });
77
+
78
+ it("CSS has no hardcoded duration values", () => {
79
+ const css = readFileSync(resolve(distDir, "css/strand-ui.css"), "utf-8");
80
+ const withoutComments = css.replace(/\/\*[\s\S]*?\*\//g, "");
81
+ // Strip @keyframes blocks entirely (durations inside keyframes are acceptable)
82
+ const withoutKeyframes = withoutComments.replace(/@keyframes\s+[\w-]+\s*\{[^}]*(?:\{[^}]*\}[^}]*)*\}/g, "");
83
+ // Strip animation shorthand lines (duration is part of the shorthand spec, not standalone)
84
+ const withoutAnimations = withoutKeyframes.replace(/animation:.*$/gm, "");
85
+ // Strip var() references (tokenized durations are fine)
86
+ const withoutVars = withoutAnimations.replace(/var\([^)]+\)/g, "VAR_REF");
87
+
88
+ const durationPattern = /(?<!\w)(150ms|250ms|400ms|700ms|1\.8s|1\.5s|1\.2s|0\.8s)\b/g;
89
+ const matches: string[] = [];
90
+ let match: RegExpExecArray | null;
91
+ while ((match = durationPattern.exec(withoutVars)) !== null) {
92
+ matches.push(match[0]);
93
+ }
94
+
95
+ // Exception: 75ms in transition-duration for active/pressed states (Part XII spec)
96
+ const nonExemptPattern = /(?<!\w)75ms\b/g;
97
+ const lines = withoutVars.split("\n");
98
+ for (const line of lines) {
99
+ if (nonExemptPattern.test(line) && !line.includes("transition-duration")) {
100
+ matches.push("75ms (outside transition-duration)");
101
+ }
102
+ nonExemptPattern.lastIndex = 0;
103
+ }
104
+
105
+ expect(matches, `Hardcoded duration values found: ${matches.join(", ")}`).toEqual([]);
106
+ });
107
+
108
+ it("CSS has no hardcoded easing values", () => {
109
+ const css = readFileSync(resolve(distDir, "css/strand-ui.css"), "utf-8");
110
+ const withoutComments = css.replace(/\/\*[\s\S]*?\*\//g, "");
111
+ // Strip var() references
112
+ const withoutVars = withoutComments.replace(/var\([^)]+\)/g, "VAR_REF");
113
+
114
+ const violations: string[] = [];
115
+
116
+ // Check for raw cubic-bezier()
117
+ const cubicBezierPattern = /cubic-bezier\([^)]+\)/g;
118
+ let match: RegExpExecArray | null;
119
+ while ((match = cubicBezierPattern.exec(withoutVars)) !== null) {
120
+ violations.push(match[0]);
121
+ }
122
+
123
+ // Check for bare easing keywords (not inside var() and not "linear")
124
+ // linear is acceptable for spinners/continuous rotation
125
+ const easingKeywordPattern = /\b(ease-in-out|ease-in|ease-out)\b/g;
126
+ while ((match = easingKeywordPattern.exec(withoutVars)) !== null) {
127
+ violations.push(match[0]);
128
+ }
129
+
130
+ expect(violations, `Hardcoded easing values found: ${violations.join(", ")}`).toEqual([]);
131
+ });
132
+
133
+ it("CSS has no hardcoded border-radius pixel values", () => {
134
+ const css = readFileSync(resolve(distDir, "css/strand-ui.css"), "utf-8");
135
+ const withoutComments = css.replace(/\/\*[\s\S]*?\*\//g, "");
136
+ // Strip var() references
137
+ const withoutVars = withoutComments.replace(/var\([^)]+\)/g, "VAR_REF");
138
+
139
+ // Match border-radius with raw pixel values
140
+ const borderRadiusPattern = /border-radius:\s*\d+px/g;
141
+ const matches: string[] = [];
142
+ let match: RegExpExecArray | null;
143
+ while ((match = borderRadiusPattern.exec(withoutVars)) !== null) {
144
+ matches.push(match[0]);
145
+ }
146
+
147
+ expect(matches, `Hardcoded border-radius values found: ${matches.join(", ")}`).toEqual([]);
148
+ });
149
+
150
+ it("All interactive component CSS files include :focus-visible", () => {
151
+ const interactiveComponents = [
152
+ "Button", "Link", "Card", "Checkbox", "Input", "Radio",
153
+ "Select", "Slider", "Switch", "Tabs", "Breadcrumb", "Nav", "Table",
154
+ ];
155
+
156
+ for (const name of interactiveComponents) {
157
+ const cssPath = resolve(__dirname, `../components/${name}/${name}.css`);
158
+ const css = readFileSync(cssPath, "utf-8");
159
+ // Compound inputs (Input, Select, Textarea) use :focus-within on the wrapper,
160
+ // which is equivalent for components with a visually hidden native input
161
+ const hasFocusHandling = css.includes(":focus-visible") || css.includes(":focus-within");
162
+ expect(hasFocusHandling, `${name}.css missing :focus-visible or :focus-within`).toBe(true);
163
+ }
164
+ });
165
+
166
+ it("All animated component CSS files include prefers-reduced-motion", () => {
167
+ const allComponents = [
168
+ "Alert", "Avatar", "Badge", "Breadcrumb", "Button", "Card",
169
+ "Checkbox", "Container", "DataReadout", "Dialog", "Divider",
170
+ "FormField", "Grid", "Input", "Link", "Nav", "Progress", "Radio",
171
+ "Section", "Select", "Skeleton", "Slider", "Spinner", "Stack",
172
+ "Switch", "Table", "Tabs", "Tag", "Textarea", "Toast", "Tooltip",
173
+ ];
174
+
175
+ for (const name of allComponents) {
176
+ const cssPath = resolve(__dirname, `../components/${name}/${name}.css`);
177
+ const css = readFileSync(cssPath, "utf-8");
178
+ const usesAnimation = /\banimation\b/.test(css) || /\btransition\b/.test(css);
179
+ if (usesAnimation) {
180
+ expect(css, `${name}.css uses animation/transition but missing prefers-reduced-motion`).toContain("prefers-reduced-motion");
181
+ }
182
+ }
183
+ });
184
+
185
+ it("All component CSS files start with MIT license banner", () => {
186
+ const allComponents = [
187
+ "Alert", "Avatar", "Badge", "Breadcrumb", "Button", "Card",
188
+ "Checkbox", "Container", "DataReadout", "Dialog", "Divider",
189
+ "FormField", "Grid", "Input", "Link", "Nav", "Progress", "Radio",
190
+ "Section", "Select", "Skeleton", "Slider", "Spinner", "Stack",
191
+ "Switch", "Table", "Tabs", "Tag", "Textarea", "Toast", "Tooltip",
192
+ ];
193
+
194
+ for (const name of allComponents) {
195
+ const cssPath = resolve(__dirname, `../components/${name}/${name}.css`);
196
+ const css = readFileSync(cssPath, "utf-8");
197
+ expect(css, `${name}.css missing MIT license banner`).toMatch(/^\/\*! Strand/);
198
+ }
199
+ });
77
200
  });
@@ -0,0 +1,137 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { readFileSync } from "node:fs";
3
+ import { resolve } from "node:path";
4
+
5
+ const componentDir = resolve(__dirname, "../components");
6
+
7
+ const allComponents: { name: string; cssPath: string }[] = [
8
+ { name: "Alert", cssPath: resolve(componentDir, "Alert/Alert.css") },
9
+ { name: "Avatar", cssPath: resolve(componentDir, "Avatar/Avatar.css") },
10
+ { name: "Badge", cssPath: resolve(componentDir, "Badge/Badge.css") },
11
+ { name: "Breadcrumb", cssPath: resolve(componentDir, "Breadcrumb/Breadcrumb.css") },
12
+ { name: "Button", cssPath: resolve(componentDir, "Button/Button.css") },
13
+ { name: "Card", cssPath: resolve(componentDir, "Card/Card.css") },
14
+ { name: "Checkbox", cssPath: resolve(componentDir, "Checkbox/Checkbox.css") },
15
+ { name: "Container", cssPath: resolve(componentDir, "Container/Container.css") },
16
+ { name: "DataReadout", cssPath: resolve(componentDir, "DataReadout/DataReadout.css") },
17
+ { name: "Dialog", cssPath: resolve(componentDir, "Dialog/Dialog.css") },
18
+ { name: "Divider", cssPath: resolve(componentDir, "Divider/Divider.css") },
19
+ { name: "FormField", cssPath: resolve(componentDir, "FormField/FormField.css") },
20
+ { name: "Grid", cssPath: resolve(componentDir, "Grid/Grid.css") },
21
+ { name: "Input", cssPath: resolve(componentDir, "Input/Input.css") },
22
+ { name: "Link", cssPath: resolve(componentDir, "Link/Link.css") },
23
+ { name: "Nav", cssPath: resolve(componentDir, "Nav/Nav.css") },
24
+ { name: "Progress", cssPath: resolve(componentDir, "Progress/Progress.css") },
25
+ { name: "Radio", cssPath: resolve(componentDir, "Radio/Radio.css") },
26
+ { name: "Section", cssPath: resolve(componentDir, "Section/Section.css") },
27
+ { name: "Select", cssPath: resolve(componentDir, "Select/Select.css") },
28
+ { name: "Skeleton", cssPath: resolve(componentDir, "Skeleton/Skeleton.css") },
29
+ { name: "Slider", cssPath: resolve(componentDir, "Slider/Slider.css") },
30
+ { name: "Spinner", cssPath: resolve(componentDir, "Spinner/Spinner.css") },
31
+ { name: "Stack", cssPath: resolve(componentDir, "Stack/Stack.css") },
32
+ { name: "Switch", cssPath: resolve(componentDir, "Switch/Switch.css") },
33
+ { name: "Table", cssPath: resolve(componentDir, "Table/Table.css") },
34
+ { name: "Tabs", cssPath: resolve(componentDir, "Tabs/Tabs.css") },
35
+ { name: "Tag", cssPath: resolve(componentDir, "Tag/Tag.css") },
36
+ { name: "Textarea", cssPath: resolve(componentDir, "Textarea/Textarea.css") },
37
+ { name: "Toast", cssPath: resolve(componentDir, "Toast/Toast.css") },
38
+ { name: "Tooltip", cssPath: resolve(componentDir, "Tooltip/Tooltip.css") },
39
+ ];
40
+
41
+ describe("CSS design language compliance", () => {
42
+ it("No component CSS uses raw hex colors except known exceptions", () => {
43
+ // Known exceptions: rgba() for Tag tint backgrounds and Alert status backgrounds
44
+ // (opacity variants without tokens yet)
45
+ const rgbaExemptComponents = ["Tag", "Alert"];
46
+ const violations: string[] = [];
47
+
48
+ for (const { name, cssPath } of allComponents) {
49
+ const css = readFileSync(cssPath, "utf-8");
50
+ const withoutComments = css.replace(/\/\*[\s\S]*?\*\//g, "");
51
+ // Strip var() references
52
+ const withoutVars = withoutComments.replace(/var\([^)]+\)/g, "VAR_REF");
53
+
54
+ // For exempted components, also strip rgba() calls
55
+ let searchable = withoutVars;
56
+ if (rgbaExemptComponents.includes(name)) {
57
+ searchable = searchable.replace(/rgba\([^)]+\)/g, "RGBA_REF");
58
+ }
59
+
60
+ const hexPattern = /#[0-9a-fA-F]{3,8}\b/g;
61
+ let match: RegExpExecArray | null;
62
+ while ((match = hexPattern.exec(searchable)) !== null) {
63
+ violations.push(`${name}: ${match[0]}`);
64
+ }
65
+ }
66
+
67
+ expect(violations, `Raw hex colors found:\n${violations.join("\n")}`).toEqual([]);
68
+ });
69
+
70
+ it("All component CSS uses only --strand- prefixed custom properties", () => {
71
+ const violations: string[] = [];
72
+
73
+ for (const { name, cssPath } of allComponents) {
74
+ const css = readFileSync(cssPath, "utf-8");
75
+ const withoutComments = css.replace(/\/\*[\s\S]*?\*\//g, "");
76
+
77
+ // Find all var() references
78
+ const varPattern = /var\(--([^)]+)\)/g;
79
+ let match: RegExpExecArray | null;
80
+ while ((match = varPattern.exec(withoutComments)) !== null) {
81
+ const propName = match[1];
82
+ if (!propName.startsWith("strand-")) {
83
+ violations.push(`${name}: var(--${propName})`);
84
+ }
85
+ }
86
+ }
87
+
88
+ expect(
89
+ violations,
90
+ `Non --strand- prefixed custom properties found:\n${violations.join("\n")}`,
91
+ ).toEqual([]);
92
+ });
93
+
94
+ it("Interactive components have correct hover transform", () => {
95
+ // Button and Card (interactive) should have translateY(-1px) or translateY(-2px)
96
+ const interactiveSpecs: { name: string; cssPath: string }[] = [
97
+ { name: "Button", cssPath: resolve(componentDir, "Button/Button.css") },
98
+ { name: "Card", cssPath: resolve(componentDir, "Card/Card.css") },
99
+ ];
100
+
101
+ for (const { name, cssPath } of interactiveSpecs) {
102
+ const css = readFileSync(cssPath, "utf-8");
103
+ const hasHoverTransform =
104
+ css.includes("translateY(-1px)") || css.includes("translateY(-2px)");
105
+ expect(
106
+ hasHoverTransform,
107
+ `${name} missing hover translateY(-1px) or translateY(-2px)`,
108
+ ).toBe(true);
109
+ }
110
+ });
111
+
112
+ it("Disabled states use opacity 0.4", () => {
113
+ // Components with :disabled or --disabled class
114
+ const componentsWithDisabled: { name: string; cssPath: string }[] = [
115
+ { name: "Button", cssPath: resolve(componentDir, "Button/Button.css") },
116
+ { name: "Checkbox", cssPath: resolve(componentDir, "Checkbox/Checkbox.css") },
117
+ { name: "Input", cssPath: resolve(componentDir, "Input/Input.css") },
118
+ { name: "Radio", cssPath: resolve(componentDir, "Radio/Radio.css") },
119
+ { name: "Select", cssPath: resolve(componentDir, "Select/Select.css") },
120
+ { name: "Slider", cssPath: resolve(componentDir, "Slider/Slider.css") },
121
+ { name: "Switch", cssPath: resolve(componentDir, "Switch/Switch.css") },
122
+ { name: "Textarea", cssPath: resolve(componentDir, "Textarea/Textarea.css") },
123
+ ];
124
+
125
+ for (const { name, cssPath } of componentsWithDisabled) {
126
+ const css = readFileSync(cssPath, "utf-8");
127
+ const hasDisabled = css.includes(":disabled") || css.includes("--disabled");
128
+ expect(hasDisabled, `${name} expected to have disabled styles`).toBe(true);
129
+
130
+ // Verify opacity: 0.4 is present
131
+ expect(
132
+ css,
133
+ `${name} disabled state should use opacity: 0.4`,
134
+ ).toContain("opacity: 0.4");
135
+ }
136
+ });
137
+ });
@@ -0,0 +1,60 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import { render } from "@testing-library/preact";
3
+ import { Button } from "../components/Button/index.js";
4
+
5
+ describe("strand-static presentation mode", () => {
6
+ it("CSS file exists and is included in build output", async () => {
7
+ const fs = await import("node:fs");
8
+ const path = await import("node:path");
9
+ const staticPath = path.resolve(__dirname, "../../dist/css/strand-ui.css");
10
+ const content = fs.readFileSync(staticPath, "utf-8");
11
+ expect(content).toContain(".strand-static");
12
+ expect(content).toContain("pointer-events: none");
13
+ });
14
+
15
+ it("disabled button inside .strand-static has opacity override class", async () => {
16
+ const fs = await import("node:fs");
17
+ const path = await import("node:path");
18
+ const staticPath = path.resolve(__dirname, "../../dist/css/strand-ui.css");
19
+ const content = fs.readFileSync(staticPath, "utf-8");
20
+ expect(content).toContain(".strand-static [disabled]");
21
+ expect(content).toContain("opacity: 1");
22
+ });
23
+
24
+ it("strand-static overrides toast position", async () => {
25
+ const fs = await import("node:fs");
26
+ const path = await import("node:path");
27
+ const staticPath = path.resolve(__dirname, "../../dist/css/strand-ui.css");
28
+ const content = fs.readFileSync(staticPath, "utf-8");
29
+ expect(content).toContain(".strand-static .strand-toast");
30
+ expect(content).toContain("position: static");
31
+ });
32
+ });
33
+
34
+ describe("layout utility classes", () => {
35
+ it("Stack gap utilities exist in build output", async () => {
36
+ const fs = await import("node:fs");
37
+ const path = await import("node:path");
38
+ const cssPath = path.resolve(__dirname, "../../dist/css/strand-ui.css");
39
+ const content = fs.readFileSync(cssPath, "utf-8");
40
+ expect(content).toContain(".strand-stack--gap-4");
41
+ expect(content).toContain("var(--strand-space-4)");
42
+ });
43
+
44
+ it("Grid column utilities exist in build output", async () => {
45
+ const fs = await import("node:fs");
46
+ const path = await import("node:path");
47
+ const cssPath = path.resolve(__dirname, "../../dist/css/strand-ui.css");
48
+ const content = fs.readFileSync(cssPath, "utf-8");
49
+ expect(content).toContain(".strand-grid--cols-3");
50
+ expect(content).toContain("repeat(3, 1fr)");
51
+ });
52
+
53
+ it("Grid gap utilities exist in build output", async () => {
54
+ const fs = await import("node:fs");
55
+ const path = await import("node:path");
56
+ const cssPath = path.resolve(__dirname, "../../dist/css/strand-ui.css");
57
+ const content = fs.readFileSync(cssPath, "utf-8");
58
+ expect(content).toContain(".strand-grid--gap-4");
59
+ });
60
+ });
@@ -7,7 +7,8 @@
7
7
  align-items: flex-start;
8
8
  justify-content: space-between;
9
9
  width: 100%;
10
- padding: var(--strand-space-4);
10
+ padding: var(--strand-space-6);
11
+ padding-left: var(--strand-space-5);
11
12
  border-radius: var(--strand-radius-md);
12
13
  border-left: 4px solid transparent;
13
14
  font-family: var(--strand-font-sans);
@@ -57,11 +58,18 @@
57
58
  color: var(--strand-gray-500);
58
59
  font-size: var(--strand-text-base);
59
60
  cursor: pointer;
60
- transition: background var(--strand-duration-fast) ease,
61
- color var(--strand-duration-fast) ease;
61
+ transition: background var(--strand-duration-fast) var(--strand-ease-out-quart),
62
+ color var(--strand-duration-fast) var(--strand-ease-out-quart);
62
63
  }
63
64
 
64
65
  .strand-alert__dismiss:hover {
65
66
  background: var(--strand-gray-100);
66
67
  color: var(--strand-gray-600);
67
68
  }
69
+
70
+ /* ── Reduced motion ── */
71
+ @media (prefers-reduced-motion: reduce) {
72
+ .strand-alert__dismiss {
73
+ transition: none;
74
+ }
75
+ }
@@ -18,7 +18,7 @@
18
18
  justify-content: center;
19
19
  font-family: var(--strand-font-sans);
20
20
  font-weight: var(--strand-weight-semibold);
21
- color: #fff;
21
+ color: var(--strand-on-blue-primary);
22
22
  }
23
23
 
24
24
  /* Position at top-right when wrapping children */
@@ -25,13 +25,18 @@
25
25
  .strand-breadcrumb__link {
26
26
  color: var(--strand-gray-500);
27
27
  text-decoration: none;
28
- transition: color var(--strand-duration-fast) ease;
28
+ transition: color var(--strand-duration-fast) var(--strand-ease-out-quart);
29
29
  }
30
30
 
31
31
  .strand-breadcrumb__link:hover {
32
32
  color: var(--strand-gray-600);
33
33
  }
34
34
 
35
+ .strand-breadcrumb__link:focus-visible {
36
+ outline: 2px solid var(--strand-blue-primary);
37
+ outline-offset: 2px;
38
+ }
39
+
35
40
  .strand-breadcrumb__current {
36
41
  color: var(--strand-gray-600);
37
42
  font-weight: var(--strand-weight-medium);
@@ -16,11 +16,11 @@
16
16
  cursor: pointer;
17
17
  user-select: none;
18
18
  transition:
19
- background var(--strand-duration-fast) ease,
20
- border-color var(--strand-duration-fast) ease,
21
- color var(--strand-duration-fast) ease,
19
+ background var(--strand-duration-fast) var(--strand-ease-out-quart),
20
+ border-color var(--strand-duration-fast) var(--strand-ease-out-quart),
21
+ color var(--strand-duration-fast) var(--strand-ease-out-quart),
22
22
  transform var(--strand-duration-fast) var(--strand-ease-out-expo),
23
- box-shadow var(--strand-duration-fast) ease;
23
+ box-shadow var(--strand-duration-fast) var(--strand-ease-out-quart);
24
24
  }
25
25
 
26
26
  .strand-btn:active:not(:disabled) {
@@ -82,7 +82,7 @@
82
82
  .strand-btn--primary:hover:not(:disabled) {
83
83
  background: var(--strand-blue-vivid);
84
84
  transform: translateY(-1px);
85
- box-shadow: 0 4px 12px rgba(37, 99, 235, 0.2);
85
+ box-shadow: var(--strand-hover-shadow-primary);
86
86
  }
87
87
 
88
88
  .strand-btn--primary:active:not(:disabled) {
@@ -115,6 +115,7 @@
115
115
 
116
116
  .strand-btn--ghost:hover:not(:disabled) {
117
117
  background: var(--strand-blue-glow);
118
+ transform: translateY(-1px);
118
119
  }
119
120
 
120
121
  .strand-btn--ghost:active:not(:disabled) {
@@ -128,13 +129,13 @@
128
129
  }
129
130
 
130
131
  .strand-btn--danger:hover:not(:disabled) {
131
- background: #DC2626;
132
+ background: var(--strand-red-alert-vivid);
132
133
  transform: translateY(-1px);
133
- box-shadow: 0 4px 12px rgba(239, 68, 68, 0.2);
134
+ box-shadow: var(--strand-hover-shadow-danger);
134
135
  }
135
136
 
136
137
  .strand-btn--danger:active:not(:disabled) {
137
- background: #B91C1C;
138
+ background: var(--strand-red-alert-deep);
138
139
  }
139
140
 
140
141
  /* ── Loading state ── */
@@ -171,6 +172,12 @@
171
172
  gap: var(--strand-space-2);
172
173
  }
173
174
 
175
+ /* ── Focus ring (Part XII: Accessibility Ring) ── */
176
+ .strand-btn:focus-visible {
177
+ outline: 2px solid var(--strand-blue-primary);
178
+ outline-offset: 2px;
179
+ }
180
+
174
181
  /* ── Reduced motion ── */
175
182
  @media (prefers-reduced-motion: reduce) {
176
183
  .strand-btn {
@@ -5,6 +5,9 @@
5
5
  border-radius: var(--strand-radius-lg);
6
6
  background: var(--strand-surface-elevated);
7
7
  font-family: var(--strand-font-sans);
8
+ overflow: hidden;
9
+ box-sizing: border-box;
10
+ max-width: 100%;
8
11
  }
9
12
 
10
13
  /* ── Variants ── */
@@ -36,15 +39,21 @@
36
39
  }
37
40
 
38
41
  .strand-card--pad-sm {
39
- padding: var(--strand-space-3);
42
+ padding: var(--strand-space-4);
40
43
  }
41
44
 
42
45
  .strand-card--pad-md {
43
- padding: var(--strand-space-5);
46
+ padding: var(--strand-space-6);
44
47
  }
45
48
 
46
49
  .strand-card--pad-lg {
47
- padding: var(--strand-space-8);
50
+ padding: var(--strand-space-10);
51
+ }
52
+
53
+ /* ── Focus ring (interactive cards) ── */
54
+ .strand-card--interactive:focus-visible {
55
+ outline: 2px solid var(--strand-blue-primary);
56
+ outline-offset: 2px;
48
57
  }
49
58
 
50
59
  /* ── Reduced motion ── */
@@ -39,9 +39,9 @@
39
39
  color: var(--strand-on-blue-primary);
40
40
  flex-shrink: 0;
41
41
  transition:
42
- background var(--strand-duration-fast) ease,
43
- border-color var(--strand-duration-fast) ease,
44
- box-shadow var(--strand-duration-fast) ease;
42
+ background var(--strand-duration-fast) var(--strand-ease-out-quart),
43
+ border-color var(--strand-duration-fast) var(--strand-ease-out-quart),
44
+ box-shadow var(--strand-duration-fast) var(--strand-ease-out-quart);
45
45
  }
46
46
 
47
47
  .strand-checkbox__icon {
@@ -52,7 +52,7 @@
52
52
  /* ── Focus ring ── */
53
53
  .strand-checkbox__native:focus-visible ~ .strand-checkbox__control {
54
54
  border-color: var(--strand-blue-primary);
55
- box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1);
55
+ box-shadow: var(--strand-focus-ring);
56
56
  }
57
57
 
58
58
  /* ── Checked ── */
@@ -8,7 +8,7 @@
8
8
  display: flex;
9
9
  align-items: center;
10
10
  justify-content: center;
11
- background: rgba(15, 23, 42, 0.5);
11
+ background: var(--strand-backdrop);
12
12
  }
13
13
 
14
14
  /* ── Panel ── */
@@ -17,7 +17,7 @@
17
17
  width: 100%;
18
18
  max-width: 560px;
19
19
  margin: var(--strand-space-4);
20
- padding: var(--strand-space-6);
20
+ padding: var(--strand-space-8);
21
21
  background: var(--strand-surface-elevated);
22
22
  border-radius: var(--strand-radius-xl);
23
23
  box-shadow: var(--strand-elevation-4);
@@ -43,8 +43,8 @@
43
43
  /* ── Close button ── */
44
44
  .strand-dialog__close {
45
45
  position: absolute;
46
- top: var(--strand-space-4);
47
- right: var(--strand-space-4);
46
+ top: var(--strand-space-6);
47
+ right: var(--strand-space-6);
48
48
  display: inline-flex;
49
49
  align-items: center;
50
50
  justify-content: center;
@@ -57,8 +57,8 @@
57
57
  color: var(--strand-gray-500);
58
58
  font-size: var(--strand-text-lg);
59
59
  cursor: pointer;
60
- transition: background var(--strand-duration-fast) ease,
61
- color var(--strand-duration-fast) ease;
60
+ transition: background var(--strand-duration-fast) var(--strand-ease-out-quart),
61
+ color var(--strand-duration-fast) var(--strand-ease-out-quart);
62
62
  }
63
63
 
64
64
  .strand-dialog__close:hover {
@@ -68,6 +68,7 @@
68
68
 
69
69
  /* ── Body ── */
70
70
  .strand-dialog__body {
71
+ padding-top: var(--strand-space-6);
71
72
  color: var(--strand-gray-600);
72
73
  font-size: var(--strand-text-sm);
73
74
  }
@@ -4,7 +4,7 @@
4
4
  .strand-form-field {
5
5
  display: flex;
6
6
  flex-direction: column;
7
- gap: var(--strand-space-1);
7
+ gap: var(--strand-space-2);
8
8
  }
9
9
 
10
10
  /* ── Label ── */
@@ -3,4 +3,25 @@
3
3
  /* ── Base ── */
4
4
  .strand-grid {
5
5
  display: grid;
6
+ overflow: hidden;
7
+ max-width: 100%;
8
+ box-sizing: border-box;
6
9
  }
10
+
11
+ .strand-grid > * {
12
+ min-width: 0;
13
+ }
14
+
15
+ /* ── Column utilities ── */
16
+ .strand-grid--cols-2 { grid-template-columns: repeat(2, 1fr); }
17
+ .strand-grid--cols-3 { grid-template-columns: repeat(3, 1fr); }
18
+ .strand-grid--cols-4 { grid-template-columns: repeat(4, 1fr); }
19
+
20
+ /* ── Gap utilities ── */
21
+ .strand-grid--gap-1 { gap: var(--strand-space-1); }
22
+ .strand-grid--gap-2 { gap: var(--strand-space-2); }
23
+ .strand-grid--gap-3 { gap: var(--strand-space-3); }
24
+ .strand-grid--gap-4 { gap: var(--strand-space-4); }
25
+ .strand-grid--gap-5 { gap: var(--strand-space-5); }
26
+ .strand-grid--gap-6 { gap: var(--strand-space-6); }
27
+ .strand-grid--gap-8 { gap: var(--strand-space-8); }