update
This commit is contained in:
@@ -0,0 +1,117 @@
|
||||
/**
|
||||
* Runtime accent color (settings → accent color). Predefined palettes plus a
|
||||
* custom picker; shades 50–900 are derived from a single base hex via HSL and
|
||||
* written to the --ka-accent-* CSS variables that back Tailwind's `accent`
|
||||
* palette. Persisted per-browser (localStorage), like the feature flags.
|
||||
*/
|
||||
|
||||
export type AccentShades = Record<"50" | "100" | "200" | "300" | "400" | "500" | "600" | "700" | "800" | "900", string>;
|
||||
|
||||
export interface AccentPreset {
|
||||
id: string;
|
||||
label: string;
|
||||
base: string; // hex, used as the 500 shade and to derive the ramp
|
||||
}
|
||||
|
||||
export const ACCENT_PRESETS: AccentPreset[] = [
|
||||
{ id: "emerald", label: "Emerald", base: "#10b981" },
|
||||
{ id: "rose", label: "Rose", base: "#f43f5e" },
|
||||
{ id: "blue", label: "Blue", base: "#3b82f6" },
|
||||
{ id: "violet", label: "Violet", base: "#8b5cf6" },
|
||||
{ id: "amber", label: "Yellow", base: "#f59e0b" },
|
||||
{ id: "orange", label: "Orange", base: "#f97316" },
|
||||
{ id: "teal", label: "Teal", base: "#14b8a6" },
|
||||
{ id: "pink", label: "Pink", base: "#ec4899" },
|
||||
];
|
||||
|
||||
const STORAGE_KEY = "ka:accent";
|
||||
const SHADE_KEYS = ["50", "100", "200", "300", "400", "500", "600", "700", "800", "900"] as const;
|
||||
// Lightness ramps for 50→900 around a mid 500 (HSL L values in %).
|
||||
const RAMP: Record<(typeof SHADE_KEYS)[number], number> = {
|
||||
"50": 97, "100": 93, "200": 86, "300": 76, "400": 64,
|
||||
"500": 52, "600": 45, "700": 38, "800": 30, "900": 24,
|
||||
};
|
||||
|
||||
function hexToHsl(hex: string): { h: number; s: number; l: number } {
|
||||
const m = /^#?([0-9a-f]{6})$/i.exec(hex.trim());
|
||||
if (!m) return { h: 160, s: 84, l: 52 }; // emerald fallback
|
||||
const n = parseInt(m[1]!, 16);
|
||||
const r = ((n >> 16) & 255) / 255, g = ((n >> 8) & 255) / 255, b = (n & 255) / 255;
|
||||
const max = Math.max(r, g, b), min = Math.min(r, g, b);
|
||||
let h = 0, s = 0;
|
||||
const l = (max + min) / 2;
|
||||
if (max !== min) {
|
||||
const d = max - min;
|
||||
s = l > 0.5 ? d / (2 - max - min) : d / (max + min);
|
||||
if (max === r) h = ((g - b) / d + (g < b ? 6 : 0)) * 60;
|
||||
else if (max === g) h = ((b - r) / d + 2) * 60;
|
||||
else h = ((r - g) / d + 4) * 60;
|
||||
}
|
||||
return { h, s: s * 100, l: l * 100 };
|
||||
}
|
||||
|
||||
function hslToRgbTriplet(h: number, s: number, l: number): string {
|
||||
const sn = s / 100, ln = l / 100;
|
||||
const c = (1 - Math.abs(2 * ln - 1)) * sn;
|
||||
const x = c * (1 - Math.abs(((h / 60) % 2) - 1));
|
||||
const m = ln - c / 2;
|
||||
let r = 0, g = 0, b = 0;
|
||||
if (h < 60) [r, g, b] = [c, x, 0];
|
||||
else if (h < 120) [r, g, b] = [x, c, 0];
|
||||
else if (h < 180) [r, g, b] = [0, c, x];
|
||||
else if (h < 240) [r, g, b] = [0, x, c];
|
||||
else if (h < 300) [r, g, b] = [x, 0, c];
|
||||
else [r, g, b] = [c, 0, x];
|
||||
const to = (v: number) => Math.round((v + m) * 255);
|
||||
return `${to(r)} ${to(g)} ${to(b)}`;
|
||||
}
|
||||
|
||||
/** Build the 50–900 ramp from a base hex (HSL ramp, emerald-style). */
|
||||
export function shadesFor(baseHex: string): AccentShades {
|
||||
const { h, s } = hexToHsl(baseHex);
|
||||
const out = {} as AccentShades;
|
||||
for (const k of SHADE_KEYS) {
|
||||
// Keep saturation lively at the light end, richer at the dark end.
|
||||
const sat = k === "50" || k === "100" ? s * 0.9 : s;
|
||||
out[k] = hslToRgbTriplet(h, sat, RAMP[k]);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export interface AccentState {
|
||||
/** preset id, or "custom" */
|
||||
presetId: string;
|
||||
/** hex for custom (ignored for presets — they derive from their base) */
|
||||
customHex: string;
|
||||
}
|
||||
|
||||
export function currentAccent(): AccentState {
|
||||
try {
|
||||
const raw = JSON.parse(localStorage.getItem(STORAGE_KEY) ?? "null") as AccentState | null;
|
||||
if (raw && typeof raw.presetId === "string") return raw;
|
||||
} catch { /* ignore */ }
|
||||
return { presetId: "emerald", customHex: "#10b981" };
|
||||
}
|
||||
|
||||
export function applyAccent(state: AccentState): void {
|
||||
const preset = ACCENT_PRESETS.find((p) => p.id === state.presetId);
|
||||
const base = preset ? preset.base : (/^#?[0-9a-f]{6}$/i.test(state.customHex) ? state.customHex : "#10b981");
|
||||
const shades = shadesFor(base);
|
||||
const root = document.documentElement;
|
||||
for (const k of SHADE_KEYS) root.style.setProperty(`--ka-accent-${k}`, shades[k]);
|
||||
try {
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(state));
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
|
||||
/** Preview without persisting (used while the picker is open). */
|
||||
export function previewAccent(baseHex: string): void {
|
||||
const shades = shadesFor(baseHex);
|
||||
const root = document.documentElement;
|
||||
for (const k of SHADE_KEYS) root.style.setProperty(`--ka-accent-${k}`, shades[k]);
|
||||
}
|
||||
|
||||
// Restore the saved accent on load.
|
||||
if (typeof document !== "undefined") {
|
||||
applyAccent(currentAccent());
|
||||
}
|
||||
@@ -46,7 +46,9 @@ export const api = {
|
||||
verify: () => req<{ ok: boolean; user: User }>("/api/auth/verify"),
|
||||
logout: () => req<{ ok: boolean }>("/api/auth/logout", { method: "POST" }).catch(() => ({ ok: true as const })),
|
||||
|
||||
methods: () => req<{ recovery: boolean; from?: string }>("/api/auth/methods", {}, false),
|
||||
methods: () => req<{ recovery: boolean; from?: string; registration: boolean }>("/api/auth/methods", {}, false),
|
||||
register: (u: { username: string; displayName?: string; email?: string }) =>
|
||||
req<{ user: User; key: string }>("/api/auth/register", { method: "POST", body: JSON.stringify(u) }, false),
|
||||
recover: (username: string) =>
|
||||
req<{ ok: boolean }>("/api/auth/recover", { method: "POST", body: JSON.stringify({ username }) }, false),
|
||||
resetCheck: (token: string) => req<{ ok: boolean; username: string }>(`/api/auth/reset/${token}`, {}, false),
|
||||
@@ -64,6 +66,10 @@ export const api = {
|
||||
adminPatch: (id: string, patch: { displayName?: string; avatarUrl?: string; theme?: string; email?: string }) =>
|
||||
req<{ user: User }>(`/api/admin/users/${id}`, { method: "PATCH", body: JSON.stringify(patch) }),
|
||||
|
||||
adminGetSettings: () => req<{ settings: { registrationEnabled: boolean; ocrEnabled: boolean } }>("/api/admin/settings"),
|
||||
adminPatchSettings: (patch: { registrationEnabled?: boolean; ocrEnabled?: boolean }) =>
|
||||
req<{ settings: { registrationEnabled: boolean; ocrEnabled: boolean } }>("/api/admin/settings", { method: "PATCH", body: JSON.stringify(patch) }),
|
||||
|
||||
convs: () => req<{ conversations: Conversation[] }>("/api/conversations"),
|
||||
createConv: (c: { title?: string; topic?: string; model?: string }) =>
|
||||
req<{ conversation: Conversation }>("/api/conversations", { method: "POST", body: JSON.stringify(c) }),
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
import { useSyncExternalStore } from "react";
|
||||
|
||||
/**
|
||||
* Per-browser feature flags (localStorage). Gate optional composer
|
||||
* capabilities; kept client-side on purpose — they only change what the
|
||||
* UI offers / what prompt decorations are sent, never auth or data access.
|
||||
*/
|
||||
export type FeatureKey = "thinking" | "attachments" | "deepSearch";
|
||||
|
||||
export const FEATURES: { key: FeatureKey; label: string; hint: string; ready: boolean }[] = [
|
||||
{
|
||||
key: "thinking",
|
||||
label: "Thinking",
|
||||
hint: "Ask the model to reason step-by-step before answering.",
|
||||
ready: true,
|
||||
},
|
||||
{
|
||||
key: "attachments",
|
||||
label: "Attachments",
|
||||
hint: "Attach images (OCR transcription) and text files to messages.",
|
||||
ready: true,
|
||||
},
|
||||
{
|
||||
key: "deepSearch",
|
||||
label: "Deep search",
|
||||
hint: "Let the assistant search the web for fresh sources. Coming soon.",
|
||||
ready: false,
|
||||
},
|
||||
];
|
||||
|
||||
const STORAGE_KEY = "ka:features";
|
||||
const DEFAULTS: Record<FeatureKey, boolean> = {
|
||||
thinking: false,
|
||||
attachments: true,
|
||||
deepSearch: false,
|
||||
};
|
||||
|
||||
function load(): Record<FeatureKey, boolean> {
|
||||
try {
|
||||
const raw = JSON.parse(localStorage.getItem(STORAGE_KEY) ?? "{}") as Partial<Record<FeatureKey, boolean>>;
|
||||
return { ...DEFAULTS, ...raw };
|
||||
} catch {
|
||||
return { ...DEFAULTS };
|
||||
}
|
||||
}
|
||||
|
||||
let flags = load();
|
||||
const listeners = new Set<() => void>();
|
||||
|
||||
function persist() {
|
||||
try {
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(flags));
|
||||
} catch {
|
||||
// private mode etc. — flags just won't survive a reload
|
||||
}
|
||||
listeners.forEach((l) => l());
|
||||
}
|
||||
|
||||
export function featureEnabled(key: FeatureKey): boolean {
|
||||
return flags[key];
|
||||
}
|
||||
|
||||
export function setFeature(key: FeatureKey, value: boolean) {
|
||||
flags = { ...flags, [key]: value };
|
||||
persist();
|
||||
}
|
||||
|
||||
export function useFeatures(): Record<FeatureKey, boolean> {
|
||||
return useSyncExternalStore(
|
||||
(cb) => {
|
||||
listeners.add(cb);
|
||||
return () => { listeners.delete(cb); };
|
||||
},
|
||||
() => flags,
|
||||
);
|
||||
}
|
||||
|
||||
/** System message prepended to /api/chat when the "thinking" flag is on. */
|
||||
export const THINKING_SYSTEM_PROMPT =
|
||||
"Think step-by-step before answering. Start with a short 'Thoughts' section showing your reasoning, then give the final answer under an 'Answer' heading.";
|
||||
Reference in New Issue
Block a user