freezmo/lib/utils.ts
Martin Dvorak 6635b5d405 Design pass (all themes + chat UI), model picker, reduce-motion toggle
- Aero: blue gradients back on the active segment and every primary
  (Review due, the card's Study button, the composer's send button); a
  frosted backing behind the page heading and the All decks label; a
  bottom-weighted rail tint; chat header/composer/welcome panels; calmer
  orbs; stronger card veil + a stronger blur bake.
- Dark/Light: a dedicated --track token makes progress bars read (the
  first attempt shifted --surface-sunken and the pixel scan caught the
  sheet-contrast regression); brighter dark secondary text; darker light
  segmented labels and difficulty tints; stronger light card border and
  shadow.
- Chat UI: friendly model chip ("Gemma 4 31B") with the raw name in a
  tooltip, larger chips, plain centered disclaimer, vertically centered
  welcome panel, distinct chat-plus icon for new chat.
- Chat models: every chat-capable model from the server's /api/tags is
  selectable (favourites first, search field when long, live-list
  validation in the route); default CHAT_MODEL_DEFAULT or gemma4:31b.
- Reduce motion: Settings toggle (default off) that parks the scene,
  collapses CSS animations and drives framer-motion's calm mode.
- Folders hint, "New" badge on fresh decks.

Contrast: 0 findings, axe clean, mobile 126/126, chat 102/102, wizard
95/95, regression 44/44, i18n 13/13, native 33/33.
Version 0.4.6, SW -> freezmo-v71.
2026-10-14 05:06:02 +00:00

86 lines
2.7 KiB
TypeScript

export function cn(...parts: Array<string | false | null | undefined>): string {
return parts.filter(Boolean).join(" ");
}
export function uid(prefix = "id"): string {
const rand =
typeof crypto !== "undefined" && "randomUUID" in crypto
? crypto.randomUUID().slice(0, 8)
: Math.random().toString(36).slice(2, 10);
return `${prefix}_${Date.now().toString(36)}_${rand}`;
}
/** Deterministic-ish shuffle (Fisher-Yates). Returns a new array. */
export function shuffle<T>(input: readonly T[]): T[] {
const out = [...input];
for (let i = out.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[out[i], out[j]] = [out[j], out[i]];
}
return out;
}
export function formatDate(timestamp: number): string {
try {
return new Intl.DateTimeFormat(undefined, {
day: "numeric",
month: "short",
year: "numeric",
}).format(new Date(timestamp));
} catch {
return "";
}
}
/** Remove ```json fences and stray prose around a JSON object. */
export function stripFences(text: string): string {
let value = text.trim();
const fence = value.match(/```(?:json)?\s*([\s\S]*?)```/i);
if (fence) value = fence[1].trim();
if (value.startsWith("{") || value.startsWith("[")) return value;
const start = value.indexOf("{");
const end = value.lastIndexOf("}");
if (start !== -1 && end > start) return value.slice(start, end + 1);
return value;
}
export function parseJsonLoose(text: string): unknown {
const cleaned = stripFences(text);
try {
return JSON.parse(cleaned);
} catch {
// Last resort: tolerate trailing commas from chatty models.
return JSON.parse(cleaned.replace(/,\s*([}\]])/g, "$1"));
}
}
export function truncate(value: string, max: number): string {
return value.length > max ? `${value.slice(0, max - 1)}…` : value;
}
/**
* A friendlier label for a model name: "gemma4:31b-cloud" → "Gemma 4 31B",
* "gpt-oss:120b" → "GPT OSS 120B". Chips show this; the raw name lives in a
* tooltip (and the picker keeps the technical names).
*/
export function prettyModelName(name: string): string {
const base = name.replace(/:cloud$/i, "").replace(/-cloud$/i, "");
const [family, ...tagParts] = base.split(":");
const words = family
.replace(/[-_.]+/g, " ")
.replace(/([a-z])(\d)/gi, "$1 $2")
.trim()
.split(/\s+/)
.filter(Boolean);
const prettyFamily = words
.map((word) =>
/^[a-z]{1,3}$/i.test(word) && word === word.toLowerCase()
? word.toUpperCase()
: word.charAt(0).toUpperCase() + word.slice(1),
)
.join(" ");
const tag = tagParts
.join(" ")
.replace(/^(\d+)([a-z])$/i, (_match, size: string, unit: string) => `${size}${unit.toUpperCase()}`);
return [prettyFamily, tag].filter(Boolean).join(" ");
}