create-fluxstack 1.18.0 → 1.19.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 (54) hide show
  1. package/CHANGELOG.md +132 -0
  2. package/app/client/src/App.tsx +7 -7
  3. package/app/client/src/components/AppLayout.tsx +60 -23
  4. package/app/client/src/components/ColorWheel.tsx +195 -0
  5. package/app/client/src/components/DemoPage.tsx +5 -3
  6. package/app/client/src/components/LiveUploadWidget.tsx +1 -1
  7. package/app/client/src/components/ThemePicker.tsx +307 -0
  8. package/app/client/src/config/theme.config.ts +127 -0
  9. package/app/client/src/hooks/useThemeClock.ts +66 -0
  10. package/app/client/src/index.css +193 -0
  11. package/app/client/src/lib/theme-clock.ts +201 -0
  12. package/app/client/src/live/AuthDemo.tsx +9 -9
  13. package/app/client/src/live/CounterDemo.tsx +10 -10
  14. package/app/client/src/live/FormDemo.tsx +8 -8
  15. package/app/client/src/live/PingPongDemo.tsx +10 -10
  16. package/app/client/src/live/RoomChatDemo.tsx +10 -10
  17. package/app/client/src/live/SharedCounterDemo.tsx +5 -5
  18. package/app/client/src/pages/ApiTestPage.tsx +5 -5
  19. package/app/client/src/pages/HomePage.tsx +12 -12
  20. package/app/server/index.ts +8 -0
  21. package/app/server/live/auto-generated-components.ts +1 -1
  22. package/app/server/live/rooms/ChatRoom.ts +13 -8
  23. package/app/server/routes/index.ts +20 -10
  24. package/core/build/index.ts +1 -1
  25. package/core/cli/command-registry.ts +1 -1
  26. package/core/cli/commands/build.ts +25 -6
  27. package/core/cli/commands/plugin-deps.ts +1 -2
  28. package/core/cli/generators/plugin.ts +433 -581
  29. package/core/framework/server.ts +34 -8
  30. package/core/index.ts +6 -5
  31. package/core/plugins/index.ts +71 -199
  32. package/core/plugins/types.ts +76 -461
  33. package/core/server/index.ts +1 -1
  34. package/core/utils/logger/startup-banner.ts +26 -4
  35. package/core/utils/version.ts +6 -6
  36. package/create-fluxstack.ts +216 -107
  37. package/package.json +108 -107
  38. package/tsconfig.json +2 -1
  39. package/app/client/.live-stubs/LiveAdminPanel.js +0 -15
  40. package/app/client/.live-stubs/LiveCounter.js +0 -9
  41. package/app/client/.live-stubs/LiveForm.js +0 -11
  42. package/app/client/.live-stubs/LiveLocalCounter.js +0 -8
  43. package/app/client/.live-stubs/LivePingPong.js +0 -10
  44. package/app/client/.live-stubs/LiveRoomChat.js +0 -11
  45. package/app/client/.live-stubs/LiveSharedCounter.js +0 -10
  46. package/app/client/.live-stubs/LiveUpload.js +0 -15
  47. package/core/plugins/config.ts +0 -356
  48. package/core/plugins/dependency-manager.ts +0 -481
  49. package/core/plugins/discovery.ts +0 -379
  50. package/core/plugins/executor.ts +0 -353
  51. package/core/plugins/manager.ts +0 -645
  52. package/core/plugins/module-resolver.ts +0 -227
  53. package/core/plugins/registry.ts +0 -913
  54. package/vitest.config.live.ts +0 -69
package/CHANGELOG.md CHANGED
@@ -2,6 +2,138 @@
2
2
 
3
3
  All notable changes to FluxStack will be documented in this file.
4
4
 
5
+ ## [1.19.0] - 2026-04-11
6
+
7
+ ### Major Refactor: Plugin System Extracted to `@fluxstack/plugin-kit`
8
+
9
+ The plugin system (manager, registry, executor, discovery, dependency
10
+ manager, module resolver, types) has been extracted from `core/plugins/`
11
+ into the new standalone package `@fluxstack/plugin-kit`. This is the
12
+ same playbook we used for `@fluxstack/live` in 1.16.0 — implementation
13
+ lives in the lib, `core/plugins/` is now a thin re-export shim layer
14
+ for backwards compatibility with existing `@core/plugins/*` imports.
15
+
16
+ **Impact:** ~3,300 lines of plugin system implementation deleted from
17
+ the app, `core/plugins/` went from 9 runtime files (4017 lines) to
18
+ 2 thin shims (types.ts + index.ts, ~200 lines combined). Single
19
+ source of truth for plugin types and runtime is now
20
+ `@fluxstack/plugin-kit`.
21
+
22
+ ### Breaking Changes
23
+
24
+ - **Auto-discovery removed from `PluginManager.initialize()`** (plugin-kit 0.4.0).
25
+ The old code called `readdir('node_modules')` and `readdir('plugins')`
26
+ at startup to discover plugins. This silently broke in production
27
+ bundles where those directories don't exist, and prevented bundlers
28
+ from statically including plugin code. Host apps must now register
29
+ plugins explicitly via `framework.use(pluginObject)`. Dev and prod
30
+ are now identical; the bundler can tree-shake.
31
+
32
+ - **`PluginManager` constructor requires `settings` and `clientHooks`**
33
+ explicitly. These used to be read from the full config / imported
34
+ as module-level singletons. Now they're injected:
35
+ ```ts
36
+ new PluginManager<FluxStackConfig>({
37
+ config: fullConfig,
38
+ settings: fullConfig.plugins,
39
+ logger: pluginLogger,
40
+ clientHooks: { register: (...) => pluginClientHooks.register(...) },
41
+ app: this.app,
42
+ })
43
+ ```
44
+
45
+ - **`FluxStack.Plugin` generic over `TConfig`**. The legacy form
46
+ (no generic) still works and defaults to `unknown`. FluxStack's
47
+ shim specializes to `Plugin<FluxStackConfig>`.
48
+
49
+ - **Plugin classes discouraged, object literals are canonical**.
50
+ All built-in plugins and `@fluxstack/plugin-csrf-protection` use
51
+ `export const xxxPlugin: Plugin = { name, setup, ... }`. The
52
+ `class X implements Plugin` form is retired from the generators
53
+ but still technically accepted at runtime (it implements the
54
+ same interface).
55
+
56
+ ### Added
57
+
58
+ - **`@fluxstack/plugin-kit`** — new npm package. Types + runtime
59
+ for the plugin system. Published at 0.4.0 as of this release.
60
+ Used by FluxStack app and external plugin packages alike.
61
+ - **`tests/integration/framework/registered-plugins.test.ts`** (10 tests)
62
+ — end-to-end verification that the four plugins registered via
63
+ `framework.use()` in `app/server/index.ts` actually inject their
64
+ hooks at runtime. Catches the class of bug where plugin objects
65
+ are registered but no hook actually fires (the exact failure
66
+ mode of the old auto-discovery in prod bundles).
67
+ - **Static plugin registration everywhere**. `app/server/index.ts`
68
+ now imports `csrfProtectionPlugin` from `@fluxstack/plugin-csrf-protection`
69
+ directly and registers via `.use()`. Same pattern as the built-ins.
70
+ - **Startup banner reads from the PluginRegistry**. The banner line
71
+ `Plugins (N): ...` now lists exactly what `framework.getPluginRegistry().getAll()`
72
+ returns, instead of relying on each plugin manually pushing itself
73
+ to `globalThis.__fluxstackPlugins`. Backwards compatible — the old
74
+ global is still read as a fallback.
75
+ - **`@fluxstack/sdk` deprecated on npm** with a message pointing
76
+ users to `@fluxstack/plugin-kit`. The SDK was a static copy of
77
+ plugin types + a duplicate of `@fluxstack/config`; both have
78
+ canonical sources now.
79
+ - **`make:plugin` CLI** generates plain object literal plugins
80
+ importing from `@fluxstack/plugin-kit`. Identifier generation
81
+ fixed: `my-plugin` → `myPlugin` (not `myPluginPlugin`).
82
+
83
+ ### Changed
84
+
85
+ - **`@fluxstack/live` family bumped** from `^0.6.0` to `^0.7.1`.
86
+ Ships three follow-up bug fixes: opt-in `includeSelf` on `$room`
87
+ proxy emit (#15), `deepAssign` clones plain objects to break
88
+ external aliasing (#13), fail-loud protocol framing + telemetry (#7).
89
+ - **`create-fluxstack` README template**: removed the `loggerPlugin`
90
+ example (that plugin was deleted), replaced class-based plugin
91
+ example with object literal, added a hook reference table.
92
+ - **`plugins/README.md` template**: rewritten to reflect the static
93
+ `.use()` model. Explicitly calls out that plugins are NOT
94
+ auto-discovered. Points at `@fluxstack/plugin-csrf-protection`
95
+ as the living reference implementation.
96
+ - **Bundle prod size** grew from ~2.46 MB to ~3.34 MB because
97
+ `@fluxstack/plugin-csrf-protection` is now statically included.
98
+ Before, it was dynamically loaded via `readdir('node_modules')`
99
+ and the bundler couldn't see it.
100
+ - **Vite plugin startup banner label fixed** — `| Vite: embedded`
101
+ only shows in development now. In production the vite plugin
102
+ runs in static-fallback mode (serving `dist/client/`) and doesn't
103
+ actually run a Vite dev server, so the label was misleading.
104
+
105
+ ### Removed
106
+
107
+ - **6934 lines of dead test code** across 24 test files. Orphaned
108
+ tests under `core/**/__tests__/*` never ran (vitest config was
109
+ `include: tests/**/*.test.ts`) and 14 of 18 were broken on
110
+ import when run directly. Also deleted 5 `describe.skip`'d test
111
+ suites under `tests/` with abandoned TODOs pointing at APIs
112
+ that were refactored away (Eden Treaty, ProjectCreator). Plus
113
+ `vitest.config.live.ts`, an orphan config pointing at a
114
+ directory that doesn't exist anymore.
115
+ - **`core/plugins/{manager,registry,executor,discovery,dependency-manager,module-resolver,config}.ts`**
116
+ deleted from FluxStack app. Implementation lives in
117
+ `@fluxstack/plugin-kit` now. `core/plugins/types.ts` and
118
+ `core/plugins/index.ts` kept as thin shim barrels that re-export
119
+ from the lib and specialize `<TConfig>` against `FluxStackConfig`.
120
+ - **Deprecated `configSchema` and `defaultConfig` fields** from the
121
+ Plugin interface. Were marked `@deprecated` and had no call sites.
122
+ Plugins use `@fluxstack/config` for declarative config instead.
123
+ - **`loggerPlugin`** — old built-in plugin that was already absent
124
+ from the real codebase but still referenced in generated templates.
125
+ Template references removed.
126
+
127
+ ### Validation
128
+
129
+ - Typecheck (`bunx tsc --noEmit -p tsconfig.api-strict.json`) holds
130
+ at the 60 pre-existing errors baseline throughout every step —
131
+ zero regression across all four phases of the extraction.
132
+ - Full test suite: 42 test files, 652 passing, 5 skipped (all
133
+ intentional individual `it.skip` TODOs).
134
+ - Dev and prod both show `Plugins (4): swagger, live-components,
135
+ csrf-protection, vite` in the startup banner — identical output.
136
+
5
137
  ## [1.16.0] - 2026-03-13
6
138
 
7
139
  ### Major Refactor: Extract Live Components to Monorepo
@@ -21,11 +21,11 @@ function NotFoundPage() {
21
21
  <h1 className="text-6xl font-black text-white mb-4">404</h1>
22
22
  <p className="text-xl text-gray-400 mb-6">Pagina nao encontrada</p>
23
23
  <p className="text-sm text-gray-500 mb-8">
24
- O caminho <code className="text-purple-400">{window.location.pathname}</code> nao existe.
24
+ O caminho <code className="text-theme">{window.location.pathname}</code> nao existe.
25
25
  </p>
26
26
  <a
27
27
  href="/"
28
- className="px-6 py-3 rounded-xl bg-purple-500/20 border border-purple-500/30 text-purple-200 hover:bg-purple-500/30 transition-all"
28
+ className="px-6 py-3 rounded-xl bg-theme-muted border border-theme-active text-theme hover:bg-theme-muted transition-all"
29
29
  >
30
30
  Voltar ao inicio
31
31
  </a>
@@ -129,7 +129,7 @@ function AppContent() {
129
129
  path="/form"
130
130
  element={
131
131
  <DemoPage
132
- note={<>? Este formul?rio usa <code className="text-purple-400">Live.use()</code> - cada campo sincroniza automaticamente com o servidor!</>}
132
+ note={<>? Este formul?rio usa <code className="text-theme">Live.use()</code> - cada campo sincroniza automaticamente com o servidor!</>}
133
133
  >
134
134
  <FormDemo />
135
135
  </DemoPage>
@@ -155,7 +155,7 @@ function AppContent() {
155
155
  path="/shared-counter"
156
156
  element={
157
157
  <DemoPage
158
- note={<>Contador compartilhado usando <code className="text-purple-400">LiveRoom</code> - abra em varias abas!</>}
158
+ note={<>Contador compartilhado usando <code className="text-theme">LiveRoom</code> - abra em varias abas!</>}
159
159
  >
160
160
  <SharedCounterDemo />
161
161
  </DemoPage>
@@ -165,7 +165,7 @@ function AppContent() {
165
165
  path="/room-chat"
166
166
  element={
167
167
  <DemoPage
168
- note={<>Chat com múltiplas salas usando o sistema <code className="text-purple-400">$room</code>.</>}
168
+ note={<>Chat com múltiplas salas usando o sistema <code className="text-theme">$room</code>.</>}
169
169
  >
170
170
  <RoomChatDemo />
171
171
  </DemoPage>
@@ -175,7 +175,7 @@ function AppContent() {
175
175
  path="/auth"
176
176
  element={
177
177
  <DemoPage
178
- note={<>🔒 Sistema de autenticação declarativo para Live Components com <code className="text-purple-400">$auth</code>!</>}
178
+ note={<>🔒 Sistema de autenticação declarativo para Live Components com <code className="text-theme">$auth</code>!</>}
179
179
  >
180
180
  <AuthDemo />
181
181
  </DemoPage>
@@ -185,7 +185,7 @@ function AppContent() {
185
185
  path="/ping-pong"
186
186
  element={
187
187
  <DemoPage
188
- note={<>Latency demo com <code className="text-cyan-400">msgpack</code> binary codec - mensagens binárias no WebSocket!</>}
188
+ note={<>Latency demo com <code className="text-theme-secondary">msgpack</code> binary codec - mensagens binárias no WebSocket!</>}
189
189
  >
190
190
  <PingPongDemo />
191
191
  </DemoPage>
@@ -3,6 +3,10 @@ import { Link, Outlet, useLocation } from 'react-router'
3
3
  import { FaBook, FaGithub, FaBars, FaTimes } from 'react-icons/fa'
4
4
  import FluxStackLogo from '@client/src/assets/fluxstack.svg'
5
5
  import faviconSvg from '@client/src/assets/fluxstack-static.svg?raw'
6
+ import { useThemeClock } from '../hooks/useThemeClock'
7
+ import { ThemePicker } from './ThemePicker'
8
+ import type { ColorPalette } from '../lib/theme-clock'
9
+ import { themeConfig } from '../config/theme.config'
6
10
 
7
11
  const navItems = [
8
12
  { to: '/', label: 'Home' },
@@ -36,13 +40,16 @@ const faviconUrlCache = new Map<string, string>()
36
40
  export function AppLayout() {
37
41
  const location = useLocation()
38
42
  const [menuOpen, setMenuOpen] = useState(false)
43
+ const autoTheme = useThemeClock()
44
+ const [overrideTheme, setOverrideTheme] = useState<ColorPalette | null>(null)
45
+ const theme = overrideTheme || autoTheme
39
46
 
40
47
  useEffect(() => {
41
48
  const current = navItems.find(item => item.to === location.pathname)
42
49
  document.title = current ? `${current.label} - FluxStack` : 'FluxStack'
43
50
 
44
- // Dynamic favicon with hue-rotate (cached per hue value)
45
- const hue = routeFlameHue[location.pathname] || '0deg'
51
+ // Dynamic favicon with hue-rotate based on theme clock
52
+ const hue = `${Math.round(theme.baseHue - 270)}deg`
46
53
  let url = faviconUrlCache.get(hue)
47
54
  if (!url) {
48
55
  // Evict oldest entry if cache is full, revoking blob URL to free memory
@@ -68,20 +75,27 @@ export function AppLayout() {
68
75
  }
69
76
  link.type = 'image/svg+xml'
70
77
  link.href = url
71
- }, [location.pathname])
78
+ }, [location.pathname, theme.baseHue])
72
79
 
73
80
  return (
74
- <div className="min-h-screen bg-gradient-to-br from-slate-900 via-purple-900 to-slate-900">
75
- <header className="sticky top-0 z-50 backdrop-blur-md bg-slate-900/60 border-b border-white/10">
81
+ <div className="min-h-screen text-white flex flex-col" style={{ backgroundColor: `oklch(8% 0.02 ${theme.baseHue})` }}>
82
+ <header className="sticky top-0 z-50 backdrop-blur-xl bg-[#0a0a1a]/80 border-b border-white/[0.06]">
76
83
  <div className="container mx-auto px-4 sm:px-6 py-3 sm:py-4 flex items-center justify-between gap-4">
77
- <Link to="/" className="flex items-center gap-2 text-white font-semibold tracking-wide">
84
+ <Link to="/" className="flex items-center gap-2 font-semibold tracking-wide">
78
85
  <img
79
86
  src={FluxStackLogo}
80
87
  alt="FluxStack"
81
- className="w-9 h-9 transition-[filter] duration-500 drop-shadow-[0_0_8px_rgba(168,85,247,0.5)]"
82
- style={{ filter: `hue-rotate(${routeFlameHue[location.pathname] || '0deg'})` }}
88
+ className="w-9 h-9 transition-[filter] duration-500"
89
+ style={{
90
+ filter: `hue-rotate(${theme.baseHue - 270}deg) drop-shadow(0 0 8px ${theme.primaryGlow})`,
91
+ }}
83
92
  />
84
- FluxStack
93
+ <span
94
+ className="bg-clip-text text-transparent"
95
+ style={{ backgroundImage: theme.gradientPrimary }}
96
+ >
97
+ FluxStack
98
+ </span>
85
99
  </Link>
86
100
 
87
101
  {/* Desktop nav */}
@@ -94,9 +108,13 @@ export function AppLayout() {
94
108
  to={item.to}
95
109
  className={`px-3 py-1.5 rounded-lg text-sm transition-all ${
96
110
  active
97
- ? 'bg-white/15 text-white'
98
- : 'text-gray-300 hover:bg-white/10'
111
+ ? 'font-medium'
112
+ : 'text-gray-400 hover:text-white hover:bg-white/[0.05]'
99
113
  }`}
114
+ style={active ? {
115
+ backgroundColor: theme.primaryMuted,
116
+ color: theme.textPrimary,
117
+ } : undefined}
100
118
  >
101
119
  {item.label}
102
120
  </Link>
@@ -109,7 +127,7 @@ export function AppLayout() {
109
127
  href="https://live-docs.marcosbrendon.com/"
110
128
  target="_blank"
111
129
  rel="noopener noreferrer"
112
- className="hidden sm:inline-flex items-center gap-2 px-3 py-1.5 bg-purple-500/20 border border-purple-500/30 text-purple-200 rounded-lg text-sm hover:bg-purple-500/30 transition-all"
130
+ className="hidden sm:inline-flex items-center gap-2 px-3 py-1.5 bg-purple-500/20 border border-purple-500/20 text-purple-300 rounded-xl text-sm hover:bg-purple-500/30 transition-all"
113
131
  >
114
132
  <FaBook />
115
133
  Live Docs
@@ -118,7 +136,7 @@ export function AppLayout() {
118
136
  href="/swagger"
119
137
  target="_blank"
120
138
  rel="noopener noreferrer"
121
- className="hidden sm:inline-flex items-center gap-2 px-3 py-1.5 bg-white/10 border border-white/20 text-white rounded-lg text-sm hover:bg-white/20 transition-all"
139
+ className="hidden sm:inline-flex items-center gap-2 px-3 py-1.5 bg-white/[0.03] border border-white/[0.06] text-gray-400 rounded-xl text-sm hover:bg-white/[0.06] hover:text-white transition-all"
122
140
  >
123
141
  <FaBook />
124
142
  API Docs
@@ -127,7 +145,7 @@ export function AppLayout() {
127
145
  href="https://github.com/MarcosBrendonDePaula/FluxStack"
128
146
  target="_blank"
129
147
  rel="noopener noreferrer"
130
- className="hidden sm:inline-flex items-center gap-2 px-3 py-1.5 bg-white/10 border border-white/20 text-white rounded-lg text-sm hover:bg-white/20 transition-all"
148
+ className="hidden sm:inline-flex items-center gap-2 px-3 py-1.5 bg-white/[0.03] border border-white/[0.06] text-gray-400 rounded-xl text-sm hover:bg-white/[0.06] hover:text-white transition-all"
131
149
  >
132
150
  <FaGithub />
133
151
  GitHub
@@ -136,7 +154,7 @@ export function AppLayout() {
136
154
  {/* Mobile menu toggle */}
137
155
  <button
138
156
  onClick={() => setMenuOpen(!menuOpen)}
139
- className="md:hidden p-2 text-gray-300 hover:text-white transition-colors"
157
+ className="md:hidden p-2 text-gray-400 hover:text-white transition-colors"
140
158
  aria-label="Toggle menu"
141
159
  >
142
160
  {menuOpen ? <FaTimes size={20} /> : <FaBars size={20} />}
@@ -146,7 +164,7 @@ export function AppLayout() {
146
164
 
147
165
  {/* Mobile nav */}
148
166
  {menuOpen && (
149
- <div className="md:hidden border-t border-white/10 bg-slate-900/90 backdrop-blur-md">
167
+ <div className="md:hidden border-t border-white/[0.06] bg-[#0a0a1a]/90 backdrop-blur-xl">
150
168
  <nav className="container mx-auto px-4 py-3 flex gap-4 relative">
151
169
  <div className="flex flex-col gap-1 flex-1">
152
170
  {navItems.map((item) => {
@@ -158,20 +176,24 @@ export function AppLayout() {
158
176
  onClick={() => setMenuOpen(false)}
159
177
  className={`px-3 py-2 rounded-lg text-sm transition-all ${
160
178
  active
161
- ? 'bg-white/15 text-white'
162
- : 'text-gray-300 hover:bg-white/10'
179
+ ? 'font-medium'
180
+ : 'text-gray-400 hover:text-white hover:bg-white/[0.05]'
163
181
  }`}
182
+ style={active ? {
183
+ backgroundColor: theme.primaryMuted,
184
+ color: theme.textPrimary,
185
+ } : undefined}
164
186
  >
165
187
  {item.label}
166
188
  </Link>
167
189
  )
168
190
  })}
169
- <div className="flex flex-wrap gap-2 mt-2 pt-2 border-t border-white/10">
191
+ <div className="flex flex-wrap gap-2 mt-2 pt-2 border-t border-white/[0.06]">
170
192
  <a
171
193
  href="https://live-docs.marcosbrendon.com/"
172
194
  target="_blank"
173
195
  rel="noopener noreferrer"
174
- className="inline-flex items-center gap-2 px-3 py-2 bg-purple-500/20 border border-purple-500/30 text-purple-200 rounded-lg text-sm hover:bg-purple-500/30 transition-all"
196
+ className="inline-flex items-center gap-2 px-3 py-2 bg-purple-500/20 border border-purple-500/20 text-purple-300 rounded-xl text-sm hover:bg-purple-500/30 transition-all"
175
197
  >
176
198
  <FaBook />
177
199
  Live Docs
@@ -180,7 +202,7 @@ export function AppLayout() {
180
202
  href="/swagger"
181
203
  target="_blank"
182
204
  rel="noopener noreferrer"
183
- className="inline-flex items-center gap-2 px-3 py-2 bg-white/10 border border-white/20 text-white rounded-lg text-sm hover:bg-white/20 transition-all"
205
+ className="inline-flex items-center gap-2 px-3 py-2 bg-white/[0.03] border border-white/[0.06] text-gray-400 rounded-xl text-sm hover:bg-white/[0.06] hover:text-white transition-all"
184
206
  >
185
207
  <FaBook />
186
208
  API Docs
@@ -189,7 +211,7 @@ export function AppLayout() {
189
211
  href="https://github.com/MarcosBrendonDePaula/FluxStack"
190
212
  target="_blank"
191
213
  rel="noopener noreferrer"
192
- className="inline-flex items-center gap-2 px-3 py-2 bg-white/10 border border-white/20 text-white rounded-lg text-sm hover:bg-white/20 transition-all"
214
+ className="inline-flex items-center gap-2 px-3 py-2 bg-white/[0.03] border border-white/[0.06] text-gray-400 rounded-xl text-sm hover:bg-white/[0.06] hover:text-white transition-all"
193
215
  >
194
216
  <FaGithub />
195
217
  GitHub
@@ -209,7 +231,22 @@ export function AppLayout() {
209
231
  )}
210
232
  </header>
211
233
 
212
- <Outlet />
234
+ <main className="flex-1">
235
+ <Outlet />
236
+ </main>
237
+
238
+ <footer className="border-t border-white/[0.06] py-6 mt-auto">
239
+ <div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 text-center">
240
+ <p className="text-gray-500 text-sm">
241
+ Built with <span style={{ color: theme.primary }}>FluxStack</span> — Bun + Elysia + React
242
+ </p>
243
+ <p className="text-gray-600 text-xs mt-1">
244
+ 🎨 <span style={{ color: theme.primary }}>{theme.period}</span> palette — colors shift with the time of day
245
+ </p>
246
+ </div>
247
+ </footer>
248
+
249
+ {themeConfig.showPicker && <ThemePicker palette={theme} onOverride={setOverrideTheme} />}
213
250
  </div>
214
251
  )
215
252
  }
@@ -0,0 +1,195 @@
1
+ /**
2
+ * ColorWheel — Interactive color wheel like Adobe Color
3
+ *
4
+ * Modes:
5
+ * - Preset harmonies: all dots move together based on base hue
6
+ * - Custom: each dot is independently draggable
7
+ */
8
+ import { useRef, useState, useCallback, useEffect } from 'react'
9
+
10
+ export type HarmonyMode = 'analogous' | 'complementary' | 'triadic' | 'split-complementary' | 'square' | 'monochromatic' | 'custom'
11
+
12
+ interface ColorWheelProps {
13
+ /** Hues for each point: [primary, secondary, tertiary, complement, accent] */
14
+ hues: number[]
15
+ mode: HarmonyMode
16
+ size?: number
17
+ /** Called when any hue changes. index = which point, hue = new value */
18
+ onChange: (index: number, hue: number) => void
19
+ }
20
+
21
+ const POINT_LABELS = ['P', 'S', 'T', 'C', 'A']
22
+
23
+ /** Get harmony offsets for preset modes */
24
+ function getHarmonyOffsets(mode: HarmonyMode): number[] {
25
+ switch (mode) {
26
+ case 'analogous': return [0, -30, 30, -60, 60]
27
+ case 'complementary': return [0, 180]
28
+ case 'triadic': return [0, 120, 240]
29
+ case 'split-complementary': return [0, 150, 210]
30
+ case 'square': return [0, 90, 180, 270]
31
+ case 'monochromatic': return [0]
32
+ case 'custom': return [0, 40, -30, 180, 120] // defaults, but each is independent
33
+ }
34
+ }
35
+
36
+ /** Get number of points for each mode */
37
+ export function getPointCount(mode: HarmonyMode): number {
38
+ return getHarmonyOffsets(mode).length
39
+ }
40
+
41
+ /** Generate initial hues from base hue and mode */
42
+ export function generateHuesFromMode(baseHue: number, mode: HarmonyMode): number[] {
43
+ return getHarmonyOffsets(mode).map(o => ((baseHue + o) % 360 + 360) % 360)
44
+ }
45
+
46
+ function hueToXY(hue: number, radius: number, cx: number, cy: number) {
47
+ const rad = ((hue - 90) * Math.PI) / 180
48
+ return { x: cx + radius * Math.cos(rad), y: cy + radius * Math.sin(rad) }
49
+ }
50
+
51
+ function xyToHue(x: number, y: number, cx: number, cy: number): number {
52
+ return ((Math.atan2(y - cy, x - cx) * 180) / Math.PI + 90 + 360) % 360
53
+ }
54
+
55
+ export function ColorWheel({ hues, mode, size = 220, onChange }: ColorWheelProps) {
56
+ const svgRef = useRef<SVGSVGElement>(null)
57
+ const [dragging, setDragging] = useState<number | null>(null) // index of dragged point
58
+
59
+ const cx = size / 2
60
+ const cy = size / 2
61
+ const outerR = size / 2 - 8
62
+ const innerR = outerR - 24
63
+ const dotR = outerR - 12
64
+
65
+ const isCustom = mode === 'custom'
66
+
67
+ const getMouseHue = useCallback((e: MouseEvent | React.MouseEvent) => {
68
+ const svg = svgRef.current
69
+ if (!svg) return 0
70
+ const rect = svg.getBoundingClientRect()
71
+ return xyToHue(e.clientX - rect.left, e.clientY - rect.top, cx, cy)
72
+ }, [cx, cy])
73
+
74
+ // Mouse down on a specific point
75
+ const handlePointDown = useCallback((e: React.MouseEvent, index: number) => {
76
+ e.preventDefault()
77
+ e.stopPropagation()
78
+ setDragging(index)
79
+ }, [])
80
+
81
+ // Mouse down on wheel background — moves the base (index 0)
82
+ // Click on wheel background = rotate ALL points together (preserving relative positions)
83
+ const handleWheelDown = useCallback((e: React.MouseEvent) => {
84
+ e.preventDefault()
85
+ setDragging(-1) // -1 = rotate all
86
+ const hue = getMouseHue(e)
87
+ const delta = hue - hues[0]
88
+ for (let i = 0; i < hues.length; i++) {
89
+ onChange(i, ((hues[i] + delta) % 360 + 360) % 360)
90
+ }
91
+ }, [getMouseHue, hues, onChange])
92
+
93
+ useEffect(() => {
94
+ if (dragging === null) return
95
+
96
+ const handleMove = (e: MouseEvent) => {
97
+ const hue = getMouseHue(e)
98
+ if (dragging === -1) {
99
+ // Rotate all points together (background drag)
100
+ const delta = hue - hues[0]
101
+ for (let i = 0; i < hues.length; i++) {
102
+ onChange(i, ((hues[i] + delta) % 360 + 360) % 360)
103
+ }
104
+ } else if (isCustom) {
105
+ // Custom mode: move only the dragged point
106
+ onChange(dragging, hue)
107
+ } else {
108
+ // Preset mode: move all points relative to base
109
+ const delta = hue - hues[0]
110
+ for (let i = 0; i < hues.length; i++) {
111
+ onChange(i, ((hues[i] + delta) % 360 + 360) % 360)
112
+ }
113
+ }
114
+ }
115
+ const handleUp = () => setDragging(null)
116
+
117
+ window.addEventListener('mousemove', handleMove)
118
+ window.addEventListener('mouseup', handleUp)
119
+ return () => {
120
+ window.removeEventListener('mousemove', handleMove)
121
+ window.removeEventListener('mouseup', handleUp)
122
+ }
123
+ }, [dragging, getMouseHue, hues, isCustom, onChange])
124
+
125
+ return (
126
+ <svg
127
+ ref={svgRef}
128
+ width={size}
129
+ height={size}
130
+ onMouseDown={handleWheelDown}
131
+ style={{ cursor: dragging !== null ? 'grabbing' : 'pointer', userSelect: 'none' }}
132
+ >
133
+ {/* Hue ring segments */}
134
+ {Array.from({ length: 72 }, (_, i) => {
135
+ const a1 = (i / 72) * 360
136
+ const a2 = ((i + 1) / 72) * 360
137
+ const r1 = ((a1 - 90) * Math.PI) / 180
138
+ const r2 = ((a2 - 90) * Math.PI) / 180
139
+ return (
140
+ <path
141
+ key={i}
142
+ d={`M ${cx + outerR * Math.cos(r1)} ${cy + outerR * Math.sin(r1)} A ${outerR} ${outerR} 0 0 1 ${cx + outerR * Math.cos(r2)} ${cy + outerR * Math.sin(r2)} L ${cx + innerR * Math.cos(r2)} ${cy + innerR * Math.sin(r2)} A ${innerR} ${innerR} 0 0 0 ${cx + innerR * Math.cos(r1)} ${cy + innerR * Math.sin(r1)} Z`}
143
+ fill={`oklch(70% 0.22 ${a1})`}
144
+ />
145
+ )
146
+ })}
147
+
148
+ {/* Inner dark circle */}
149
+ <circle cx={cx} cy={cy} r={innerR - 2} fill="#0a0a1a" />
150
+
151
+ {/* Connection lines */}
152
+ {hues.length > 1 && (
153
+ <polygon
154
+ points={hues.map(h => {
155
+ const p = hueToXY(h, dotR * 0.55, cx, cy)
156
+ return `${p.x},${p.y}`
157
+ }).join(' ')}
158
+ fill={`oklch(65% 0.15 ${hues[0]} / 0.08)`}
159
+ stroke="rgba(255,255,255,0.12)"
160
+ strokeWidth="1"
161
+ />
162
+ )}
163
+
164
+ {/* Center color preview */}
165
+ <circle cx={cx} cy={cy} r={innerR * 0.35} fill={`oklch(65% 0.25 ${hues[0]})`} opacity="0.8" />
166
+ <text x={cx} y={cy - 6} textAnchor="middle" dominantBaseline="central" fill="white" fontSize="11" fontWeight="700">
167
+ {Math.round(hues[0])}°
168
+ </text>
169
+ <text x={cx} y={cy + 8} textAnchor="middle" dominantBaseline="central" fill="rgba(255,255,255,0.5)" fontSize="8">
170
+ {isCustom ? 'CUSTOM' : mode.toUpperCase()}
171
+ </text>
172
+
173
+ {/* Harmony dots — each draggable in custom mode */}
174
+ {hues.map((hue, i) => {
175
+ const pos = hueToXY(hue, dotR, cx, cy)
176
+ const isBase = i === 0
177
+ const isDraggable = isCustom || isBase
178
+ const r = isBase ? 9 : 7
179
+ return (
180
+ <g
181
+ key={i}
182
+ onMouseDown={isDraggable ? (e) => handlePointDown(e, i) : undefined}
183
+ style={{ cursor: isDraggable ? 'grab' : 'default' }}
184
+ >
185
+ <circle cx={pos.x} cy={pos.y} r={r + 3} fill={`oklch(65% 0.25 ${hue} / 0.3)`} />
186
+ <circle cx={pos.x} cy={pos.y} r={r} fill={`oklch(65% 0.25 ${hue})`} stroke="white" strokeWidth={isBase ? 2.5 : 1.5} />
187
+ <text x={pos.x} y={pos.y} textAnchor="middle" dominantBaseline="central" fill="white" fontSize="7" fontWeight="600" pointerEvents="none">
188
+ {POINT_LABELS[i] || ''}
189
+ </text>
190
+ </g>
191
+ )
192
+ })}
193
+ </svg>
194
+ )
195
+ }
@@ -11,9 +11,11 @@ export function DemoPage({ children, note }: { children: ReactNode; note?: React
11
11
  {children}
12
12
  </div>
13
13
  {note && (
14
- <p className="mt-4 sm:mt-6 text-gray-400 text-xs sm:text-sm max-w-md text-center px-2">
15
- {note}
16
- </p>
14
+ <div className="mt-4 sm:mt-6 bg-theme-accent border border-theme rounded-xl px-4 py-3 max-w-md text-center">
15
+ <p className="text-gray-400 text-xs sm:text-sm">
16
+ {note}
17
+ </p>
18
+ </div>
17
19
  )}
18
20
  </div>
19
21
  )
@@ -168,7 +168,7 @@ export function LiveUploadWidget({
168
168
  </div>
169
169
  <div className="w-full h-3 bg-white/10 rounded-full overflow-hidden">
170
170
  <div
171
- className="h-full bg-gradient-to-r from-blue-500 to-purple-500 transition-all"
171
+ className="h-full bg-theme-gradient transition-all"
172
172
  style={{ width: `${live.$state.progress}%` }}
173
173
  />
174
174
  </div>