work-board: migrate onto @luxfi/ui@7.4.0 (@hanzo/gui Tamagui engine) — Phase 1

Run the live 4-org Dework hub on the @luxfi/ui / @hanzo/gui (Tamagui) engine
without regressing the owner-approved look/feel.

- Vite+Tamagui weld (proven exchange recipe): react-native->react-native-web
  alias, dedupe + single-copy of @hanzo/gui/@hanzogui/core/@hanzogui/web,
  optimizeDeps pre-bundle, __DEV__/EXPO_OS/IS_WEB defines, commonjsOptions so
  the engine stays in React's chunk. guiPlugin extraction deferred (its 7.3.0
  peer pins vite@8.0.3); app renders on the runtime style-injection path.
- Wrap in AppProvider (GuiProvider + TanStack Query); brand = VALUE: root theme
  row dark_${BRAND_KEY} + --brand sourced from @luxfi/ui LUX_BRAND (hex identical
  to brands.ts, zero cross-brand leak).
- tokens.css remap layer maps the @luxfi/ui --color-* contract onto the Dework
  palette + brand (no wholesale exchange-palette import).
- Text fields -> @luxfi/ui Input/Textarea (native inputs, onChangeText<->onChange
  bridge): CommandPalette ⌘K, Suggestions editor, TaskDetail comment, chrome
  Toolbar, Leaderboards search. Keystroke round-trip verified.
- Fix Leaderboards mobile clip: page container was a flex item whose mx-auto
  disabled cross-axis stretch, so min-w-[460px] tables forced cards to 460px and
  bled past 390px. w-full + min-w-0 chain + overflow-hidden cards -> tables now
  scroll inside rounded cards, no horizontal page scroll.

Build green (tsc + vite). Visual-regression: 19/21 golden views pixel-match
(<=2%); 2 mobile bottom-sheet overlays (task-detail, palette) diverge ~3% (under
review). Preview branch — do NOT merge to main until owner review.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
This commit is contained in:
zeekay
2026-07-18 11:08:50 -07:00
co-authored by Hanzo Dev
parent 54540233a9
commit 1bda286d36
15 changed files with 9007 additions and 62 deletions
+1
View File
@@ -2,3 +2,4 @@ node_modules
dist
*.local
.DS_Store
dist/
+8788 -33
View File
File diff suppressed because it is too large Load Diff
+10
View File
@@ -10,8 +10,18 @@
"preview": "vite preview"
},
"dependencies": {
"@hanzo/gui": "7.3.0",
"@hanzogui/colors": "7.3.0",
"@hanzogui/config": "7.3.0",
"@hanzogui/core": "7.3.0",
"@hanzogui/lucide-icons-2": "7.3.0",
"@hanzogui/themes": "7.3.0",
"@hanzogui/web": "7.3.0",
"@luxfi/ui": "file:/Users/z/work/luxfi/ui/packages/ui",
"@tanstack/react-query": "^5.76.0",
"react": "^19.2.7",
"react-dom": "^19.2.7",
"react-native-web": "^0.21.0",
"viem": "^2.55.2"
},
"devDependencies": {
+46
View File
@@ -0,0 +1,46 @@
// Reusable capture: load a work-board hash route at a viewport and screenshot it,
// matching how the goldens were captured (dark, dsf 2, 1440x900 / 390x844).
// Usage: node scripts/capture.mjs <baseUrl> <outDir> [view:hash ...]
// node scripts/capture.mjs http://localhost:5188 /tmp/shots overview:#/ board:#/board
import pw from '/Users/z/work/luxfi/ui/node_modules/@playwright/test/index.js';
import { mkdirSync } from 'node:fs';
const { chromium } = pw;
const base = process.argv[2] || 'http://localhost:5188';
const outDir = process.argv[3] || '/private/tmp/claude-501/-Users-z-work-lux-private/a30ef7fe-e14c-464e-8783-67708506331d/scratchpad/shots';
const specs = process.argv.slice(4);
const DEFAULT = [
'overview:#/', 'board:#/board', 'open-tasks:#/space/engineering/tasks',
'leaderboards:#/leaderboards', 'suggestions:#/suggestions', 'space:#/space/engineering/board',
'task-detail:#/board?task=0', 'explore:#/explore',
];
const views = (specs.length ? specs : DEFAULT).map((s) => {
const i = s.indexOf(':');
return { view: s.slice(0, i), hash: s.slice(i + 1) };
});
const VIEWPORTS = [
{ name: 'desktop', width: 1440, height: 900 },
{ name: 'mobile', width: 390, height: 844 },
];
mkdirSync(outDir, { recursive: true });
const errors = [];
const browser = await chromium.launch();
for (const vp of VIEWPORTS) {
const ctx = await browser.newContext({
viewport: { width: vp.width, height: vp.height },
deviceScaleFactor: 2,
colorScheme: 'dark',
});
const page = await ctx.newPage();
page.on('pageerror', (e) => errors.push(`[${vp.name}] PAGEERROR ${e.message}`));
page.on('console', (m) => { if (m.type() === 'error') errors.push(`[${vp.name}] ${m.text()}`); });
for (const { view, hash } of views) {
await page.goto(`${base}/${hash}`, { waitUntil: 'networkidle', timeout: 60000 });
await page.waitForTimeout(700); // settle fixture render + fonts
await page.screenshot({ path: `${outDir}/${view}-${vp.name}.png` });
}
await ctx.close();
}
await browser.close();
console.log(JSON.stringify({ captured: views.map((v) => v.view), viewports: VIEWPORTS.map((v) => v.name), errors: errors.slice(0, 15) }, null, 2));
+38
View File
@@ -0,0 +1,38 @@
// Isolated weld probe runner: loads /weldcheck.html, asserts the @luxfi/ui
// Button rendered and the Input round-trips keystrokes, and screenshots it.
// Run: node scripts/weld-probe.mjs (Playwright resolved from ~/work/luxfi/ui)
import pw from '/Users/z/work/luxfi/ui/node_modules/@playwright/test/index.js';
const { chromium } = pw;
const URL = process.env.WELD_URL || 'http://localhost:5188/weldcheck.html';
const OUT = process.env.WELD_OUT || '/private/tmp/claude-501/-Users-z-work-lux-private/a30ef7fe-e14c-464e-8783-67708506331d/scratchpad/weld.png';
const errors = [];
const browser = await chromium.launch();
const page = await browser.newPage({ viewport: { width: 640, height: 480 }, deviceScaleFactor: 2, colorScheme: 'dark' });
page.on('console', (m) => { if (m.type() === 'error') errors.push(m.text()); });
page.on('pageerror', (e) => errors.push('PAGEERROR: ' + e.message));
await page.goto(URL, { waitUntil: 'networkidle', timeout: 60000 });
await page.waitForTimeout(800);
const btn = page.getByTestId('btn');
const btnVisible = await btn.isVisible().catch(() => false);
const btnText = (await btn.textContent().catch(() => '')) || '';
// Round-trip: type into the @luxfi/ui Input, assert the controlled echo updates.
const inp = page.getByTestId('inp');
await inp.click();
await inp.type('hello123', { delay: 20 });
const echo = ((await page.getByTestId('echo').textContent().catch(() => '')) || '').trim();
const inpValue = await inp.inputValue().catch(() => '');
await page.screenshot({ path: OUT });
await browser.close();
const roundTrip = echo === 'echo:hello123' && inpValue === 'hello123';
console.log(JSON.stringify({
btnVisible, btnText: btnText.trim(), inpValue, echo, roundTrip,
consoleErrors: errors.slice(0, 10),
}, null, 2));
process.exit(btnVisible && roundTrip && errors.length === 0 ? 0 : 1);
+6 -3
View File
@@ -1,5 +1,6 @@
import { useEffect, useMemo, useState } from 'react';
import { ORG, CHAIN_ID } from './config';
import { LUX_BRAND } from '@luxfi/ui';
import { ORG, CHAIN_ID, BRAND_KEY } from './config';
import { useWorkspace } from './useWorkspace';
import type { Source } from './useWorkspace';
import { useRoute } from './router';
@@ -80,8 +81,10 @@ export function App() {
const route = useRoute();
// Inject the per-brand accent once; every accented element reads var(--brand).
// Sourced from the @luxfi/ui LUX_BRAND value (dark_${BRAND_KEY} row) so brand
// is one value in one place — identical hex to the legacy brands.ts accent.
useEffect(() => {
document.documentElement.style.setProperty('--brand', ORG.accent);
document.documentElement.style.setProperty('--brand', LUX_BRAND[BRAND_KEY]);
}, []);
const modalTask = useMemo(
@@ -148,7 +151,7 @@ export function App() {
<Topbar onSearch={() => setPaletteOpen(true)} />
</div>
<div className="flex flex-1 flex-col">
<div className="flex min-w-0 flex-1 flex-col">
{loading && ws === null ? <Spinner /> : ws ? <Routed ws={ws} tasks={tasks} /> : null}
</div>
</main>
+4 -3
View File
@@ -3,7 +3,7 @@ import { ORG } from '../config';
import type { Task } from '../types';
import { navigate, openTask } from '../router';
import { openConnect } from '../auth';
import { IconSearch, IconGrid, IconBoard, IconTrophy, IconBulb, IconPlus, IconDoc, IconExternal } from '../ui';
import { Input, IconSearch, IconGrid, IconBoard, IconTrophy, IconBulb, IconPlus, IconDoc, IconExternal } from '../ui';
// ⌘K command palette — global fuzzy search + quick-nav across views, spaces and
// tasks, plus quick actions. Keyboard-navigable (↑/↓/Enter, Esc). Centered on
@@ -108,10 +108,11 @@ export function CommandPalette({ tasks, onClose }: { tasks: Task[]; onClose: ()
<div className="flex max-h-[85vh] w-full max-w-xl flex-col overflow-hidden rounded-t-2xl bg-[var(--surface)] shadow-2xl ring-1 ring-inset ring-white/10 sm:max-h-[70vh] sm:rounded-xl" onClick={(e) => e.stopPropagation()}>
<div className="flex items-center gap-2 border-b border-[var(--border)] px-4 py-3">
<IconSearch className="h-4 w-4 text-neutral-500" />
<input
<Input
variant="unstyled"
autoFocus
value={q}
onChange={(e) => setQ(e.target.value)}
onChangeText={setQ}
placeholder="Search bounties, spaces, actions…"
className="w-full bg-transparent text-sm text-neutral-100 placeholder:text-neutral-600 focus:outline-none"
/>
+6 -6
View File
@@ -3,7 +3,7 @@ import type { Address } from 'viem';
import type { Workspace } from '../chain';
import type { Contributor } from '../types';
import { formatAmount, short } from '../format';
import { Avatar, IconSearch, IconTrophy } from '../ui';
import { Avatar, Input, IconSearch, IconTrophy } from '../ui';
type Window = 'all' | '30d' | '7d';
@@ -70,7 +70,7 @@ function Search({ placeholder }: { placeholder: string }) {
return (
<label className="flex items-center gap-1.5 rounded-md bg-white/5 px-2 py-1 text-xs text-neutral-400 ring-1 ring-inset ring-white/8">
<IconSearch className="h-3.5 w-3.5" />
<input placeholder={placeholder} className="w-36 bg-transparent text-neutral-200 placeholder:text-neutral-600 focus:outline-none" />
<Input variant="unstyled" placeholder={placeholder} className="w-36 bg-transparent text-neutral-200 placeholder:text-neutral-600 focus:outline-none" />
</label>
);
}
@@ -178,11 +178,11 @@ export function Leaderboards({ ws }: { ws: Workspace }) {
const contributorRows = useMemo(() => base.filter((c) => c.completed > 0n), [base]);
return (
<div className="mx-auto max-w-6xl px-4 py-5 md:px-6 md:py-6">
<div className="mx-auto w-full max-w-6xl px-4 py-5 md:px-6 md:py-6">
<h1 className="mb-5 text-2xl font-bold text-neutral-100">Leaderboards</h1>
<div className="grid grid-cols-1 gap-5 lg:grid-cols-2">
<div className="min-w-0 rounded-xl bg-[var(--surface)] ring-1 ring-inset ring-white/6">
<div className="min-w-0 overflow-hidden rounded-xl bg-[var(--surface)] ring-1 ring-inset ring-white/6">
<div className="flex flex-wrap items-center gap-3 px-4 py-3">
<h2 className="text-sm font-semibold text-neutral-200">Top Contributors</h2>
<WindowToggle value={win} onChange={setWin} />
@@ -193,7 +193,7 @@ export function Leaderboards({ ws }: { ws: Workspace }) {
<ContributorTable rows={contributorRows} />
</div>
<div className="min-w-0 rounded-xl bg-[var(--surface)] ring-1 ring-inset ring-white/6">
<div className="min-w-0 overflow-hidden rounded-xl bg-[var(--surface)] ring-1 ring-inset ring-white/6">
<div className="flex flex-wrap items-center gap-3 px-4 py-3">
<h2 className="text-sm font-semibold text-neutral-200">Top Reviewers</h2>
<WindowToggle value={win} onChange={setWin} />
@@ -205,7 +205,7 @@ export function Leaderboards({ ws }: { ws: Workspace }) {
</div>
</div>
<div className="mt-6 min-w-0 rounded-xl bg-[var(--surface)] ring-1 ring-inset ring-white/6">
<div className="mt-6 min-w-0 overflow-hidden rounded-xl bg-[var(--surface)] ring-1 ring-inset ring-white/6">
<div className="flex items-center justify-between px-4 py-3">
<h2 className="text-sm font-semibold text-neutral-200">All Contributors</h2>
<Search placeholder="Search contributors..." />
+7 -5
View File
@@ -1,6 +1,6 @@
import { useEffect, useState } from 'react';
import { ORG } from '../config';
import { PrimaryButton, IconPlus, IconChevronDown, IconBulb, IconX } from '../ui';
import { PrimaryButton, Input, Textarea, IconPlus, IconChevronDown, IconBulb, IconX } from '../ui';
import { timeAgo } from '../format';
// Community Suggestions — governance ideation. Persisted client-side (localStorage,
@@ -34,10 +34,11 @@ function NewSuggestionModal({ onClose, onCreate }: { onClose: () => void; onCrea
<div className="fixed inset-0 z-50 flex items-end justify-center bg-black/60 sm:items-start sm:px-4 sm:pt-28" onClick={onClose}>
<div className="w-full max-w-2xl rounded-t-2xl bg-[var(--surface)] p-5 pb-8 shadow-2xl ring-1 ring-inset ring-white/10 sm:rounded-xl sm:pb-5" onClick={(e) => e.stopPropagation()}>
<div className="flex items-start justify-between">
<input
<Input
variant="unstyled"
autoFocus
value={title}
onChange={(e) => setTitle(e.target.value)}
onChangeText={setTitle}
placeholder="Enter your suggestion here"
className="w-full bg-transparent text-lg font-semibold text-neutral-100 placeholder:text-neutral-500 focus:outline-none"
/>
@@ -45,9 +46,10 @@ function NewSuggestionModal({ onClose, onCreate }: { onClose: () => void; onCrea
<IconX className="h-4 w-4" />
</button>
</div>
<textarea
<Textarea
variant="unstyled"
value={body}
onChange={(e) => setBody(e.target.value)}
onChangeText={setBody}
placeholder='Add more details about your suggestion here. Type "/" to insert.'
rows={4}
className="mt-3 w-full resize-none bg-transparent text-sm text-neutral-300 placeholder:text-neutral-600 focus:outline-none"
+4 -2
View File
@@ -10,6 +10,7 @@ import { closeTask } from '../router';
import { short, isZero, absDate, timeAgo, refToLink, formatAmount } from '../format';
import {
Avatar,
Input,
SkillTag,
RewardBadge,
OpenToBadge,
@@ -221,9 +222,10 @@ export function TaskDetail({ task, ws }: { task: Task; ws: Workspace }) {
))}
<div className="mt-1 flex items-center gap-2">
<input
<Input
variant="unstyled"
value={draft}
onChange={(e) => setDraft(e.target.value)}
onChangeText={setDraft}
onKeyDown={(e) => e.key === 'Enter' && addComment()}
placeholder="Add a comment or application…"
className="flex-1 rounded-md bg-white/5 px-3 py-2 text-sm text-neutral-200 placeholder:text-neutral-600 ring-1 ring-inset ring-white/8 focus:outline-none"
+4 -3
View File
@@ -1,6 +1,6 @@
import type { ReactNode } from 'react';
import { useState } from 'react';
import { GhostButton, IconSort, IconFilter, IconSearch } from '../ui';
import { GhostButton, Input, IconSort, IconFilter, IconSearch } from '../ui';
import { ConnectButton } from './Connect';
// Connect / Follow — top-right on every content view. Connect resolves through the
@@ -67,9 +67,10 @@ export function Toolbar({ query, onQuery }: { query: string; onQuery: (q: string
</button>
<label className="flex items-center gap-1.5 rounded-md bg-white/5 px-2 py-1 ring-1 ring-inset ring-white/8">
<IconSearch className="h-3.5 w-3.5" />
<input
<Input
variant="unstyled"
value={query}
onChange={(e) => onQuery(e.target.value)}
onChangeText={onQuery}
placeholder="Search tasks..."
className="w-32 bg-transparent text-neutral-200 placeholder:text-neutral-600 focus:outline-none"
/>
+21 -1
View File
@@ -1,5 +1,9 @@
@import "tailwindcss";
/* @luxfi/ui ships its primitives as compiled classes in its dist — Tailwind must
scan them so the utilities the adopted Button / Input / Textarea emit exist. */
@source "../node_modules/@luxfi/ui/dist";
:root {
color-scheme: dark;
/* Dework dark palette — the one place the surface tokens live. */
@@ -10,8 +14,24 @@
--border: #232328;
--hover: #1e1e24;
--muted: #8a8a93;
/* --brand is set per-org at runtime from brands.ts (App.tsx). */
/* --brand is set per-org at runtime from the @luxfi/ui LUX_BRAND value (App.tsx). */
--brand: #6c5ce7;
/* @luxfi/ui adopted-primitive tokens, remapped onto the Dework palette + brand.
Only the tokens the adopted primitives read live here — the exchange
tokens.css is deliberately NOT imported wholesale, so no exchange palette
bleeds into the Dework surfaces. Static (non-brand-varying) except --brand. */
--color-button-solid-bg: var(--brand);
--color-button-solid-text: #ffffff;
--color-hover: color-mix(in srgb, var(--brand) 88%, #000000);
--color-button-subtle-bg: rgba(255, 255, 255, 0.05);
--color-button-subtle-fg: #e5e5e5;
--color-input-bg: transparent;
--color-input-fg: #e5e5e5;
--color-input-border: var(--border);
--color-input-border-hover: #3a3a42;
--color-input-border-focus: var(--brand);
--color-input-placeholder: var(--muted);
}
html,
+13 -1
View File
@@ -1,12 +1,22 @@
import { StrictMode } from 'react';
import { createRoot } from 'react-dom/client';
import { AppProvider } from '@luxfi/ui';
import './index.css';
import { App } from './App';
import { BRAND_KEY } from './config';
import { isAuthCallback, handleAuthCallback } from './auth';
const root = document.getElementById('root');
if (!root) throw new Error('#root not found');
// The board runs on the @luxfi/ui / @hanzo/gui (Tamagui) engine. AppProvider
// mounts GuiProvider (the Tamagui runtime every @luxfi/ui primitive needs) +
// TanStack Query, and selects the per-org brand child-theme row as a VALUE —
// `dark_${BRAND_KEY}` resolves to dark_lux | dark_zoo | dark_hanzo | dark_pars.
// Bespoke Dework markup still reads the `--brand` CSS var (set in App.tsx from
// the same @luxfi/ui LUX_BRAND source), so brand is one value in one place.
const brandTheme = `dark_${BRAND_KEY}`;
// The OAuth redirect (Discord / GitHub) lands on /auth/callback — the static
// server's SPA fallback serves index.html, so we finish the PKCE code exchange
// here and then navigate back to '/'. The board itself never mounts on this path.
@@ -21,7 +31,9 @@ if (isAuthCallback()) {
} else {
createRoot(root).render(
<StrictMode>
<App />
<AppProvider defaultTheme={brandTheme}>
<App />
</AppProvider>
</StrictMode>,
);
}
+5
View File
@@ -7,6 +7,11 @@ import type { Reward } from './types';
import { NATIVE_SYMBOL, ORG, BRAND_KEY } from './config';
import { BRAND_LOGOS } from './brand-logos';
// Text fields = @luxfi/ui primitives (native <input>/<textarea> under the hood,
// bridging onChangeText<->onChange so keystrokes always round-trip). Re-exported
// here so every view reaches text inputs through the one ../ui primitive surface.
export { Input, Textarea } from '@luxfi/ui';
// ---- Brand accent ----
export const accent: CSSProperties = { backgroundColor: 'var(--brand)' };
export const accentText: CSSProperties = { color: 'var(--brand)' };
+54 -5
View File
@@ -11,9 +11,10 @@ const RPC_TARGET = process.env.RPC_TARGET || 'http://127.0.0.1:9631';
// build-time brand (VITE_BRAND, default zoo). The CSP is strict in production
// (no external hosts; only the brand's own RPC host for data) and relaxed in dev
// so Vite HMR works. The app ships zero external CDNs/fonts/scripts, so the
// strict policy is a tight fit. The connect-src host is derived from the RPC
// actually baked in (VITE_RPC_URL, else the brand default) so the CSP can never
// drift from the endpoint the board calls.
// strict policy is a tight fit. `style-src 'unsafe-inline'` is required for the
// @hanzo/gui (Tamagui) runtime style injection. The connect-src host is derived
// from the RPC actually baked in (VITE_RPC_URL, else the brand default) so the
// CSP can never drift from the endpoint the board calls.
function brandHtml(): Plugin {
const brand = resolveBrand(process.env.VITE_BRAND);
const rpcOrigin = new URL(process.env.VITE_RPC_URL || brand.rpcUrl).origin;
@@ -45,8 +46,56 @@ function brandHtml(): Plugin {
};
}
export default defineConfig({
// @luxfi/ui is the Lux skin of @hanzo/gui (Tamagui). Welding its cross-platform
// primitives into this Vite web app follows the proven exchange recipe:
// 1. react-native → react-native-web (Tamagui authors against RN primitives).
// 2. dedupe react / react-dom / the gui engine to ONE physical copy each, so
// createGui() and getGui() read the same module-level config registry
// (else: "Can't find GUI configuration" on first themed render).
// 3. pre-bundle react-native-web + the CJS-only @react-native/normalize-color
// and the gui engine via optimizeDeps.
// 4. define __DEV__ / EXPO_OS / IS_WEB — the RN/Tamagui runtime reads them.
// 5. keep the gui engine + RNW in React's chunk (commonjsOptions) so the CJS
// interop wrapper never yields an undefined createContext.
// Atomic-CSS extraction (@hanzogui/vite-plugin, prod-only) is intentionally NOT
// wired here: its published peer pins vite@8.0.3 which conflicts with 8.1.x, and
// the app renders correctly on the runtime style-injection path without it. See
// LLM.md for how to re-enable extraction once the peer is loosened.
export default defineConfig(({ mode }) => ({
define: {
__DEV__: mode !== 'production',
'process.env.NODE_ENV': JSON.stringify(mode === 'production' ? 'production' : 'development'),
'process.env.EXPO_OS': JSON.stringify('web'),
'process.env.IS_WEB': JSON.stringify('true'),
},
resolve: {
alias: {
'react-native': 'react-native-web',
},
dedupe: [
'react',
'react-dom',
'@tanstack/react-query',
'@hanzo/gui',
'@hanzogui/core',
'@hanzogui/web',
],
},
optimizeDeps: {
include: [
'react-native-web',
'@react-native/normalize-color',
'@hanzo/gui',
'@hanzogui/core',
'@luxfi/ui',
],
},
plugins: [react(), tailwindcss(), brandHtml()],
build: {
// Never split the gui engine / RNW out of React's chunk — they use CJS
// require('react'); a separate chunk makes the interop createContext undefined.
commonjsOptions: { include: [/node_modules/] },
},
server: {
proxy: {
'/rpc': {
@@ -56,4 +105,4 @@ export default defineConfig({
},
},
},
});
}));