Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
60 changes: 60 additions & 0 deletions apps/web/__tests__/unit/model-colors.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
import { describe, expect, it } from "vitest";
import { prettifyModel } from "@straude/shared/models";
import {
MODEL_COLOR_FALLBACK_PALETTE,
MODEL_COLOR_PATTERNS,
modelColor,
} from "@/lib/constants/model-colors";

describe("modelColor", () => {
it("maps every known family to its chip colour", () => {
// Keyed off prettifyModel output, which is what ActivityCard passes in —
// a pattern that doesn't match the display names is dead code.
expect(modelColor(prettifyModel("claude-fable-5"))).toBe("#C2410C");
expect(modelColor(prettifyModel("claude-opus-5"))).toBe("#DF561F");
expect(modelColor(prettifyModel("claude-sonnet-5"))).toBe("#F08A5D");
expect(modelColor(prettifyModel("claude-haiku-4-5-20251001"))).toBe("#F7B267");
expect(modelColor(prettifyModel("gpt-5.6-codex"))).toBe("#2A9D8F");
expect(modelColor(prettifyModel("gpt-4o"))).toBe("#4C78A8");
expect(modelColor(prettifyModel("o3-mini"))).toBe("#3B82F6");
expect(modelColor(prettifyModel("o4-mini"))).toBe("#6366F1");
});

it("keeps Fable ahead of the broader Claude patterns", () => {
// First match wins, so reordering MODEL_COLOR_PATTERNS silently repaints
// chips. Fable and Opus share the accent family and are easy to swap.
const order = MODEL_COLOR_PATTERNS.map(([pattern]) => pattern.source);
expect(order.indexOf("Claude Fable")).toBeLessThan(order.indexOf("Claude Opus"));
});

it("always returns a colour for an unknown model", () => {
// hashString is a signed 32-bit value, so roughly half of these names
// hashed negative and indexed off the front of the palette, handing the
// chip an undefined backgroundColor. Now that collection accepts every
// ccusage source, these names reach the feed for real.
const unknown = [
"kimi-k2",
"glm-4.6",
"deepseek-v3",
"grok-code-fast-1",
"qwen3-coder",
"mistral-large",
"gemini-2.5-pro",
"llama-4",
];

for (const name of unknown) {
expect(MODEL_COLOR_FALLBACK_PALETTE).toContain(
modelColor(prettifyModel(name)),
);
}
});

it("is stable for the same name", () => {
expect(modelColor("kimi-k2")).toBe(modelColor("kimi-k2"));
});

it("handles the empty name without throwing", () => {
expect(MODEL_COLOR_FALLBACK_PALETTE).toContain(modelColor(""));
});
});
24 changes: 1 addition & 23 deletions apps/web/components/app/feed/ActivityCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ export { prettifyModel } from "@straude/shared/models";
import { cn } from "@/lib/utils/cn";
import { formatCurrency, formatTokens } from "@/lib/utils/format";
import { mentionsToMarkdownLinks } from "@/lib/utils/mentions";
import { modelColor } from "@/lib/constants/model-colors";
import type { Post, ModelBreakdownEntry } from "@/types";
import dynamic from "next/dynamic";
import { useState } from "react";
Expand Down Expand Up @@ -156,29 +157,6 @@ function buildModelUsageSegments(
}));
}

function hashString(input: string): number {
let hash = 0;
for (let i = 0; i < input.length; i++) {
hash = ((hash << 5) - hash) + input.charCodeAt(i);
hash |= 0;
}
return Math.abs(hash);
}

function modelColor(name: string): string {
if (/Claude Fable/.test(name)) return "#C2410C";
if (/Claude Opus/.test(name)) return "#DF561F";
if (/Claude Sonnet/.test(name)) return "#F08A5D";
if (/Claude Haiku/.test(name)) return "#F7B267";
if (/GPT-5/.test(name)) return "#2A9D8F";
if (/GPT-4o/.test(name)) return "#4C78A8";
if (/^o3/i.test(name)) return "#3B82F6";
if (/^o4/i.test(name)) return "#6366F1";

const palette = ["#EF4444", "#F59E0B", "#10B981", "#06B6D4", "#8B5CF6", "#EC4899"];
return palette[hashString(name) % palette.length]!;
}

function ModelUsageBar({ segments }: { segments: ModelUsageSegment[] }) {
const tooltip = segments.map((entry) => `${entry.pct}% ${entry.name}`).join(", ");

Expand Down
47 changes: 47 additions & 0 deletions apps/web/lib/constants/model-colors.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
/** Fallback palette for models that don't match a known name pattern. */
export const MODEL_COLOR_FALLBACK_PALETTE = [
"#EF4444",
"#F59E0B",
"#10B981",
"#06B6D4",
"#8B5CF6",
"#EC4899",
] as const;

/** Ordered [pattern, color] pairs for known model families. First match wins,
* so a more specific pattern must come before a broader one. Inputs are the
* display names produced by `prettifyModel`, not raw model IDs. */
export const MODEL_COLOR_PATTERNS: readonly (readonly [RegExp, string])[] = [
[/Claude Fable/, "#C2410C"],
[/Claude Opus/, "#DF561F"],
[/Claude Sonnet/, "#F08A5D"],
[/Claude Haiku/, "#F7B267"],
[/GPT-5/, "#2A9D8F"],
[/GPT-4o/, "#4C78A8"],
[/^o3/i, "#3B82F6"],
[/^o4/i, "#6366F1"],
] as const;

/** Stable chip colour for a model display name. Never returns undefined: every
* name that misses the known-family patterns still gets a deterministic
* palette entry, which matters now that collection accepts every ccusage
* source (Gemini, Kimi, DeepSeek, Qwen, …) rather than just Claude/Codex. */
export function modelColor(name: string): string {
for (const [pattern, color] of MODEL_COLOR_PATTERNS) {
if (pattern.test(name)) return color;
}

const index = hashString(name) % MODEL_COLOR_FALLBACK_PALETTE.length;
return MODEL_COLOR_FALLBACK_PALETTE[index]!;
}

function hashString(input: string): number {
let hash = 0;
for (let i = 0; i < input.length; i++) {
hash = (hash << 5) - hash + input.charCodeAt(i);
hash |= 0;
}
// `|= 0` yields a signed 32-bit int, so the hash is negative about half the
// time and `hash % length` would index off the front of the palette.
return Math.abs(hash);
}
6 changes: 5 additions & 1 deletion docs/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,10 @@

### Fixed

- **Model chips no longer render colourless for unknown models.** The fallback palette was indexed with a signed 32-bit hash, so any model name that hashed negative indexed off the front of the array and handed the chip an `undefined` background — about half of all names, hidden from TypeScript by a non-null assertion. Now that collection accepts every ccusage source, real names like `kimi-k2` and `deepseek-v3` hit this. The hash is folded to a positive index and the mapping is covered by unit tests.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Do not document the fallback-index fix until the implementation normalizes negative indexes.

apps/web/lib/constants/model-colors.ts still uses hashString(name) % MODEL_COLOR_FALLBACK_PALETTE.length. A negative signed hash produces a negative array property, so modelColor() can still return undefined. ActivityCard then receives an invalid backgroundColor.

Normalize the modulo result before updating this changelog entry.

Proposed fix
-  const index = hashString(name) % MODEL_COLOR_FALLBACK_PALETTE.length;
+  const hash = hashString(name);
+  const index =
+    ((hash % MODEL_COLOR_FALLBACK_PALETTE.length) +
+      MODEL_COLOR_FALLBACK_PALETTE.length) %
+    MODEL_COLOR_FALLBACK_PALETTE.length;
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@docs/CHANGELOG.md` at line 7, Update the fallback palette indexing in
modelColor() within model-colors.ts to normalize the signed hash modulo result
into a non-negative index before accessing MODEL_COLOR_FALLBACK_PALETTE.
Preserve valid palette selection for all model names, then retain the changelog
entry only once this implementation fix is applied.


- **Agent-readable content negotiation and recovery.** Public informational pages now negotiate curated Markdown through the Next.js 16 proxy with q-value and specificity handling, safe `HEAD`/`406` responses, `Vary: Accept, Accept-Encoding`, and recovery-oriented Markdown 404s without misclassifying existing app routes. OAuth callbacks bypass document negotiation so authentication is never intercepted. The branded HTML 404 links to the homepage, agent instructions, sitemap, About, and Contact. The homepage H1 retains its visual line break as one direct text node for parsers, and a visible server-rendered explanation documents the product and privacy boundary while improving raw-HTML content efficiency.

- **The CLI now waits for and renders the scorecard after a successful sync.** A healthy dashboard response taking longer than 1.5 seconds is no longer discarded with a suggestion to run `straude status` separately.

### Added
Expand All @@ -14,6 +17,8 @@

### Changed

- **Extracted `ActivityCard`'s hardcoded model chip colors** into `apps/web/lib/constants/model-colors.ts`, which now owns the whole name-to-colour decision (`modelColor`) rather than exporting raw tables for callers to recombine. Same colors, same matching order, per the design-system-consistency roadmap item.

- **All ccusage sources and the OpenAI GPT-5.6 family are now tracked.** The CLI dependency floor is `ccusage@20.0.16`, the first release with `gpt-5.6`, `gpt-5.6-sol`, `gpt-5.6-terra`, and `gpt-5.6-luna` plus request-level long-context pricing. Collection now uses current online LiteLLM pricing by default, avoiding stale embedded-price estimates. Unified rows are no longer filtered to Claude/Codex: Straude accepts every source ID emitted by ccusage, carries each row's source IDs through submission metadata, and retains source-aware handling for trusted Codex corrections. A real bundled-binary fixture locks the four GPT-5.6 variants to 440,000 total tokens and asserts that every variant resolves to a non-zero LiteLLM price, with the day total equal to the sum of the per-model breakdown. The dollar amounts themselves are owned upstream and move without notice, so they are deliberately not pinned.

- **Activation funnel events are now captured exclusively server-side.** `trackActivationEvent` no longer double-captures via browser posthog-js for consented users; the consent-exempt, privacy-limited server path (which owns anonymous→user identity stitching) is the single source of truth for funnel math.
Expand Down Expand Up @@ -342,7 +347,6 @@
- **Missing `aria-label` on PostEditor close button.** Added `aria-label="Close editor"` to the icon-only close button.
- **MentionInput missing accessible label.** Added `aria-label` derived from placeholder text to the underlying input/textarea element.


- **Multi-device usage support.** Users who code on multiple machines now get their stats summed instead of overwritten. New `device_usage` table stores per-device rows; `daily_usage` is recalculated as the aggregate. CLI auto-generates a `device_id` (UUID v4) on first push, stored in `~/.straude/config.json`. Old CLIs without `device_id` continue to work via the legacy upsert path. UI is unchanged — viewers see summed totals only.
- **CLI token normalization engine.** Added source-agnostic normalization for ccusage/codex JSON so persisted `inputTokens`/`outputTokens` match table semantics, with anomaly/confidence metadata and deterministic output adjustment safeguards.
- **Weekly digest activation email.** One-time blast to unactivated users showing this week's leaderboard top 5, new features (Codex tracking, achievements, public profiles), and a CTA to sync. Subject line includes dynamic weekly spend total. Route at `/api/cron/weekly-digest`, protected by `CRON_SECRET`.
Expand Down
3 changes: 0 additions & 3 deletions docs/ROADMAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -135,9 +135,6 @@ Extension of Team/Org Workspaces with premium features: private team leaderboard
### Client-side email validation before OTP send
Login form uses `type="email"` + `required` but no check for common mistakes (missing TLD, etc.) before the network request. Low effort, low impact.

### Model colors outside design system
`ActivityCard.tsx:147-158` uses hardcoded hex colors for model chips. Should be extracted to `lib/constants/model-colors.ts` for consistency.

### Typing indicators in DMs
No typing indicators in direct messages. Would require Supabase Realtime presence channels.

Expand Down
Loading