mirror of
https://github.com/karpathy/nanochat.git
synced 2026-05-08 16:59:59 +00:00
feat: deploy d24 SFT + polished UI redesign with dark mode (#39)
* feat(inference): deploy d24 SFT weights to Modal Repoint Modal inference app from the broken d20 checkpoint to our own ManmohanSharma/nanochat-d24 SFT step 484. Rewrites the standalone model as an inference-only port of nanochat/gpt.py so the modern architecture (smear gate, per-layer value embeddings, ve_gate, backout, sliding window attention via SDPA, rotary base 100000, padded vocab, logit softcap) loads cleanly from the checkpoint. Tokenizer loads the pickled tiktoken encoding directly so special tokens end up at their true IDs (32759-32767), and the stop check uses that set instead of hardcoded 0-8. GPU bumped to L4 for headroom. HF token sourced from the 'huggingface' Modal secret. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(frontend): polished redesign with serif display + dark mode Lifts the craft level of the landing and chat UI without changing the desi identity. Adds Fraunces for display headlines, a floating pill LandingNav, a saffron-glow hero with a large serif headline and black pill CTAs, and three gradient-tiled feature cards with inline SVG glyphs replacing the emoji cards. The chat empty state is now a serif greeting with pill-chip prompt starters, and ChatInput is a single rounded pod so the send button sits inside the input (fixes the misaligned floating button). Adds a class-based dark mode across the chat surfaces with a sun/moon toggle in the sidebar footer, powered by a small useTheme hook and a no-flash init script in the root layout. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * chore(frontend): add ESLint config so CI lint step passes next lint was failing with an interactive prompt because the repo had no ESLint config. Adds a minimal next/core-web-vitals extends and drops the now-unloadable @typescript-eslint/no-explicit-any disable directive in the stream proxy by narrowing the body type to unknown. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
272086d2c0
commit
1d2a76eec4
306
modal/_model.py
306
modal/_model.py
|
|
@ -1,202 +1,244 @@
|
|||
"""
|
||||
Minimal standalone GPT model for Modal inference.
|
||||
Extracted from nanochat/gpt.py — only the forward-pass code needed for inference.
|
||||
No training, no DDP, no flash_attention dependency.
|
||||
Inference-only port of nanochat/gpt.py.
|
||||
|
||||
Matches the actual nanochat GPT architecture used by d24 SFT checkpoints:
|
||||
- Smear gate (cheap bigram mixing)
|
||||
- Backout (mid-layer residual subtraction)
|
||||
- Per-layer value embeddings (alternating layers, last layer always)
|
||||
- ve_gate per layer with value embedding
|
||||
- Sliding-window attention (window_pattern, e.g. "SSSL"), via SDPA
|
||||
- Rotary embeddings with base=100000, split-halves layout
|
||||
- Padded vocab (multiple of 64)
|
||||
- Softcap on logits
|
||||
- No KV cache (naive autoregressive generate is fine for short responses)
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
|
||||
import torch
|
||||
import torch.nn as nn
|
||||
import torch.nn.functional as F
|
||||
import math
|
||||
|
||||
|
||||
@dataclass
|
||||
class GPTConfig:
|
||||
sequence_len: int = 2048
|
||||
vocab_size: int = 65536
|
||||
n_layer: int = 20
|
||||
n_head: int = 10
|
||||
n_kv_head: int = 10
|
||||
n_embd: int = 1280
|
||||
window_pattern: str = "L"
|
||||
vocab_size: int = 32768
|
||||
n_layer: int = 24
|
||||
n_head: int = 12
|
||||
n_kv_head: int = 12
|
||||
n_embd: int = 1536
|
||||
window_pattern: str = "SSSL"
|
||||
|
||||
|
||||
class RMSNorm(nn.Module):
|
||||
def __init__(self, dim):
|
||||
super().__init__()
|
||||
self.dim = dim
|
||||
def _norm(x):
|
||||
return F.rms_norm(x, (x.size(-1),))
|
||||
|
||||
|
||||
class Linear(nn.Linear):
|
||||
"""nn.Linear that casts weights to match input dtype in forward."""
|
||||
|
||||
def forward(self, x):
|
||||
return x * torch.rsqrt(x.pow(2).mean(-1, keepdim=True) + 1e-6)
|
||||
return F.linear(x, self.weight.to(dtype=x.dtype))
|
||||
|
||||
|
||||
class RotaryEmbedding(nn.Module):
|
||||
def __init__(self, dim, max_seq_len=2048):
|
||||
super().__init__()
|
||||
inv_freq = 1.0 / (10000 ** (torch.arange(0, dim, 2).float() / dim))
|
||||
self.register_buffer("inv_freq", inv_freq, persistent=False)
|
||||
self.max_seq_len = max_seq_len
|
||||
|
||||
def forward(self, x, offset=0):
|
||||
seq_len = x.shape[-2]
|
||||
t = torch.arange(offset, offset + seq_len, device=x.device, dtype=self.inv_freq.dtype)
|
||||
freqs = torch.outer(t, self.inv_freq)
|
||||
emb = torch.cat((freqs, freqs), dim=-1)
|
||||
return emb.cos(), emb.sin()
|
||||
def has_ve(layer_idx: int, n_layer: int) -> bool:
|
||||
"""Layers with a value embedding (alternating, last layer always included)."""
|
||||
return layer_idx % 2 == (n_layer - 1) % 2
|
||||
|
||||
|
||||
def apply_rotary_pos_emb(q, k, cos, sin):
|
||||
def rotate_half(x):
|
||||
x1, x2 = x.chunk(2, dim=-1)
|
||||
return torch.cat((-x2, x1), dim=-1)
|
||||
q_embed = (q * cos) + (rotate_half(q) * sin)
|
||||
k_embed = (k * cos) + (rotate_half(k) * sin)
|
||||
return q_embed, k_embed
|
||||
def apply_rotary_emb(x, cos, sin):
|
||||
assert x.ndim == 4
|
||||
d = x.shape[3] // 2
|
||||
x1, x2 = x[..., :d], x[..., d:]
|
||||
y1 = x1 * cos + x2 * sin
|
||||
y2 = x1 * (-sin) + x2 * cos
|
||||
return torch.cat([y1, y2], 3)
|
||||
|
||||
|
||||
class CausalSelfAttention(nn.Module):
|
||||
def __init__(self, config: GPTConfig, use_v_emb: bool = False):
|
||||
def __init__(self, config: GPTConfig, layer_idx: int):
|
||||
super().__init__()
|
||||
self.layer_idx = layer_idx
|
||||
self.n_head = config.n_head
|
||||
self.n_kv_head = config.n_kv_head
|
||||
self.head_dim = config.n_embd // config.n_head
|
||||
self.n_embd = config.n_embd
|
||||
self.use_v_emb = use_v_emb
|
||||
self.head_dim = self.n_embd // self.n_head
|
||||
self.c_q = Linear(self.n_embd, self.n_head * self.head_dim, bias=False)
|
||||
self.c_k = Linear(self.n_embd, self.n_kv_head * self.head_dim, bias=False)
|
||||
self.c_v = Linear(self.n_embd, self.n_kv_head * self.head_dim, bias=False)
|
||||
self.c_proj = Linear(self.n_embd, self.n_embd, bias=False)
|
||||
self.ve_gate_channels = 12
|
||||
if has_ve(layer_idx, config.n_layer):
|
||||
self.ve_gate = Linear(self.ve_gate_channels, self.n_kv_head, bias=False)
|
||||
else:
|
||||
self.ve_gate = None
|
||||
|
||||
self.c_q = nn.Linear(config.n_embd, config.n_head * self.head_dim, bias=False)
|
||||
self.c_k = nn.Linear(config.n_embd, config.n_kv_head * self.head_dim, bias=False)
|
||||
self.c_v = nn.Linear(config.n_embd, config.n_kv_head * self.head_dim, bias=False)
|
||||
self.c_proj = nn.Linear(config.n_head * self.head_dim, config.n_embd, bias=False)
|
||||
|
||||
self.q_norm = RMSNorm(self.head_dim)
|
||||
self.k_norm = RMSNorm(self.head_dim)
|
||||
|
||||
if use_v_emb:
|
||||
self.v_emb = nn.Parameter(torch.zeros(1, config.n_kv_head, config.sequence_len, self.head_dim))
|
||||
|
||||
self.rotary = RotaryEmbedding(self.head_dim, config.sequence_len)
|
||||
|
||||
def forward(self, x):
|
||||
def forward(self, x, ve, cos_sin, window_size):
|
||||
B, T, C = x.size()
|
||||
# (B, T, H, D) layout
|
||||
q = self.c_q(x).view(B, T, self.n_head, self.head_dim)
|
||||
k = self.c_k(x).view(B, T, self.n_kv_head, self.head_dim)
|
||||
v = self.c_v(x).view(B, T, self.n_kv_head, self.head_dim)
|
||||
|
||||
q = self.c_q(x).view(B, T, self.n_head, self.head_dim).transpose(1, 2)
|
||||
k = self.c_k(x).view(B, T, self.n_kv_head, self.head_dim).transpose(1, 2)
|
||||
v = self.c_v(x).view(B, T, self.n_kv_head, self.head_dim).transpose(1, 2)
|
||||
if ve is not None:
|
||||
ve = ve.view(B, T, self.n_kv_head, self.head_dim)
|
||||
gate = 3.0 * torch.sigmoid(self.ve_gate(x[..., : self.ve_gate_channels])) # (B, T, n_kv_head)
|
||||
v = v + gate.unsqueeze(-1) * ve
|
||||
|
||||
# QK norm
|
||||
q = self.q_norm(q)
|
||||
k = self.k_norm(k)
|
||||
cos, sin = cos_sin
|
||||
q = apply_rotary_emb(q, cos, sin)
|
||||
k = apply_rotary_emb(k, cos, sin)
|
||||
q, k = _norm(q), _norm(k)
|
||||
q = q * 1.2
|
||||
k = k * 1.2
|
||||
|
||||
# Rotary embeddings
|
||||
cos, sin = self.rotary(q)
|
||||
q, k = apply_rotary_pos_emb(q, k, cos, sin)
|
||||
# SDPA wants (B, H, T, D)
|
||||
q_sdpa = q.transpose(1, 2)
|
||||
k_sdpa = k.transpose(1, 2)
|
||||
v_sdpa = v.transpose(1, 2)
|
||||
enable_gqa = q_sdpa.size(1) != k_sdpa.size(1)
|
||||
|
||||
# GQA: repeat k,v if n_kv_head < n_head
|
||||
if self.n_kv_head < self.n_head:
|
||||
rep = self.n_head // self.n_kv_head
|
||||
k = k.repeat_interleave(rep, dim=1)
|
||||
v = v.repeat_interleave(rep, dim=1)
|
||||
window = window_size[0]
|
||||
if window < 0 or window >= T:
|
||||
y = F.scaled_dot_product_attention(q_sdpa, k_sdpa, v_sdpa, is_causal=True, enable_gqa=enable_gqa)
|
||||
else:
|
||||
# Sliding window mask (left=window)
|
||||
device = q_sdpa.device
|
||||
row_idx = torch.arange(T, device=device).unsqueeze(1)
|
||||
col_idx = torch.arange(T, device=device).unsqueeze(0)
|
||||
mask = (col_idx <= row_idx) & ((row_idx - col_idx) <= window)
|
||||
y = F.scaled_dot_product_attention(q_sdpa, k_sdpa, v_sdpa, attn_mask=mask, enable_gqa=enable_gqa)
|
||||
|
||||
# Value embeddings (if enabled)
|
||||
if self.use_v_emb:
|
||||
v = v + self.v_emb[:, :, :T, :]
|
||||
|
||||
# Scaled dot-product attention (PyTorch native, causal)
|
||||
y = F.scaled_dot_product_attention(q, k, v, is_causal=True)
|
||||
|
||||
y = y.transpose(1, 2).contiguous().view(B, T, C)
|
||||
y = y.transpose(1, 2).contiguous().view(B, T, -1)
|
||||
return self.c_proj(y)
|
||||
|
||||
|
||||
class MLP(nn.Module):
|
||||
def __init__(self, config: GPTConfig, gated: bool = False):
|
||||
def __init__(self, config: GPTConfig):
|
||||
super().__init__()
|
||||
self.gated = gated
|
||||
if gated:
|
||||
hidden = int(config.n_embd * 8 / 3)
|
||||
hidden = ((hidden + 63) // 64) * 64
|
||||
self.c_fc = nn.Linear(config.n_embd, hidden, bias=False)
|
||||
self.c_fc2 = nn.Linear(config.n_embd, hidden, bias=False)
|
||||
self.c_proj = nn.Linear(hidden, config.n_embd, bias=False)
|
||||
else:
|
||||
hidden = 4 * config.n_embd
|
||||
self.c_fc = nn.Linear(config.n_embd, hidden, bias=False)
|
||||
self.c_proj = nn.Linear(hidden, config.n_embd, bias=False)
|
||||
self.c_fc = Linear(config.n_embd, 4 * config.n_embd, bias=False)
|
||||
self.c_proj = Linear(4 * config.n_embd, config.n_embd, bias=False)
|
||||
|
||||
def forward(self, x):
|
||||
if self.gated:
|
||||
a = self.c_fc(x)
|
||||
b = self.c_fc2(x)
|
||||
return self.c_proj(F.relu(a).pow(2) * b)
|
||||
else:
|
||||
return self.c_proj(F.relu(self.c_fc(x)).pow(2))
|
||||
x = self.c_fc(x)
|
||||
x = F.relu(x).square()
|
||||
x = self.c_proj(x)
|
||||
return x
|
||||
|
||||
|
||||
class Block(nn.Module):
|
||||
def __init__(self, config: GPTConfig, layer_idx: int, gated_mlp: bool = False, use_v_emb: bool = False):
|
||||
def __init__(self, config: GPTConfig, layer_idx: int):
|
||||
super().__init__()
|
||||
self.ln_1 = RMSNorm(config.n_embd)
|
||||
self.attn = CausalSelfAttention(config, use_v_emb=use_v_emb)
|
||||
self.ln_2 = RMSNorm(config.n_embd)
|
||||
self.mlp = MLP(config, gated=gated_mlp)
|
||||
self.layer_idx = layer_idx
|
||||
self.attn = CausalSelfAttention(config, layer_idx)
|
||||
self.mlp = MLP(config)
|
||||
|
||||
def forward(self, x, resid_lambda=1.0, x0_lambda=0.0, x0=None):
|
||||
h = x * resid_lambda + self.attn(self.ln_1(x))
|
||||
if x0 is not None and x0_lambda != 0.0:
|
||||
h = h + x0_lambda * x0
|
||||
h2 = h * resid_lambda + self.mlp(self.ln_2(h))
|
||||
if x0 is not None and x0_lambda != 0.0:
|
||||
h2 = h2 + x0_lambda * x0
|
||||
return h2
|
||||
def forward(self, x, ve, cos_sin, window_size):
|
||||
x = x + self.attn(_norm(x), ve, cos_sin, window_size)
|
||||
x = x + self.mlp(_norm(x))
|
||||
return x
|
||||
|
||||
|
||||
def _compute_window_sizes(config: GPTConfig):
|
||||
pattern = config.window_pattern.upper()
|
||||
long_window = config.sequence_len
|
||||
short_window = -(-long_window // 4 // 128) * 128
|
||||
char_to_window = {"L": (long_window, 0), "S": (short_window, 0)}
|
||||
sizes = [char_to_window[pattern[i % len(pattern)]] for i in range(config.n_layer)]
|
||||
sizes[-1] = (long_window, 0)
|
||||
return sizes
|
||||
|
||||
|
||||
def _precompute_rotary(seq_len, head_dim, base=100000, device="cpu", dtype=torch.float32):
|
||||
channel_range = torch.arange(0, head_dim, 2, dtype=torch.float32, device=device)
|
||||
inv_freq = 1.0 / (base ** (channel_range / head_dim))
|
||||
t = torch.arange(seq_len, dtype=torch.float32, device=device)
|
||||
freqs = torch.outer(t, inv_freq)
|
||||
cos = freqs.cos().to(dtype)[None, :, None, :]
|
||||
sin = freqs.sin().to(dtype)[None, :, None, :]
|
||||
return cos, sin
|
||||
|
||||
|
||||
class GPT(nn.Module):
|
||||
def __init__(self, config: GPTConfig, gated_mlp: bool = False, use_v_emb: bool = False):
|
||||
def __init__(self, config: GPTConfig, pad_vocab_size_to: int = 64):
|
||||
super().__init__()
|
||||
self.config = config
|
||||
self.window_sizes = _compute_window_sizes(config)
|
||||
|
||||
self.transformer = nn.ModuleDict(dict(
|
||||
wte=nn.Embedding(config.vocab_size, config.n_embd),
|
||||
norm_emb=RMSNorm(config.n_embd),
|
||||
h=nn.ModuleList([Block(config, i, gated_mlp=gated_mlp, use_v_emb=use_v_emb) for i in range(config.n_layer)]),
|
||||
ln_f=RMSNorm(config.n_embd),
|
||||
))
|
||||
self.lm_head = nn.Linear(config.n_embd, config.vocab_size, bias=False)
|
||||
padded = ((config.vocab_size + pad_vocab_size_to - 1) // pad_vocab_size_to) * pad_vocab_size_to
|
||||
self.padded_vocab_size = padded
|
||||
|
||||
self.transformer = nn.ModuleDict({
|
||||
"wte": nn.Embedding(padded, config.n_embd),
|
||||
"h": nn.ModuleList([Block(config, i) for i in range(config.n_layer)]),
|
||||
})
|
||||
self.lm_head = Linear(config.n_embd, padded, bias=False)
|
||||
|
||||
# Residual lambdas (per-layer scaling)
|
||||
self.resid_lambdas = nn.Parameter(torch.ones(config.n_layer))
|
||||
self.x0_lambdas = nn.Parameter(torch.zeros(config.n_layer))
|
||||
|
||||
self.smear_gate = Linear(24, 1, bias=False)
|
||||
self.smear_lambda = nn.Parameter(torch.zeros(1))
|
||||
self.backout_lambda = nn.Parameter(0.2 * torch.ones(1))
|
||||
|
||||
head_dim = config.n_embd // config.n_head
|
||||
kv_dim = config.n_kv_head * head_dim
|
||||
self.value_embeds = nn.ModuleDict(
|
||||
{str(i): nn.Embedding(padded, kv_dim) for i in range(config.n_layer) if has_ve(i, config.n_layer)}
|
||||
)
|
||||
|
||||
# Rotary buffers (registered non-persistent — recomputed in init_rotary)
|
||||
self.rotary_seq_len = config.sequence_len * 10
|
||||
self.register_buffer("cos", torch.zeros(1), persistent=False)
|
||||
self.register_buffer("sin", torch.zeros(1), persistent=False)
|
||||
|
||||
@classmethod
|
||||
def from_state_dict(cls, config: GPTConfig, state_dict: dict):
|
||||
"""Auto-detect architecture features from checkpoint keys."""
|
||||
gated = any("c_fc2" in k for k in state_dict)
|
||||
v_emb = any("v_emb" in k for k in state_dict)
|
||||
model = cls(config, gated_mlp=gated, use_v_emb=v_emb)
|
||||
return model
|
||||
# Architecture is fixed for this checkpoint family; kept for API compat.
|
||||
return cls(config)
|
||||
|
||||
def init_rotary(self, device, dtype):
|
||||
head_dim = self.config.n_embd // self.config.n_head
|
||||
cos, sin = _precompute_rotary(self.rotary_seq_len, head_dim, base=100000, device=device, dtype=dtype)
|
||||
self.cos = cos
|
||||
self.sin = sin
|
||||
|
||||
# Kept for compatibility with serve.py's existing init_weights() call.
|
||||
def init_weights(self):
|
||||
"""Initialize rotary embeddings and value embeddings."""
|
||||
for module in self.modules():
|
||||
if isinstance(module, RotaryEmbedding):
|
||||
inv_freq = 1.0 / (10000 ** (torch.arange(0, module.inv_freq.shape[0] * 2, 2).float() / (module.inv_freq.shape[0] * 2)))
|
||||
module.inv_freq.copy_(inv_freq)
|
||||
pass
|
||||
|
||||
def forward(self, idx):
|
||||
B, T = idx.size()
|
||||
assert T <= self.config.sequence_len, f"Input length {T} exceeds max {self.config.sequence_len}"
|
||||
assert T <= self.cos.size(1), f"Sequence length {T} exceeds rotary cache {self.cos.size(1)}"
|
||||
cos_sin = self.cos[:, :T], self.sin[:, :T]
|
||||
|
||||
x = self.transformer.wte(idx)
|
||||
x = self.transformer.norm_emb(x)
|
||||
x0 = x # save for residual connections
|
||||
x = _norm(x)
|
||||
|
||||
# Smear: bigram mixing (training/prefill path; T >= 1 — guarded for T==1)
|
||||
if T > 1:
|
||||
gate = self.smear_lambda.to(x.dtype) * torch.sigmoid(self.smear_gate(x[:, 1:, :24]))
|
||||
x = torch.cat([x[:, :1], x[:, 1:] + gate * x[:, :-1]], dim=1)
|
||||
|
||||
x0 = x
|
||||
n_layer = self.config.n_layer
|
||||
backout_layer = n_layer // 2
|
||||
x_backout = None
|
||||
for i, block in enumerate(self.transformer.h):
|
||||
rl = self.resid_lambdas[i].item()
|
||||
xl = self.x0_lambdas[i].item()
|
||||
x = block(x, resid_lambda=rl, x0_lambda=xl, x0=x0)
|
||||
x = self.resid_lambdas[i] * x + self.x0_lambdas[i] * x0
|
||||
ve = self.value_embeds[str(i)](idx).to(x.dtype) if str(i) in self.value_embeds else None
|
||||
x = block(x, ve, cos_sin, self.window_sizes[i])
|
||||
if i == backout_layer:
|
||||
x_backout = x
|
||||
|
||||
x = self.transformer.ln_f(x)
|
||||
if x_backout is not None:
|
||||
x = x - self.backout_lambda.to(x.dtype) * x_backout
|
||||
x = _norm(x)
|
||||
|
||||
softcap = 15.0
|
||||
logits = self.lm_head(x)
|
||||
logits = logits[..., : self.config.vocab_size]
|
||||
logits = logits.float()
|
||||
logits = softcap * torch.tanh(logits / softcap)
|
||||
return logits
|
||||
|
|
|
|||
|
|
@ -1,14 +1,16 @@
|
|||
"""
|
||||
Minimal standalone tokenizer for Modal inference.
|
||||
Uses tiktoken for fast encoding/decoding with nanochat's special tokens.
|
||||
|
||||
Loads the pickled tiktoken Encoding from a nanochat tokenizer/ directory and
|
||||
exposes encode / decode / encode_special methods used by serve.py.
|
||||
"""
|
||||
|
||||
import os
|
||||
import pickle
|
||||
|
||||
import tiktoken
|
||||
|
||||
|
||||
# nanochat special tokens
|
||||
SPECIAL_TOKENS = {
|
||||
"<|bos|>": 0,
|
||||
"<|user_start|>": 1,
|
||||
|
|
@ -21,26 +23,26 @@ SPECIAL_TOKENS = {
|
|||
"<|output_end|>": 8,
|
||||
}
|
||||
|
||||
# GPT-4 split pattern
|
||||
SPLIT_PATTERN = r"(?i:[sdmt]|ll|ve|re)|[^\r\n\p{L}\p{N}]?+\p{L}+|\p{N}{1,2}| ?[^\s\p{L}\p{N}]++[\r\n]*|\s*[\r\n]|\s+(?!\S)|\s+"
|
||||
# nanochat split pattern (matches nanochat/tokenizer.py)
|
||||
SPLIT_PATTERN = r"""'(?i:[sdmt]|ll|ve|re)|[^\r\n\p{L}\p{N}]?+\p{L}+|\p{N}{1,2}| ?[^\s\p{L}\p{N}]++[\r\n]*|\s*[\r\n]|\s+(?!\S)|\s+"""
|
||||
|
||||
|
||||
class NanochatTokenizer:
|
||||
def __init__(self, model_dir: str):
|
||||
pkl_path = os.path.join(model_dir, "tokenizer.pkl")
|
||||
token_bytes_path = os.path.join(model_dir, "token_bytes.pt")
|
||||
tokenizer_pkl_path = os.path.join(model_dir, "tokenizer.pkl")
|
||||
|
||||
if os.path.exists(tokenizer_pkl_path):
|
||||
with open(tokenizer_pkl_path, "rb") as f:
|
||||
if os.path.exists(pkl_path):
|
||||
with open(pkl_path, "rb") as f:
|
||||
loaded = pickle.load(f)
|
||||
# Handle different pickle formats
|
||||
if isinstance(loaded, tiktoken.Encoding):
|
||||
self._enc = loaded
|
||||
return
|
||||
if isinstance(loaded, dict):
|
||||
mergeable_ranks = loaded
|
||||
elif hasattr(loaded, '_mergeable_ranks'):
|
||||
# It's a tiktoken Encoding object
|
||||
elif hasattr(loaded, "_mergeable_ranks"):
|
||||
mergeable_ranks = loaded._mergeable_ranks
|
||||
else:
|
||||
# Try to use it as a pre-built encoder
|
||||
self._enc = loaded
|
||||
return
|
||||
elif os.path.exists(token_bytes_path):
|
||||
|
|
@ -48,32 +50,30 @@ class NanochatTokenizer:
|
|||
token_bytes = torch.load(token_bytes_path, weights_only=True)
|
||||
mergeable_ranks = {bytes(token_bytes[i].tolist()): i for i in range(len(token_bytes))}
|
||||
else:
|
||||
from huggingface_hub import hf_hub_download
|
||||
path = hf_hub_download("karpathy/nanochat-d32", "tokenizer.pkl")
|
||||
with open(path, "rb") as f:
|
||||
mergeable_ranks = pickle.load(f)
|
||||
raise FileNotFoundError(f"No tokenizer found in {model_dir}")
|
||||
|
||||
# nanochat appends specials at the end of the merge table
|
||||
offset = len(mergeable_ranks)
|
||||
special_tokens = {name: offset + i for i, name in enumerate(SPECIAL_TOKENS)}
|
||||
self._enc = tiktoken.Encoding(
|
||||
name="nanochat",
|
||||
pat_str=SPLIT_PATTERN,
|
||||
mergeable_ranks=mergeable_ranks,
|
||||
special_tokens=SPECIAL_TOKENS,
|
||||
special_tokens=special_tokens,
|
||||
)
|
||||
|
||||
def encode(self, text: str) -> list[int]:
|
||||
return self._enc.encode(text, allowed_special=set())
|
||||
return self._enc.encode_ordinary(text)
|
||||
|
||||
def decode(self, tokens: list[int]) -> str:
|
||||
return self._enc.decode(tokens)
|
||||
|
||||
def encode_special(self, token_name: str) -> list[int]:
|
||||
return self._enc.encode(token_name, allowed_special="all")
|
||||
return [self._enc.encode_single_token(token_name)]
|
||||
|
||||
def get_vocab_size(self) -> int:
|
||||
return self._enc.n_vocab
|
||||
|
||||
|
||||
def get_tokenizer(model_dir: str | None = None) -> NanochatTokenizer:
|
||||
if model_dir is None:
|
||||
model_dir = "/weights/d20"
|
||||
def get_tokenizer(model_dir: str) -> NanochatTokenizer:
|
||||
return NanochatTokenizer(model_dir)
|
||||
|
|
|
|||
|
|
@ -19,12 +19,15 @@ import modal
|
|||
# ---------------------------------------------------------------------------
|
||||
# Configuration
|
||||
# ---------------------------------------------------------------------------
|
||||
MODEL_REPO = "nanochat-students/base-d20" # 1 GB, native nanochat format
|
||||
MODEL_PT = "model_021400.pt"
|
||||
META_JSON = "meta_021400.json"
|
||||
MODEL_TAG = "d20"
|
||||
GPU_TYPE = "T4" # cheapest, 16 GB VRAM — plenty for 1 GB model
|
||||
MODEL_REPO = "ManmohanSharma/nanochat-d24"
|
||||
MODEL_PT = "chatsft_checkpoints/d24/model_000484.pt"
|
||||
META_JSON = "chatsft_checkpoints/d24/meta_000484.json"
|
||||
TOKENIZER_PKL = "tokenizer/tokenizer.pkl"
|
||||
TOKEN_BYTES = "tokenizer/token_bytes.pt"
|
||||
MODEL_TAG = "d24-sft"
|
||||
GPU_TYPE = "L4" # 24 GB VRAM — fits 4 GB bf16 model loaded as fp32
|
||||
VOLUME_NAME = "samosachaat-weights"
|
||||
HF_SECRET_NAME = "huggingface" # Modal secret containing HF_TOKEN
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Modal app + image
|
||||
|
|
@ -56,24 +59,34 @@ volume = modal.Volume.from_name(VOLUME_NAME, create_if_missing=True)
|
|||
@app.function(
|
||||
image=inference_image,
|
||||
volumes={"/weights": volume},
|
||||
timeout=600,
|
||||
secrets=[modal.Secret.from_name(HF_SECRET_NAME)],
|
||||
timeout=1800,
|
||||
)
|
||||
def download_weights():
|
||||
"""Download model weights from HuggingFace into the Modal volume."""
|
||||
import shutil
|
||||
from huggingface_hub import hf_hub_download
|
||||
|
||||
model_dir = f"/weights/{MODEL_TAG}"
|
||||
os.makedirs(model_dir, exist_ok=True)
|
||||
|
||||
for filename in [MODEL_PT, META_JSON, "token_bytes.pt", "tokenizer.pkl"]:
|
||||
dest = os.path.join(model_dir, filename)
|
||||
token = os.environ.get("HF_TOKEN")
|
||||
|
||||
# (HF source path, local filename in volume)
|
||||
files = [
|
||||
(MODEL_PT, "model.pt"),
|
||||
(META_JSON, "meta.json"),
|
||||
(TOKENIZER_PKL, "tokenizer.pkl"),
|
||||
(TOKEN_BYTES, "token_bytes.pt"),
|
||||
]
|
||||
|
||||
for src, local_name in files:
|
||||
dest = os.path.join(model_dir, local_name)
|
||||
if os.path.exists(dest):
|
||||
print(f" Already exists: {dest}")
|
||||
continue
|
||||
print(f" Downloading {filename} from {MODEL_REPO}...")
|
||||
path = hf_hub_download(MODEL_REPO, filename)
|
||||
# Copy to volume
|
||||
import shutil
|
||||
print(f" Downloading {src} from {MODEL_REPO}...")
|
||||
path = hf_hub_download(MODEL_REPO, src, token=token)
|
||||
shutil.copy2(path, dest)
|
||||
print(f" Saved to {dest}")
|
||||
|
||||
|
|
@ -114,14 +127,26 @@ class Inference:
|
|||
self.device = device
|
||||
|
||||
model_dir = f"/weights/{MODEL_TAG}"
|
||||
meta_path = os.path.join(model_dir, META_JSON)
|
||||
model_path = os.path.join(model_dir, MODEL_PT)
|
||||
meta_path = os.path.join(model_dir, "meta.json")
|
||||
model_path = os.path.join(model_dir, "model.pt")
|
||||
|
||||
# Load meta
|
||||
with open(meta_path) as f:
|
||||
meta = json.load(f)
|
||||
model_config = meta if "model_config" not in meta else meta["model_config"]
|
||||
|
||||
# Normalize config key names (HF format → nanochat format)
|
||||
# Map HF config keys → nanochat GPTConfig keys
|
||||
seq_len = model_config.pop("n_positions", None) or model_config.pop("n_ctx", None)
|
||||
if seq_len and "sequence_len" not in model_config:
|
||||
model_config["sequence_len"] = seq_len
|
||||
# Also remove n_ctx if sequence_len was already set
|
||||
model_config.pop("n_ctx", None)
|
||||
model_config.pop("n_positions", None)
|
||||
# Remove HF-specific keys that GPTConfig doesn't accept
|
||||
for k in ["architectures", "model_type", "rotary", "rotary_base", "tie_word_embeddings"]:
|
||||
model_config.pop(k, None)
|
||||
|
||||
# Patch missing config keys
|
||||
model_config.setdefault("window_pattern", "L")
|
||||
|
||||
|
|
@ -135,37 +160,36 @@ class Inference:
|
|||
config = GPTConfig(**model_config)
|
||||
model_data = torch.load(model_path, map_location=device, weights_only=False)
|
||||
|
||||
# Fix torch compile prefix
|
||||
# Strip torch.compile prefix
|
||||
model_data = {k.removeprefix("_orig_mod."): v for k, v in model_data.items()}
|
||||
|
||||
# Patch missing keys
|
||||
n_layer = config.n_layer
|
||||
if "resid_lambdas" not in model_data:
|
||||
model_data["resid_lambdas"] = torch.ones(n_layer)
|
||||
if "x0_lambdas" not in model_data:
|
||||
model_data["x0_lambdas"] = torch.zeros(n_layer)
|
||||
|
||||
# Auto-detect architecture from checkpoint
|
||||
# Convert bfloat16 weights to float32 for compatibility
|
||||
# Convert bfloat16 weights to float32 for compatibility on non-Hopper GPUs
|
||||
model_data = {
|
||||
k: v.float() if v.dtype == torch.bfloat16 else v
|
||||
for k, v in model_data.items()
|
||||
}
|
||||
|
||||
# Auto-detect architecture from checkpoint
|
||||
model = GPT.from_state_dict(config, model_data)
|
||||
model.to(device)
|
||||
model.init_weights()
|
||||
model.load_state_dict(model_data, strict=True, assign=True)
|
||||
model.to(device)
|
||||
model.init_rotary(device=device, dtype=torch.float32)
|
||||
model.eval()
|
||||
|
||||
self.model = model
|
||||
self.config = config
|
||||
|
||||
# Load tokenizer
|
||||
from _tokenizer import get_tokenizer
|
||||
from _tokenizer import get_tokenizer, SPECIAL_TOKENS
|
||||
self.tokenizer = get_tokenizer(model_dir)
|
||||
|
||||
# Resolve actual special-token IDs (nanochat appends specials at end of vocab)
|
||||
self.special_token_ids = set()
|
||||
for name in SPECIAL_TOKENS:
|
||||
ids = self.tokenizer.encode_special(name)
|
||||
self.special_token_ids.update(ids)
|
||||
self.assistant_end_id = self.tokenizer.encode_special("<|assistant_end|>")[0]
|
||||
print(f" Special token IDs: {sorted(self.special_token_ids)}")
|
||||
|
||||
dt = time.time() - t0
|
||||
print(f"Model loaded in {dt:.1f}s on {device}")
|
||||
|
||||
|
|
@ -236,12 +260,15 @@ class Inference:
|
|||
|
||||
token_id = next_token.item()
|
||||
|
||||
# Check for stop tokens
|
||||
if token_id in [t[0] for t in [assistant_end, bos]]:
|
||||
# Stop on any special token (assistant_end, bos, etc.)
|
||||
if token_id in self.special_token_ids:
|
||||
break
|
||||
|
||||
# Decode and yield
|
||||
token_text = self.tokenizer.decode([token_id])
|
||||
# Decode and yield (skip tokens that can't be decoded)
|
||||
try:
|
||||
token_text = self.tokenizer.decode([token_id])
|
||||
except (KeyError, Exception):
|
||||
continue
|
||||
yield f"data: {json.dumps({'token': token_text, 'gpu': 0})}\n\n"
|
||||
|
||||
# Append for next iteration
|
||||
|
|
|
|||
3
services/frontend/.eslintrc.json
Normal file
3
services/frontend/.eslintrc.json
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
{
|
||||
"extends": "next/core-web-vitals"
|
||||
}
|
||||
|
|
@ -18,8 +18,7 @@ function sseEvent(data: Record<string, unknown>) {
|
|||
return encoder.encode(`data: ${JSON.stringify(data)}\n\n`);
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
async function proxyUpstream(body: any, upstreamUrl: string, authHeader: string | null) {
|
||||
async function proxyUpstream(body: unknown, upstreamUrl: string, authHeader: string | null) {
|
||||
const headers: Record<string, string> = { 'Content-Type': 'application/json' };
|
||||
if (authHeader) headers['Authorization'] = authHeader;
|
||||
|
||||
|
|
|
|||
|
|
@ -11,7 +11,7 @@ function ChatContent() {
|
|||
const { authenticated, loading } = useAuth();
|
||||
|
||||
if (loading) {
|
||||
return <div className="flex h-dvh items-center justify-center">Loading...</div>;
|
||||
return <div className="flex h-dvh items-center justify-center bg-white dark:bg-ink text-gray-700 dark:text-ink-text-soft">Loading…</div>;
|
||||
}
|
||||
if (!authenticated) {
|
||||
redirect('/login');
|
||||
|
|
@ -19,7 +19,7 @@ function ChatContent() {
|
|||
}
|
||||
|
||||
return (
|
||||
<main className="flex h-dvh overflow-hidden">
|
||||
<main className="flex h-dvh overflow-hidden bg-white dark:bg-ink">
|
||||
<Sidebar />
|
||||
<ChatWindow />
|
||||
</main>
|
||||
|
|
@ -28,7 +28,7 @@ function ChatContent() {
|
|||
|
||||
export default function ChatPage() {
|
||||
return (
|
||||
<Suspense fallback={<div className="flex h-dvh items-center justify-center">Loading...</div>}>
|
||||
<Suspense fallback={<div className="flex h-dvh items-center justify-center bg-white dark:bg-ink text-gray-700 dark:text-ink-text-soft">Loading…</div>}>
|
||||
<ChatContent />
|
||||
</Suspense>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -13,6 +13,10 @@
|
|||
--light-cream: #fffdf5;
|
||||
}
|
||||
|
||||
.dark {
|
||||
color-scheme: dark;
|
||||
}
|
||||
|
||||
html, body { height: 100%; }
|
||||
|
||||
body {
|
||||
|
|
@ -21,6 +25,10 @@ body {
|
|||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
.dark body {
|
||||
@apply bg-ink text-ink-text;
|
||||
}
|
||||
|
||||
/* Markdown prose tweaks inside message bubbles */
|
||||
.markdown-body > *:first-child { margin-top: 0 !important; }
|
||||
.markdown-body > *:last-child { margin-bottom: 0 !important; }
|
||||
|
|
@ -30,6 +38,9 @@ body {
|
|||
.markdown-body code:not(pre code) {
|
||||
@apply px-1 py-0.5 rounded bg-cream-light border border-cream-border text-brown text-[0.9em];
|
||||
}
|
||||
.dark .markdown-body code:not(pre code) {
|
||||
@apply bg-ink-elev border-ink-border text-saffron-soft;
|
||||
}
|
||||
.markdown-body p { @apply my-2 leading-relaxed; }
|
||||
.markdown-body ul { @apply list-disc pl-6 my-2; }
|
||||
.markdown-body ol { @apply list-decimal pl-6 my-2; }
|
||||
|
|
@ -39,12 +50,22 @@ body {
|
|||
.markdown-body blockquote {
|
||||
@apply border-l-4 border-cream-border pl-4 italic text-brown-light my-2;
|
||||
}
|
||||
.dark .markdown-body blockquote {
|
||||
@apply border-ink-border text-ink-text-soft;
|
||||
}
|
||||
.markdown-body a { @apply text-chutney-green underline hover:text-gold; }
|
||||
|
||||
/* Scrollbar tuning */
|
||||
.nice-scrollbar::-webkit-scrollbar { width: 6px; height: 6px; }
|
||||
.nice-scrollbar::-webkit-scrollbar-thumb { background: #e0d5c0; border-radius: 3px; }
|
||||
.nice-scrollbar::-webkit-scrollbar-thumb:hover { background: var(--warm-grey); }
|
||||
.dark .nice-scrollbar::-webkit-scrollbar-thumb { background: #2a2a2e; }
|
||||
.dark .nice-scrollbar::-webkit-scrollbar-thumb:hover { background: #3a3a40; }
|
||||
|
||||
/* Highlight.js minimal tweaks */
|
||||
.hljs { background: transparent; }
|
||||
|
||||
/* Soft serif optical sizing for display headlines */
|
||||
.font-display {
|
||||
font-optical-sizing: auto;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import type { Metadata, Viewport } from 'next';
|
||||
import { Baloo_2, Great_Vibes, Caveat, Inter } from 'next/font/google';
|
||||
import { Baloo_2, Great_Vibes, Caveat, Inter, Fraunces } from 'next/font/google';
|
||||
import './globals.css';
|
||||
|
||||
const baloo = Baloo_2({
|
||||
|
|
@ -29,9 +29,17 @@ const inter = Inter({
|
|||
display: 'swap',
|
||||
});
|
||||
|
||||
const fraunces = Fraunces({
|
||||
subsets: ['latin'],
|
||||
weight: ['400', '500', '600', '700'],
|
||||
variable: '--font-fraunces',
|
||||
display: 'swap',
|
||||
});
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'समोसाचाट — samosaChaat',
|
||||
description: 'Crafted with care. For India, from India. A warm, desi-flavored chat experience powered by nanochat.',
|
||||
description:
|
||||
'Crafted with care. For India, from India. A warm, desi-flavored chat experience powered by nanochat.',
|
||||
icons: { icon: '/logo.svg' },
|
||||
};
|
||||
|
||||
|
|
@ -42,10 +50,25 @@ export const viewport: Viewport = {
|
|||
viewportFit: 'cover',
|
||||
};
|
||||
|
||||
// Set theme class before paint to avoid flash
|
||||
const themeInitScript = `
|
||||
(function(){try{
|
||||
var t=localStorage.getItem('theme');
|
||||
if(t==='dark'){document.documentElement.classList.add('dark');}
|
||||
else if(t==='light'){document.documentElement.classList.remove('dark');}
|
||||
}catch(e){}})();
|
||||
`;
|
||||
|
||||
export default function RootLayout({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<html lang="en" className={`${baloo.variable} ${vibes.variable} ${caveat.variable} ${inter.variable}`}>
|
||||
<body className="min-h-dvh bg-white text-gray-900">
|
||||
<html
|
||||
lang="en"
|
||||
className={`${baloo.variable} ${vibes.variable} ${caveat.variable} ${inter.variable} ${fraunces.variable}`}
|
||||
>
|
||||
<head>
|
||||
<script dangerouslySetInnerHTML={{ __html: themeInitScript }} />
|
||||
</head>
|
||||
<body className="min-h-dvh bg-white text-gray-900 dark:bg-ink dark:text-ink-text">
|
||||
{children}
|
||||
</body>
|
||||
</html>
|
||||
|
|
|
|||
|
|
@ -6,11 +6,11 @@ import Doodles from '@/components/svg/Doodles';
|
|||
|
||||
export default function LandingPage() {
|
||||
return (
|
||||
<main className="relative flex min-h-dvh flex-col overflow-x-hidden bg-gradient-to-br from-[#fff8e7] via-white to-[#fff8e7]">
|
||||
<main className="relative flex min-h-dvh flex-col overflow-x-hidden bg-gradient-to-br from-[#fffaf0] via-white to-[#fff5e1] dark:from-ink dark:via-ink-soft dark:to-ink">
|
||||
<Doodles />
|
||||
|
||||
{/* Hero section: full viewport height with warm gradient */}
|
||||
<div className="relative min-h-dvh flex flex-col">
|
||||
{/* Hero */}
|
||||
<div className="relative flex flex-col">
|
||||
<LandingNav />
|
||||
<Hero />
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -5,51 +5,58 @@ import { useAuth } from '@/hooks/useAuth';
|
|||
|
||||
export default function LandingNav() {
|
||||
const { authenticated } = useAuth();
|
||||
const ctaHref = authenticated ? '/chat' : '/login';
|
||||
const ctaLabel = authenticated ? 'Open chat' : 'Try samosaChaat';
|
||||
|
||||
return (
|
||||
<nav className="relative flex justify-between items-start px-4 md:px-9 pt-4 pb-2 z-10 flex-shrink-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<Link href="/" aria-label="Home" className="transition-transform hover:scale-105">
|
||||
<svg viewBox="0 0 30 30" width={30} height={30} fill="none" stroke="#444" strokeWidth={1.3} strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M4,16 L15.2,5 L26,16" />
|
||||
<path d="M7,14.5 L7,26 L23,26 L23,14.5" />
|
||||
<rect x="12" y="19" width="6" height="7" rx="0.5" />
|
||||
<rect x="8.5" y="16" width="3" height="2.8" rx="0.3" />
|
||||
<rect x="18.5" y="16" width="3" height="2.8" rx="0.3" />
|
||||
</svg>
|
||||
</Link>
|
||||
<nav className="relative z-20 px-4 pt-5 flex justify-center">
|
||||
<div className="flex items-center gap-2 px-2 py-2 rounded-full bg-white/80 dark:bg-ink-soft/80 backdrop-blur-md border border-cream-border/70 dark:border-ink-border shadow-[0_8px_30px_rgba(180,120,40,0.08)] w-full max-w-3xl">
|
||||
{/* Brand */}
|
||||
<Link
|
||||
href="/"
|
||||
className="relative font-caveat text-[1.2rem] md:text-[1.35rem] font-semibold text-gray-800 after:content-[''] after:absolute after:-bottom-0.5 after:left-0 after:w-full after:h-[1.5px] after:bg-gray-500 after:rounded after:-rotate-[0.5deg]"
|
||||
aria-label="samosaChaat home"
|
||||
className="flex items-center gap-2 pl-3 pr-4 py-1.5 rounded-full hover:bg-cream/60 dark:hover:bg-ink-elev transition-colors"
|
||||
>
|
||||
samosaChaat
|
||||
<svg viewBox="0 0 28 28" width={22} height={22} fill="none" stroke="currentColor" strokeWidth={1.6} strokeLinecap="round" strokeLinejoin="round" className="text-saffron">
|
||||
<path d="M4 22 L14 5 L24 22 Z" />
|
||||
<circle cx="14" cy="17" r="1.4" fill="currentColor" />
|
||||
</svg>
|
||||
<span className="font-display text-[1.05rem] font-semibold text-gray-900 dark:text-ink-text tracking-tight">
|
||||
samosaChaat
|
||||
</span>
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-4 font-caveat text-[1.05rem] text-gray-600 pt-1">
|
||||
<a
|
||||
href="https://instagram.com/samosachaat.art"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="hidden sm:inline hover:text-brown transition-colors"
|
||||
>
|
||||
@samosachaat
|
||||
</a>
|
||||
{authenticated ? (
|
||||
<Link
|
||||
href="/chat"
|
||||
className="px-5 py-2 rounded-full bg-gold text-white font-semibold hover:bg-gold-dark transition-colors shadow-sm"
|
||||
{/* Center links */}
|
||||
<div className="hidden md:flex items-center gap-1 mx-auto text-[0.85rem] font-medium uppercase tracking-[0.08em] text-gray-600 dark:text-ink-text-soft">
|
||||
<a href="#features" className="px-3 py-2 rounded-full hover:text-gray-900 dark:hover:text-ink-text transition-colors">Why</a>
|
||||
<a href="#how" className="px-3 py-2 rounded-full hover:text-gray-900 dark:hover:text-ink-text transition-colors">How it works</a>
|
||||
<a
|
||||
href="https://github.com/manmohan659/nanochat"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="px-3 py-2 rounded-full hover:text-gray-900 dark:hover:text-ink-text transition-colors"
|
||||
>
|
||||
Chat
|
||||
</Link>
|
||||
) : (
|
||||
<Link
|
||||
href="/login"
|
||||
className="px-5 py-2 rounded-full bg-gold text-white font-semibold hover:bg-gold-dark transition-colors shadow-sm"
|
||||
Github
|
||||
</a>
|
||||
</div>
|
||||
|
||||
{/* CTAs */}
|
||||
<div className="ml-auto flex items-center gap-2">
|
||||
<a
|
||||
href="https://instagram.com/samosachaat.art"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="hidden sm:inline-flex px-4 py-2 rounded-full text-sm font-medium text-gray-700 dark:text-ink-text-soft hover:text-gray-900 dark:hover:text-ink-text transition-colors"
|
||||
>
|
||||
Sign in
|
||||
@samosachaat
|
||||
</a>
|
||||
<Link
|
||||
href={ctaHref}
|
||||
className="px-5 py-2.5 rounded-full bg-gray-900 dark:bg-ink-text text-white dark:text-ink text-sm font-medium shadow-[0_8px_24px_rgba(0,0,0,0.18)] hover:shadow-[0_10px_28px_rgba(0,0,0,0.25)] hover:-translate-y-px transition-all"
|
||||
>
|
||||
{ctaLabel}
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
'use client';
|
||||
|
||||
import { useEffect, useRef } from 'react';
|
||||
import { Send, Square } from 'lucide-react';
|
||||
import { ArrowUp, Square } from 'lucide-react';
|
||||
import clsx from 'clsx';
|
||||
|
||||
interface Props {
|
||||
|
|
@ -37,49 +37,63 @@ export default function ChatInput({ value, onChange, onSubmit, onStop, isStreami
|
|||
};
|
||||
|
||||
return (
|
||||
<div className="sticky bottom-0 bg-white pt-3 pb-[calc(1rem+env(safe-area-inset-bottom))] px-4 border-t border-cream-border/50 shadow-[0_-2px_8px_rgba(0,0,0,0.04)]">
|
||||
<div className="max-w-3xl mx-auto flex items-end gap-3">
|
||||
<div className="flex-1 relative">
|
||||
<div className="sticky bottom-0 bg-white/85 dark:bg-ink/85 backdrop-blur pt-3 pb-[calc(1rem+env(safe-area-inset-bottom))] px-4">
|
||||
<div className="max-w-3xl mx-auto">
|
||||
{/* Input pod — single rounded container with the button inside */}
|
||||
<div
|
||||
className={clsx(
|
||||
'relative flex items-end gap-2 rounded-[26px] border bg-white dark:bg-ink-soft transition-shadow',
|
||||
'border-cream-border dark:border-ink-border',
|
||||
'focus-within:border-saffron/60 dark:focus-within:border-saffron/50 focus-within:shadow-[0_8px_30px_rgba(255,153,51,0.12)]',
|
||||
)}
|
||||
>
|
||||
<textarea
|
||||
ref={ref}
|
||||
rows={1}
|
||||
placeholder="What's on your mind?"
|
||||
placeholder="Ask anything…"
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
disabled={disabled}
|
||||
className="w-full resize-none px-4 py-3 pr-12 rounded-2xl border border-warm-grey bg-white text-gray-900 placeholder-[#b8a88a] focus:outline-none focus:border-gold focus:ring-2 focus:ring-gold/20 min-h-[54px] max-h-[200px] leading-relaxed text-[0.95rem]"
|
||||
className="flex-1 resize-none bg-transparent px-5 py-4 pr-2 text-[0.95rem] leading-relaxed text-gray-900 dark:text-ink-text placeholder-gray-400 dark:placeholder-ink-text-soft focus:outline-none min-h-[52px] max-h-[200px]"
|
||||
/>
|
||||
</div>
|
||||
{isStreaming && onStop ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onStop}
|
||||
className="w-12 h-12 flex-shrink-0 rounded-full bg-chutney-red text-white flex items-center justify-center hover:brightness-110 transition"
|
||||
aria-label="Stop generating"
|
||||
>
|
||||
<Square size={18} fill="currentColor" />
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onSubmit}
|
||||
disabled={!canSend}
|
||||
className={clsx(
|
||||
'w-12 h-12 flex-shrink-0 rounded-full flex items-center justify-center transition',
|
||||
canSend
|
||||
? 'bg-gold hover:bg-gold-dark text-white'
|
||||
: 'bg-gold/30 text-white cursor-not-allowed',
|
||||
|
||||
{/* Send / stop button — vertically centered with the textarea baseline */}
|
||||
<div className="self-end p-2">
|
||||
{isStreaming && onStop ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onStop}
|
||||
className="w-10 h-10 rounded-full bg-chutney-red text-white flex items-center justify-center hover:brightness-110 transition shadow-md"
|
||||
aria-label="Stop generating"
|
||||
>
|
||||
<Square size={14} fill="currentColor" />
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onSubmit}
|
||||
disabled={!canSend}
|
||||
className={clsx(
|
||||
'w-10 h-10 rounded-full flex items-center justify-center transition-all',
|
||||
canSend
|
||||
? 'bg-gray-900 dark:bg-ink-text text-white dark:text-ink shadow-[0_6px_18px_rgba(0,0,0,0.2)] hover:-translate-y-px'
|
||||
: 'bg-gray-200 dark:bg-ink-elev text-gray-400 dark:text-ink-text-soft cursor-not-allowed',
|
||||
)}
|
||||
aria-label="Send message"
|
||||
>
|
||||
<ArrowUp size={18} strokeWidth={2.4} />
|
||||
</button>
|
||||
)}
|
||||
aria-label="Send message"
|
||||
>
|
||||
<Send size={18} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p className="mt-3 text-[11px] text-gray-400 dark:text-ink-text-soft text-center">
|
||||
Tip: try <code className="text-brown dark:text-saffron-soft">/temperature 0.7</code>,{' '}
|
||||
<code className="text-brown dark:text-saffron-soft">/topk 40</code>, or{' '}
|
||||
<code className="text-brown dark:text-saffron-soft">/clear</code>.
|
||||
</p>
|
||||
</div>
|
||||
<p className="max-w-3xl mx-auto mt-2 text-[11px] text-gray-400 text-center">
|
||||
Tip: try <code className="text-brown">/temperature 0.7</code>, <code className="text-brown">/topk 40</code>, or <code className="text-brown">/clear</code>.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -191,24 +191,25 @@ export default function ChatWindow() {
|
|||
);
|
||||
|
||||
return (
|
||||
<section className="flex-1 flex flex-col min-w-0 bg-white">
|
||||
<header className="flex items-center justify-between px-4 md:px-6 py-3 border-b border-cream-border">
|
||||
<section className="flex-1 flex flex-col min-w-0 bg-white dark:bg-ink">
|
||||
<header className="flex items-center justify-between px-4 md:px-6 py-3 border-b border-cream-border dark:border-ink-border">
|
||||
<div className="flex items-center gap-3">
|
||||
{!sidebarOpen && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={toggleSidebar}
|
||||
aria-label="Open sidebar"
|
||||
className="p-1.5 rounded hover:bg-cream text-brown-light"
|
||||
className="p-1.5 rounded-md hover:bg-cream dark:hover:bg-ink-elev text-brown-light dark:text-ink-text-soft"
|
||||
>
|
||||
<PanelLeftOpen size={18} />
|
||||
</button>
|
||||
)}
|
||||
<span className="text-xs px-2 py-0.5 rounded-full border border-warm-grey bg-cream-light text-brown">
|
||||
<span className="inline-flex items-center gap-1.5 text-xs px-2.5 py-1 rounded-full border border-warm-grey dark:border-ink-border bg-cream-light dark:bg-ink-soft text-brown dark:text-ink-text-soft">
|
||||
<span className="w-1.5 h-1.5 rounded-full bg-saffron" />
|
||||
{model}
|
||||
</span>
|
||||
</div>
|
||||
<div className="text-sm text-gray-600 font-medium">
|
||||
<div className="text-sm text-gray-600 dark:text-ink-text-soft font-medium">
|
||||
{user?.name ? `Hi, ${user.name.split(' ')[0]}` : ''}
|
||||
</div>
|
||||
</header>
|
||||
|
|
|
|||
|
|
@ -1,65 +1,39 @@
|
|||
'use client';
|
||||
|
||||
import SamosaLogo from '@/components/svg/SamosaLogo';
|
||||
import { useAuth } from '@/hooks/useAuth';
|
||||
import { BookOpen, Sparkles, Code2, Smile } from 'lucide-react';
|
||||
|
||||
const SUGGESTIONS = [
|
||||
{
|
||||
icon: '📚',
|
||||
label: 'Summarize a topic',
|
||||
description: 'Get a concise overview of any subject',
|
||||
prompt: 'Summarize the history of samosas in 3 paragraphs.',
|
||||
},
|
||||
{
|
||||
icon: '✨',
|
||||
label: 'Explain a concept',
|
||||
description: 'Break down complex ideas simply',
|
||||
prompt: 'Explain transformers to a curious beginner.',
|
||||
},
|
||||
{
|
||||
icon: '💻',
|
||||
label: 'Write some code',
|
||||
description: 'Get help with any programming task',
|
||||
prompt: 'Write a Python function that reverses a linked list.',
|
||||
},
|
||||
{
|
||||
icon: '😄',
|
||||
label: 'Tell me a joke',
|
||||
description: 'Lighten the mood with some humor',
|
||||
prompt: 'Tell me a joke about chai.',
|
||||
},
|
||||
{ icon: BookOpen, label: 'Summarize a topic', prompt: 'Summarize the history of samosas in 3 paragraphs.' },
|
||||
{ icon: Sparkles, label: 'Explain a concept', prompt: 'Explain transformers to a curious beginner.' },
|
||||
{ icon: Code2, label: 'Write some code', prompt: 'Write a Python function that reverses a linked list.' },
|
||||
{ icon: Smile, label: 'Tell me a joke', prompt: 'Tell me a joke about chai.' },
|
||||
];
|
||||
|
||||
export default function EmptyState({ onPick }: { onPick: (prompt: string) => void }) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center flex-1 px-4 -mt-20">
|
||||
{/* Small logo */}
|
||||
<div className="w-16 h-16 mb-6 opacity-20">
|
||||
<SamosaLogo size={64} />
|
||||
</div>
|
||||
const { user } = useAuth();
|
||||
const firstName = (user?.name ?? 'friend').split(' ')[0];
|
||||
|
||||
<h2 className="font-baloo font-bold text-3xl text-gray-800 mb-2">
|
||||
How can I help you today?
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center flex-1 px-4 py-10 text-center">
|
||||
<h2 className="font-display font-medium text-[clamp(2.25rem,5vw,3.75rem)] leading-tight tracking-tight text-gray-900 dark:text-ink-text">
|
||||
Hello, <span className="italic text-saffron">{firstName}</span>.
|
||||
</h2>
|
||||
<p className="font-caveat text-lg text-brown/60 mb-10">
|
||||
Ask anything — a doubt, a recipe, a code snippet, or a fresh idea.
|
||||
<p className="mt-3 max-w-xl text-base md:text-lg text-gray-600 dark:text-ink-text-soft">
|
||||
What shall we cook today — a doubt, a recipe, a code snippet, or a fresh idea?
|
||||
</p>
|
||||
|
||||
{/* Bigger suggestion cards - 2x2 grid */}
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3 w-full max-w-xl">
|
||||
{SUGGESTIONS.map((s) => (
|
||||
{/* Pill chips — pick a starter */}
|
||||
<div className="mt-10 flex flex-wrap items-center justify-center gap-2.5 max-w-2xl">
|
||||
{SUGGESTIONS.map(({ icon: Icon, label, prompt }) => (
|
||||
<button
|
||||
key={s.label}
|
||||
key={label}
|
||||
type="button"
|
||||
onClick={() => onPick(s.prompt)}
|
||||
className="flex items-start gap-3 p-4 rounded-xl border border-cream-border bg-white hover:bg-cream/50 hover:border-gold/30 transition-all text-left group"
|
||||
onClick={() => onPick(prompt)}
|
||||
className="inline-flex items-center gap-2 px-4 py-2.5 rounded-full bg-white dark:bg-ink-soft border border-cream-border dark:border-ink-border text-sm font-medium text-gray-700 dark:text-ink-text hover:border-saffron/60 dark:hover:border-saffron/50 hover:text-gray-900 dark:hover:text-white hover:bg-cream/50 dark:hover:bg-ink-elev transition-colors shadow-sm"
|
||||
>
|
||||
<span className="text-xl mt-0.5">{s.icon}</span>
|
||||
<div>
|
||||
<div className="font-medium text-sm text-gray-800 group-hover:text-brown">
|
||||
{s.label}
|
||||
</div>
|
||||
<div className="text-xs text-gray-500 mt-0.5">{s.description}</div>
|
||||
</div>
|
||||
<Icon size={15} className="text-saffron" />
|
||||
{label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -67,7 +67,7 @@ export default function MessageBubble({ message, isStreaming }: Props) {
|
|||
if (isConsole) {
|
||||
return (
|
||||
<div className="flex justify-start mb-2 animate-fade-in">
|
||||
<div className="font-mono text-sm bg-cream-light border border-cream-border text-brown-light px-4 py-3 rounded-xl max-w-[80%]">
|
||||
<div className="font-mono text-sm bg-cream-light dark:bg-ink-soft border border-cream-border dark:border-ink-border text-brown-light dark:text-ink-text-soft px-4 py-3 rounded-xl max-w-[80%]">
|
||||
{message.content}
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -80,18 +80,18 @@ export default function MessageBubble({ message, isStreaming }: Props) {
|
|||
className={clsx(
|
||||
'max-w-[85%] md:max-w-[75%]',
|
||||
isUser
|
||||
? 'bg-cream border border-cream-border rounded-[1.25rem] px-4 py-3'
|
||||
: 'bg-white px-2 py-1',
|
||||
? 'bg-cream dark:bg-ink-elev border border-cream-border dark:border-ink-border rounded-[1.25rem] px-4 py-3'
|
||||
: 'bg-transparent px-2 py-1',
|
||||
)}
|
||||
>
|
||||
{!isUser && isStreaming && message.content.length === 0 ? (
|
||||
<SteamTyping />
|
||||
) : isUser ? (
|
||||
<div className="whitespace-pre-wrap leading-relaxed text-[0.95rem] text-gray-900">
|
||||
<div className="whitespace-pre-wrap leading-relaxed text-[0.95rem] text-gray-900 dark:text-ink-text">
|
||||
{message.content}
|
||||
</div>
|
||||
) : (
|
||||
<div className="markdown-body text-[0.95rem] text-gray-900 leading-relaxed">
|
||||
<div className="markdown-body text-[0.95rem] text-gray-900 dark:text-ink-text leading-relaxed">
|
||||
<ReactMarkdown
|
||||
remarkPlugins={[remarkGfm]}
|
||||
rehypePlugins={[rehypeHighlight]}
|
||||
|
|
|
|||
|
|
@ -2,14 +2,25 @@
|
|||
|
||||
import { useEffect } from 'react';
|
||||
import Link from 'next/link';
|
||||
import { Plus, PanelLeftClose, PanelLeftOpen, LogOut, ChevronDown, Trash2 } from 'lucide-react';
|
||||
import {
|
||||
Plus,
|
||||
PanelLeftClose,
|
||||
PanelLeftOpen,
|
||||
LogOut,
|
||||
ChevronDown,
|
||||
Trash2,
|
||||
Sun,
|
||||
Moon,
|
||||
} from 'lucide-react';
|
||||
import SamosaLogo from '@/components/svg/SamosaLogo';
|
||||
import { useChatStore, groupConversations, MODEL_OPTIONS } from '@/store/chatStore';
|
||||
import { useAuth } from '@/hooks/useAuth';
|
||||
import { useTheme } from '@/hooks/useTheme';
|
||||
import clsx from 'clsx';
|
||||
|
||||
export default function Sidebar() {
|
||||
const { user, logout } = useAuth();
|
||||
const { theme, toggle } = useTheme();
|
||||
const {
|
||||
conversations,
|
||||
currentConversationId,
|
||||
|
|
@ -32,19 +43,21 @@ export default function Sidebar() {
|
|||
return (
|
||||
<aside
|
||||
className={clsx(
|
||||
'flex flex-col bg-cream-light border-r border-cream-border transition-all duration-300 ease-in-out overflow-hidden',
|
||||
'flex flex-col bg-cream-light dark:bg-ink-soft border-r border-cream-border dark:border-ink-border transition-all duration-300 ease-in-out overflow-hidden',
|
||||
sidebarOpen ? 'w-[260px]' : 'w-0 md:w-[56px]',
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center justify-between px-3 py-3 border-b border-cream-border">
|
||||
<div className="flex items-center justify-between px-3 py-3 border-b border-cream-border dark:border-ink-border">
|
||||
<Link href="/" className={clsx('flex items-center gap-2 overflow-hidden', !sidebarOpen && 'md:hidden')}>
|
||||
<SamosaLogo size={28} />
|
||||
<span className="font-baloo font-bold text-base text-gray-900 whitespace-nowrap">samosaChaat</span>
|
||||
<span className="font-display font-semibold text-base text-gray-900 dark:text-ink-text whitespace-nowrap tracking-tight">
|
||||
samosaChaat
|
||||
</span>
|
||||
</Link>
|
||||
<button
|
||||
aria-label="Toggle sidebar"
|
||||
onClick={toggleSidebar}
|
||||
className="p-1.5 rounded hover:bg-cream text-brown-light"
|
||||
className="p-1.5 rounded-md hover:bg-cream dark:hover:bg-ink-elev text-brown-light dark:text-ink-text-soft"
|
||||
>
|
||||
{sidebarOpen ? <PanelLeftClose size={18} /> : <PanelLeftOpen size={18} />}
|
||||
</button>
|
||||
|
|
@ -56,9 +69,9 @@ export default function Sidebar() {
|
|||
<button
|
||||
type="button"
|
||||
onClick={() => createConversation()}
|
||||
className="w-full flex items-center justify-center gap-2 px-3 py-2.5 rounded-lg bg-gold/10 border border-gold/40 hover:bg-gold/20 text-brown font-baloo font-semibold text-sm transition-colors"
|
||||
className="w-full flex items-center justify-center gap-2 px-3 py-2.5 rounded-full bg-gray-900 dark:bg-ink-text text-white dark:text-ink text-sm font-medium hover:-translate-y-px shadow-[0_6px_20px_rgba(0,0,0,0.18)] transition-all"
|
||||
>
|
||||
<Plus size={16} className="text-gold" />
|
||||
<Plus size={16} />
|
||||
New chat
|
||||
</button>
|
||||
</div>
|
||||
|
|
@ -67,15 +80,15 @@ export default function Sidebar() {
|
|||
{conversations.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center h-full px-4 text-center py-12">
|
||||
<div className="text-3xl mb-3 opacity-30">💬</div>
|
||||
<p className="text-sm text-gray-400 font-medium">No conversations yet.</p>
|
||||
<p className="text-xs text-gray-400 mt-1">Start your first chat!</p>
|
||||
<p className="text-sm text-gray-400 dark:text-ink-text-soft font-medium">No conversations yet.</p>
|
||||
<p className="text-xs text-gray-400 dark:text-ink-text-soft mt-1">Start your first chat!</p>
|
||||
</div>
|
||||
) : (
|
||||
Object.entries(grouped).map(([group, items]) => {
|
||||
if (items.length === 0) return null;
|
||||
return (
|
||||
<div key={group} className="mb-4">
|
||||
<div className="px-2 mb-1 text-[11px] uppercase tracking-wider text-gray-400 font-medium">
|
||||
<div className="px-2 mb-1 text-[11px] uppercase tracking-wider text-gray-400 dark:text-ink-text-soft font-medium">
|
||||
{group}
|
||||
</div>
|
||||
<ul className="space-y-0.5">
|
||||
|
|
@ -85,10 +98,10 @@ export default function Sidebar() {
|
|||
type="button"
|
||||
onClick={() => selectConversation(c.id)}
|
||||
className={clsx(
|
||||
'w-full text-left px-2.5 py-1.5 rounded text-sm truncate transition-colors pr-8',
|
||||
'w-full text-left px-3 py-2 rounded-full text-sm truncate transition-colors pr-9',
|
||||
c.id === currentConversationId
|
||||
? 'bg-cream text-brown font-medium'
|
||||
: 'text-gray-700 hover:bg-cream/70',
|
||||
? 'bg-cream dark:bg-ink-elev text-brown dark:text-ink-text font-medium'
|
||||
: 'text-gray-700 dark:text-ink-text-soft hover:bg-cream/70 dark:hover:bg-ink-elev/70',
|
||||
)}
|
||||
title={c.title}
|
||||
>
|
||||
|
|
@ -100,7 +113,7 @@ export default function Sidebar() {
|
|||
e.stopPropagation();
|
||||
deleteConversation(c.id);
|
||||
}}
|
||||
className="absolute right-1 top-1/2 -translate-y-1/2 p-1 rounded opacity-0 group-hover:opacity-100 hover:bg-cream text-gray-400 hover:text-chutney-red transition-all"
|
||||
className="absolute right-1.5 top-1/2 -translate-y-1/2 p-1 rounded opacity-0 group-hover:opacity-100 hover:bg-cream dark:hover:bg-ink-elev text-gray-400 hover:text-chutney-red transition-all"
|
||||
aria-label={`Delete ${c.title}`}
|
||||
>
|
||||
<Trash2 size={14} />
|
||||
|
|
@ -114,9 +127,9 @@ export default function Sidebar() {
|
|||
)}
|
||||
</div>
|
||||
|
||||
<div className="px-3 py-3 border-t border-cream-border space-y-3">
|
||||
<div className="px-3 py-3 border-t border-cream-border dark:border-ink-border space-y-3">
|
||||
<div>
|
||||
<label htmlFor="model-select" className="block text-[11px] uppercase tracking-wider text-gray-400 mb-1">
|
||||
<label htmlFor="model-select" className="block text-[11px] uppercase tracking-wider text-gray-400 dark:text-ink-text-soft mb-1">
|
||||
Model
|
||||
</label>
|
||||
<div className="relative">
|
||||
|
|
@ -124,7 +137,7 @@ export default function Sidebar() {
|
|||
id="model-select"
|
||||
value={model}
|
||||
onChange={(e) => setModel(e.target.value)}
|
||||
className="w-full appearance-none px-3 py-2 pr-8 rounded-lg border border-cream-border bg-white text-sm text-gray-800 focus:outline-none focus:border-gold"
|
||||
className="w-full appearance-none px-3 py-2 pr-8 rounded-xl border border-cream-border dark:border-ink-border bg-white dark:bg-ink text-sm text-gray-800 dark:text-ink-text focus:outline-none focus:border-saffron"
|
||||
>
|
||||
{MODEL_OPTIONS.map((m) => (
|
||||
<option key={m.id} value={m.id}>
|
||||
|
|
@ -134,28 +147,36 @@ export default function Sidebar() {
|
|||
</select>
|
||||
<ChevronDown
|
||||
size={14}
|
||||
className="absolute right-2 top-1/2 -translate-y-1/2 text-gray-400 pointer-events-none"
|
||||
className="absolute right-2 top-1/2 -translate-y-1/2 text-gray-400 dark:text-ink-text-soft pointer-events-none"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 pt-1">
|
||||
<div className="h-8 w-8 rounded-full bg-gold/20 text-brown flex items-center justify-center text-sm font-semibold">
|
||||
<div className="h-9 w-9 rounded-full bg-gradient-to-br from-saffron to-gold text-white flex items-center justify-center text-sm font-semibold shadow-sm">
|
||||
{(user?.name ?? 'G')[0].toUpperCase()}
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="text-sm font-medium text-gray-800 truncate">
|
||||
<div className="text-sm font-medium text-gray-800 dark:text-ink-text truncate">
|
||||
{user?.name ?? 'Guest'}
|
||||
</div>
|
||||
<div className="text-xs text-gray-500 truncate">
|
||||
<div className="text-xs text-gray-500 dark:text-ink-text-soft truncate">
|
||||
{user?.email ?? 'Not signed in'}
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={theme === 'dark' ? 'Switch to light mode' : 'Switch to dark mode'}
|
||||
onClick={toggle}
|
||||
className="p-1.5 rounded-md hover:bg-cream dark:hover:bg-ink-elev text-gray-500 dark:text-ink-text-soft hover:text-brown dark:hover:text-ink-text transition-colors"
|
||||
>
|
||||
{theme === 'dark' ? <Sun size={16} /> : <Moon size={16} />}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Sign out"
|
||||
onClick={logout}
|
||||
className="p-1.5 rounded hover:bg-cream text-gray-500 hover:text-brown"
|
||||
className="p-1.5 rounded-md hover:bg-cream dark:hover:bg-ink-elev text-gray-500 dark:text-ink-text-soft hover:text-brown dark:hover:text-ink-text transition-colors"
|
||||
>
|
||||
<LogOut size={16} />
|
||||
</button>
|
||||
|
|
|
|||
|
|
@ -1,38 +1,146 @@
|
|||
'use client';
|
||||
|
||||
import { motion } from 'framer-motion';
|
||||
|
||||
type Tile = {
|
||||
title: string;
|
||||
body: string;
|
||||
caption: string;
|
||||
bg: string;
|
||||
glyph: React.ReactNode;
|
||||
rotate: string;
|
||||
};
|
||||
|
||||
const TILES: Tile[] = [
|
||||
{
|
||||
title: 'Conversations that simmer',
|
||||
body:
|
||||
'Every chat is saved in a warm pot — come back anytime and pick up exactly where the masala was last stirred.',
|
||||
caption: 'memory · history',
|
||||
bg: 'bg-tile-saffron',
|
||||
rotate: '-rotate-1',
|
||||
glyph: (
|
||||
<svg viewBox="0 0 200 140" className="w-full h-full">
|
||||
<defs>
|
||||
<linearGradient id="potGrad" x1="0" x2="0" y1="0" y2="1">
|
||||
<stop offset="0%" stopColor="#fff" stopOpacity="0.8" />
|
||||
<stop offset="100%" stopColor="#fff" stopOpacity="0.1" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<g fill="none" stroke="#fff" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
{/* steam */}
|
||||
<path d="M85 30 Q90 20 85 12 Q80 4 90 0" opacity="0.7" />
|
||||
<path d="M105 32 Q110 22 105 14 Q100 6 110 2" opacity="0.55" />
|
||||
<path d="M125 30 Q130 20 125 12 Q120 4 130 0" opacity="0.4" />
|
||||
{/* pot */}
|
||||
<path d="M55 60 L60 122 Q60 130 70 130 L140 130 Q150 130 150 122 L155 60 Z" fill="url(#potGrad)" />
|
||||
<ellipse cx="105" cy="60" rx="55" ry="10" fill="#fff" fillOpacity="0.35" />
|
||||
<line x1="40" y1="60" x2="170" y2="60" />
|
||||
<circle cx="80" cy="55" r="3" fill="#fff" />
|
||||
<circle cx="105" cy="50" r="3" fill="#fff" />
|
||||
<circle cx="130" cy="55" r="3" fill="#fff" />
|
||||
</g>
|
||||
</svg>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: 'Swap models like spices',
|
||||
body:
|
||||
'Pinch of Base, dash of SFT — switch between chefs in a single click and taste the difference for yourself.',
|
||||
caption: 'inference · choice',
|
||||
bg: 'bg-tile-gold',
|
||||
rotate: 'rotate-1',
|
||||
glyph: (
|
||||
<svg viewBox="0 0 200 140" className="w-full h-full">
|
||||
<g fill="none" stroke="#fff" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<circle cx="60" cy="70" r="28" fill="#fff" fillOpacity="0.18" />
|
||||
<circle cx="140" cy="70" r="28" fill="#fff" fillOpacity="0.30" />
|
||||
<path d="M75 56 Q100 30 125 56" />
|
||||
<path d="M120 50 L128 56 L122 64" />
|
||||
<path d="M125 84 Q100 110 75 84" />
|
||||
<path d="M80 90 L72 84 L78 76" />
|
||||
<text x="60" y="76" textAnchor="middle" fill="#fff" fontFamily="serif" fontSize="14" fontStyle="italic">d20</text>
|
||||
<text x="140" y="76" textAnchor="middle" fill="#fff" fontFamily="serif" fontSize="14" fontStyle="italic">d24</text>
|
||||
</g>
|
||||
</svg>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: 'Desi at heart',
|
||||
body:
|
||||
'Built with love in saffron, gold and brown. Speaks like a friend, jokes like a cousin, never forgets the chai.',
|
||||
caption: 'culture · craft',
|
||||
bg: 'bg-tile-chutney',
|
||||
rotate: '-rotate-1',
|
||||
glyph: (
|
||||
<svg viewBox="0 0 200 140" className="w-full h-full">
|
||||
<g fill="none" stroke="#fff" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
{/* lotus / ashoka chakra-ish */}
|
||||
<circle cx="100" cy="70" r="32" fill="#fff" fillOpacity="0.2" />
|
||||
<circle cx="100" cy="70" r="44" />
|
||||
{Array.from({ length: 12 }).map((_, i) => {
|
||||
const a = (i * Math.PI) / 6;
|
||||
const x1 = 100 + Math.cos(a) * 32;
|
||||
const y1 = 70 + Math.sin(a) * 32;
|
||||
const x2 = 100 + Math.cos(a) * 44;
|
||||
const y2 = 70 + Math.sin(a) * 44;
|
||||
return <line key={i} x1={x1} y1={y1} x2={x2} y2={y2} />;
|
||||
})}
|
||||
<circle cx="100" cy="70" r="4" fill="#fff" />
|
||||
</g>
|
||||
</svg>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
export default function Features() {
|
||||
return (
|
||||
<section className="bg-[#fff8e7]/60 py-20 px-4">
|
||||
<div className="max-w-4xl mx-auto">
|
||||
<h3 className="text-center font-baloo text-2xl text-brown mb-12">
|
||||
Why samosaChaat?
|
||||
</h3>
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-8">
|
||||
<div className="bg-white rounded-2xl p-6 shadow-sm border border-[#f0e0b8]/50 text-center">
|
||||
<div className="text-3xl mb-3">💬</div>
|
||||
<h4 className="font-baloo font-bold text-lg text-brown mb-2">
|
||||
Conversations that stick
|
||||
</h4>
|
||||
<p className="text-sm text-brown/70">
|
||||
Your chats are saved and organized. Pick up right where you left off.
|
||||
</p>
|
||||
</div>
|
||||
<div className="bg-white rounded-2xl p-6 shadow-sm border border-[#f0e0b8]/50 text-center">
|
||||
<div className="text-3xl mb-3">🔄</div>
|
||||
<h4 className="font-baloo font-bold text-lg text-brown mb-2">
|
||||
Swap models anytime
|
||||
</h4>
|
||||
<p className="text-sm text-brown/70">
|
||||
Switch between different AI models with a click. Your choice, your style.
|
||||
</p>
|
||||
</div>
|
||||
<div className="bg-white rounded-2xl p-6 shadow-sm border border-[#f0e0b8]/50 text-center">
|
||||
<div className="text-3xl mb-3">🇮🇳</div>
|
||||
<h4 className="font-baloo font-bold text-lg text-brown mb-2">
|
||||
Desi at heart
|
||||
</h4>
|
||||
<p className="text-sm text-brown/70">
|
||||
Built with love, inspired by Indian culture. A little desi, a lot thoughtful.
|
||||
</p>
|
||||
</div>
|
||||
<section id="features" className="relative px-4 py-20 md:py-28">
|
||||
<div className="max-w-6xl mx-auto">
|
||||
<div className="text-center mb-14">
|
||||
<p className="text-xs md:text-sm uppercase tracking-[0.18em] text-saffron font-medium">
|
||||
For the curious · the hungry · the desi
|
||||
</p>
|
||||
<h3 className="mt-3 font-display font-medium text-[clamp(2rem,4.5vw,3.25rem)] leading-tight tracking-tight text-gray-900 dark:text-ink-text">
|
||||
Why <em className="italic text-saffron">samosaChaat</em>?
|
||||
</h3>
|
||||
<p className="mt-4 max-w-2xl mx-auto text-gray-600 dark:text-ink-text-soft">
|
||||
A small chatbot with a big personality. Every flavor on this plate
|
||||
was prepared by hand — model, server, and UI.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-6 lg:gap-8">
|
||||
{TILES.map((t, i) => (
|
||||
<motion.article
|
||||
key={t.title}
|
||||
initial={{ opacity: 0, y: 16 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
viewport={{ once: true, amount: 0.2 }}
|
||||
transition={{ duration: 0.5, delay: i * 0.08 }}
|
||||
className="group relative rounded-3xl bg-white dark:bg-ink-soft border border-cream-border/70 dark:border-ink-border p-3 hover:-translate-y-1 transition-transform shadow-[0_10px_40px_rgba(180,120,40,0.08)]"
|
||||
>
|
||||
{/* Gradient art tile */}
|
||||
<div className={`relative rounded-2xl ${t.bg} h-44 overflow-hidden flex items-center justify-center`}>
|
||||
<div className={`absolute inset-0 mix-blend-overlay opacity-90 ${t.rotate} transition-transform group-hover:scale-105 duration-500 p-6`}>
|
||||
{t.glyph}
|
||||
</div>
|
||||
<span className="absolute top-3 left-3 text-[10px] uppercase tracking-[0.16em] text-white/85 bg-black/15 backdrop-blur px-2 py-0.5 rounded-full">
|
||||
{t.caption}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Body */}
|
||||
<div className="px-3 pt-5 pb-4">
|
||||
<h4 className="font-display font-semibold text-[1.35rem] leading-snug text-gray-900 dark:text-ink-text tracking-tight">
|
||||
{t.title}
|
||||
</h4>
|
||||
<p className="mt-2 text-sm leading-relaxed text-gray-600 dark:text-ink-text-soft">
|
||||
{t.body}
|
||||
</p>
|
||||
</div>
|
||||
</motion.article>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
|
|
|||
|
|
@ -13,75 +13,104 @@ export default function Hero() {
|
|||
const ctaHref = authenticated ? '/chat' : '/login';
|
||||
|
||||
return (
|
||||
<section className="flex-1 flex flex-col items-center justify-center relative px-4">
|
||||
{/* Toran animation at top center */}
|
||||
<div className="absolute left-1/2 top-0 origin-top transform -translate-x-1/2 animate-pendulum z-[5]">
|
||||
<section className="relative overflow-hidden">
|
||||
{/* Soft saffron glow background */}
|
||||
<div aria-hidden className="absolute inset-0 bg-hero-glow pointer-events-none" />
|
||||
{/* Toran swing on top center */}
|
||||
<div className="absolute left-1/2 top-0 origin-top -translate-x-1/2 animate-pendulum z-[5]">
|
||||
<ToranSvg />
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-center gap-8 md:gap-16 lg:gap-24 w-full max-w-6xl">
|
||||
{/* Left illustration - Samosa */}
|
||||
<div className="hidden md:block flex-shrink-0 animate-float">
|
||||
<SamosaSvg className="w-44 h-44 lg:w-56 lg:h-56" width={224} height={224} />
|
||||
<span className="mt-1.5 block text-center font-caveat text-[1.1rem] text-brown-light bg-[#f5edd6] px-4 py-0.5 border border-[#d4c4a0] rounded-sm -rotate-3 shadow-sm">
|
||||
<div className="relative z-[2] max-w-6xl mx-auto px-4 pt-24 pb-20 md:pt-28 md:pb-28 flex flex-col items-center text-center">
|
||||
{/* Eyebrow pill */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 8 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.4 }}
|
||||
className="inline-flex items-center gap-2 px-4 py-1.5 rounded-full bg-white/70 dark:bg-ink-soft/80 border border-cream-border/70 dark:border-ink-border backdrop-blur text-xs md:text-sm font-medium tracking-wide text-saffron"
|
||||
>
|
||||
<span className="w-1.5 h-1.5 rounded-full bg-saffron" />
|
||||
A small, hand-cooked AI · made in India
|
||||
</motion.div>
|
||||
|
||||
{/* Hindi script — playful crown */}
|
||||
<motion.h2
|
||||
initial={{ opacity: 0, y: 12 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.5, delay: 0.05 }}
|
||||
className="mt-7 font-baloo font-extrabold text-[clamp(2.5rem,6vw,4.25rem)] text-gray-900 dark:text-ink-text leading-[1.05] -rotate-1"
|
||||
>
|
||||
समोसा चाट
|
||||
</motion.h2>
|
||||
|
||||
{/* Big serif English headline */}
|
||||
<motion.h1
|
||||
initial={{ opacity: 0, y: 12 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.5, delay: 0.15 }}
|
||||
className="font-display font-medium text-[clamp(2.75rem,7.5vw,5.75rem)] leading-[1.02] tracking-tight text-gray-900 dark:text-ink-text mt-3 max-w-4xl"
|
||||
style={{ fontVariationSettings: '"opsz" 144' }}
|
||||
>
|
||||
A chatbot with a <em className="italic font-display text-saffron">dash</em> of masala.
|
||||
</motion.h1>
|
||||
|
||||
<motion.p
|
||||
initial={{ opacity: 0, y: 8 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.5, delay: 0.3 }}
|
||||
className="mt-6 max-w-2xl text-base md:text-lg text-gray-600 dark:text-ink-text-soft leading-relaxed"
|
||||
>
|
||||
A tiny, full-stack AI cooked from scratch — trained, served and shipped from one tiny kitchen.
|
||||
Conversational, a little playful, very desi.
|
||||
</motion.p>
|
||||
|
||||
{/* CTAs */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 8 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.5, delay: 0.45 }}
|
||||
className="mt-10 flex flex-wrap items-center justify-center gap-3"
|
||||
>
|
||||
<Link
|
||||
href={ctaHref}
|
||||
className="inline-flex items-center gap-2 px-7 py-3.5 rounded-full bg-gray-900 dark:bg-ink-text text-white dark:text-ink font-medium shadow-[0_14px_40px_rgba(0,0,0,0.25)] hover:shadow-[0_18px_50px_rgba(0,0,0,0.3)] hover:-translate-y-px transition-all"
|
||||
>
|
||||
Start chatting
|
||||
<svg width={16} height={16} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2.2} strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M5 12h14" /><path d="M13 5l7 7-7 7" />
|
||||
</svg>
|
||||
</Link>
|
||||
<a
|
||||
href="#features"
|
||||
className="inline-flex items-center gap-2 px-7 py-3.5 rounded-full bg-white/80 dark:bg-ink-soft/80 border border-cream-border dark:border-ink-border text-gray-800 dark:text-ink-text font-medium hover:bg-white dark:hover:bg-ink-elev transition-colors"
|
||||
>
|
||||
See the recipe
|
||||
</a>
|
||||
</motion.div>
|
||||
|
||||
{/* Caveat caption */}
|
||||
<motion.p
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
transition={{ duration: 0.6, delay: 0.7 }}
|
||||
className="mt-6 font-caveat text-lg text-brown-light/80 dark:text-saffron-soft"
|
||||
>
|
||||
your AI, with a dash of masala
|
||||
</motion.p>
|
||||
|
||||
{/* Floating illustrations */}
|
||||
<div className="hidden md:block absolute left-6 lg:left-16 top-1/2 -translate-y-1/2 animate-float pointer-events-none">
|
||||
<SamosaSvg className="w-36 h-36 lg:w-44 lg:h-44" width={176} height={176} />
|
||||
<span className="mt-1 block text-center font-caveat text-[1rem] text-brown-light bg-[#f5edd6] dark:bg-ink-elev dark:text-saffron-soft px-3 py-0.5 border border-[#d4c4a0] dark:border-ink-border rounded-sm -rotate-3 shadow-sm">
|
||||
Samosa
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Center hero text */}
|
||||
<div className="text-center relative z-[2]">
|
||||
<motion.h1
|
||||
initial={{ opacity: 0, y: 12 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.5 }}
|
||||
className="font-baloo font-extrabold text-[clamp(3rem,7vw,5.5rem)] text-[#1a1a1a] leading-[1.1] -rotate-1 -mb-[0.35em] relative z-[2]"
|
||||
>
|
||||
समोसा चाट
|
||||
</motion.h1>
|
||||
<motion.h2
|
||||
initial={{ opacity: 0, y: 12 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.5, delay: 0.15 }}
|
||||
className="font-vibes text-[clamp(2rem,5vw,3.8rem)] text-[rgba(30,30,30,0.55)] leading-none rotate-[0.5deg] relative z-[1] -mt-[0.1em]"
|
||||
>
|
||||
samosaChaat
|
||||
</motion.h2>
|
||||
|
||||
<motion.p
|
||||
initial={{ opacity: 0, y: 8 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.5, delay: 0.3 }}
|
||||
className="mt-6 font-caveat text-lg md:text-xl text-brown-light max-w-xl mx-auto"
|
||||
>
|
||||
Your AI, with a dash of masala
|
||||
</motion.p>
|
||||
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 8 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.5, delay: 0.45 }}
|
||||
className="mt-6"
|
||||
>
|
||||
<Link
|
||||
href={ctaHref}
|
||||
className="inline-flex items-center gap-2 px-8 py-3.5 rounded-full bg-gold hover:bg-gold-dark text-white font-baloo font-semibold text-lg shadow-lg shadow-gold/25 hover:shadow-xl hover:shadow-gold/30 transition-all"
|
||||
>
|
||||
Start Chatting
|
||||
<svg width={18} height={18} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2.5} strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M5 12h14" />
|
||||
<path d="M13 5l7 7-7 7" />
|
||||
</svg>
|
||||
</Link>
|
||||
</motion.div>
|
||||
</div>
|
||||
|
||||
{/* Right illustration - Chai Kettle */}
|
||||
<div className="hidden md:block flex-shrink-0 animate-wobble">
|
||||
<div className="hidden md:block absolute right-6 lg:right-16 top-1/2 -translate-y-1/2 animate-wobble pointer-events-none">
|
||||
<div className="relative">
|
||||
<KettleSteam />
|
||||
<KettleSvg className="w-40 h-40 lg:w-48 lg:h-48" width={192} height={192} />
|
||||
<KettleSvg className="w-32 h-32 lg:w-40 lg:h-40" width={160} height={160} />
|
||||
</div>
|
||||
<span className="mt-1.5 block text-center font-caveat text-[1.1rem] text-brown-light bg-[#f5edd6] px-4 py-0.5 border border-[#d4c4a0] rounded-sm rotate-2 shadow-sm">
|
||||
<span className="mt-1 block text-center font-caveat text-[1rem] text-brown-light bg-[#f5edd6] dark:bg-ink-elev dark:text-saffron-soft px-3 py-0.5 border border-[#d4c4a0] dark:border-ink-border rounded-sm rotate-2 shadow-sm">
|
||||
Chai
|
||||
</span>
|
||||
</div>
|
||||
|
|
|
|||
38
services/frontend/hooks/useTheme.ts
Normal file
38
services/frontend/hooks/useTheme.ts
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
'use client';
|
||||
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
|
||||
type Theme = 'light' | 'dark';
|
||||
|
||||
function readTheme(): Theme {
|
||||
if (typeof window === 'undefined') return 'light';
|
||||
const stored = localStorage.getItem('theme');
|
||||
if (stored === 'dark' || stored === 'light') return stored;
|
||||
return 'light';
|
||||
}
|
||||
|
||||
export function useTheme() {
|
||||
const [theme, setThemeState] = useState<Theme>('light');
|
||||
|
||||
useEffect(() => {
|
||||
setThemeState(readTheme());
|
||||
}, []);
|
||||
|
||||
const setTheme = useCallback((next: Theme) => {
|
||||
setThemeState(next);
|
||||
try {
|
||||
localStorage.setItem('theme', next);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
const root = document.documentElement;
|
||||
if (next === 'dark') root.classList.add('dark');
|
||||
else root.classList.remove('dark');
|
||||
}, []);
|
||||
|
||||
const toggle = useCallback(() => {
|
||||
setTheme(theme === 'dark' ? 'light' : 'dark');
|
||||
}, [theme, setTheme]);
|
||||
|
||||
return { theme, setTheme, toggle };
|
||||
}
|
||||
|
|
@ -1,6 +1,7 @@
|
|||
import type { Config } from 'tailwindcss';
|
||||
|
||||
const config: Config = {
|
||||
darkMode: 'class',
|
||||
content: [
|
||||
'./app/**/*.{js,ts,jsx,tsx,mdx}',
|
||||
'./components/**/*.{js,ts,jsx,tsx,mdx}',
|
||||
|
|
@ -20,14 +21,33 @@ const config: Config = {
|
|||
'chutney-red': '#c0392b',
|
||||
'warm-grey': '#d4c4a0',
|
||||
saffron: '#ff9933',
|
||||
'saffron-soft': '#ffb063',
|
||||
// Dark mode palette (near-black, matches the reference dark UI)
|
||||
ink: '#0f0f10',
|
||||
'ink-soft': '#161618',
|
||||
'ink-elev': '#1e1e21',
|
||||
'ink-border': '#2a2a2e',
|
||||
'ink-text': '#e7e5e4',
|
||||
'ink-text-soft': '#9a9797',
|
||||
},
|
||||
fontFamily: {
|
||||
display: ['var(--font-fraunces)', 'Fraunces', 'ui-serif', 'Georgia', 'serif'],
|
||||
baloo: ['var(--font-baloo)', 'Baloo 2', 'cursive'],
|
||||
vibes: ['var(--font-vibes)', 'Great Vibes', 'cursive'],
|
||||
caveat: ['var(--font-caveat)', 'Caveat', 'cursive'],
|
||||
sans: ['var(--font-inter)', 'Inter', 'ui-sans-serif', 'system-ui', 'sans-serif'],
|
||||
mono: ['Monaco', 'Menlo', 'Consolas', 'monospace'],
|
||||
},
|
||||
backgroundImage: {
|
||||
'hero-glow':
|
||||
'radial-gradient(60% 45% at 50% 0%, rgba(255,153,51,0.28) 0%, rgba(255,255,255,0) 60%), radial-gradient(50% 40% at 20% 20%, rgba(232,168,56,0.18) 0%, rgba(255,255,255,0) 70%), radial-gradient(55% 45% at 85% 30%, rgba(255,176,99,0.22) 0%, rgba(255,255,255,0) 70%)',
|
||||
'tile-saffron':
|
||||
'linear-gradient(180deg, #ff9933 0%, #ffb063 55%, #ffd2a0 100%)',
|
||||
'tile-gold':
|
||||
'linear-gradient(180deg, #e8a838 0%, #f3c678 60%, #fde6be 100%)',
|
||||
'tile-chutney':
|
||||
'linear-gradient(180deg, #2d8a4e 0%, #5cb17a 55%, #cfe7d6 100%)',
|
||||
},
|
||||
keyframes: {
|
||||
pendulum: {
|
||||
'0%': { transform: 'translateX(-50%) rotate(-4deg)' },
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user