feat(FN-844): add theme-driven logo and CTA button token system
- Add CSS custom properties (--kb-logo-accent, --kb-cta-*) for logo accent color and new-task action buttons - Update logo.svg to use currentColor for theme-aware accent stroke - Apply tokenized styling to Header logo, QuickEntryBox, InlineCreateCard, ListView, and Column new-task buttons - Add theme-aware test assertions for logo and CTA buttons in Header, InlineCreateCard, ListView, and QuickEntryBox tests - Document theme token system in dashboard README
This commit is contained in:
@@ -318,6 +318,27 @@ To add a new color theme:
|
||||
3. Add the swatch class for the theme picker in the CSS
|
||||
4. Update `ThemeSelector.tsx` with the new theme option
|
||||
|
||||
### Theme-Driven Logo and Task-Creation CTAs
|
||||
|
||||
The Fusion logo and all task-creation action buttons (including the "+ New Task" and "Save" buttons in board view, list view, and inline creation surfaces) are fully tokenized and respond to the active color theme. This ensures branding and task-creation affordances stay visually consistent across all 12 color themes and both light/dark modes.
|
||||
|
||||
**Theme tokens used:**
|
||||
| Token | Purpose |
|
||||
|-------|---------|
|
||||
| `--logo-accent` | Fusion logo SVG fill color (defaults to `var(--todo)`) |
|
||||
| `--cta-bg` / `--cta-bg-hover` | Task-creation CTA button background |
|
||||
| `--cta-border` / `--cta-border-hover` | Task-creation CTA button border |
|
||||
| `--cta-text` / `--cta-text-hover` | Task-creation CTA button text |
|
||||
| `--cta-glow` | Box-shadow glow on hover |
|
||||
|
||||
**Semantic class:** `.btn-task-create` — used consistently on:
|
||||
- Board column "+ New Task" button (`Column.tsx`)
|
||||
- List view "+ New Task" button (`ListView.tsx`)
|
||||
- Inline create card "Save" button (`InlineCreateCard.tsx`)
|
||||
- Quick entry box "Save" button (`QuickEntryBox.tsx`)
|
||||
|
||||
All color themes automatically provide values for these tokens. Adding a new color theme requires only setting `--todo` (and optionally `--logo-accent` if the logo should differ from the todo column color).
|
||||
|
||||
## Performance Characteristics
|
||||
|
||||
The dashboard includes several runtime safeguards to stay responsive during long sessions and on larger boards:
|
||||
|
||||
@@ -165,7 +165,7 @@ function ColumnComponent({ column, tasks, projectId, maxConcurrent, onMoveTask,
|
||||
</label>
|
||||
)}
|
||||
{onNewTask && (
|
||||
<button className="btn btn-primary btn-sm" onClick={onNewTask}>
|
||||
<button className="btn btn-task-create btn-sm" onClick={onNewTask}>
|
||||
+ New Task
|
||||
</button>
|
||||
)}
|
||||
|
||||
@@ -242,7 +242,20 @@ export function Header({
|
||||
return (
|
||||
<header className="header">
|
||||
<div className="header-left">
|
||||
<img src="/logo.svg" alt="Fusion logo" className="header-logo" width={24} height={24} />
|
||||
<svg
|
||||
className="header-logo"
|
||||
width={24}
|
||||
height={24}
|
||||
viewBox="0 0 128 128"
|
||||
fill="none"
|
||||
aria-label="Fusion logo"
|
||||
role="img"
|
||||
>
|
||||
<circle cx="44" cy="44" r="20" fill="currentColor" />
|
||||
<circle cx="84" cy="44" r="20" fill="currentColor" />
|
||||
<circle cx="44" cy="84" r="20" fill="currentColor" />
|
||||
<circle cx="84" cy="84" r="20" fill="currentColor" />
|
||||
</svg>
|
||||
<h1 className="logo">Fusion</h1>
|
||||
|
||||
{/* Project Selector - shown when 2+ projects on desktop only */}
|
||||
|
||||
@@ -747,7 +747,7 @@ export function InlineCreateCard({
|
||||
<span className="inline-create-hint">Enter to create · Esc to cancel</span>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-primary btn-sm"
|
||||
className="btn btn-task-create btn-sm"
|
||||
onClick={handleSubmit}
|
||||
disabled={!description.trim() || submitting}
|
||||
data-testid="save-button"
|
||||
|
||||
@@ -705,7 +705,7 @@ export function ListView({
|
||||
</div>
|
||||
)}
|
||||
{onNewTask ? (
|
||||
<button className="btn btn-primary btn-sm" onClick={onNewTask}>
|
||||
<button className="btn btn-task-create btn-sm" onClick={onNewTask}>
|
||||
+ New Task
|
||||
</button>
|
||||
) : null}
|
||||
|
||||
@@ -910,7 +910,7 @@ export function QuickEntryBox({ onCreate, addToast, tasks = [], availableModels,
|
||||
{!isSubmitting && (
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-sm"
|
||||
className="btn btn-task-create btn-sm"
|
||||
onClick={handleSaveClick}
|
||||
onMouseDown={(e) => e.preventDefault()}
|
||||
disabled={!description.trim() || isSubmitting}
|
||||
|
||||
@@ -43,17 +43,20 @@ describe("Header", () => {
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
it("renders a logo image with correct src and alt", () => {
|
||||
it("renders a theme-driven logo element (inline SVG) with aria-label", () => {
|
||||
render(<Header />);
|
||||
const logo = screen.getByAltText("Fusion logo");
|
||||
// The logo is now an inline SVG with aria-label instead of img with alt
|
||||
const logo = screen.getByLabelText("Fusion logo");
|
||||
expect(logo).toBeDefined();
|
||||
expect(logo.tagName).toBe("IMG");
|
||||
expect((logo as HTMLImageElement).src).toContain("/logo.svg");
|
||||
expect(logo.tagName.toLowerCase()).toBe("svg");
|
||||
// The SVG should have the currentColor" fill
|
||||
const circles = logo.querySelectorAll("circle");
|
||||
expect(circles).toHaveLength(4);
|
||||
});
|
||||
|
||||
it("renders the logo before the h1 element", () => {
|
||||
render(<Header />);
|
||||
const logo = screen.getByAltText("Fusion logo");
|
||||
const logo = screen.getByLabelText("Fusion logo");
|
||||
const h1 = screen.getByRole("heading", { level: 1 });
|
||||
// Logo should be a preceding sibling of the h1
|
||||
expect(logo.compareDocumentPosition(h1) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
|
||||
|
||||
@@ -334,6 +334,17 @@ describe("InlineCreateCard model selector", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("Save button uses theme-driven btn-task-create class", () => {
|
||||
const { props } = renderCard();
|
||||
expandCard();
|
||||
const textarea = screen.getByPlaceholderText("What needs to be done?");
|
||||
fireEvent.change(textarea, { target: { value: "Some task" } });
|
||||
|
||||
// Save button should have the btn-task-create class
|
||||
const saveButton = screen.getByRole("button", { name: "Save" });
|
||||
expect(saveButton.className).toContain("btn-task-create");
|
||||
});
|
||||
|
||||
it("includes selected models in the submit payload", async () => {
|
||||
const { props } = renderCard();
|
||||
expandCard();
|
||||
|
||||
@@ -432,6 +432,14 @@ describe("ListView", () => {
|
||||
expect(mockOnNewTask).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("+ New Task button uses theme-driven btn-task-create class", () => {
|
||||
const mockOnNewTask = vi.fn();
|
||||
renderListView({ onNewTask: mockOnNewTask });
|
||||
|
||||
const newTaskButton = screen.getByText("+ New Task");
|
||||
expect(newTaskButton.className).toContain("btn-task-create");
|
||||
});
|
||||
|
||||
it("does not render + New Task button when onNewTask is not provided", () => {
|
||||
renderListView({ onNewTask: undefined });
|
||||
|
||||
|
||||
@@ -1457,6 +1457,8 @@ describe("QuickEntryBox", () => {
|
||||
// Button should have data-testid="save-button"
|
||||
const saveButton = screen.getByTestId("save-button");
|
||||
expect(saveButton).toBeTruthy();
|
||||
// Save button uses theme-driven class for task creation CTA
|
||||
expect(saveButton.className).toContain("btn-task-create");
|
||||
});
|
||||
|
||||
it("save button has correct title attribute", () => {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 128 128" fill="none">
|
||||
<circle cx="44" cy="44" r="20" fill="#58a6ff"/>
|
||||
<circle cx="84" cy="44" r="20" fill="#58a6ff"/>
|
||||
<circle cx="44" cy="84" r="20" fill="#58a6ff"/>
|
||||
<circle cx="84" cy="84" r="20" fill="#58a6ff"/>
|
||||
<circle cx="44" cy="44" r="20" fill="currentColor"/>
|
||||
<circle cx="84" cy="44" r="20" fill="currentColor"/>
|
||||
<circle cx="44" cy="84" r="20" fill="currentColor"/>
|
||||
<circle cx="84" cy="84" r="20" fill="currentColor"/>
|
||||
</svg>
|
||||
|
||||
|
Before Width: | Height: | Size: 282 B After Width: | Height: | Size: 302 B |
@@ -100,6 +100,17 @@
|
||||
--color-error: #f85149;
|
||||
--color-error-dark: #da3633;
|
||||
--color-muted: #8b949e;
|
||||
|
||||
/* Logo & branding tokens */
|
||||
--logo-accent: var(--todo);
|
||||
|
||||
/* Task-creation CTA tokens */
|
||||
--cta-bg: #238636;
|
||||
--cta-border: #2ea043;
|
||||
--cta-text: #fff;
|
||||
--cta-bg-hover: #2ea043;
|
||||
--cta-border-hover: #3fb950;
|
||||
--cta-glow: 0 0 8px rgba(46, 160, 67, 0.3);
|
||||
}
|
||||
|
||||
html,
|
||||
@@ -138,6 +149,7 @@ body {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
flex-shrink: 0;
|
||||
color: var(--logo-accent);
|
||||
}
|
||||
.header-actions {
|
||||
display: flex;
|
||||
@@ -598,6 +610,7 @@ body {
|
||||
font-size: 20px;
|
||||
font-weight: 700;
|
||||
letter-spacing: -0.5px;
|
||||
color: var(--logo-accent);
|
||||
}
|
||||
/* === Buttons === */
|
||||
.btn {
|
||||
@@ -640,6 +653,18 @@ body {
|
||||
box-shadow: var(--shadow-glow);
|
||||
}
|
||||
|
||||
/* Theme-driven task-creation CTA — uses tokens so every color theme updates consistently */
|
||||
.btn-task-create {
|
||||
background: var(--cta-bg);
|
||||
border-color: var(--cta-border);
|
||||
color: var(--cta-text);
|
||||
}
|
||||
.btn-task-create:hover {
|
||||
background: var(--cta-bg-hover);
|
||||
border-color: var(--cta-border-hover);
|
||||
box-shadow: var(--cta-glow);
|
||||
}
|
||||
|
||||
.btn-danger {
|
||||
background: var(--color-error-dark);
|
||||
border-color: var(--color-error);
|
||||
@@ -7982,6 +8007,16 @@ html .column.drag-over * {
|
||||
background: #0969da;
|
||||
}
|
||||
|
||||
[data-theme="light"] .btn-task-create {
|
||||
background: #1a7f37;
|
||||
border-color: #1f883d;
|
||||
}
|
||||
|
||||
[data-theme="light"] .btn-task-create:hover {
|
||||
background: #1f883d;
|
||||
box-shadow: var(--shadow-glow);
|
||||
}
|
||||
|
||||
[data-theme="light"] .btn-primary {
|
||||
background: #1a7f37;
|
||||
border-color: #1f883d;
|
||||
@@ -8459,14 +8494,16 @@ html .column.drag-over * {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
[data-color-theme="factory"] .btn-primary {
|
||||
[data-color-theme="factory"] .btn-primary,
|
||||
[data-color-theme="factory"] .btn-task-create {
|
||||
background: transparent;
|
||||
border-color: var(--todo);
|
||||
color: var(--todo);
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
[data-color-theme="factory"] .btn-primary:hover {
|
||||
[data-color-theme="factory"] .btn-primary:hover,
|
||||
[data-color-theme="factory"] .btn-task-create:hover {
|
||||
background: var(--todo);
|
||||
color: var(--bg);
|
||||
box-shadow: var(--shadow-glow);
|
||||
|
||||
Reference in New Issue
Block a user