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:
Manmohan 2026-04-16 19:55:16 -04:00 committed by GitHub
parent 272086d2c0
commit 1d2a76eec4
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
19 changed files with 766 additions and 439 deletions

View File

@ -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

View File

@ -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)

View File

@ -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

View File

@ -0,0 +1,3 @@
{
"extends": "next/core-web-vitals"
}

View File

@ -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;

View File

@ -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>
);

View File

@ -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;
}

View File

@ -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>

View File

@ -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>

View File

@ -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>
);

View File

@ -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>
);
}

View File

@ -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>

View File

@ -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>

View File

@ -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]}

View File

@ -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>

View File

@ -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>

View File

@ -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>

View 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 };
}

View File

@ -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)' },