work-board: gate FIXTURE/RPC-error chrome out of prod, wrap bounty titles, per-brand favicon

Three prod UI fixes from the 4-res audit (work.lux.network + work.hanzo.network):

1. FIXTURE debug bar in prod. The LIVE/FIXTURE data-source pill (and the raw
   RPC error string it surfaces, e.g. "Missing or invalid parameters") was
   pinned bottom-left for every visitor and clipped past the mobile viewport.
   Gate it to `vite dev` (import.meta.env.DEV) or an explicit VITE_SHOW_STATUS
   flag, and cap the pill at calc(100vw-1.5rem) + overflow-hidden so it can
   never overflow even in dev.

2. Bounty-card title truncation. Space-card names ("Ecosystem &
   Partnerships", "Community & Social", "Content Creation") were ellipsis-
   clipped at tablet/laptop. Drop `truncate`; let the title wrap.

3. Favicon. Replace the generic emerald green-check placeholder with each
   brand's own square mark, injected per-build by the brand-html Vite plugin
   as an inline data: URI (CSP-safe, no extra request). New purpose-built
   16px-legible favicon.svg per brand (lux/hanzo/zoo/pars).

Board still reads $0 / 0 tasks because the work-market contracts are not yet
deployed on 96369/36963 (owner-gated) — untouched here.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
This commit is contained in:
zeekay
2026-07-19 01:13:58 -07:00
co-authored by Hanzo Dev
parent 7fcb5c5ac9
commit 23a5a89070
8 changed files with 46 additions and 4 deletions
+8 -2
View File
@@ -19,11 +19,17 @@ import { IconMenu, IconSearch } from './ui';
import type { Task } from './types'; import type { Task } from './types';
import type { Workspace } from './chain'; import type { Workspace } from './chain';
// The LIVE/FIXTURE data-source pill (and the raw RPC error it surfaces) is a
// developer diagnostic, never production chrome — a production visitor must never
// see "FIXTURE" or an RPC error string. Gated to `vite dev`; force it on in a
// prod-like build for debugging with VITE_SHOW_STATUS=true.
const SHOW_STATUS = import.meta.env.DEV || import.meta.env.VITE_SHOW_STATUS === 'true';
function StatusPill({ source, updatedAt, error }: { source: Source | null; updatedAt: number | null; error: string | null }) { function StatusPill({ source, updatedAt, error }: { source: Source | null; updatedAt: number | null; error: string | null }) {
if (source === null) return null; if (source === null) return null;
const live = source === 'live'; const live = source === 'live';
return ( return (
<div className="fixed bottom-3 left-3 z-40 flex items-center gap-1.5 rounded-full bg-black/60 px-2.5 py-1 text-[11px] text-neutral-400 ring-1 ring-inset ring-white/10 backdrop-blur"> <div className="fixed bottom-3 left-3 z-40 flex max-w-[calc(100vw-1.5rem)] items-center gap-1.5 overflow-hidden rounded-full bg-black/60 px-2.5 py-1 text-[11px] text-neutral-400 ring-1 ring-inset ring-white/10 backdrop-blur">
<span className={`h-1.5 w-1.5 rounded-full ${live ? 'animate-pulse bg-emerald-400' : 'bg-amber-400'}`} /> <span className={`h-1.5 w-1.5 rounded-full ${live ? 'animate-pulse bg-emerald-400' : 'bg-amber-400'}`} />
<span className={live ? 'text-emerald-300' : 'text-amber-300'}>{live ? 'LIVE' : 'FIXTURE'}</span> <span className={live ? 'text-emerald-300' : 'text-amber-300'}>{live ? 'LIVE' : 'FIXTURE'}</span>
<span className="text-neutral-600">· chain {CHAIN_ID}</span> <span className="text-neutral-600">· chain {CHAIN_ID}</span>
@@ -159,7 +165,7 @@ export function App() {
{modalTask && ws && <TaskDetail task={modalTask} ws={ws} />} {modalTask && ws && <TaskDetail task={modalTask} ws={ws} />}
{paletteOpen && <CommandPalette tasks={tasks} onClose={() => setPaletteOpen(false)} />} {paletteOpen && <CommandPalette tasks={tasks} onClose={() => setPaletteOpen(false)} />}
<ConnectSheetHost /> <ConnectSheetHost />
<StatusPill source={source} updatedAt={updatedAt} error={error} /> {SHOW_STATUS && <StatusPill source={source} updatedAt={updatedAt} error={error} />}
</div> </div>
); );
} }
+10
View File
@@ -0,0 +1,10 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
<rect width="32" height="32" rx="7" fill="#000000"/>
<g transform="translate(7 7) scale(0.269)" fill="#FFFFFF">
<path d="M22.21 67V44.6369H0V67H22.21Z"/>
<path d="M66.7038 22.3184H22.2534L0.0878906 44.6367H44.4634L66.7038 22.3184Z"/>
<path d="M22.21 0H0V22.3184H22.21V0Z"/>
<path d="M66.7198 0H44.5098V22.3184H66.7198V0Z"/>
<path d="M66.7198 67V44.6369H44.5098V67H66.7198Z"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 475 B

+4
View File
@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
<rect width="32" height="32" rx="7" fill="#000000"/>
<path d="M11 8h4.2v11.8H23V24H11z" fill="#FFFFFF"/>
</svg>

After

Width:  |  Height:  |  Size: 177 B

+4
View File
@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
<rect width="32" height="32" rx="7" fill="#1C3879"/>
<path transform="translate(16 16) scale(0.16)" fill="#D4AF37" d="M0,-70 L22,-42 L70,-28 L42,0 L70,28 L22,42 L0,70 L-22,42 L-70,28 L-42,0 L-70,-28 L-22,-42 Z"/>
</svg>

After

Width:  |  Height:  |  Size: 285 B

+4
View File
@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
<rect width="32" height="32" rx="7" fill="#6c5efb"/>
<path d="M9 8H23V12L16 20H23V24H9V20L16 12H9Z" fill="#FFFFFF"/>
</svg>

After

Width:  |  Height:  |  Size: 189 B

+1 -1
View File
@@ -113,7 +113,7 @@ function SpaceCard({ space, rollup }: { space: Space; rollup: SpaceRollup }) {
{space.emoji} {space.emoji}
</span> </span>
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1">
<div className="truncate text-sm font-semibold text-neutral-100">{space.name}</div> <div className="text-sm font-semibold leading-snug text-neutral-100">{space.name}</div>
<div className="mt-1 flex flex-wrap items-center gap-x-1.5 gap-y-0.5 text-xs text-neutral-500"> <div className="mt-1 flex flex-wrap items-center gap-x-1.5 gap-y-0.5 text-xs text-neutral-500">
<span className="tabular-nums">{plural(rollup.open, 'open task')}</span> <span className="tabular-nums">{plural(rollup.open, 'open task')}</span>
<span aria-hidden>·</span> <span aria-hidden>·</span>
+1
View File
@@ -3,6 +3,7 @@
interface ImportMetaEnv { interface ImportMetaEnv {
readonly VITE_RPC_URL?: string; readonly VITE_RPC_URL?: string;
readonly VITE_BRAND?: string; readonly VITE_BRAND?: string;
readonly VITE_SHOW_STATUS?: string;
} }
interface ImportMeta { interface ImportMeta {
+14 -1
View File
@@ -1,7 +1,9 @@
import { readFileSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
import { defineConfig, type Plugin, type IndexHtmlTransformContext } from 'vite'; import { defineConfig, type Plugin, type IndexHtmlTransformContext } from 'vite';
import react from '@vitejs/plugin-react'; import react from '@vitejs/plugin-react';
import tailwindcss from '@tailwindcss/vite'; import tailwindcss from '@tailwindcss/vite';
import { resolveBrand, brandTitle } from './src/brands'; import { resolveBrand, resolveBrandKey, brandTitle } from './src/brands';
// Dev proxy target. Defaults to the kubectl port-forward; override to read a // Dev proxy target. Defaults to the kubectl port-forward; override to read a
// remote node, e.g. `RPC_TARGET=https://api.pars.network npm run dev`. // remote node, e.g. `RPC_TARGET=https://api.pars.network npm run dev`.
@@ -17,6 +19,16 @@ const RPC_TARGET = process.env.RPC_TARGET || 'http://127.0.0.1:9631';
// CSP can never drift from the endpoint the board calls. // CSP can never drift from the endpoint the board calls.
function brandHtml(): Plugin { function brandHtml(): Plugin {
const brand = resolveBrand(process.env.VITE_BRAND); const brand = resolveBrand(process.env.VITE_BRAND);
const brandKey = resolveBrandKey(process.env.VITE_BRAND);
// Favicon = the brand's own square mark, inlined as a data: URI so it needs no
// separate request and satisfies the strict CSP (img-src 'self' data:). Kept as
// a purpose-built 16px-legible favicon.svg per brand (never the generic
// placeholder, never cross-brand).
const faviconSvg = readFileSync(
fileURLToPath(new URL(`./src/assets/brands/${brandKey}/favicon.svg`, import.meta.url)),
'utf8',
);
const faviconHref = `data:image/svg+xml,${encodeURIComponent(faviconSvg)}`;
const rpcOrigin = new URL(process.env.VITE_RPC_URL || brand.rpcUrl).origin; const rpcOrigin = new URL(process.env.VITE_RPC_URL || brand.rpcUrl).origin;
// The board fetches the brand's Hanzo IAM tenant (OIDC token/userinfo + web3 // The board fetches the brand's Hanzo IAM tenant (OIDC token/userinfo + web3
// SIWE) — its origin must be in connect-src alongside the RPC host. The // SIWE) — its origin must be in connect-src alongside the RPC host. The
@@ -38,6 +50,7 @@ function brandHtml(): Plugin {
const policy = ctx.server ? dev : prod; const policy = ctx.server ? dev : prod;
return html return html
.replace(/<title>[^<]*<\/title>/, `<title>${brandTitle(brand)}</title>`) .replace(/<title>[^<]*<\/title>/, `<title>${brandTitle(brand)}</title>`)
.replace(/<link rel="icon"[^>]*>/, `<link rel="icon" type="image/svg+xml" href="${faviconHref}" />`)
.replace( .replace(
'</title>', '</title>',
`</title>\n <meta http-equiv="Content-Security-Policy" content="${policy}" />`, `</title>\n <meta http-equiv="Content-Security-Policy" content="${policy}" />`,