@featurefoundry/ui 0.1.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 +18 -0
- package/package.json +35 -0
- package/src/assets/favicon.svg +6 -0
- package/src/assets/feature-foundry-app-icon.png +0 -0
- package/src/assets/feature-foundry-wordmark.png +0 -0
- package/src/assets.d.ts +2 -0
- package/src/index.tsx +113 -0
- package/src/styles.css +228 -0
- package/styles.css +1 -0
package/README.md
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
# Feature Foundry UI
|
|
2
|
+
|
|
3
|
+
Shared brand assets, base CSS, and React primitives for Feature Foundry apps.
|
|
4
|
+
|
|
5
|
+
This package is intentionally small: it keeps future apps visually aligned without forcing every feature into a heavy design system too early.
|
|
6
|
+
|
|
7
|
+
## Usage
|
|
8
|
+
|
|
9
|
+
```ts
|
|
10
|
+
import { BrandLink } from "@featurefoundry/ui";
|
|
11
|
+
import "@featurefoundry/ui/styles.css";
|
|
12
|
+
```
|
|
13
|
+
|
|
14
|
+
For local apps, install it as a file dependency while the package is private:
|
|
15
|
+
|
|
16
|
+
```sh
|
|
17
|
+
npm install ../featurefoundry-ui
|
|
18
|
+
```
|
package/package.json
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@featurefoundry/ui",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"description": "Shared Feature Foundry brand assets, base styles, and React UI primitives.",
|
|
6
|
+
"peerDependencies": {
|
|
7
|
+
"react": "^19.1.0"
|
|
8
|
+
},
|
|
9
|
+
"devDependencies": {
|
|
10
|
+
"@types/react": "^19.1.0",
|
|
11
|
+
"typescript": "^5.8.0"
|
|
12
|
+
},
|
|
13
|
+
"scripts": {
|
|
14
|
+
"typecheck": "tsc --noEmit"
|
|
15
|
+
},
|
|
16
|
+
"main": "./src/index.tsx",
|
|
17
|
+
"types": "./src/index.tsx",
|
|
18
|
+
"style": "./styles.css",
|
|
19
|
+
"license": "MIT",
|
|
20
|
+
"files": [
|
|
21
|
+
"src",
|
|
22
|
+
"styles.css"
|
|
23
|
+
],
|
|
24
|
+
"exports": {
|
|
25
|
+
".": {
|
|
26
|
+
"types": "./src/index.tsx",
|
|
27
|
+
"import": "./src/index.tsx"
|
|
28
|
+
},
|
|
29
|
+
"./styles.css": "./styles.css",
|
|
30
|
+
"./favicon.svg": "./src/assets/favicon.svg"
|
|
31
|
+
},
|
|
32
|
+
"publishConfig": {
|
|
33
|
+
"access": "public"
|
|
34
|
+
}
|
|
35
|
+
}
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" role="img" aria-label="Feature Foundry">
|
|
2
|
+
<rect width="64" height="64" rx="14" fill="#17201a" />
|
|
3
|
+
<path fill="#fffdf8" d="M15 16h35v9H25v8h20v9H25v16H15V16Z" />
|
|
4
|
+
<path fill="#fffdf8" d="M34 33h17v9h-7v16H34V33Z" />
|
|
5
|
+
<rect x="47" y="48" width="7" height="7" fill="#e05a3c" />
|
|
6
|
+
</svg>
|
|
Binary file
|
|
Binary file
|
package/src/assets.d.ts
ADDED
package/src/index.tsx
ADDED
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
import type { AnchorHTMLAttributes, ButtonHTMLAttributes, ReactNode } from "react";
|
|
2
|
+
import { useEffect, useRef, useState } from "react";
|
|
3
|
+
import brandMarkUrl from "./assets/feature-foundry-app-icon.png";
|
|
4
|
+
import brandWordmarkUrl from "./assets/feature-foundry-wordmark.png";
|
|
5
|
+
import faviconUrl from "./assets/favicon.svg";
|
|
6
|
+
|
|
7
|
+
export { brandMarkUrl, brandWordmarkUrl, faviconUrl };
|
|
8
|
+
|
|
9
|
+
type BrandLinkProps = AnchorHTMLAttributes<HTMLAnchorElement> & {
|
|
10
|
+
label?: string;
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
export function BrandLink({ label = "Feature Foundry", className = "", ...props }: BrandLinkProps) {
|
|
14
|
+
return (
|
|
15
|
+
<a className={["ffBrand", className].filter(Boolean).join(" ")} {...props}>
|
|
16
|
+
<img className="ffBrandMark" src={brandMarkUrl} alt="" aria-hidden="true" />
|
|
17
|
+
<span>{label}</span>
|
|
18
|
+
</a>
|
|
19
|
+
);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
type ButtonProps = ButtonHTMLAttributes<HTMLButtonElement> & {
|
|
23
|
+
active?: boolean;
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
export function ControlButton({ active = false, className = "", ...props }: ButtonProps) {
|
|
27
|
+
return (
|
|
28
|
+
<button
|
|
29
|
+
className={["ffControlButton", active ? "active" : "", className].filter(Boolean).join(" ")}
|
|
30
|
+
{...props}
|
|
31
|
+
/>
|
|
32
|
+
);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function Card({ children, className = "" }: { children: ReactNode; className?: string }) {
|
|
36
|
+
return <div className={["ffCard", className].filter(Boolean).join(" ")}>{children}</div>;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export type MenuItem = {
|
|
40
|
+
label: string;
|
|
41
|
+
href: string;
|
|
42
|
+
emphasis?: boolean;
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
type HamburgerMenuProps = {
|
|
46
|
+
items: MenuItem[];
|
|
47
|
+
label?: string;
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
export function HamburgerMenu({ items, label = "Primary navigation" }: HamburgerMenuProps) {
|
|
51
|
+
const [isOpen, setIsOpen] = useState(false);
|
|
52
|
+
const rootRef = useRef<HTMLDivElement>(null);
|
|
53
|
+
|
|
54
|
+
useEffect(() => {
|
|
55
|
+
function handlePointerDown(event: PointerEvent) {
|
|
56
|
+
if (!rootRef.current?.contains(event.target as Node)) {
|
|
57
|
+
setIsOpen(false);
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function handleKeyDown(event: KeyboardEvent) {
|
|
62
|
+
if (event.key === "Escape") {
|
|
63
|
+
setIsOpen(false);
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
document.addEventListener("pointerdown", handlePointerDown);
|
|
68
|
+
document.addEventListener("keydown", handleKeyDown);
|
|
69
|
+
|
|
70
|
+
return () => {
|
|
71
|
+
document.removeEventListener("pointerdown", handlePointerDown);
|
|
72
|
+
document.removeEventListener("keydown", handleKeyDown);
|
|
73
|
+
};
|
|
74
|
+
}, []);
|
|
75
|
+
|
|
76
|
+
return (
|
|
77
|
+
<div className="ffNavControls" ref={rootRef}>
|
|
78
|
+
<button
|
|
79
|
+
className="ffMenuButton"
|
|
80
|
+
type="button"
|
|
81
|
+
aria-controls="featurefoundry-menu"
|
|
82
|
+
aria-expanded={isOpen}
|
|
83
|
+
aria-label={isOpen ? "Close primary navigation" : "Open primary navigation"}
|
|
84
|
+
onClick={() => setIsOpen((open) => !open)}
|
|
85
|
+
>
|
|
86
|
+
<span className="ffMenuGlyph" aria-hidden="true">
|
|
87
|
+
<span />
|
|
88
|
+
<span />
|
|
89
|
+
<span />
|
|
90
|
+
<span />
|
|
91
|
+
</span>
|
|
92
|
+
</button>
|
|
93
|
+
<nav
|
|
94
|
+
className={isOpen ? "ffNavMenu open" : "ffNavMenu"}
|
|
95
|
+
id="featurefoundry-menu"
|
|
96
|
+
aria-label={label}
|
|
97
|
+
>
|
|
98
|
+
<div className="ffNavMenuPanel">
|
|
99
|
+
{items.map((item) => (
|
|
100
|
+
<a
|
|
101
|
+
className={item.emphasis ? "ffNavLink emphasis" : "ffNavLink"}
|
|
102
|
+
href={item.href}
|
|
103
|
+
key={item.href}
|
|
104
|
+
onClick={() => setIsOpen(false)}
|
|
105
|
+
>
|
|
106
|
+
{item.label}
|
|
107
|
+
</a>
|
|
108
|
+
))}
|
|
109
|
+
</div>
|
|
110
|
+
</nav>
|
|
111
|
+
</div>
|
|
112
|
+
);
|
|
113
|
+
}
|
package/src/styles.css
ADDED
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
:root {
|
|
2
|
+
color: #17201a;
|
|
3
|
+
background: #f7f5ef;
|
|
4
|
+
font-family:
|
|
5
|
+
Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
|
6
|
+
font-synthesis: none;
|
|
7
|
+
text-rendering: optimizeLegibility;
|
|
8
|
+
-webkit-font-smoothing: antialiased;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
* {
|
|
12
|
+
box-sizing: border-box;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
body {
|
|
16
|
+
margin: 0;
|
|
17
|
+
min-width: 320px;
|
|
18
|
+
min-height: 100vh;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
a {
|
|
22
|
+
color: inherit;
|
|
23
|
+
text-decoration: none;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
button,
|
|
27
|
+
input,
|
|
28
|
+
textarea,
|
|
29
|
+
select {
|
|
30
|
+
font: inherit;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
h1,
|
|
34
|
+
h2,
|
|
35
|
+
h3,
|
|
36
|
+
p {
|
|
37
|
+
margin-top: 0;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
h1 {
|
|
41
|
+
max-width: 920px;
|
|
42
|
+
margin-bottom: 24px;
|
|
43
|
+
font-size: clamp(2.7rem, 7vw, 6.3rem);
|
|
44
|
+
line-height: 0.98;
|
|
45
|
+
letter-spacing: 0;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
h2 {
|
|
49
|
+
margin-bottom: 0;
|
|
50
|
+
max-width: 760px;
|
|
51
|
+
font-size: clamp(2rem, 4vw, 3.2rem);
|
|
52
|
+
line-height: 1.03;
|
|
53
|
+
letter-spacing: 0;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
p {
|
|
57
|
+
color: #536057;
|
|
58
|
+
line-height: 1.65;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
.ffPageWidth {
|
|
62
|
+
width: min(1120px, calc(100% - 40px));
|
|
63
|
+
margin-right: auto;
|
|
64
|
+
margin-left: auto;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
.ffTopBar {
|
|
68
|
+
position: relative;
|
|
69
|
+
display: flex;
|
|
70
|
+
align-items: center;
|
|
71
|
+
justify-content: space-between;
|
|
72
|
+
gap: 24px;
|
|
73
|
+
min-height: 78px;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
.ffBrand {
|
|
77
|
+
display: inline-flex;
|
|
78
|
+
align-items: center;
|
|
79
|
+
gap: 10px;
|
|
80
|
+
font-weight: 850;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
.ffBrandMark {
|
|
84
|
+
width: 34px;
|
|
85
|
+
height: 34px;
|
|
86
|
+
border: 1px solid rgba(23, 32, 26, 0.2);
|
|
87
|
+
border-radius: 8px;
|
|
88
|
+
background: #17201a;
|
|
89
|
+
object-fit: cover;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
.ffEyebrow {
|
|
93
|
+
margin: 0 0 14px;
|
|
94
|
+
color: #b4432f;
|
|
95
|
+
font-size: 0.76rem;
|
|
96
|
+
font-weight: 850;
|
|
97
|
+
letter-spacing: 0.08em;
|
|
98
|
+
text-transform: uppercase;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
.ffCard {
|
|
102
|
+
border: 1px solid rgba(23, 32, 26, 0.14);
|
|
103
|
+
border-radius: 8px;
|
|
104
|
+
background: #fffdf8;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
.ffBackLink,
|
|
108
|
+
.ffControlButton {
|
|
109
|
+
display: inline-flex;
|
|
110
|
+
min-height: 42px;
|
|
111
|
+
align-items: center;
|
|
112
|
+
justify-content: center;
|
|
113
|
+
border: 1px solid rgba(23, 32, 26, 0.18);
|
|
114
|
+
border-radius: 8px;
|
|
115
|
+
padding: 0 14px;
|
|
116
|
+
background: #fffdf8;
|
|
117
|
+
color: #536057;
|
|
118
|
+
cursor: pointer;
|
|
119
|
+
font-weight: 820;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
.ffBackLink:hover,
|
|
123
|
+
.ffControlButton:hover {
|
|
124
|
+
border-color: rgba(180, 67, 47, 0.55);
|
|
125
|
+
color: #17201a;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
.ffControlButton.active {
|
|
129
|
+
border-color: #17201a;
|
|
130
|
+
background: #17201a;
|
|
131
|
+
color: #fffdf8;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
.ffControlButton:disabled {
|
|
135
|
+
cursor: not-allowed;
|
|
136
|
+
opacity: 0.46;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
@media (max-width: 860px) {
|
|
140
|
+
.ffPageWidth {
|
|
141
|
+
width: min(100% - 24px, 1120px);
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
.ffNavControls {
|
|
146
|
+
position: relative;
|
|
147
|
+
display: flex;
|
|
148
|
+
align-items: center;
|
|
149
|
+
gap: 10px;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
.ffMenuButton {
|
|
153
|
+
display: inline-grid;
|
|
154
|
+
width: 46px;
|
|
155
|
+
height: 46px;
|
|
156
|
+
place-items: center;
|
|
157
|
+
border: 1px solid rgba(23, 32, 26, 0.18);
|
|
158
|
+
border-radius: 8px;
|
|
159
|
+
padding: 0;
|
|
160
|
+
background: #fffdf8;
|
|
161
|
+
color: #17201a;
|
|
162
|
+
cursor: pointer;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
.ffMenuGlyph {
|
|
166
|
+
display: grid;
|
|
167
|
+
grid-template-columns: repeat(2, 6px);
|
|
168
|
+
gap: 5px;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
.ffMenuGlyph span {
|
|
172
|
+
display: block;
|
|
173
|
+
width: 6px;
|
|
174
|
+
height: 6px;
|
|
175
|
+
border-radius: 2px;
|
|
176
|
+
background: currentColor;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
.ffMenuGlyph span:last-child {
|
|
180
|
+
background: #e05a3c;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
.ffMenuButton:hover,
|
|
184
|
+
.ffMenuButton[aria-expanded="true"] {
|
|
185
|
+
background: rgba(23, 32, 26, 0.08);
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
.ffNavMenu {
|
|
189
|
+
position: absolute;
|
|
190
|
+
top: calc(100% + 8px);
|
|
191
|
+
right: 0;
|
|
192
|
+
z-index: 10;
|
|
193
|
+
display: none;
|
|
194
|
+
min-width: 220px;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
.ffNavMenu.open {
|
|
198
|
+
display: block;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
.ffNavMenuPanel {
|
|
202
|
+
display: grid;
|
|
203
|
+
gap: 4px;
|
|
204
|
+
border: 1px solid rgba(23, 32, 26, 0.14);
|
|
205
|
+
border-radius: 8px;
|
|
206
|
+
padding: 8px;
|
|
207
|
+
background: #fffdf8;
|
|
208
|
+
box-shadow: 0 18px 42px rgba(23, 32, 26, 0.12);
|
|
209
|
+
color: #536057;
|
|
210
|
+
font-size: 0.94rem;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
.ffNavLink {
|
|
214
|
+
display: inline-flex;
|
|
215
|
+
align-items: center;
|
|
216
|
+
border-radius: 8px;
|
|
217
|
+
padding: 9px 11px;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
.ffNavLink:hover {
|
|
221
|
+
background: rgba(23, 32, 26, 0.08);
|
|
222
|
+
color: #17201a;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
.ffNavLink.emphasis {
|
|
226
|
+
color: #b4432f;
|
|
227
|
+
font-weight: 760;
|
|
228
|
+
}
|
package/styles.css
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
@import "./src/styles.css";
|