@5voltfx/design-system 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.
Files changed (43) hide show
  1. package/package.json +24 -0
  2. package/src/components/Button/Button.css +77 -0
  3. package/src/components/Button/Button.jsx +20 -0
  4. package/src/components/Button/index.js +1 -0
  5. package/src/components/Card/Card.css +11 -0
  6. package/src/components/Card/Card.jsx +6 -0
  7. package/src/components/Card/index.js +1 -0
  8. package/src/components/Footer/Footer.css +48 -0
  9. package/src/components/Footer/Footer.jsx +37 -0
  10. package/src/components/Footer/index.js +1 -0
  11. package/src/components/Header/Header.css +47 -0
  12. package/src/components/Header/Header.jsx +30 -0
  13. package/src/components/Header/index.js +1 -0
  14. package/src/components/Layout/Container.jsx +10 -0
  15. package/src/components/Layout/Grid.jsx +22 -0
  16. package/src/components/Layout/Layout.css +26 -0
  17. package/src/components/Layout/Stack.jsx +24 -0
  18. package/src/components/Layout/index.js +3 -0
  19. package/src/components/Nav/Nav.css +61 -0
  20. package/src/components/Nav/Nav.jsx +29 -0
  21. package/src/components/Nav/index.js +1 -0
  22. package/src/components/ThemeSwitcher/ThemeSwitcher.css +30 -0
  23. package/src/components/ThemeSwitcher/ThemeSwitcher.jsx +26 -0
  24. package/src/components/ThemeSwitcher/index.js +1 -0
  25. package/src/components/Typography/Heading.jsx +29 -0
  26. package/src/components/Typography/Text.jsx +20 -0
  27. package/src/components/Typography/Typography.css +35 -0
  28. package/src/components/Typography/index.js +2 -0
  29. package/src/index.js +12 -0
  30. package/src/theme/ThemeContext.js +3 -0
  31. package/src/theme/ThemeProvider.jsx +66 -0
  32. package/src/theme/themeConstants.js +12 -0
  33. package/src/theme/useTheme.js +10 -0
  34. package/src/tokens/base.css +21 -0
  35. package/src/tokens/colors.css +29 -0
  36. package/src/tokens/elevation.css +10 -0
  37. package/src/tokens/index.css +8 -0
  38. package/src/tokens/motion.css +6 -0
  39. package/src/tokens/radius.css +6 -0
  40. package/src/tokens/spacing.css +10 -0
  41. package/src/tokens/themes.css +156 -0
  42. package/src/tokens/typography.css +29 -0
  43. package/vite-plugin.js +30 -0
package/package.json ADDED
@@ -0,0 +1,24 @@
1
+ {
2
+ "name": "@5voltfx/design-system",
3
+ "version": "0.1.0",
4
+ "type": "module",
5
+ "main": "./src/index.js",
6
+ "exports": {
7
+ ".": "./src/index.js",
8
+ "./styles.css": "./src/tokens/index.css",
9
+ "./components/*": "./src/components/*/index.js",
10
+ "./vite-plugin": "./vite-plugin.js"
11
+ },
12
+ "files": [
13
+ "src",
14
+ "vite-plugin.js"
15
+ ],
16
+ "publishConfig": {
17
+ "access": "public"
18
+ },
19
+ "peerDependencies": {
20
+ "react": "^19.0.0",
21
+ "react-dom": "^19.0.0",
22
+ "react-router-dom": "^7.0.0"
23
+ }
24
+ }
@@ -0,0 +1,77 @@
1
+ .ds-button {
2
+ display: inline-flex;
3
+ align-items: center;
4
+ justify-content: center;
5
+ gap: var(--ds-space-2);
6
+ border: 1px solid transparent;
7
+ border-radius: var(--ds-radius-pill);
8
+ font-family: var(--ds-font-body);
9
+ font-weight: var(--ds-font-weight-semibold);
10
+ font-size: var(--ds-font-size-200);
11
+ line-height: var(--ds-line-height-tight);
12
+ text-decoration: none;
13
+ cursor: pointer;
14
+ transition:
15
+ background-color var(--ds-motion-duration-base) var(--ds-motion-ease-standard),
16
+ border-color var(--ds-motion-duration-base) var(--ds-motion-ease-standard),
17
+ color var(--ds-motion-duration-base) var(--ds-motion-ease-standard),
18
+ transform var(--ds-motion-duration-fast) var(--ds-motion-ease-standard),
19
+ box-shadow var(--ds-motion-duration-base) var(--ds-motion-ease-standard);
20
+ }
21
+
22
+ .ds-button--md {
23
+ padding: var(--ds-space-3) var(--ds-space-5);
24
+ }
25
+
26
+ .ds-button--sm {
27
+ padding: var(--ds-space-2) var(--ds-space-4);
28
+ font-size: var(--ds-font-size-100);
29
+ }
30
+
31
+ .ds-button--lg {
32
+ padding: var(--ds-space-4) var(--ds-space-6);
33
+ font-size: var(--ds-font-size-300);
34
+ }
35
+
36
+ .ds-button--primary {
37
+ background-color: var(--ds-color-bg-2);
38
+ color: var(--ds-color-font-0);
39
+ box-shadow: var(--ds-shadow-sm);
40
+ }
41
+
42
+ .ds-button--primary:hover {
43
+ transform: translateY(-1px);
44
+ box-shadow: var(--ds-shadow-md);
45
+ }
46
+
47
+ .ds-button--secondary {
48
+ background-color: transparent;
49
+ color: var(--ds-color-font-0);
50
+ border-color: var(--ds-color-bw-3);
51
+ }
52
+
53
+ .ds-button--secondary:hover {
54
+ background-color: var(--ds-color-bw-1);
55
+ }
56
+
57
+ .ds-button--ghost {
58
+ background: transparent;
59
+ color: var(--ds-color-font-1);
60
+ padding-inline: var(--ds-space-3);
61
+ }
62
+
63
+ .ds-button--ghost:hover {
64
+ color: var(--ds-color-font-0);
65
+ }
66
+
67
+ .ds-button:focus-visible {
68
+ outline: 2px solid var(--ds-color-bg-2);
69
+ outline-offset: 2px;
70
+ }
71
+
72
+ .ds-button:disabled {
73
+ opacity: 0.5;
74
+ cursor: not-allowed;
75
+ transform: none;
76
+ box-shadow: none;
77
+ }
@@ -0,0 +1,20 @@
1
+ import "./Button.css";
2
+
3
+ export function Button({
4
+ as: Component = "button",
5
+ variant = "primary",
6
+ size = "md",
7
+ className = "",
8
+ ...rest
9
+ }) {
10
+ const classes = [
11
+ "ds-button",
12
+ `ds-button--${variant}`,
13
+ `ds-button--${size}`,
14
+ className,
15
+ ]
16
+ .filter(Boolean)
17
+ .join(" ");
18
+
19
+ return <Component className={classes} {...rest} />;
20
+ }
@@ -0,0 +1 @@
1
+ export { Button } from "./Button.jsx";
@@ -0,0 +1,11 @@
1
+ .ds-card {
2
+ background-color: var(--ds-color-bg-1);
3
+ color: var(--ds-color-font-0);
4
+ border: 1px solid var(--ds-color-bw-1);
5
+ border-radius: var(--ds-radius-lg);
6
+ padding: var(--ds-space-5);
7
+ box-shadow: var(--ds-shadow-sm);
8
+ transition:
9
+ box-shadow var(--ds-motion-duration-base) var(--ds-motion-ease-standard),
10
+ transform var(--ds-motion-duration-base) var(--ds-motion-ease-standard);
11
+ }
@@ -0,0 +1,6 @@
1
+ import "./Card.css";
2
+
3
+ export function Card({ as: Component = "div", className = "", ...rest }) {
4
+ const classes = ["ds-card", className].filter(Boolean).join(" ");
5
+ return <Component className={classes} {...rest} />;
6
+ }
@@ -0,0 +1 @@
1
+ export { Card } from "./Card.jsx";
@@ -0,0 +1,48 @@
1
+ .ds-footer {
2
+ background-color: var(--ds-color-bg-1);
3
+ color: var(--ds-color-font-1);
4
+ border-top: 1px solid var(--ds-color-bw-1);
5
+ font-family: var(--ds-font-body);
6
+ }
7
+
8
+ .ds-footer__inner {
9
+ max-width: 1200px;
10
+ margin: 0 auto;
11
+ padding: var(--ds-space-4) var(--ds-space-6);
12
+ display: flex;
13
+ align-items: center;
14
+ justify-content: space-between;
15
+ gap: var(--ds-space-4);
16
+ }
17
+
18
+ .ds-footer__left,
19
+ .ds-footer__right {
20
+ flex: 1;
21
+ display: flex;
22
+ align-items: center;
23
+ }
24
+
25
+ .ds-footer__right {
26
+ justify-content: flex-end;
27
+ }
28
+
29
+ .ds-footer__center {
30
+ display: flex;
31
+ justify-content: center;
32
+ }
33
+
34
+ .ds-footer__copyright {
35
+ font-size: var(--ds-font-size-100);
36
+ }
37
+
38
+ @media (width <= 660px) {
39
+ .ds-footer__inner {
40
+ flex-direction: column;
41
+ padding: var(--ds-space-4);
42
+ }
43
+
44
+ .ds-footer__left,
45
+ .ds-footer__right {
46
+ justify-content: center;
47
+ }
48
+ }
@@ -0,0 +1,37 @@
1
+ import { ThemeSwitcher } from "../ThemeSwitcher/ThemeSwitcher.jsx";
2
+ import "./Footer.css";
3
+
4
+ /**
5
+ * Generalizes the 3 existing footers: 5voltfx.com's simple centered
6
+ * copyright, song-chart's minimal bar, and mattwelker.com's 3-column
7
+ * layout (auth links / theme swatches / copyright). `left` and `right`
8
+ * default to nothing and a copyright string respectively, so a single
9
+ * centered ThemeSwitcher is the only thing rendered out of the box.
10
+ */
11
+ export function Footer({
12
+ brand = "5VoltFX/Matt Welker",
13
+ showThemeSwitcher = true,
14
+ left,
15
+ right,
16
+ className = "",
17
+ }) {
18
+ const year = new Date().getFullYear();
19
+
20
+ return (
21
+ <footer className={["ds-footer", className].filter(Boolean).join(" ")}>
22
+ <div className="ds-footer__inner">
23
+ <div className="ds-footer__left">{left}</div>
24
+ <div className="ds-footer__center">
25
+ {showThemeSwitcher && <ThemeSwitcher />}
26
+ </div>
27
+ <div className="ds-footer__right">
28
+ {right ?? (
29
+ <span className="ds-footer__copyright">
30
+ © {year} {brand}
31
+ </span>
32
+ )}
33
+ </div>
34
+ </div>
35
+ </footer>
36
+ );
37
+ }
@@ -0,0 +1 @@
1
+ export { Footer } from "./Footer.jsx";
@@ -0,0 +1,47 @@
1
+ .ds-header {
2
+ background-color: var(--ds-color-bg-0);
3
+ color: var(--ds-color-font-0);
4
+ border-bottom: 1px solid var(--ds-color-bw-1);
5
+ font-family: var(--ds-font-body);
6
+ }
7
+
8
+ .ds-header__inner {
9
+ max-width: 1200px;
10
+ margin: 0 auto;
11
+ padding: var(--ds-space-4) var(--ds-space-6);
12
+ display: flex;
13
+ align-items: center;
14
+ gap: var(--ds-space-6);
15
+ }
16
+
17
+ .ds-header__brand {
18
+ display: flex;
19
+ align-items: center;
20
+ color: var(--ds-color-font-0);
21
+ text-decoration: none;
22
+ font-weight: var(--ds-font-weight-bold);
23
+ font-size: var(--ds-font-size-300);
24
+ letter-spacing: 0.02em;
25
+ }
26
+
27
+ .ds-header__nav {
28
+ flex: 1;
29
+ }
30
+
31
+ .ds-header__actions {
32
+ display: flex;
33
+ align-items: center;
34
+ gap: var(--ds-space-3);
35
+ margin-left: auto;
36
+ }
37
+
38
+ @media (width <= 480px) {
39
+ .ds-header__inner {
40
+ padding: var(--ds-space-3) var(--ds-space-4);
41
+ gap: var(--ds-space-4);
42
+ }
43
+
44
+ .ds-header__brand {
45
+ font-size: var(--ds-font-size-200);
46
+ }
47
+ }
@@ -0,0 +1,30 @@
1
+ import { Link } from "react-router-dom";
2
+ import { Nav } from "../Nav/Nav.jsx";
3
+ import "./Header.css";
4
+
5
+ /**
6
+ * Generic header shell with brand/nav/actions slots, so each site's
7
+ * bespoke chrome (song-chart's auth-aware menu + fullscreen toggle,
8
+ * mattwelker.com's audio-loop toggle + Venmo link, 5voltfx.com's plain
9
+ * logo-with-text-fallback) stays app-specific and is passed in rather
10
+ * than encoded into this component.
11
+ */
12
+ export function Header({
13
+ brand,
14
+ brandHref = "/",
15
+ links = [],
16
+ actions,
17
+ className = "",
18
+ }) {
19
+ return (
20
+ <header className={["ds-header", className].filter(Boolean).join(" ")}>
21
+ <div className="ds-header__inner">
22
+ <Link to={brandHref} className="ds-header__brand">
23
+ {brand}
24
+ </Link>
25
+ {links.length > 0 && <Nav links={links} className="ds-header__nav" />}
26
+ {actions && <div className="ds-header__actions">{actions}</div>}
27
+ </div>
28
+ </header>
29
+ );
30
+ }
@@ -0,0 +1 @@
1
+ export { Header } from "./Header.jsx";
@@ -0,0 +1,10 @@
1
+ import "./Layout.css";
2
+
3
+ export function Container({ as: Component = "div", className = "", ...rest }) {
4
+ return (
5
+ <Component
6
+ className={["ds-container", className].filter(Boolean).join(" ")}
7
+ {...rest}
8
+ />
9
+ );
10
+ }
@@ -0,0 +1,22 @@
1
+ import "./Layout.css";
2
+
3
+ export function Grid({
4
+ as: Component = "div",
5
+ columns = 2,
6
+ gap = "4",
7
+ className = "",
8
+ style,
9
+ ...rest
10
+ }) {
11
+ return (
12
+ <Component
13
+ className={["ds-grid", className].filter(Boolean).join(" ")}
14
+ style={{
15
+ gridTemplateColumns: `repeat(${columns}, minmax(0, 1fr))`,
16
+ gap: `var(--ds-space-${gap})`,
17
+ ...style,
18
+ }}
19
+ {...rest}
20
+ />
21
+ );
22
+ }
@@ -0,0 +1,26 @@
1
+ .ds-container {
2
+ max-width: 1200px;
3
+ margin: 0 auto;
4
+ padding: 0 var(--ds-space-6);
5
+ }
6
+
7
+ @media (width <= 660px) {
8
+ .ds-container {
9
+ padding: 0 var(--ds-space-4);
10
+ }
11
+ }
12
+
13
+ .ds-stack {
14
+ display: flex;
15
+ gap: var(--ds-stack-gap, var(--ds-space-4));
16
+ }
17
+
18
+ .ds-grid {
19
+ display: grid;
20
+ }
21
+
22
+ @media (width <= 660px) {
23
+ .ds-grid {
24
+ grid-template-columns: 1fr !important;
25
+ }
26
+ }
@@ -0,0 +1,24 @@
1
+ import "./Layout.css";
2
+
3
+ export function Stack({
4
+ as: Component = "div",
5
+ gap = "4",
6
+ direction = "column",
7
+ align,
8
+ className = "",
9
+ style,
10
+ ...rest
11
+ }) {
12
+ return (
13
+ <Component
14
+ className={["ds-stack", className].filter(Boolean).join(" ")}
15
+ style={{
16
+ "--ds-stack-gap": `var(--ds-space-${gap})`,
17
+ flexDirection: direction,
18
+ alignItems: align,
19
+ ...style,
20
+ }}
21
+ {...rest}
22
+ />
23
+ );
24
+ }
@@ -0,0 +1,3 @@
1
+ export { Container } from "./Container.jsx";
2
+ export { Stack } from "./Stack.jsx";
3
+ export { Grid } from "./Grid.jsx";
@@ -0,0 +1,61 @@
1
+ .ds-nav__list {
2
+ display: flex;
3
+ align-items: center;
4
+ gap: var(--ds-space-5);
5
+ list-style: none;
6
+ margin: 0;
7
+ padding: 0;
8
+ }
9
+
10
+ .ds-nav__link {
11
+ position: relative;
12
+ display: inline-block;
13
+ padding: var(--ds-space-2) 0;
14
+ color: var(--ds-color-font-1);
15
+ font-family: var(--ds-font-body);
16
+ font-weight: var(--ds-font-weight-medium);
17
+ font-size: var(--ds-font-size-100);
18
+ text-transform: uppercase;
19
+ letter-spacing: 0.04em;
20
+ text-decoration: none;
21
+ transition: color var(--ds-motion-duration-base) var(--ds-motion-ease-standard);
22
+ }
23
+
24
+ .ds-nav__link::after {
25
+ content: "";
26
+ position: absolute;
27
+ left: 0;
28
+ bottom: -2px;
29
+ width: 100%;
30
+ height: 2px;
31
+ background-color: var(--ds-color-bg-2);
32
+ transform: scaleX(0);
33
+ transform-origin: left;
34
+ transition: transform var(--ds-motion-duration-base) var(--ds-motion-ease-standard);
35
+ }
36
+
37
+ .ds-nav__link:hover {
38
+ color: var(--ds-color-font-0);
39
+ }
40
+
41
+ .ds-nav__link:hover::after {
42
+ transform: scaleX(1);
43
+ }
44
+
45
+ .ds-nav__link--active {
46
+ color: var(--ds-color-font-0);
47
+ }
48
+
49
+ .ds-nav__link--active::after {
50
+ transform: scaleX(1);
51
+ }
52
+
53
+ @media (width <= 660px) {
54
+ .ds-nav__list {
55
+ gap: var(--ds-space-3);
56
+ }
57
+
58
+ .ds-nav__link {
59
+ font-size: 0.75rem;
60
+ }
61
+ }
@@ -0,0 +1,29 @@
1
+ import { NavLink } from "react-router-dom";
2
+ import "./Nav.css";
3
+
4
+ /**
5
+ * Generic nav-links list. Generalizes 5voltfx.com's Navbar.jsx (active
6
+ * underline via NavLink), song-chart's SiteHeader nav, and mattwelker.com's
7
+ * PageNav link list into one component driven by a `links` array.
8
+ */
9
+ export function Nav({ links = [], className = "" }) {
10
+ return (
11
+ <nav className={["ds-nav", className].filter(Boolean).join(" ")}>
12
+ <ul className="ds-nav__list">
13
+ {links.map((link) => (
14
+ <li key={link.to}>
15
+ <NavLink
16
+ to={link.to}
17
+ end={link.end}
18
+ className={({ isActive }) =>
19
+ "ds-nav__link" + (isActive ? " ds-nav__link--active" : "")
20
+ }
21
+ >
22
+ {link.label}
23
+ </NavLink>
24
+ </li>
25
+ ))}
26
+ </ul>
27
+ </nav>
28
+ );
29
+ }
@@ -0,0 +1 @@
1
+ export { Nav } from "./Nav.jsx";
@@ -0,0 +1,30 @@
1
+ .ds-theme-switcher {
2
+ display: flex;
3
+ align-items: center;
4
+ gap: var(--ds-space-2);
5
+ }
6
+
7
+ .ds-theme-switcher__swatch {
8
+ width: 20px;
9
+ height: 20px;
10
+ border-radius: var(--ds-radius-pill);
11
+ border: 2px solid transparent;
12
+ cursor: pointer;
13
+ padding: 0;
14
+ transition:
15
+ transform var(--ds-motion-duration-fast) var(--ds-motion-ease-standard),
16
+ border-color var(--ds-motion-duration-base) var(--ds-motion-ease-standard);
17
+ }
18
+
19
+ .ds-theme-switcher__swatch:hover {
20
+ transform: scale(1.15);
21
+ }
22
+
23
+ .ds-theme-switcher__swatch--active {
24
+ border-color: var(--ds-color-font-0);
25
+ }
26
+
27
+ .ds-theme-switcher__swatch:focus-visible {
28
+ outline: 2px solid var(--ds-color-bg-2);
29
+ outline-offset: 2px;
30
+ }
@@ -0,0 +1,26 @@
1
+ import { useTheme } from "../../theme/useTheme.js";
2
+ import "./ThemeSwitcher.css";
3
+
4
+ export function ThemeSwitcher({ className = "" }) {
5
+ const { theme, setTheme, themes } = useTheme();
6
+
7
+ return (
8
+ <div className={["ds-theme-switcher", className].filter(Boolean).join(" ")}>
9
+ {themes.map((option) => (
10
+ <button
11
+ key={option.id}
12
+ type="button"
13
+ className={
14
+ "ds-theme-switcher__swatch" +
15
+ (option.id === theme ? " ds-theme-switcher__swatch--active" : "")
16
+ }
17
+ style={{ backgroundColor: option.swatch }}
18
+ aria-label={option.label}
19
+ aria-pressed={option.id === theme}
20
+ title={option.label}
21
+ onClick={() => setTheme(option.id)}
22
+ />
23
+ ))}
24
+ </div>
25
+ );
26
+ }
@@ -0,0 +1 @@
1
+ export { ThemeSwitcher } from "./ThemeSwitcher.jsx";
@@ -0,0 +1,29 @@
1
+ import "./Typography.css";
2
+
3
+ const SCALE_BY_LEVEL = { 1: "700", 2: "600", 3: "500", 4: "400", 5: "300", 6: "200" };
4
+
5
+ export function Heading({
6
+ level = 1,
7
+ size,
8
+ decorative = false,
9
+ className = "",
10
+ children,
11
+ ...rest
12
+ }) {
13
+ const Component = `h${level}`;
14
+ const scale = size ?? SCALE_BY_LEVEL[level] ?? "300";
15
+ const classes = [
16
+ "ds-heading",
17
+ `ds-heading--${scale}`,
18
+ decorative ? "ds-heading--decorative" : "",
19
+ className,
20
+ ]
21
+ .filter(Boolean)
22
+ .join(" ");
23
+
24
+ return (
25
+ <Component className={classes} {...rest}>
26
+ {children}
27
+ </Component>
28
+ );
29
+ }
@@ -0,0 +1,20 @@
1
+ import "./Typography.css";
2
+
3
+ export function Text({
4
+ as: Component = "p",
5
+ size = "200",
6
+ tone = "default",
7
+ className = "",
8
+ children,
9
+ ...rest
10
+ }) {
11
+ const classes = ["ds-text", `ds-text--${size}`, `ds-text--${tone}`, className]
12
+ .filter(Boolean)
13
+ .join(" ");
14
+
15
+ return (
16
+ <Component className={classes} {...rest}>
17
+ {children}
18
+ </Component>
19
+ );
20
+ }
@@ -0,0 +1,35 @@
1
+ .ds-heading {
2
+ margin: 0;
3
+ font-family: var(--ds-font-body);
4
+ font-weight: var(--ds-font-weight-bold);
5
+ line-height: var(--ds-line-height-tight);
6
+ color: var(--ds-color-font-0);
7
+ }
8
+
9
+ .ds-heading--decorative {
10
+ font-family: var(--ds-font-decorative);
11
+ font-weight: var(--ds-font-weight-regular);
12
+ }
13
+
14
+ .ds-heading--700 { font-size: var(--ds-font-size-700); }
15
+ .ds-heading--600 { font-size: var(--ds-font-size-600); }
16
+ .ds-heading--500 { font-size: var(--ds-font-size-500); }
17
+ .ds-heading--400 { font-size: var(--ds-font-size-400); }
18
+ .ds-heading--300 { font-size: var(--ds-font-size-300); }
19
+ .ds-heading--200 { font-size: var(--ds-font-size-200); }
20
+
21
+ .ds-text {
22
+ margin: 0;
23
+ font-family: var(--ds-font-body);
24
+ font-weight: var(--ds-font-weight-regular);
25
+ line-height: var(--ds-line-height-normal);
26
+ color: var(--ds-color-font-0);
27
+ }
28
+
29
+ .ds-text--100 { font-size: var(--ds-font-size-100); }
30
+ .ds-text--200 { font-size: var(--ds-font-size-200); }
31
+ .ds-text--300 { font-size: var(--ds-font-size-300); }
32
+
33
+ .ds-text--muted {
34
+ color: var(--ds-color-font-1);
35
+ }
@@ -0,0 +1,2 @@
1
+ export { Heading } from "./Heading.jsx";
2
+ export { Text } from "./Text.jsx";
package/src/index.js ADDED
@@ -0,0 +1,12 @@
1
+ export { ThemeProvider } from "./theme/ThemeProvider.jsx";
2
+ export { useTheme } from "./theme/useTheme.js";
3
+ export { THEMES, THEME_IDS, DEFAULT_THEME } from "./theme/themeConstants.js";
4
+
5
+ export { Button } from "./components/Button/index.js";
6
+ export { Card } from "./components/Card/index.js";
7
+ export { Header } from "./components/Header/index.js";
8
+ export { Nav } from "./components/Nav/index.js";
9
+ export { Footer } from "./components/Footer/index.js";
10
+ export { ThemeSwitcher } from "./components/ThemeSwitcher/index.js";
11
+ export { Heading, Text } from "./components/Typography/index.js";
12
+ export { Container, Stack, Grid } from "./components/Layout/index.js";
@@ -0,0 +1,3 @@
1
+ import { createContext } from "react";
2
+
3
+ export const ThemeContext = createContext(null);
@@ -0,0 +1,66 @@
1
+ import { useCallback, useLayoutEffect, useMemo, useState } from "react";
2
+ import { ThemeContext } from "./ThemeContext.js";
3
+ import { THEMES, THEME_IDS, DEFAULT_THEME } from "./themeConstants.js";
4
+
5
+ function readStoredTheme(storageKey, fallback) {
6
+ if (typeof window === "undefined") return fallback;
7
+ try {
8
+ const stored = window.localStorage.getItem(storageKey);
9
+ return THEME_IDS.includes(stored) ? stored : fallback;
10
+ } catch {
11
+ return fallback;
12
+ }
13
+ }
14
+
15
+ /**
16
+ * data-theme lives on <html> rather than a class or a wrapper div so every
17
+ * consuming site (song-chart used a class on <html>, mattwelker.com used a
18
+ * class on a wrapper div) lands on one shared convention.
19
+ */
20
+ export function ThemeProvider({
21
+ children,
22
+ storageKey = "ds.theme",
23
+ defaultTheme = DEFAULT_THEME,
24
+ onThemeChange,
25
+ }) {
26
+ const [theme, setThemeState] = useState(() =>
27
+ readStoredTheme(storageKey, defaultTheme)
28
+ );
29
+
30
+ useLayoutEffect(() => {
31
+ document.documentElement.setAttribute("data-theme", theme);
32
+ try {
33
+ window.localStorage.setItem(storageKey, theme);
34
+ } catch {
35
+ // localStorage unavailable (private browsing, etc.) — theme still applies for this session
36
+ }
37
+ }, [theme, storageKey]);
38
+
39
+ useLayoutEffect(() => {
40
+ function handleStorage(event) {
41
+ if (event.key === storageKey && THEME_IDS.includes(event.newValue)) {
42
+ setThemeState(event.newValue);
43
+ }
44
+ }
45
+ window.addEventListener("storage", handleStorage);
46
+ return () => window.removeEventListener("storage", handleStorage);
47
+ }, [storageKey]);
48
+
49
+ const setTheme = useCallback(
50
+ (id) => {
51
+ if (!THEME_IDS.includes(id)) return;
52
+ setThemeState(id);
53
+ onThemeChange?.(id);
54
+ },
55
+ [onThemeChange]
56
+ );
57
+
58
+ const value = useMemo(
59
+ () => ({ theme, setTheme, themes: THEMES }),
60
+ [theme, setTheme]
61
+ );
62
+
63
+ return (
64
+ <ThemeContext.Provider value={value}>{children}</ThemeContext.Provider>
65
+ );
66
+ }
@@ -0,0 +1,12 @@
1
+ export const THEMES = [
2
+ { id: "coral", label: "Coral — creative warm", swatch: "#ff6b6b" },
3
+ { id: "ink", label: "Ink — dark indigo", swatch: "#6366f1" },
4
+ { id: "sage", label: "Sage — muted olive", swatch: "#6b7568" },
5
+ { id: "midnight", label: "Midnight — high contrast", swatch: "#00d4ff" },
6
+ { id: "amber", label: "Amber — golden", swatch: "#b8652a" },
7
+ { id: "harvest", label: "Harvest — earthy warm", swatch: "#9f5508" },
8
+ ];
9
+
10
+ export const THEME_IDS = THEMES.map((theme) => theme.id);
11
+
12
+ export const DEFAULT_THEME = "coral";
@@ -0,0 +1,10 @@
1
+ import { useContext } from "react";
2
+ import { ThemeContext } from "./ThemeContext.js";
3
+
4
+ export function useTheme() {
5
+ const context = useContext(ThemeContext);
6
+ if (!context) {
7
+ throw new Error("useTheme must be used within a ThemeProvider");
8
+ }
9
+ return context;
10
+ }
@@ -0,0 +1,21 @@
1
+ /*
2
+ Every theme pairs bg-0 (page surface) with font-0 (on-surface text) —
3
+ without this, the page background stays the browser default (white) while
4
+ text/components assume a themed surface behind them, e.g. font-0 is
5
+ near-white in every theme because it's meant to sit on the dark bg-0.
6
+ */
7
+ html,
8
+ body {
9
+ background-color: var(--ds-color-bg-0);
10
+ color: var(--ds-color-font-0);
11
+ }
12
+
13
+ body {
14
+ margin: 0;
15
+ min-height: 100vh;
16
+ font-family: var(--ds-font-body);
17
+ }
18
+
19
+ * {
20
+ box-sizing: border-box;
21
+ }
@@ -0,0 +1,29 @@
1
+ /*
2
+ Base color token shape. Every theme in themes.css overrides this full set
3
+ via [data-theme="<id>"]. These :root values are the default (coral) theme,
4
+ so components render sensibly even before ThemeProvider sets data-theme.
5
+ */
6
+ :root {
7
+ --ds-color-url: #ff8787;
8
+ --ds-color-visited: #ffb4a2;
9
+
10
+ --ds-color-bg-0: #1a2f2f;
11
+ --ds-color-bg-1: #2d4a4a;
12
+ --ds-color-bg-2: #ff6b6b;
13
+ --ds-color-bg-3: #5c8a8a;
14
+ --ds-color-bg-4: #f7f0eb;
15
+
16
+ --ds-color-font-0: #fff5f0;
17
+ --ds-color-font-1: #ffb4a2;
18
+ --ds-color-font-2: #ff6b6b;
19
+ --ds-color-font-3: #2d4a4a;
20
+ --ds-color-font-4: #1a2f2f;
21
+
22
+ --ds-color-bw-0: #142424;
23
+ --ds-color-bw-1: #2d4a4a;
24
+ --ds-color-bw-2: #5c8a8a;
25
+ --ds-color-bw-3: #c5d5d5;
26
+ --ds-color-bw-4: #fff;
27
+
28
+ --ds-color-highlight: #7c3aed;
29
+ }
@@ -0,0 +1,10 @@
1
+ /*
2
+ Shadows are tinted with the active theme's accent (--ds-color-bg-2) at low
3
+ opacity rather than flat black, since none of the 3 sites uses any shadow
4
+ today and a flat-black shadow would read as generic rather than "on-brand".
5
+ */
6
+ :root {
7
+ --ds-shadow-sm: 0 1px 3px color-mix(in srgb, var(--ds-color-bg-2) 18%, transparent);
8
+ --ds-shadow-md: 0 6px 16px color-mix(in srgb, var(--ds-color-bg-2) 20%, transparent);
9
+ --ds-shadow-lg: 0 16px 40px color-mix(in srgb, var(--ds-color-bg-2) 24%, transparent);
10
+ }
@@ -0,0 +1,8 @@
1
+ @import "./colors.css";
2
+ @import "./themes.css";
3
+ @import "./base.css";
4
+ @import "./typography.css";
5
+ @import "./spacing.css";
6
+ @import "./radius.css";
7
+ @import "./elevation.css";
8
+ @import "./motion.css";
@@ -0,0 +1,6 @@
1
+ :root {
2
+ --ds-motion-duration-fast: 120ms;
3
+ --ds-motion-duration-base: 200ms;
4
+ --ds-motion-duration-slow: 320ms;
5
+ --ds-motion-ease-standard: cubic-bezier(0.2, 0, 0, 1);
6
+ }
@@ -0,0 +1,6 @@
1
+ :root {
2
+ --ds-radius-sm: 6px;
3
+ --ds-radius-md: 10px;
4
+ --ds-radius-lg: 16px;
5
+ --ds-radius-pill: 999px;
6
+ }
@@ -0,0 +1,10 @@
1
+ :root {
2
+ --ds-space-1: 0.25rem;
3
+ --ds-space-2: 0.5rem;
4
+ --ds-space-3: 0.75rem;
5
+ --ds-space-4: 1rem;
6
+ --ds-space-5: 1.5rem;
7
+ --ds-space-6: 2rem;
8
+ --ds-space-7: 3rem;
9
+ --ds-space-8: 4rem;
10
+ }
@@ -0,0 +1,156 @@
1
+ /*
2
+ Canonical theme set. Carries song-chart's 5 themes forward near-verbatim
3
+ (ink, sage, coral, midnight, amber) plus one new "harvest" theme
4
+ consolidating mattwelker.com's warm/retro/cirro family into a single palette.
5
+ Applied via ThemeProvider setting data-theme="<id>" on <html>.
6
+ */
7
+
8
+ [data-theme="ink"] {
9
+ --ds-color-url: #818cf8;
10
+ --ds-color-visited: #a5b4fc;
11
+
12
+ --ds-color-bg-0: #0d0f14;
13
+ --ds-color-bg-1: #1a1f2e;
14
+ --ds-color-bg-2: #6366f1;
15
+ --ds-color-bg-3: #2d3348;
16
+ --ds-color-bg-4: #384260;
17
+
18
+ --ds-color-font-0: #ffffff;
19
+ --ds-color-font-1: #9aa8d4;
20
+ --ds-color-font-2: #818cf8;
21
+ --ds-color-font-3: #c5cce8;
22
+ --ds-color-font-4: #f0f2ff;
23
+
24
+ --ds-color-bw-0: #000;
25
+ --ds-color-bw-1: #1e2230;
26
+ --ds-color-bw-2: #4a5068;
27
+ --ds-color-bw-3: #5a6380;
28
+ --ds-color-bw-4: #fff;
29
+
30
+ --ds-color-highlight: #fbbf24;
31
+ }
32
+
33
+ [data-theme="sage"] {
34
+ --ds-color-url: #7a8578;
35
+ --ds-color-visited: #8a9388;
36
+
37
+ --ds-color-bg-0: #3a3f38;
38
+ --ds-color-bg-1: #4a5248;
39
+ --ds-color-bg-2: #6b7568;
40
+ --ds-color-bg-3: #9aa392;
41
+ --ds-color-bg-4: #d4d8d0;
42
+
43
+ --ds-color-font-0: #e8ebe6;
44
+ --ds-color-font-1: #c5cbc0;
45
+ --ds-color-font-2: #6b7568;
46
+ --ds-color-font-3: #5c6359;
47
+ --ds-color-font-4: #3a3f38;
48
+
49
+ --ds-color-bw-0: #323632;
50
+ --ds-color-bw-1: #4a5248;
51
+ --ds-color-bw-2: #6b7568;
52
+ --ds-color-bw-3: #9aa392;
53
+ --ds-color-bw-4: #e8ebe6;
54
+
55
+ --ds-color-highlight: #6b4c9a;
56
+ }
57
+
58
+ [data-theme="coral"] {
59
+ --ds-color-url: #ff8787;
60
+ --ds-color-visited: #ffb4a2;
61
+
62
+ --ds-color-bg-0: #1a2f2f;
63
+ --ds-color-bg-1: #2d4a4a;
64
+ --ds-color-bg-2: #ff6b6b;
65
+ --ds-color-bg-3: #5c8a8a;
66
+ --ds-color-bg-4: #f7f0eb;
67
+
68
+ --ds-color-font-0: #fff5f0;
69
+ --ds-color-font-1: #ffb4a2;
70
+ --ds-color-font-2: #ff6b6b;
71
+ --ds-color-font-3: #2d4a4a;
72
+ --ds-color-font-4: #1a2f2f;
73
+
74
+ --ds-color-bw-0: #142424;
75
+ --ds-color-bw-1: #2d4a4a;
76
+ --ds-color-bw-2: #5c8a8a;
77
+ --ds-color-bw-3: #c5d5d5;
78
+ --ds-color-bw-4: #fff;
79
+
80
+ --ds-color-highlight: #7c3aed;
81
+ }
82
+
83
+ [data-theme="midnight"] {
84
+ --ds-color-url: #0099cc;
85
+ --ds-color-visited: #007aa3;
86
+
87
+ --ds-color-bg-0: #000000;
88
+ --ds-color-bg-1: #111111;
89
+ --ds-color-bg-2: #00d4ff;
90
+ --ds-color-bg-3: #ececec;
91
+ --ds-color-bg-4: #ffffff;
92
+
93
+ --ds-color-font-0: #ffffff;
94
+ --ds-color-font-1: #555555;
95
+ --ds-color-font-2: #007799;
96
+ --ds-color-font-3: #333333;
97
+ --ds-color-font-4: #000000;
98
+
99
+ --ds-color-bw-0: #000;
100
+ --ds-color-bw-1: #222;
101
+ --ds-color-bw-2: #666;
102
+ --ds-color-bw-3: #bbb;
103
+ --ds-color-bw-4: #fff;
104
+
105
+ --ds-color-highlight: #ff6600;
106
+ }
107
+
108
+ [data-theme="amber"] {
109
+ --ds-color-url: #a0522d;
110
+ --ds-color-visited: #8b4513;
111
+
112
+ --ds-color-bg-0: #1a1510;
113
+ --ds-color-bg-1: #2c2218;
114
+ --ds-color-bg-2: #b8652a;
115
+ --ds-color-bg-3: #d9cbb0;
116
+ --ds-color-bg-4: #f5efe3;
117
+
118
+ --ds-color-font-0: #faf6ef;
119
+ --ds-color-font-1: #6b5344;
120
+ --ds-color-font-2: #8b6914;
121
+ --ds-color-font-3: #4a3d30;
122
+ --ds-color-font-4: #1a1510;
123
+
124
+ --ds-color-bw-0: #100c08;
125
+ --ds-color-bw-1: #3d2e1f;
126
+ --ds-color-bw-2: #6b5344;
127
+ --ds-color-bw-3: #b8a088;
128
+ --ds-color-bw-4: #fff;
129
+
130
+ --ds-color-highlight: #1d4ed8;
131
+ }
132
+
133
+ [data-theme="harvest"] {
134
+ --ds-color-url: #f4230c;
135
+ --ds-color-visited: #9bb4e2;
136
+
137
+ --ds-color-bg-0: #005558;
138
+ --ds-color-bg-1: #684022;
139
+ --ds-color-bg-2: #9f5508;
140
+ --ds-color-bg-3: #decebd;
141
+ --ds-color-bg-4: #fdf7e3;
142
+
143
+ --ds-color-font-0: #fdf7e3;
144
+ --ds-color-font-1: #decebd;
145
+ --ds-color-font-2: #9f5508;
146
+ --ds-color-font-3: #684022;
147
+ --ds-color-font-4: #005558;
148
+
149
+ --ds-color-bw-0: #000;
150
+ --ds-color-bw-1: #333;
151
+ --ds-color-bw-2: #666;
152
+ --ds-color-bw-3: #ccc;
153
+ --ds-color-bw-4: #fff;
154
+
155
+ --ds-color-highlight: #f4230c;
156
+ }
@@ -0,0 +1,29 @@
1
+ /*
2
+ Shared type scale. Space Grotesk is already used by song-chart and
3
+ mattwelker.com; consuming apps must load the Google Fonts <link> themselves
4
+ (this package doesn't inject font-loading side effects). Pangolin is kept
5
+ as an optional decorative token for mattwelker.com's setlist page only.
6
+ */
7
+ :root {
8
+ --ds-font-body: "Space Grotesk", system-ui, -apple-system, "Segoe UI", Roboto,
9
+ Helvetica, Arial, sans-serif;
10
+ --ds-font-decorative: "Pangolin", cursive;
11
+
12
+ --ds-font-weight-regular: 400;
13
+ --ds-font-weight-medium: 500;
14
+ --ds-font-weight-semibold: 600;
15
+ --ds-font-weight-bold: 700;
16
+
17
+ --ds-line-height-tight: 1.15;
18
+ --ds-line-height-normal: 1.5;
19
+ --ds-line-height-relaxed: 1.7;
20
+
21
+ /* modular scale, ~1.25 ratio, base 1rem */
22
+ --ds-font-size-100: 0.8rem;
23
+ --ds-font-size-200: 1rem;
24
+ --ds-font-size-300: 1.25rem;
25
+ --ds-font-size-400: 1.563rem;
26
+ --ds-font-size-500: 1.953rem;
27
+ --ds-font-size-600: 2.441rem;
28
+ --ds-font-size-700: 3.052rem;
29
+ }
package/vite-plugin.js ADDED
@@ -0,0 +1,30 @@
1
+ import path from "node:path";
2
+ import { fileURLToPath } from "node:url";
3
+
4
+ const packageRoot = path.dirname(fileURLToPath(import.meta.url));
5
+ const designSystemSrc = path.join(packageRoot, "src");
6
+
7
+ /**
8
+ * Vite only watches files inside the consuming project's root by default, so
9
+ * edits made through the symlinked file: dependency (which lives outside
10
+ * that root) are invisible to HMR no matter what `server.watch.ignored`
11
+ * patterns say. This plugin explicitly adds the design system's real source
12
+ * directory to the watcher and forces a full reload when anything in it
13
+ * changes. Add it to every site's vite.config.js alongside the file:
14
+ * dependency on @5voltfx/design-system.
15
+ */
16
+ export function watchDesignSystem() {
17
+ return {
18
+ name: "watch-design-system",
19
+ configureServer(server) {
20
+ server.watcher.add(designSystemSrc);
21
+ server.watcher.on("change", (file) => {
22
+ if (file.startsWith(designSystemSrc)) {
23
+ server.ws.send({ type: "full-reload" });
24
+ }
25
+ });
26
+ },
27
+ };
28
+ }
29
+
30
+ export const DESIGN_SYSTEM_SRC = designSystemSrc;