feat: implement Material Design 3 styles for buttons and dialogs

- Revamped button styles to align with Material Design 3 guidelines, including new variants (filled, tonal, elevated, outlined, text, and icon buttons).
- Enhanced button states with hover, focus, and active effects, incorporating opacity transitions and background color changes.
- Updated dialog styles to reflect MD3 design principles, including scrim overlay, dialog content, headers, footers, and close buttons.
- Introduced animations for dialogs, dropdowns, and snackbars to improve user experience.
- Refined dropdown menu and select component styles, ensuring consistency with MD3 aesthetics.
This commit is contained in:
2026-01-19 23:41:56 +00:00
parent f5c11d3113
commit 1cbcb2051f
19 changed files with 3395 additions and 488 deletions

153
CODE_STYLE.md Normal file
View File

@@ -0,0 +1,153 @@
Heres a practical, engineering-grade set of coding principles that actually keep a codebase healthy over years—not just during the honeymoon phase. Think of this as preventative maintenance, not moral philosophy 🛠️🧠.
1. Smallness Is a Feature
Large things rot faster.
• Small files
• Small functions
• Small modules
• Small responsibilities
If a file feels “important,” its probably doing too much. Decomposition beats cleverness every time.
Size is friction. Friction accumulates.
2. One Reason to Change
This is the only part of SOLID that really matters in practice.
• Every file should exist for one reason
• If a change request makes you touch unrelated logic, the boundary is wrong
Violations show up as:
• “While Im here…” edits
• Fear-driven refactors
• Accidental breakage
3. Make State Explicit (and Rare)
Hidden state is technical debt wearing camouflage.
• Prefer pure functions
• Push IO to the edges
• Name state transitions clearly
• Avoid “ambient” globals, singletons, magic context
If you cant explain when state changes, it will betray you at scale 🧨.
4. Clarity Beats Brevity
Concise code is nice. Readable code survives.
• Prefer obvious over clever
• Use boring names that describe intent
• Avoid dense expressions that require mental simulation
The best compliment for code:
“I didnt have to think.”
5. Structure > Comments
Comments decay. Structure persists.
• Encode intent in function names and types
• Let data shape explain behavior
• Use comments only for why, never what
If you need a paragraph to explain a function, split the function.
6. Types Are a Design Tool
Even in dynamic languages.
• Types clarify contracts
• They force edge cases into daylight
• They prevent “just trust me” APIs
A good type definition is executable documentation 📐.
7. Tests Define Reality
Untested code is hypothetical.
• Tests lock in behavior
• Refactors without tests are rewrites in disguise
• Focus on behavior, not implementation
Healthy ratio:
• Few unit tests per function
• Strong integration tests per feature
8. Errors Are First-Class
Failure paths deserve the same respect as success paths.
• Handle errors explicitly
• Avoid silent fallbacks
• Fail fast, fail loud, fail usefully
Most production bugs live in “this cant happen” branches.
9. Consistency Is More Valuable Than Correctness
A consistent codebase is navigable—even if imperfect.
• Same patterns everywhere
• Same naming conventions
• Same folder logic
Inconsistency multiplies cognitive load faster than any algorithmic inefficiency.
10. Refactor Continuously, Not Heroically
Big refactors are usually a smell.
• Refactor as you touch code
• Leave things slightly better than you found them
• Dont let cleanup pile up into fear
Entropy is real. You either fight it daily or lose spectacularly 🌪️.
11. Design for Deletion
The best code is easy to remove.
• Avoid tight coupling
• Prefer composition over inheritance
• Keep feature boundaries clean
If you cant delete a feature safely, it owns you—not the other way around.
12. Tooling Is Part of the Codebase
Linters, formatters, CI, and automation are not optional polish.
• Enforce rules mechanically
• Remove human judgment where possible
• Let tools be the bad cop
Humans are bad at consistency. Machines love it 🤖.
The Meta-Principle
A good codebase optimizes for future humans, not present cleverness.
Every line you write is a tiny act of communication across time. Write like Future-You is tired, busy, and slightly annoyed—but still clever enough to appreciate clean design.
Entropy never sleeps. Engineers must 😄

View File

@@ -16,86 +16,596 @@
::before,
::backdrop,
::file-selector-button {
border-color: var(--color-neutral-3, $border);
border-color: var(--md3-outline-variant);
}
// Body and base styles
// Body and base styles - Android Material You feel
body {
background: $background;
color: $foreground;
font-family: var(--font-inter), 'Inter', 'Bricolage Grotesque', sans-serif;
line-height: 1.5;
transition: background-color 0.2s ease, color 0.2s ease;
font-family: var(--font-inter), 'Inter', 'Roboto', system-ui, sans-serif;
font-size: $font-body-medium;
line-height: $line-height-body-medium;
letter-spacing: $letter-spacing-body;
transition: background-color $duration-medium-2 $easing-standard,
color $duration-medium-2 $easing-standard;
overflow-x: hidden;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
// Typography base - detailed styles in components/_typography.scss
// ============================================
// MD3 Typography Classes
// ============================================
// Display styles
.md3-display-large {
font-size: $font-display-large;
line-height: $line-height-display-large;
letter-spacing: $letter-spacing-display;
font-weight: 400;
}
.md3-display-medium {
font-size: $font-display-medium;
line-height: $line-height-display-medium;
letter-spacing: $letter-spacing-display;
font-weight: 400;
}
.md3-display-small {
font-size: $font-display-small;
line-height: $line-height-display-small;
letter-spacing: $letter-spacing-display;
font-weight: 400;
}
// Headline styles
.md3-headline-large {
font-size: $font-headline-large;
line-height: $line-height-headline-large;
letter-spacing: $letter-spacing-headline;
font-weight: 400;
}
.md3-headline-medium {
font-size: $font-headline-medium;
line-height: $line-height-headline-medium;
letter-spacing: $letter-spacing-headline;
font-weight: 400;
}
.md3-headline-small {
font-size: $font-headline-small;
line-height: $line-height-headline-small;
letter-spacing: $letter-spacing-headline;
font-weight: 400;
}
// Title styles
.md3-title-large {
font-size: $font-title-large;
line-height: $line-height-title-large;
letter-spacing: $letter-spacing-title;
font-weight: 400;
}
.md3-title-medium {
font-size: $font-title-medium;
line-height: $line-height-title-medium;
letter-spacing: $letter-spacing-title;
font-weight: 500;
}
.md3-title-small {
font-size: $font-title-small;
line-height: $line-height-title-small;
letter-spacing: $letter-spacing-title;
font-weight: 500;
}
// Body styles
.md3-body-large {
font-size: $font-body-large;
line-height: $line-height-body-large;
letter-spacing: $letter-spacing-body;
font-weight: 400;
}
.md3-body-medium {
font-size: $font-body-medium;
line-height: $line-height-body-medium;
letter-spacing: $letter-spacing-body;
font-weight: 400;
}
.md3-body-small {
font-size: $font-body-small;
line-height: $line-height-body-small;
letter-spacing: $letter-spacing-body;
font-weight: 400;
}
// Label styles
.md3-label-large {
font-size: $font-label-large;
line-height: $line-height-label-large;
letter-spacing: $letter-spacing-label;
font-weight: 500;
}
.md3-label-medium {
font-size: $font-label-medium;
line-height: $line-height-label-medium;
letter-spacing: $letter-spacing-label;
font-weight: 500;
}
.md3-label-small {
font-size: $font-label-small;
line-height: $line-height-label-small;
letter-spacing: $letter-spacing-label;
font-weight: 500;
}
// Typography base for headings
h1, h2, h3, h4, h5, h6 {
font-family: var(--font-inter), 'Inter', 'Bricolage Grotesque', sans-serif;
font-weight: 600;
line-height: 1.2;
font-family: var(--font-inter), 'Inter', 'Roboto', system-ui, sans-serif;
font-weight: 400;
letter-spacing: $letter-spacing-headline;
}
h1 {
font-size: $font-headline-large;
line-height: $line-height-headline-large;
}
h2 {
font-size: $font-headline-medium;
line-height: $line-height-headline-medium;
}
h3 {
font-size: $font-headline-small;
line-height: $line-height-headline-small;
}
h4 {
font-size: $font-title-large;
line-height: $line-height-title-large;
}
h5 {
font-size: $font-title-medium;
line-height: $line-height-title-medium;
font-weight: 500;
}
h6 {
font-size: $font-title-small;
line-height: $line-height-title-small;
font-weight: 500;
}
code, pre {
font-family: var(--font-jetbrains-mono), 'JetBrains Mono', monospace;
font-family: var(--font-jetbrains-mono), 'JetBrains Mono', 'Roboto Mono', monospace;
}
// CSS Custom Properties for theme
// ============================================
// MD3 CSS Custom Properties (Dark Theme)
// ============================================
:root {
--background: 222.2 84% 4.9%;
--foreground: 210 40% 98%;
--card: 222.2 84% 4.9%;
--card-foreground: 210 40% 98%;
--popover: 222.2 84% 4.9%;
--popover-foreground: 210 40% 98%;
--primary: 263 70% 50%;
--primary-foreground: 210 40% 98%;
--secondary: 217.2 32.6% 17.5%;
--secondary-foreground: 210 40% 98%;
--muted: 217.2 32.6% 17.5%;
--muted-foreground: 215 20.2% 65.1%;
--accent: 195 100% 70%;
--accent-foreground: 222.2 84% 4.9%;
--destructive: 0 62.8% 30.6%;
--destructive-foreground: 210 40% 98%;
--border: 217.2 32.6% 17.5%;
--input: 217.2 32.6% 17.5%;
--ring: 195 100% 70%;
--radius: 0.5rem;
// Primary
--md3-primary: #{$primary};
--md3-on-primary: #{$on-primary};
--md3-primary-container: #{$primary-container};
--md3-on-primary-container: #{$on-primary-container};
// Chart colors
--chart-1: oklch(0.646 0.222 41.116);
--chart-2: oklch(0.6 0.118 184.704);
--chart-3: oklch(0.398 0.07 227.392);
--chart-4: oklch(0.828 0.189 84.429);
--chart-5: oklch(0.769 0.188 70.08);
// Secondary
--md3-secondary: #{$secondary};
--md3-on-secondary: #{$on-secondary};
--md3-secondary-container: #{$secondary-container};
--md3-on-secondary-container: #{$on-secondary-container};
// Sidebar colors
--sidebar: 217.2 32.6% 17.5%;
--sidebar-foreground: 210 40% 98%;
--sidebar-primary: 263 70% 50%;
--sidebar-primary-foreground: 210 40% 98%;
--sidebar-accent: 217.2 32.6% 17.5%;
--sidebar-accent-foreground: 210 40% 98%;
--sidebar-border: 217.2 32.6% 17.5%;
--sidebar-ring: 195 100% 70%;
// Tertiary
--md3-tertiary: #{$accent};
--md3-on-tertiary: #{$on-tertiary};
--md3-tertiary-container: #{$tertiary-container};
--md3-on-tertiary-container: #{$on-tertiary-container};
// Error
--md3-error: #{$destructive};
--md3-on-error: #{$on-error};
--md3-error-container: #{$error-container};
--md3-on-error-container: #{$on-error-container};
// Surface
--md3-surface: #{$surface};
--md3-on-surface: #{$on-surface};
--md3-surface-variant: #{$surface-variant};
--md3-on-surface-variant: #{$on-surface-variant};
--md3-surface-dim: #{$surface-dim};
--md3-surface-bright: #{$surface-bright};
// Surface Containers (MD3 tonal elevation)
--md3-surface-container-lowest: #{$surface-container-lowest};
--md3-surface-container-low: #{$surface-container-low};
--md3-surface-container: #{$surface-container};
--md3-surface-container-high: #{$surface-container-high};
--md3-surface-container-highest: #{$surface-container-highest};
// Background
--md3-background: #{$background};
--md3-on-background: #{$on-background};
// Outline
--md3-outline: #{$outline};
--md3-outline-variant: #{$outline-variant};
// Inverse
--md3-inverse-surface: #{$inverse-surface};
--md3-inverse-on-surface: #{$inverse-on-surface};
--md3-inverse-primary: #{$inverse-primary};
// Scrim & Shadow
--md3-scrim: #{$scrim};
--md3-shadow: #{$shadow};
// Semantic colors
--md3-success: #{$md3-success-80};
--md3-on-success: #{$md3-success-40};
--md3-warning: #{$md3-warning-80};
--md3-on-warning: #{$md3-warning-40};
--md3-info: #{$md3-info-80};
--md3-on-info: #{$md3-info-40};
// State layers
--md3-state-hover: #{$state-hover-opacity};
--md3-state-focus: #{$state-focus-opacity};
--md3-state-pressed: #{$state-pressed-opacity};
--md3-state-dragged: #{$state-dragged-opacity};
--md3-state-disabled: #{$state-disabled-opacity};
// Elevation
--md3-elevation-1: #{$elevation-1};
--md3-elevation-2: #{$elevation-2};
--md3-elevation-3: #{$elevation-3};
--md3-elevation-4: #{$elevation-4};
--md3-elevation-5: #{$elevation-5};
// Motion
--md3-duration-short: #{$duration-short-4};
--md3-duration-medium: #{$duration-medium-2};
--md3-duration-long: #{$duration-long-2};
--md3-easing-standard: #{$easing-standard};
--md3-easing-emphasized: #{$easing-emphasized};
--md3-easing-emphasized-decelerate: #{$easing-emphasized-decelerate};
// Shape
--md3-shape-extra-small: #{$radius-extra-small};
--md3-shape-small: #{$radius-small};
--md3-shape-medium: #{$radius-medium};
--md3-shape-large: #{$radius-large};
--md3-shape-extra-large: #{$radius-extra-large};
--md3-shape-full: #{$radius-full};
// Legacy compatibility
--background: 260 10% 6%;
--foreground: 260 10% 90%;
--card: 260 10% 10%;
--card-foreground: 260 10% 90%;
--popover: 260 10% 17%;
--popover-foreground: 260 10% 90%;
--primary: 260 50% 80%;
--primary-foreground: 260 50% 20%;
--secondary: 260 15% 80%;
--secondary-foreground: 260 15% 20%;
--muted: 260 10% 12%;
--muted-foreground: 260 15% 80%;
--accent: 180 50% 80%;
--accent-foreground: 180 50% 20%;
--destructive: 0 75% 80%;
--destructive-foreground: 0 75% 20%;
--border: 260 15% 30%;
--input: 260 10% 22%;
--ring: 260 50% 80%;
--radius: 0.75rem;
// Chart colors (MD3 inspired)
--chart-1: #{$md3-primary-60};
--chart-2: #{$md3-secondary-60};
--chart-3: #{$md3-tertiary-60};
--chart-4: #{$md3-error-60};
--chart-5: #{$md3-warning-80};
// Sidebar colors (MD3)
--sidebar: 260 10% 10%;
--sidebar-foreground: 260 10% 90%;
--sidebar-primary: 260 50% 80%;
--sidebar-primary-foreground: 260 50% 20%;
--sidebar-accent: 260 10% 17%;
--sidebar-accent-foreground: 260 10% 90%;
--sidebar-border: 260 15% 30%;
--sidebar-ring: 260 50% 80%;
}
// Accordion animations
// ============================================
// MD3 Surface Utility Classes
// ============================================
.md3-surface {
background-color: var(--md3-surface);
color: var(--md3-on-surface);
}
.md3-surface-dim {
background-color: var(--md3-surface-dim);
color: var(--md3-on-surface);
}
.md3-surface-bright {
background-color: var(--md3-surface-bright);
color: var(--md3-on-surface);
}
.md3-surface-container-lowest {
background-color: var(--md3-surface-container-lowest);
color: var(--md3-on-surface);
}
.md3-surface-container-low {
background-color: var(--md3-surface-container-low);
color: var(--md3-on-surface);
}
.md3-surface-container {
background-color: var(--md3-surface-container);
color: var(--md3-on-surface);
}
.md3-surface-container-high {
background-color: var(--md3-surface-container-high);
color: var(--md3-on-surface);
}
.md3-surface-container-highest {
background-color: var(--md3-surface-container-highest);
color: var(--md3-on-surface);
}
// ============================================
// MD3 Elevation Classes
// ============================================
.md3-elevation-1 {
box-shadow: var(--md3-elevation-1);
}
.md3-elevation-2 {
box-shadow: var(--md3-elevation-2);
}
.md3-elevation-3 {
box-shadow: var(--md3-elevation-3);
}
.md3-elevation-4 {
box-shadow: var(--md3-elevation-4);
}
.md3-elevation-5 {
box-shadow: var(--md3-elevation-5);
}
// ============================================
// MD3 State Layer Mixin Classes
// ============================================
.md3-state-layer {
position: relative;
overflow: hidden;
&::before {
content: '';
position: absolute;
inset: 0;
background-color: currentColor;
opacity: 0;
transition: opacity $duration-short-4 $easing-standard;
pointer-events: none;
border-radius: inherit;
}
&:hover::before {
opacity: var(--md3-state-hover);
}
&:focus-visible::before {
opacity: var(--md3-state-focus);
}
&:active::before {
opacity: var(--md3-state-pressed);
}
}
// ============================================
// MD3 Ripple Effect
// ============================================
@keyframes md3-ripple {
from {
transform: scale(0);
opacity: 0.12;
}
to {
transform: scale(4);
opacity: 0;
}
}
.md3-ripple {
position: relative;
overflow: hidden;
&::after {
content: '';
position: absolute;
width: 100%;
height: 100%;
top: 50%;
left: 50%;
transform: translate(-50%, -50%) scale(0);
background: radial-gradient(circle, currentColor 10%, transparent 10.01%);
opacity: 0;
pointer-events: none;
border-radius: 50%;
}
&:active::after {
animation: md3-ripple $duration-medium-4 $easing-standard;
}
}
// ============================================
// Animations (MD3 Motion)
// ============================================
@keyframes accordion-down {
from {
height: 0;
opacity: 0;
}
to {
height: var(--radix-accordion-content-height);
opacity: 1;
}
}
@keyframes accordion-up {
from {
height: var(--radix-accordion-content-height);
opacity: 1;
}
to {
height: 0;
opacity: 0;
}
}
@keyframes md3-fade-in {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
@keyframes md3-fade-out {
from {
opacity: 1;
}
to {
opacity: 0;
}
}
@keyframes md3-scale-in {
from {
opacity: 0;
transform: scale(0.8);
}
to {
opacity: 1;
transform: scale(1);
}
}
@keyframes md3-slide-in-bottom {
from {
opacity: 0;
transform: translateY(100%);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@keyframes md3-slide-in-right {
from {
opacity: 0;
transform: translateX(100%);
}
to {
opacity: 1;
transform: translateX(0);
}
}
// ============================================
// Android-style System UI
// ============================================
// Status bar simulation (optional)
.android-status-bar {
height: 24px;
background: var(--md3-surface-container-lowest);
display: flex;
align-items: center;
justify-content: flex-end;
padding: 0 $size-4;
font-size: $font-label-small;
color: var(--md3-on-surface);
}
// Navigation bar simulation (bottom gesture bar)
.android-nav-bar {
height: 20px;
background: transparent;
display: flex;
align-items: center;
justify-content: center;
padding-bottom: $size-2;
&::after {
content: '';
width: 134px;
height: 5px;
background: var(--md3-on-surface-variant);
border-radius: $radius-full;
opacity: 0.4;
}
}
// Selection styling
::selection {
background: rgba($primary, 0.3);
color: $on-surface;
}
// Scrollbar styling (Android-like thin scrollbar)
::-webkit-scrollbar {
width: 4px;
height: 4px;
}
::-webkit-scrollbar-track {
background: transparent;
}
::-webkit-scrollbar-thumb {
background: var(--md3-outline-variant);
border-radius: $radius-full;
&:hover {
background: var(--md3-outline);
}
}
// Focus visible styling (MD3 style)
:focus-visible {
outline: 2px solid var(--md3-primary);
outline-offset: 2px;
}

View File

@@ -1,10 +1,9 @@
import { useState, useEffect } from 'react'
import { useState, useEffect, useCallback } from 'react'
import { motion } from 'framer-motion'
import { Play, CircleNotch, Terminal } from '@phosphor-icons/react'
import { Play, CircleNotch, ArrowClockwise, Warning } from '@phosphor-icons/react'
import { Button } from '@/components/ui/button'
import { Card } from '@/components/ui/card'
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
import { runPythonCode, getPyodide, isPyodideReady } from '@/lib/pyodide-runner'
import { runPythonCode, getPyodide, isPyodideReady, getPyodideError, resetPyodide } from '@/lib/pyodide-runner'
import { PythonTerminal } from '@/components/features/python-runner/PythonTerminal'
import { toast } from 'sonner'
@@ -17,30 +16,71 @@ export function PythonOutput({ code }: PythonOutputProps) {
const [error, setError] = useState<string>('')
const [isRunning, setIsRunning] = useState(false)
const [isInitializing, setIsInitializing] = useState(!isPyodideReady())
const [initError, setInitError] = useState<string | null>(null)
const [hasInput, setHasInput] = useState(false)
useEffect(() => {
if (!isPyodideReady()) {
setIsInitializing(true)
getPyodide()
.then(() => {
setIsInitializing(false)
toast.success('Python environment ready!')
})
.catch((err) => {
setIsInitializing(false)
toast.error('Failed to load Python environment')
console.error(err)
})
const initializePyodide = useCallback(async () => {
setIsInitializing(true)
setInitError(null)
try {
await getPyodide()
setIsInitializing(false)
toast.success('Python environment ready!')
} catch (err) {
setIsInitializing(false)
const errorMsg = err instanceof Error ? err.message : String(err)
setInitError(errorMsg)
toast.error('Failed to load Python environment')
console.error(err)
}
}, [])
const handleRetry = useCallback(() => {
resetPyodide()
setOutput('')
setError('')
setInitError(null)
initializePyodide()
}, [initializePyodide])
useEffect(() => {
// Check for existing initialization error
const existingError = getPyodideError()
if (existingError) {
setInitError(existingError.message)
setIsInitializing(false)
return
}
if (!isPyodideReady()) {
initializePyodide()
}
}, [initializePyodide])
useEffect(() => {
const codeToCheck = code.toLowerCase()
setHasInput(codeToCheck.includes('input('))
}, [code])
const statusTone = initError
? 'border-destructive/30 bg-destructive/10 text-destructive'
: isInitializing
? 'border-border bg-primary/5 text-primary'
: 'border-primary/30 bg-primary/10 text-primary'
const statusLabel = initError
? 'Init failed'
: isInitializing
? 'Loading'
: 'Ready'
const handleRun = async () => {
if (initError) {
toast.error('Python environment failed to start. Retry to load it again.')
return
}
if (isInitializing) {
toast.info('Python environment is still loading...')
return
@@ -69,36 +109,112 @@ export function PythonOutput({ code }: PythonOutputProps) {
return (
<div className="flex flex-col h-full bg-card">
<div className="flex items-center justify-between p-4 border-b border-border">
<h3 className="text-sm font-semibold text-foreground">Python Output</h3>
<Button
onClick={handleRun}
disabled={isRunning || isInitializing}
size="sm"
className="gap-2"
>
{isRunning || isInitializing ? (
<>
<CircleNotch className="animate-spin" size={16} />
{isInitializing ? 'Loading...' : 'Running...'}
</>
) : (
<>
<Play size={16} weight="fill" />
Run
</>
<div className="flex items-center justify-between p-4 border-b border-border bg-muted/30">
<div className="flex items-center gap-2">
<h3 className="text-sm font-semibold text-foreground">Python Output</h3>
<span
className={`flex items-center gap-2 rounded-full border px-3 py-1 text-[11px] font-semibold uppercase tracking-wide ${statusTone}`}
>
<span
className={`size-2.5 rounded-full ${initError ? 'bg-destructive' : 'bg-primary'}`}
/>
{statusLabel}
</span>
</div>
<div className="flex items-center gap-2">
{initError && (
<Button
onClick={handleRetry}
size="sm"
variant="tonal"
className="gap-2"
>
<ArrowClockwise size={16} />
Retry
</Button>
)}
</Button>
<Button
onClick={handleRun}
disabled={isRunning || isInitializing || Boolean(initError)}
size="sm"
className="gap-2"
>
{isRunning || isInitializing ? (
<>
<CircleNotch className="animate-spin" size={16} />
{isInitializing ? 'Loading...' : 'Running...'}
</>
) : (
<>
<Play size={16} weight="fill" />
Run
</>
)}
</Button>
</div>
</div>
<div className="flex-1 overflow-auto p-4">
{!output && !error && (
<div className="flex-1 overflow-auto p-4 space-y-4">
{isInitializing && (
<Card variant="filled" className="p-4 border border-border/60">
<div className="flex items-start gap-3 text-sm text-foreground">
<div className="mt-1 rounded-full bg-primary/10 p-2 text-primary">
<CircleNotch className="animate-spin" size={18} />
</div>
<div className="space-y-1">
<p className="font-semibold">Preparing Python environment</p>
<p className="text-muted-foreground">
This takes a few seconds the first time while Pyodide downloads.
</p>
</div>
</div>
</Card>
)}
{initError && (
<Card
variant="outlined"
className="p-4 border-destructive/40 bg-destructive/5"
>
<div className="flex items-start gap-3">
<div className="mt-1 rounded-full bg-destructive/10 p-2 text-destructive">
<Warning size={18} weight="fill" />
</div>
<div className="space-y-3">
<div className="space-y-1">
<p className="text-sm font-semibold text-destructive">Python environment failed to start</p>
<p className="text-sm text-muted-foreground">{initError}</p>
</div>
<div className="flex flex-wrap gap-2">
<Button
size="sm"
variant="tonal"
className="gap-2"
onClick={handleRetry}
>
<ArrowClockwise size={16} />
Retry loading
</Button>
<Button
size="sm"
variant="text"
onClick={() => toast.info('Check your network connection or disable blockers that may block Pyodide.')}
>
Troubleshoot
</Button>
</div>
</div>
</div>
</Card>
)}
{!isInitializing && !initError && !output && !error && (
<div className="flex items-center justify-center h-full text-muted-foreground text-sm">
Click "Run" to execute the Python code
</div>
)}
{output && (
{!isInitializing && !initError && output && (
<motion.div
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}

View File

@@ -1,67 +1,146 @@
'use client';
import { motion } from 'framer-motion';
import { motion, AnimatePresence } from 'framer-motion';
import Link from 'next/link';
import { usePathname } from 'next/navigation';
import { X } from '@phosphor-icons/react';
import { Button } from '@/components/ui/button';
import { cn } from '@/lib/utils';
import { navigationItems } from './navigation-items';
import { useNavigation } from './useNavigation';
/**
* Material Design 3 Navigation Drawer
*
* Android-style modal navigation drawer with:
* - Scrim overlay
* - Slide-in animation
* - Active indicator pill
* - MD3 typography and spacing
*/
export function NavigationSidebar() {
const { menuOpen, setMenuOpen } = useNavigation();
const pathname = usePathname();
return (
<motion.aside
initial={false}
animate={{ x: menuOpen ? 0 : -320 }}
transition={{ type: 'spring', damping: 30, stiffness: 300 }}
className="fixed left-0 top-0 h-screen w-80 bg-card border-r border-border z-30 flex flex-col"
>
<div className="flex-1 overflow-y-auto">
<div className="p-6 border-b border-border flex items-center justify-between">
<h2 className="text-lg font-semibold">Navigation</h2>
<Button
variant="ghost"
size="icon"
<AnimatePresence>
{menuOpen && (
<>
{/* Scrim/Overlay */}
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.2 }}
className="fixed inset-0 z-40 bg-black/32"
onClick={() => setMenuOpen(false)}
>
<X className="h-5 w-5" />
</Button>
</div>
<nav className="p-4">
<ul className="space-y-2">
{navigationItems.map((item) => {
const Icon = item.icon;
const isActive = pathname === item.path;
return (
<li key={item.path}>
<Link href={item.path}>
<Button
variant={isActive ? 'secondary' : 'ghost'}
className={cn(
'w-full justify-start gap-3',
isActive && 'bg-accent text-accent-foreground'
)}
>
<Icon className="h-5 w-5" />
{item.label}
</Button>
</Link>
</li>
);
})}
</ul>
</nav>
aria-hidden="true"
/>
<div className="p-6 border-t border-border">
<p className="text-sm text-muted-foreground">
CodeSnippet Library
</p>
</div>
</div>
</motion.aside>
{/* Navigation Drawer */}
<motion.aside
initial={{ x: -320 }}
animate={{ x: 0 }}
exit={{ x: -320 }}
transition={{
type: 'spring',
damping: 30,
stiffness: 300,
mass: 0.8
}}
className={cn(
"fixed left-0 top-0 h-screen w-80 z-50",
"bg-[hsl(var(--card))]",
"flex flex-col",
"shadow-xl"
)}
>
{/* Header */}
<div className="flex items-center justify-between h-16 px-4 border-b border-border/50">
<span className="text-base font-medium text-foreground pl-3">
CodeSnippet
</span>
<button
onClick={() => setMenuOpen(false)}
className={cn(
"flex items-center justify-center",
"size-10 rounded-full",
"text-muted-foreground",
"hover:bg-foreground/[0.08]",
"active:bg-foreground/[0.12]",
"transition-colors duration-200",
"outline-none focus-visible:ring-2 focus-visible:ring-primary"
)}
aria-label="Close navigation"
>
<X weight="bold" className="size-5" />
</button>
</div>
{/* Navigation Items */}
<nav className="flex-1 overflow-y-auto py-2 px-3">
<ul className="space-y-0.5">
{navigationItems.map((item) => {
const Icon = item.icon;
const isActive = pathname === item.path;
return (
<li key={item.path}>
<Link
href={item.path}
onClick={() => setMenuOpen(false)}
className={cn(
"relative flex items-center gap-3",
"h-14 px-4 rounded-full",
"text-sm font-medium",
"transition-all duration-200",
"outline-none",
// State layer
"before:absolute before:inset-0 before:rounded-full",
"before:bg-current before:opacity-0 before:transition-opacity",
"hover:before:opacity-[0.08]",
"focus-visible:before:opacity-[0.12]",
"active:before:opacity-[0.12]",
// Active state
isActive
? "bg-secondary text-secondary-foreground"
: "text-muted-foreground hover:text-foreground"
)}
>
<Icon
weight={isActive ? "fill" : "regular"}
className="size-6 relative z-10 shrink-0"
/>
<span className="relative z-10">{item.label}</span>
{/* Active indicator line */}
{isActive && (
<motion.div
layoutId="nav-indicator"
className="absolute right-3 w-1 h-4 bg-primary rounded-full"
transition={{ type: 'spring', damping: 30, stiffness: 500 }}
/>
)}
</Link>
</li>
);
})}
</ul>
</nav>
{/* Footer */}
<div className="p-4 border-t border-border/50">
<p className="text-xs text-muted-foreground text-center">
Material Design 3
</p>
</div>
{/* Android gesture bar indicator */}
<div className="h-5 flex items-center justify-center pb-2">
<div className="w-[134px] h-[5px] bg-muted-foreground/40 rounded-full" />
</div>
</motion.aside>
</>
)}
</AnimatePresence>
);
}

View File

@@ -4,23 +4,80 @@ import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
/**
* Material Design 3 Badge Component
*
* Used for:
* - Status indicators
* - Labels
* - Small counts (notification badges use a different pattern)
*/
const badgeVariants = cva(
"inline-flex items-center justify-center rounded-md border px-2 py-0.5 text-xs font-medium w-fit whitespace-nowrap shrink-0 [&>svg]:size-3 gap-1 [&>svg]:pointer-events-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive transition-[color,box-shadow] overflow-hidden",
// Base styles
[
"inline-flex items-center justify-center gap-1",
"rounded-full px-2.5 py-0.5",
"text-xs font-medium",
"whitespace-nowrap shrink-0 w-fit",
"transition-colors duration-200",
"[&>svg]:size-3 [&>svg]:pointer-events-none",
"outline-none",
"focus-visible:ring-2 focus-visible:ring-primary focus-visible:ring-offset-2",
].join(" "),
{
variants: {
variant: {
default:
"border-transparent bg-primary text-primary-foreground [a&]:hover:bg-primary/90",
secondary:
"border-transparent bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90",
destructive:
"border-transparent bg-destructive text-white [a&]:hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
outline:
"text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground",
// Filled badge (primary)
default: [
"border-transparent bg-primary text-primary-foreground",
"[a&]:hover:bg-primary/90",
].join(" "),
// Tonal badge (secondary container)
secondary: [
"border-transparent bg-secondary text-secondary-foreground",
"[a&]:hover:bg-secondary/90",
].join(" "),
// Error badge
destructive: [
"border-transparent bg-destructive text-destructive-foreground",
"[a&]:hover:bg-destructive/90",
].join(" "),
// Outlined badge
outline: [
"border border-border bg-transparent text-foreground",
"[a&]:hover:bg-muted",
].join(" "),
// Success badge
success: [
"border-transparent bg-[hsl(145,60%,35%)] text-white",
"[a&]:hover:opacity-90",
].join(" "),
// Warning badge
warning: [
"border-transparent bg-[hsl(40,80%,45%)] text-white",
"[a&]:hover:opacity-90",
].join(" "),
// Info badge
info: [
"border-transparent bg-[hsl(210,70%,45%)] text-white",
"[a&]:hover:opacity-90",
].join(" "),
},
size: {
default: "h-5 px-2.5 text-xs",
sm: "h-4 px-2 text-[10px]",
lg: "h-6 px-3 text-sm",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
)
@@ -28,6 +85,7 @@ const badgeVariants = cva(
function Badge({
className,
variant,
size,
asChild = false,
...props
}: ComponentProps<"span"> &
@@ -37,10 +95,45 @@ function Badge({
return (
<Comp
data-slot="badge"
className={cn(badgeVariants({ variant }), className)}
className={cn(badgeVariants({ variant, size }), className)}
{...props}
/>
)
}
export { Badge, badgeVariants }
/**
* MD3 Small Badge (for notification counts)
* Can be a dot or show a number
*/
function SmallBadge({
className,
count,
max = 99,
...props
}: ComponentProps<"span"> & {
count?: number
max?: number
}) {
const showDot = count === undefined || count === 0
const displayCount = count && count > max ? `${max}+` : count
return (
<span
data-slot="small-badge"
className={cn(
"inline-flex items-center justify-center",
"bg-destructive text-destructive-foreground",
"font-medium",
showDot
? "size-2 rounded-full"
: "min-w-[16px] h-4 px-1 rounded-full text-[10px]",
className
)}
{...props}
>
{!showDot && displayCount}
</span>
)
}
export { Badge, SmallBadge, badgeVariants }

View File

@@ -0,0 +1,149 @@
'use client'
import { ComponentProps, createContext, forwardRef, useContext } from "react"
import { cn } from "@/lib/utils"
/**
* Material Design 3 Bottom Navigation Bar
*
* Android-style bottom navigation with:
* - 3-5 destinations
* - Active indicator pill
* - Icon + label layout
* - Badge support
*/
interface BottomNavigationContextValue {
activeValue?: string
onValueChange?: (value: string) => void
}
const BottomNavigationContext = createContext<BottomNavigationContextValue>({})
interface BottomNavigationProps extends ComponentProps<"nav"> {
value?: string
onValueChange?: (value: string) => void
}
const BottomNavigation = forwardRef<HTMLElement, BottomNavigationProps>(
({ className, value, onValueChange, children, ...props }, ref) => {
return (
<BottomNavigationContext.Provider value={{ activeValue: value, onValueChange }}>
<nav
ref={ref}
data-slot="bottom-navigation"
className={cn(
// Container styles
"fixed bottom-0 left-0 right-0 z-50",
"h-20 px-2",
"bg-[hsl(var(--card))]",
"border-t border-border/50",
"shadow-[0_-2px_10px_rgba(0,0,0,0.1)]",
// Flex layout
"flex items-center justify-around",
// Safe area for notched phones
"pb-safe",
className
)}
{...props}
>
{children}
</nav>
</BottomNavigationContext.Provider>
)
}
)
BottomNavigation.displayName = "BottomNavigation"
interface BottomNavigationItemProps extends ComponentProps<"button"> {
value: string
icon: React.ReactNode
activeIcon?: React.ReactNode
label: string
badge?: number | boolean
}
const BottomNavigationItem = forwardRef<HTMLButtonElement, BottomNavigationItemProps>(
({ className, value, icon, activeIcon, label, badge, ...props }, ref) => {
const context = useContext(BottomNavigationContext)
const isActive = context.activeValue === value
const handleClick = () => {
context.onValueChange?.(value)
}
return (
<button
ref={ref}
data-slot="bottom-navigation-item"
data-active={isActive}
className={cn(
// Container
"relative flex flex-col items-center justify-center",
"min-w-[64px] h-full px-3 py-2",
"bg-transparent border-0 cursor-pointer",
"transition-all duration-200",
"outline-none",
// Focus state
"focus-visible:outline-none",
className
)}
onClick={handleClick}
{...props}
>
{/* Active indicator pill */}
<span
className={cn(
"absolute top-2 w-16 h-8 rounded-full",
"bg-primary/20",
"transform transition-all duration-200",
isActive ? "scale-100 opacity-100" : "scale-75 opacity-0"
)}
/>
{/* Icon container */}
<span
className={cn(
"relative z-10 flex items-center justify-center",
"w-6 h-6 mb-1",
"[&_svg]:size-6",
"transition-colors duration-200",
isActive ? "text-primary" : "text-muted-foreground"
)}
>
{isActive && activeIcon ? activeIcon : icon}
{/* Badge */}
{badge !== undefined && badge !== false && (
<span
className={cn(
"absolute -top-1 -right-2",
"flex items-center justify-center",
"min-w-[16px] h-4 px-1",
"bg-destructive text-destructive-foreground",
"text-[10px] font-medium",
"rounded-full"
)}
>
{typeof badge === "number" ? (badge > 99 ? "99+" : badge) : ""}
</span>
)}
</span>
{/* Label */}
<span
className={cn(
"relative z-10 text-xs font-medium",
"transition-colors duration-200",
isActive ? "text-primary" : "text-muted-foreground"
)}
>
{label}
</span>
</button>
)
}
)
BottomNavigationItem.displayName = "BottomNavigationItem"
export { BottomNavigation, BottomNavigationItem }

View File

@@ -4,28 +4,111 @@ import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
/**
* Material Design 3 Button Component
*
* Variants:
* - filled (default): High emphasis, primary actions (MD3 Filled Button)
* - tonal: Medium emphasis, secondary actions (MD3 Filled Tonal Button)
* - elevated: Medium emphasis with shadow (MD3 Elevated Button)
* - outlined: Low emphasis, secondary actions (MD3 Outlined Button)
* - text: Lowest emphasis, tertiary actions (MD3 Text Button)
* - destructive: Error/danger actions
* - ghost: Legacy support
* - link: Text link style
*/
const buttonVariants = cva(
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
// Base styles - MD3 common button properties
[
"inline-flex items-center justify-center gap-2 whitespace-nowrap",
"rounded-full text-sm font-medium",
"transition-all duration-200",
"disabled:pointer-events-none disabled:opacity-[0.38]",
"[&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-[18px]",
"shrink-0 [&_svg]:shrink-0",
"outline-none focus-visible:ring-2 focus-visible:ring-primary focus-visible:ring-offset-2 focus-visible:ring-offset-background",
"relative overflow-hidden select-none",
// State layer using pseudo-element
"before:absolute before:inset-0 before:rounded-[inherit] before:bg-current before:opacity-0 before:transition-opacity before:duration-200",
"hover:before:opacity-[0.08] focus-visible:before:opacity-[0.12] active:before:opacity-[0.12]",
].join(" "),
{
variants: {
variant: {
default:
"bg-primary text-primary-foreground shadow-xs hover:bg-primary/90",
destructive:
"bg-destructive text-white shadow-xs hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
outline:
"border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50",
secondary:
"bg-secondary text-secondary-foreground shadow-xs hover:bg-secondary/80",
ghost:
"hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50",
link: "text-primary underline-offset-4 hover:underline",
// MD3 Filled Button - Primary, highest emphasis
default: [
"bg-primary text-primary-foreground",
"shadow-sm",
"hover:shadow-md",
"active:shadow-sm",
].join(" "),
// MD3 Filled Tonal Button - Secondary, medium emphasis
tonal: [
"bg-secondary text-secondary-foreground",
"hover:shadow-sm",
].join(" "),
// MD3 Elevated Button - Medium emphasis with elevation
elevated: [
"bg-card text-primary",
"shadow-md",
"hover:shadow-lg",
"active:shadow-md",
].join(" "),
// MD3 Outlined Button - Low emphasis
outline: [
"border border-border bg-transparent text-primary",
"hover:bg-primary/[0.08]",
].join(" "),
// MD3 Text Button - Lowest emphasis
text: [
"bg-transparent text-primary",
"hover:bg-primary/[0.08]",
"px-3",
].join(" "),
// Destructive/Error variant
destructive: [
"bg-destructive text-destructive-foreground",
"shadow-sm",
"hover:shadow-md",
"focus-visible:ring-destructive",
].join(" "),
// Legacy secondary (maps to tonal)
secondary: [
"bg-secondary text-secondary-foreground",
"hover:shadow-sm",
].join(" "),
// Ghost - for icon buttons and minimal UI
ghost: [
"bg-transparent text-muted-foreground",
"hover:bg-muted hover:text-foreground",
].join(" "),
// Link style
link: [
"text-primary underline-offset-4 hover:underline",
"bg-transparent",
].join(" "),
},
size: {
default: "h-9 px-4 py-2 has-[>svg]:px-3",
sm: "h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5",
lg: "h-10 rounded-md px-6 has-[>svg]:px-4",
icon: "size-9",
// MD3 standard button height is 40dp
default: "h-10 px-6 py-2.5 min-w-[64px] has-[>svg]:px-4",
// Compact size
sm: "h-8 px-4 gap-1.5 text-xs has-[>svg]:px-3",
// Large for prominent actions
lg: "h-12 px-8 text-base has-[>svg]:px-6",
// Icon button (MD3 standard icon button is 40x40)
icon: "size-10 p-0 min-w-0 rounded-full",
// Small icon button
"icon-sm": "size-8 p-0 min-w-0 rounded-full",
// Large icon button
"icon-lg": "size-12 p-0 min-w-0 rounded-full",
},
},
defaultVariants: {

View File

@@ -1,15 +1,58 @@
import { ComponentProps } from "react"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
function Card({ className, ...props }: ComponentProps<"div">) {
/**
* Material Design 3 Card Component
*
* Variants:
* - elevated: Default card with shadow (MD3 Elevated Card)
* - filled: Card with tonal surface color (MD3 Filled Card)
* - outlined: Card with outline border (MD3 Outlined Card)
*/
const cardVariants = cva(
// Base styles
[
"flex flex-col gap-4 rounded-xl text-card-foreground",
"transition-all duration-200",
"relative overflow-hidden",
].join(" "),
{
variants: {
variant: {
// MD3 Elevated Card - default surface with shadow
elevated: [
"bg-card shadow-md",
"hover:shadow-lg",
].join(" "),
// MD3 Filled Card - surface container color, no shadow
filled: [
"bg-secondary/50",
].join(" "),
// MD3 Outlined Card - transparent with border
outlined: [
"bg-card border border-border",
].join(" "),
},
},
defaultVariants: {
variant: "elevated",
},
}
)
function Card({
className,
variant,
...props
}: ComponentProps<"div"> & VariantProps<typeof cardVariants>) {
return (
<div
data-slot="card"
className={cn(
"bg-card text-card-foreground flex flex-col gap-6 rounded-xl border py-6 shadow-sm",
className
)}
className={cn(cardVariants({ variant }), className)}
{...props}
/>
)
@@ -20,7 +63,7 @@ function CardHeader({ className, ...props }: ComponentProps<"div">) {
<div
data-slot="card-header"
className={cn(
"@container/card-header grid auto-rows-min grid-rows-[auto_auto] items-start gap-1.5 px-6 has-data-[slot=card-action]:grid-cols-[1fr_auto] [.border-b]:pb-6",
"@container/card-header grid auto-rows-min grid-rows-[auto_auto] items-start gap-1 px-4 pt-4 has-data-[slot=card-action]:grid-cols-[1fr_auto] [.border-b]:pb-4",
className
)}
{...props}
@@ -32,7 +75,10 @@ function CardTitle({ className, ...props }: ComponentProps<"div">) {
return (
<div
data-slot="card-title"
className={cn("leading-none font-semibold", className)}
className={cn(
"text-base font-medium leading-tight tracking-tight",
className
)}
{...props}
/>
)
@@ -42,7 +88,7 @@ function CardDescription({ className, ...props }: ComponentProps<"div">) {
return (
<div
data-slot="card-description"
className={cn("text-muted-foreground text-sm", className)}
className={cn("text-muted-foreground text-sm leading-relaxed", className)}
{...props}
/>
)
@@ -65,7 +111,7 @@ function CardContent({ className, ...props }: ComponentProps<"div">) {
return (
<div
data-slot="card-content"
className={cn("px-6", className)}
className={cn("px-4 pb-2", className)}
{...props}
/>
)
@@ -75,7 +121,30 @@ function CardFooter({ className, ...props }: ComponentProps<"div">) {
return (
<div
data-slot="card-footer"
className={cn("flex items-center px-6 [.border-t]:pt-6", className)}
className={cn(
"flex items-center gap-2 px-4 pb-4 pt-2 [.border-t]:pt-4",
className
)}
{...props}
/>
)
}
/**
* MD3 Card Media - for images/videos at top of card
*/
function CardMedia({
className,
...props
}: ComponentProps<"div">) {
return (
<div
data-slot="card-media"
className={cn(
"relative -mx-0 -mt-0 overflow-hidden rounded-t-xl",
"aspect-video bg-muted",
className
)}
{...props}
/>
)
@@ -89,4 +158,6 @@ export {
CardAction,
CardDescription,
CardContent,
CardMedia,
cardVariants,
}

166
src/components/ui/chip.tsx Normal file
View File

@@ -0,0 +1,166 @@
'use client'
import { ComponentProps, forwardRef } from "react"
import { cva, type VariantProps } from "class-variance-authority"
import { X } from "@phosphor-icons/react"
import { cn } from "@/lib/utils"
/**
* Material Design 3 Chip Component
*
* Types:
* - assist: Actions in context (Assist Chip)
* - filter: Toggleable filters (Filter Chip)
* - input: User input, removable (Input Chip)
* - suggestion: Dynamic suggestions (Suggestion Chip)
*/
const chipVariants = cva(
// Base styles
[
"inline-flex items-center justify-center gap-2",
"h-8 px-4 rounded-lg",
"text-sm font-medium",
"transition-all duration-200",
"cursor-pointer select-none",
"outline-none",
"relative overflow-hidden",
// State layer
"before:absolute before:inset-0 before:rounded-[inherit] before:bg-current before:opacity-0 before:transition-opacity before:duration-200",
"hover:before:opacity-[0.08] focus-visible:before:opacity-[0.12] active:before:opacity-[0.12]",
// Focus ring
"focus-visible:ring-2 focus-visible:ring-primary focus-visible:ring-offset-2 focus-visible:ring-offset-background",
// Disabled
"disabled:pointer-events-none disabled:opacity-[0.38]",
// Icon sizing
"[&_svg]:size-[18px] [&_svg]:shrink-0",
].join(" "),
{
variants: {
variant: {
// Assist Chip - outlined, for contextual actions
assist: [
"border border-border bg-transparent text-foreground",
"hover:bg-muted/50",
].join(" "),
// Filter Chip - toggleable, outlined or filled when selected
filter: [
"border border-border bg-transparent text-foreground",
"data-[selected=true]:bg-secondary data-[selected=true]:border-transparent data-[selected=true]:text-secondary-foreground",
].join(" "),
// Input Chip - user input, removable
input: [
"border border-border bg-transparent text-foreground",
"pr-2",
].join(" "),
// Suggestion Chip - outlined, for suggestions
suggestion: [
"border border-border bg-transparent text-foreground",
"hover:bg-muted/50",
].join(" "),
// Filled variant
filled: [
"bg-secondary text-secondary-foreground border-transparent",
].join(" "),
},
size: {
default: "h-8 px-4 text-sm",
sm: "h-6 px-3 text-xs",
},
},
defaultVariants: {
variant: "assist",
size: "default",
},
}
)
interface ChipProps
extends ComponentProps<"button">,
VariantProps<typeof chipVariants> {
leadingIcon?: React.ReactNode
selected?: boolean
onRemove?: () => void
}
const Chip = forwardRef<HTMLButtonElement, ChipProps>(
(
{
className,
variant,
size,
leadingIcon,
selected,
onRemove,
children,
...props
},
ref
) => {
const isInput = variant === "input"
const isFilter = variant === "filter"
return (
<button
ref={ref}
data-slot="chip"
data-selected={isFilter ? selected : undefined}
className={cn(chipVariants({ variant, size }), className)}
{...props}
>
{isFilter && selected && (
<svg
width="18"
height="18"
viewBox="0 0 24 24"
fill="none"
className="shrink-0"
>
<path
d="M9 16.17L4.83 12l-1.42 1.41L9 19 21 7l-1.41-1.41L9 16.17z"
fill="currentColor"
/>
</svg>
)}
{leadingIcon && !isFilter && leadingIcon}
<span className="relative z-10">{children}</span>
{isInput && onRemove && (
<button
type="button"
onClick={(e) => {
e.stopPropagation()
onRemove()
}}
className={cn(
"relative z-10 flex items-center justify-center",
"size-[18px] rounded-full",
"hover:bg-foreground/10",
"transition-colors duration-200"
)}
>
<X weight="bold" className="size-3" />
</button>
)}
</button>
)
}
)
Chip.displayName = "Chip"
/**
* Chip Group container
*/
function ChipGroup({ className, ...props }: ComponentProps<"div">) {
return (
<div
data-slot="chip-group"
className={cn("flex flex-wrap gap-2", className)}
{...props}
/>
)
}
export { Chip, ChipGroup, chipVariants }

View File

@@ -8,6 +8,15 @@ import CircleIcon from "lucide-react/dist/esm/icons/circle"
import { cn } from "@/lib/utils"
/**
* Material Design 3 Dropdown Menu
*
* Android-style menu with:
* - 48dp minimum touch targets
* - Proper state layers
* - Clear focus indicators
*/
function DropdownMenu({
...props
}: ComponentProps<typeof DropdownMenuPrimitive.Root>) {
@@ -35,7 +44,7 @@ function DropdownMenuTrigger({
function DropdownMenuContent({
className,
sideOffset = 4,
sideOffset = 8,
...props
}: ComponentProps<typeof DropdownMenuPrimitive.Content>) {
return (
@@ -44,7 +53,22 @@ function DropdownMenuContent({
data-slot="dropdown-menu-content"
sideOffset={sideOffset}
className={cn(
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 max-h-(--radix-dropdown-menu-content-available-height) min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border p-1 shadow-md",
// MD3 surface container
"bg-[hsl(var(--popover))] text-[hsl(var(--popover-foreground))]",
// Shape and shadow
"z-50 min-w-[180px] overflow-hidden rounded-lg border-0 p-1",
"shadow-lg",
// Constrain height and enable scrolling
"max-h-[min(var(--radix-dropdown-menu-content-available-height),400px)]",
"overflow-y-auto",
// Animations
"data-[state=open]:animate-in data-[state=closed]:animate-out",
"data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
"data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95",
"data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2",
"data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
// Transform origin
"origin-[var(--radix-dropdown-menu-content-transform-origin)]",
className
)}
{...props}
@@ -76,7 +100,29 @@ function DropdownMenuItem({
data-inset={inset}
data-variant={variant}
className={cn(
"focus:bg-accent focus:text-accent-foreground data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 dark:data-[variant=destructive]:focus:bg-destructive/20 data-[variant=destructive]:focus:text-destructive data-[variant=destructive]:*:[svg]:!text-destructive [&_svg:not([class*='text-'])]:text-muted-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
// MD3 menu item - minimum 48dp height for touch
"relative flex items-center gap-3",
"min-h-[48px] px-3 py-2",
"text-sm font-normal",
"rounded-md cursor-pointer select-none outline-none",
// State layer
"transition-colors duration-150",
// Default colors
"text-[hsl(var(--popover-foreground))]",
// Hover/focus state
"hover:bg-[hsl(var(--muted))]",
"focus:bg-[hsl(var(--muted))]",
// Destructive variant
"data-[variant=destructive]:text-destructive",
"data-[variant=destructive]:hover:bg-destructive/10",
"data-[variant=destructive]:focus:bg-destructive/10",
// Disabled state
"data-[disabled]:pointer-events-none data-[disabled]:opacity-[0.38]",
// Inset for items without icons
"data-[inset]:pl-10",
// Icon styling
"[&_svg]:size-5 [&_svg]:shrink-0 [&_svg]:text-[hsl(var(--muted-foreground))]",
"[&_svg]:pointer-events-none",
className
)}
{...props}
@@ -94,15 +140,24 @@ function DropdownMenuCheckboxItem({
<DropdownMenuPrimitive.CheckboxItem
data-slot="dropdown-menu-checkbox-item"
className={cn(
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
// MD3 checkbox item
"relative flex items-center gap-3",
"min-h-[48px] pl-10 pr-3 py-2",
"text-sm font-normal",
"rounded-md cursor-pointer select-none outline-none",
"transition-colors duration-150",
"hover:bg-[hsl(var(--muted))]",
"focus:bg-[hsl(var(--muted))]",
"data-[disabled]:pointer-events-none data-[disabled]:opacity-[0.38]",
"[&_svg]:size-5 [&_svg]:shrink-0",
className
)}
checked={checked}
{...props}
>
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
<span className="absolute left-3 flex size-5 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator>
<CheckIcon className="size-4" />
<CheckIcon className="size-5 text-primary" />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
@@ -130,14 +185,23 @@ function DropdownMenuRadioItem({
<DropdownMenuPrimitive.RadioItem
data-slot="dropdown-menu-radio-item"
className={cn(
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
// MD3 radio item
"relative flex items-center gap-3",
"min-h-[48px] pl-10 pr-3 py-2",
"text-sm font-normal",
"rounded-md cursor-pointer select-none outline-none",
"transition-colors duration-150",
"hover:bg-[hsl(var(--muted))]",
"focus:bg-[hsl(var(--muted))]",
"data-[disabled]:pointer-events-none data-[disabled]:opacity-[0.38]",
"[&_svg]:size-5 [&_svg]:shrink-0",
className
)}
{...props}
>
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
<span className="absolute left-3 flex size-5 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator>
<CircleIcon className="size-2 fill-current" />
<CircleIcon className="size-2 fill-primary text-primary" />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
@@ -157,7 +221,10 @@ function DropdownMenuLabel({
data-slot="dropdown-menu-label"
data-inset={inset}
className={cn(
"px-2 py-1.5 text-sm font-medium data-[inset]:pl-8",
// MD3 label style
"px-3 py-2 text-xs font-medium uppercase tracking-wider",
"text-[hsl(var(--muted-foreground))]",
"data-[inset]:pl-10",
className
)}
{...props}
@@ -172,7 +239,7 @@ function DropdownMenuSeparator({
return (
<DropdownMenuPrimitive.Separator
data-slot="dropdown-menu-separator"
className={cn("bg-border -mx-1 my-1 h-px", className)}
className={cn("my-1 h-px bg-[hsl(var(--border))]", className)}
{...props}
/>
)
@@ -186,7 +253,7 @@ function DropdownMenuShortcut({
<span
data-slot="dropdown-menu-shortcut"
className={cn(
"text-muted-foreground ml-auto text-xs tracking-widest",
"ml-auto text-xs text-[hsl(var(--muted-foreground))] tracking-widest",
className
)}
{...props}
@@ -213,13 +280,22 @@ function DropdownMenuSubTrigger({
data-slot="dropdown-menu-sub-trigger"
data-inset={inset}
className={cn(
"focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground flex cursor-default items-center rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[inset]:pl-8",
// MD3 sub-menu trigger
"relative flex items-center gap-3",
"min-h-[48px] px-3 py-2",
"text-sm font-normal",
"rounded-md cursor-pointer select-none outline-none",
"transition-colors duration-150",
"hover:bg-[hsl(var(--muted))]",
"focus:bg-[hsl(var(--muted))]",
"data-[state=open]:bg-[hsl(var(--muted))]",
"data-[inset]:pl-10",
className
)}
{...props}
>
{children}
<ChevronRightIcon className="ml-auto size-4" />
<ChevronRightIcon className="ml-auto size-5 text-[hsl(var(--muted-foreground))]" />
</DropdownMenuPrimitive.SubTrigger>
)
}
@@ -232,7 +308,17 @@ function DropdownMenuSubContent({
<DropdownMenuPrimitive.SubContent
data-slot="dropdown-menu-sub-content"
className={cn(
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-hidden rounded-md border p-1 shadow-lg",
// MD3 sub-menu surface
"bg-[hsl(var(--popover))] text-[hsl(var(--popover-foreground))]",
"z-50 min-w-[180px] overflow-hidden rounded-lg border-0 p-1",
"shadow-lg",
// Animations
"data-[state=open]:animate-in data-[state=closed]:animate-out",
"data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
"data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95",
"data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2",
"data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
"origin-[var(--radix-dropdown-menu-content-transform-origin)]",
className
)}
{...props}

172
src/components/ui/fab.tsx Normal file
View File

@@ -0,0 +1,172 @@
'use client'
import { ComponentProps, forwardRef } from "react"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
/**
* Material Design 3 Floating Action Button (FAB)
*
* Variants:
* - primary: Primary container color (default)
* - secondary: Secondary container color
* - tertiary: Tertiary container color
* - surface: Surface container color
*
* Sizes:
* - small: 40x40 (MD3 Small FAB)
* - default: 56x56 (MD3 FAB)
* - large: 96x96 (MD3 Large FAB)
*/
const fabVariants = cva(
// Base styles
[
"inline-flex items-center justify-center",
"rounded-2xl font-medium",
"transition-all duration-200",
"disabled:pointer-events-none disabled:opacity-[0.38]",
"[&_svg]:pointer-events-none",
"shrink-0 [&_svg]:shrink-0",
"outline-none focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:ring-offset-background",
"relative overflow-hidden select-none",
"shadow-lg hover:shadow-xl active:shadow-md",
// State layer
"before:absolute before:inset-0 before:rounded-[inherit] before:bg-current before:opacity-0 before:transition-opacity before:duration-200",
"hover:before:opacity-[0.08] focus-visible:before:opacity-[0.12] active:before:opacity-[0.12]",
].join(" "),
{
variants: {
variant: {
// Primary container (default)
primary: [
"bg-primary text-primary-foreground",
"focus-visible:ring-primary",
].join(" "),
// Secondary container
secondary: [
"bg-secondary text-secondary-foreground",
"focus-visible:ring-secondary",
].join(" "),
// Tertiary container
tertiary: [
"bg-accent text-accent-foreground",
"focus-visible:ring-accent",
].join(" "),
// Surface container
surface: [
"bg-card text-primary",
"focus-visible:ring-primary",
].join(" "),
},
size: {
// MD3 Small FAB - 40x40
small: "size-10 rounded-xl [&_svg]:size-5",
// MD3 FAB - 56x56
default: "size-14 rounded-2xl [&_svg]:size-6",
// MD3 Large FAB - 96x96
large: "size-24 rounded-[28px] [&_svg]:size-9",
},
},
defaultVariants: {
variant: "primary",
size: "default",
},
}
)
interface FABProps
extends ComponentProps<"button">,
VariantProps<typeof fabVariants> {
icon: React.ReactNode
}
const FAB = forwardRef<HTMLButtonElement, FABProps>(
({ className, variant, size, icon, ...props }, ref) => {
return (
<button
ref={ref}
data-slot="fab"
className={cn(fabVariants({ variant, size }), className)}
{...props}
>
{icon}
</button>
)
}
)
FAB.displayName = "FAB"
/**
* Extended FAB with label
*/
interface ExtendedFABProps
extends ComponentProps<"button">,
VariantProps<typeof fabVariants> {
icon?: React.ReactNode
label: string
}
const extendedFabVariants = cva(
// Base styles
[
"inline-flex items-center justify-center gap-3",
"h-14 px-4 rounded-2xl font-medium text-sm",
"transition-all duration-200",
"disabled:pointer-events-none disabled:opacity-[0.38]",
"[&_svg]:pointer-events-none [&_svg]:size-6",
"shrink-0 [&_svg]:shrink-0",
"outline-none focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:ring-offset-background",
"relative overflow-hidden select-none",
"shadow-lg hover:shadow-xl active:shadow-md",
// State layer
"before:absolute before:inset-0 before:rounded-[inherit] before:bg-current before:opacity-0 before:transition-opacity before:duration-200",
"hover:before:opacity-[0.08] focus-visible:before:opacity-[0.12] active:before:opacity-[0.12]",
].join(" "),
{
variants: {
variant: {
primary: [
"bg-primary text-primary-foreground",
"focus-visible:ring-primary",
].join(" "),
secondary: [
"bg-secondary text-secondary-foreground",
"focus-visible:ring-secondary",
].join(" "),
tertiary: [
"bg-accent text-accent-foreground",
"focus-visible:ring-accent",
].join(" "),
surface: [
"bg-card text-primary",
"focus-visible:ring-primary",
].join(" "),
},
},
defaultVariants: {
variant: "primary",
},
}
)
const ExtendedFAB = forwardRef<HTMLButtonElement, ExtendedFABProps>(
({ className, variant, icon, label, ...props }, ref) => {
return (
<button
ref={ref}
data-slot="extended-fab"
className={cn(extendedFabVariants({ variant }), className)}
{...props}
>
{icon}
<span>{label}</span>
</button>
)
}
)
ExtendedFAB.displayName = "ExtendedFAB"
export { FAB, ExtendedFAB, fabVariants, extendedFabVariants }

View File

@@ -1,16 +1,42 @@
import { ComponentProps } from "react"
import { ComponentProps, forwardRef, useId, useState } from "react"
import { cn } from "@/lib/utils"
/**
* Material Design 3 Text Field Component
*
* Standard input with MD3 styling including:
* - Filled and outlined variants
* - Proper focus states with animated indicator
* - Support for leading/trailing icons
* - Helper text and error states
*/
function Input({ className, type, ...props }: ComponentProps<"input">) {
return (
<input
type={type}
data-slot="input"
className={cn(
"file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground dark:bg-input/30 border-input flex h-9 w-full min-w-0 rounded-md border bg-transparent px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
"focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]",
"aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
// Base styles
"flex h-14 w-full min-w-0 rounded-t-[4px] rounded-b-none",
"bg-[hsl(var(--muted))] px-4 pt-5 pb-2",
"text-base text-foreground",
"border-0 border-b-2 border-[hsl(var(--border))]",
"transition-all duration-200",
"outline-none",
// Placeholder
"placeholder:text-muted-foreground placeholder:opacity-0",
"focus:placeholder:opacity-100",
// Focus state - MD3 style active indicator
"focus:border-b-primary focus:bg-[hsl(var(--muted))]/80",
// File input styles
"file:text-foreground file:inline-flex file:h-8 file:border-0 file:bg-transparent file:text-sm file:font-medium",
// Selection
"selection:bg-primary/30 selection:text-foreground",
// Disabled state
"disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-[0.38]",
// Error state
"aria-invalid:border-b-destructive aria-invalid:focus:border-b-destructive",
className
)}
{...props}
@@ -18,4 +44,172 @@ function Input({ className, type, ...props }: ComponentProps<"input">) {
)
}
export { Input }
/**
* MD3 Outlined Text Field
*/
function InputOutlined({ className, type, ...props }: ComponentProps<"input">) {
return (
<input
type={type}
data-slot="input-outlined"
className={cn(
// Base styles
"flex h-14 w-full min-w-0 rounded-[4px]",
"bg-transparent px-4 py-4",
"text-base text-foreground",
"border border-[hsl(var(--border))]",
"transition-all duration-200",
"outline-none",
// Placeholder
"placeholder:text-muted-foreground",
// Focus state
"focus:border-2 focus:border-primary",
// Selection
"selection:bg-primary/30 selection:text-foreground",
// Disabled state
"disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-[0.38]",
// Error state
"aria-invalid:border-destructive aria-invalid:focus:border-destructive",
className
)}
{...props}
/>
)
}
/**
* MD3 Text Field with Floating Label
*/
interface TextFieldProps extends ComponentProps<"input"> {
label: string
helperText?: string
error?: boolean
errorText?: string
leadingIcon?: React.ReactNode
trailingIcon?: React.ReactNode
variant?: "filled" | "outlined"
}
const TextField = forwardRef<HTMLInputElement, TextFieldProps>(
(
{
className,
label,
helperText,
error,
errorText,
leadingIcon,
trailingIcon,
variant = "filled",
id,
...props
},
ref
) => {
const generatedId = useId()
const inputId = id || generatedId
const [isFocused, setIsFocused] = useState(false)
const [hasValue, setHasValue] = useState(
Boolean(props.value || props.defaultValue)
)
const isLabelFloating = isFocused || hasValue
const handleFocus = (e: React.FocusEvent<HTMLInputElement>) => {
setIsFocused(true)
props.onFocus?.(e)
}
const handleBlur = (e: React.FocusEvent<HTMLInputElement>) => {
setIsFocused(false)
props.onBlur?.(e)
}
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
setHasValue(Boolean(e.target.value))
props.onChange?.(e)
}
const isFilled = variant === "filled"
return (
<div className={cn("relative w-full", className)}>
<div
className={cn(
"relative flex items-center",
isFilled
? "rounded-t-[4px] rounded-b-none bg-[hsl(var(--muted))]"
: "rounded-[4px] bg-transparent",
isFilled
? "border-0 border-b-2"
: "border",
error
? "border-destructive"
: isFocused
? isFilled
? "border-b-primary"
: "border-2 border-primary"
: "border-[hsl(var(--border))]",
"transition-all duration-200"
)}
>
{leadingIcon && (
<span className="pl-3 text-muted-foreground">{leadingIcon}</span>
)}
<div className="relative flex-1">
<input
ref={ref}
id={inputId}
data-slot="text-field"
className={cn(
"peer w-full bg-transparent outline-none",
"h-14 px-4 pt-5 pb-2 text-base text-foreground",
"placeholder:text-transparent focus:placeholder:text-muted-foreground",
"disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-[0.38]",
leadingIcon && "pl-2",
trailingIcon && "pr-2"
)}
placeholder={label}
aria-invalid={error}
onFocus={handleFocus}
onBlur={handleBlur}
onChange={handleChange}
{...props}
/>
<label
htmlFor={inputId}
className={cn(
"absolute left-4 transition-all duration-200 pointer-events-none",
"text-muted-foreground",
isLabelFloating
? "top-2 text-xs"
: "top-1/2 -translate-y-1/2 text-base",
isFocused && !error && "text-primary",
error && "text-destructive",
leadingIcon && "left-12"
)}
>
{label}
</label>
</div>
{trailingIcon && (
<span className="pr-3 text-muted-foreground">{trailingIcon}</span>
)}
</div>
{(helperText || errorText) && (
<p
className={cn(
"mt-1 px-4 text-xs",
error ? "text-destructive" : "text-muted-foreground"
)}
>
{error ? errorText : helperText}
</p>
)}
</div>
)
}
)
TextField.displayName = "TextField"
export { Input, InputOutlined, TextField }

View File

@@ -5,6 +5,15 @@ import * as PopoverPrimitive from "@radix-ui/react-popover"
import { cn } from "@/lib/utils"
/**
* Material Design 3 Popover
*
* Android-style popover with:
* - Proper elevation and shadow
* - Smooth animations
* - Accessible focus management
*/
function Popover({
...props
}: ComponentProps<typeof PopoverPrimitive.Root>) {
@@ -20,7 +29,7 @@ function PopoverTrigger({
function PopoverContent({
className,
align = "center",
sideOffset = 4,
sideOffset = 8,
...props
}: ComponentProps<typeof PopoverPrimitive.Content>) {
return (
@@ -30,7 +39,23 @@ function PopoverContent({
align={align}
sideOffset={sideOffset}
className={cn(
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-72 origin-(--radix-popover-content-transform-origin) rounded-md border p-4 shadow-md outline-hidden",
// MD3 surface container high
"bg-[hsl(var(--popover))] text-[hsl(var(--popover-foreground))]",
// Shape and shadow
"z-50 w-72 rounded-xl border-0 p-4",
"shadow-lg",
// Animations
"data-[state=open]:animate-in data-[state=closed]:animate-out",
"data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
"data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95",
"data-[side=bottom]:slide-in-from-top-2",
"data-[side=left]:slide-in-from-right-2",
"data-[side=right]:slide-in-from-left-2",
"data-[side=top]:slide-in-from-bottom-2",
// Transform origin
"origin-[var(--radix-popover-content-transform-origin)]",
// Focus
"outline-none",
className
)}
{...props}
@@ -45,4 +70,29 @@ function PopoverAnchor({
return <PopoverPrimitive.Anchor data-slot="popover-anchor" {...props} />
}
export { Popover, PopoverTrigger, PopoverContent, PopoverAnchor }
/**
* PopoverClose - Close button for popover
*/
function PopoverClose({
className,
...props
}: ComponentProps<typeof PopoverPrimitive.Close>) {
return (
<PopoverPrimitive.Close
data-slot="popover-close"
className={cn(
"absolute right-2 top-2",
"flex items-center justify-center",
"size-8 rounded-full",
"text-[hsl(var(--muted-foreground))]",
"hover:bg-[hsl(var(--muted))]",
"focus:outline-none focus:ring-2 focus:ring-primary",
"transition-colors duration-150",
className
)}
{...props}
/>
)
}
export { Popover, PopoverTrigger, PopoverContent, PopoverAnchor, PopoverClose }

View File

@@ -5,6 +5,15 @@ import * as SwitchPrimitive from "@radix-ui/react-switch"
import { cn } from "@/lib/utils"
/**
* Material Design 3 Switch Component
*
* Android-style toggle switch with:
* - 52x32 track (MD3 spec)
* - 24x24 handle when unchecked, 28x28 when checked
* - State layer on handle
* - Proper focus ring
*/
function Switch({
className,
...props
@@ -13,7 +22,20 @@ function Switch({
<SwitchPrimitive.Root
data-slot="switch"
className={cn(
"peer data-[state=checked]:bg-primary data-[state=unchecked]:bg-input focus-visible:border-ring focus-visible:ring-ring/50 dark:data-[state=unchecked]:bg-input/80 inline-flex h-[1.15rem] w-8 shrink-0 items-center rounded-full border border-transparent shadow-xs transition-all outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50",
// Track dimensions (MD3: 52x32)
"inline-flex h-8 w-[52px] shrink-0 items-center rounded-full",
"border-2 border-transparent",
"transition-colors duration-200",
"outline-none cursor-pointer",
// Unchecked track
"data-[state=unchecked]:bg-muted",
"data-[state=unchecked]:border-border",
// Checked track
"data-[state=checked]:bg-primary",
// Focus state
"focus-visible:ring-2 focus-visible:ring-primary focus-visible:ring-offset-2 focus-visible:ring-offset-background",
// Disabled state
"disabled:cursor-not-allowed disabled:opacity-[0.38]",
className
)}
{...props}
@@ -21,7 +43,23 @@ function Switch({
<SwitchPrimitive.Thumb
data-slot="switch-thumb"
className={cn(
"bg-background dark:data-[state=unchecked]:bg-foreground dark:data-[state=checked]:bg-primary-foreground pointer-events-none block size-4 rounded-full ring-0 transition-transform data-[state=checked]:translate-x-[calc(100%-2px)] data-[state=unchecked]:translate-x-0"
// Thumb base
"pointer-events-none block rounded-full",
"shadow-md",
"transition-all duration-200",
// Unchecked: smaller thumb (24x24), positioned left
"data-[state=unchecked]:size-6",
"data-[state=unchecked]:translate-x-0.5",
"data-[state=unchecked]:bg-muted-foreground",
// Checked: larger thumb (28x28), positioned right
"data-[state=checked]:size-7",
"data-[state=checked]:translate-x-[22px]",
"data-[state=checked]:bg-primary-foreground",
// State layer effect on hover (via parent)
"relative",
"before:absolute before:inset-[-8px] before:rounded-full",
"before:bg-current before:opacity-0 before:transition-opacity",
"group-hover:before:opacity-[0.08]"
)}
/>
</SwitchPrimitive.Root>

View File

@@ -0,0 +1,184 @@
'use client'
import { ComponentProps, forwardRef } from "react"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
/**
* Material Design 3 Top App Bar
*
* Variants:
* - center-aligned: Logo/title centered (default)
* - small: Standard top app bar
* - medium: Medium with larger title
* - large: Large with prominent title
*/
const topAppBarVariants = cva(
// Base styles
[
"w-full",
"bg-[hsl(var(--card))]",
"transition-all duration-200",
].join(" "),
{
variants: {
variant: {
"center-aligned": "h-16",
small: "h-16",
medium: "h-28",
large: "h-36",
},
scrolled: {
true: "shadow-md bg-[hsl(var(--card))]/95 backdrop-blur-sm",
false: "shadow-none",
},
},
defaultVariants: {
variant: "small",
scrolled: false,
},
}
)
interface TopAppBarProps
extends ComponentProps<"header">,
VariantProps<typeof topAppBarVariants> {
navigationIcon?: React.ReactNode
onNavigationClick?: () => void
title?: string
actions?: React.ReactNode
}
const TopAppBar = forwardRef<HTMLElement, TopAppBarProps>(
(
{
className,
variant,
scrolled,
navigationIcon,
onNavigationClick,
title,
actions,
children,
...props
},
ref
) => {
const isCenterAligned = variant === "center-aligned"
const isMediumOrLarge = variant === "medium" || variant === "large"
return (
<header
ref={ref}
data-slot="top-app-bar"
className={cn(topAppBarVariants({ variant, scrolled }), className)}
{...props}
>
<div
className={cn(
"flex items-center h-16 px-1",
isCenterAligned && "justify-between"
)}
>
{/* Leading navigation icon */}
{navigationIcon && (
<button
type="button"
onClick={onNavigationClick}
className={cn(
"flex items-center justify-center",
"size-12 rounded-full",
"text-foreground",
"hover:bg-foreground/[0.08]",
"focus-visible:bg-foreground/[0.12]",
"active:bg-foreground/[0.12]",
"transition-colors duration-200",
"outline-none"
)}
>
{navigationIcon}
</button>
)}
{/* Title - for small and center-aligned */}
{!isMediumOrLarge && title && (
<h1
className={cn(
"text-lg font-normal text-foreground",
"truncate",
isCenterAligned
? "absolute left-1/2 -translate-x-1/2"
: "ml-2 flex-1"
)}
>
{title}
</h1>
)}
{/* Trailing actions */}
{actions && (
<div className={cn("flex items-center gap-1", !isCenterAligned && "ml-auto")}>
{actions}
</div>
)}
</div>
{/* Title for medium and large variants */}
{isMediumOrLarge && title && (
<div className="px-4 pb-4">
<h1
className={cn(
"text-foreground font-normal",
variant === "medium" && "text-2xl",
variant === "large" && "text-3xl"
)}
>
{title}
</h1>
</div>
)}
{children}
</header>
)
}
)
TopAppBar.displayName = "TopAppBar"
/**
* Top App Bar Action Button
*/
interface TopAppBarActionProps extends ComponentProps<"button"> {
icon: React.ReactNode
}
const TopAppBarAction = forwardRef<HTMLButtonElement, TopAppBarActionProps>(
({ className, icon, ...props }, ref) => {
return (
<button
ref={ref}
type="button"
data-slot="top-app-bar-action"
className={cn(
"flex items-center justify-center",
"size-12 rounded-full",
"text-foreground",
"hover:bg-foreground/[0.08]",
"focus-visible:bg-foreground/[0.12]",
"active:bg-foreground/[0.12]",
"transition-colors duration-200",
"outline-none",
"[&_svg]:size-6",
className
)}
{...props}
>
{icon}
</button>
)
}
)
TopAppBarAction.displayName = "TopAppBarAction"
export { TopAppBar, TopAppBarAction, topAppBarVariants }

View File

@@ -2,8 +2,14 @@ import { loadPyodide, PyodideInterface } from 'pyodide'
let pyodideInstance: PyodideInterface | null = null
let pyodideLoading: Promise<PyodideInterface> | null = null
let initializationError: Error | null = null
export async function getPyodide(): Promise<PyodideInterface> {
// If we had an initialization error, throw it
if (initializationError) {
throw initializationError
}
if (pyodideInstance) {
return pyodideInstance
}
@@ -13,10 +19,15 @@ export async function getPyodide(): Promise<PyodideInterface> {
}
pyodideLoading = loadPyodide({
indexURL: 'https://cdn.jsdelivr.net/pyodide/v0.29.1/full/',
indexURL: 'https://cdn.jsdelivr.net/pyodide/v0.27.0/full/',
}).then((pyodide) => {
pyodideInstance = pyodide
initializationError = null
return pyodide
}).catch((err) => {
initializationError = err instanceof Error ? err : new Error(String(err))
pyodideLoading = null
throw initializationError
})
return pyodideLoading
@@ -25,7 +36,8 @@ export async function getPyodide(): Promise<PyodideInterface> {
export async function runPythonCode(code: string): Promise<{ output?: string; error?: string }> {
try {
const pyodide = await getPyodide()
// Reset stdout/stderr for each run
pyodide.runPython(`
import sys
from io import StringIO
@@ -33,19 +45,39 @@ sys.stdout = StringIO()
sys.stderr = StringIO()
`)
const result = pyodide.runPython(code)
const stdout = pyodide.runPython('sys.stdout.getvalue()')
let output = stdout || ''
if (result !== undefined && result !== null) {
output += (output ? '\n' : '') + String(result)
}
try {
const result = pyodide.runPython(code)
const stdout = pyodide.runPython('sys.stdout.getvalue()')
const stderr = pyodide.runPython('sys.stderr.getvalue()')
return { output: output || '(no output)' }
let output = stdout || ''
if (stderr) {
output += (output ? '\n' : '') + stderr
}
if (result !== undefined && result !== null && String(result) !== 'None') {
output += (output ? '\n' : '') + String(result)
}
return { output: output || '(no output)' }
} catch (runErr) {
// Get any stderr output that might have been captured
let stderr = ''
try {
stderr = pyodide.runPython('sys.stderr.getvalue()')
} catch {
// Ignore errors getting stderr
}
const errorMessage = runErr instanceof Error ? runErr.message : String(runErr)
return {
output: '',
error: stderr ? `${stderr}\n${errorMessage}` : errorMessage
}
}
} catch (err) {
return {
output: '',
error: err instanceof Error ? err.message : String(err)
error: `Python environment error: ${err instanceof Error ? err.message : String(err)}`
}
}
}
@@ -62,6 +94,7 @@ export async function runPythonCodeInteractive(
): Promise<void> {
const pyodide = await getPyodide()
// Set up interactive stdout/stderr handlers
pyodide.runPython(`
import sys
from io import StringIO
@@ -70,7 +103,7 @@ class InteractiveStdout:
def __init__(self, callback):
self.callback = callback
self.buffer = ""
def write(self, text):
self.buffer += text
if "\\n" in text:
@@ -80,7 +113,7 @@ class InteractiveStdout:
self.callback(line)
self.buffer = lines[-1]
return len(text)
def flush(self):
if self.buffer:
self.callback(self.buffer)
@@ -90,7 +123,7 @@ class InteractiveStderr:
def __init__(self, callback):
self.callback = callback
self.buffer = ""
def write(self, text):
self.buffer += text
if "\\n" in text:
@@ -100,7 +133,7 @@ class InteractiveStderr:
self.callback(line)
self.buffer = lines[-1]
return len(text)
def flush(self):
if self.buffer:
self.callback(self.buffer)
@@ -137,33 +170,38 @@ sys.stderr = InteractiveStderr(__error_callback__)
pyodide.globals.set('js_input_handler', customInput)
// Set up custom input function using asyncio.run() which is the modern approach
await pyodide.runPythonAsync(`
import builtins
from pyodide.ffi import to_js
import asyncio
def custom_input(prompt=""):
async def async_input(prompt=""):
import sys
sys.stdout.write(prompt)
sys.stdout.flush()
async def get_input():
result = await js_input_handler(prompt)
return result
loop = asyncio.get_event_loop()
if loop.is_running():
result = await js_input_handler(prompt)
return result
def custom_input(prompt=""):
import asyncio
try:
# Try to get the running loop (works in async context)
loop = asyncio.get_running_loop()
# If we're in an async context, we need to use a different approach
import pyodide.webloop
return pyodide.webloop.WebLoop().run_until_complete(get_input())
else:
return loop.run_until_complete(get_input())
future = asyncio.ensure_future(async_input(prompt))
return pyodide.webloop.WebLoop().run_until_complete(future)
except RuntimeError:
# No running loop, create a new one
return asyncio.run(async_input(prompt))
builtins.input = custom_input
`)
try {
await pyodide.runPythonAsync(code)
pyodide.runPython('sys.stdout.flush()')
pyodide.runPython('sys.stderr.flush()')
} catch (err) {
@@ -177,3 +215,13 @@ builtins.input = custom_input
export function isPyodideReady(): boolean {
return pyodideInstance !== null
}
export function getPyodideError(): Error | null {
return initializationError
}
export function resetPyodide(): void {
pyodideInstance = null
pyodideLoading = null
initializationError = null
}

View File

@@ -1,49 +1,370 @@
// Color Variables - Material Design 3 (Material You) inspired
$background: hsl(222.2, 84%, 4.9%);
$foreground: hsl(210, 15%, 88%); // MD3 on-surface
$card: hsl(222.2, 40%, 8%); // MD3 surface-container
$card-foreground: hsl(210, 15%, 88%);
$popover: hsl(222.2, 40%, 10%); // MD3 surface-container-high
$popover-foreground: hsl(210, 15%, 88%);
$primary: hsl(260, 45%, 55%); // MD3 primary - softer purple
$primary-foreground: hsl(210, 15%, 95%);
$secondary: hsl(217.2, 25%, 20%); // MD3 secondary-container
$secondary-foreground: hsl(210, 15%, 88%);
$muted: hsl(217.2, 25%, 22%);
$muted-foreground: hsl(215, 12%, 60%); // MD3 on-surface-variant
$accent: hsl(195, 50%, 50%); // MD3 tertiary - balanced cyan
$accent-foreground: hsl(222.2, 84%, 4.9%);
$destructive: hsl(0, 50%, 50%); // MD3 error
$destructive-foreground: hsl(210, 15%, 95%);
$border: hsl(217.2, 20%, 25%); // MD3 outline
$input: hsl(217.2, 25%, 18%);
$ring: hsl(195, 50%, 50%); // Match accent
// ============================================
// Material Design 3 (Material You) Color System
// Android Smartphone UI Theme
// ============================================
// Size scale variables (for direct use in component styles)
// === MD3 Primary Tonal Palette ===
// Purple/Violet primary color with full tonal range
$md3-primary-0: hsl(260, 50%, 0%);
$md3-primary-10: hsl(260, 50%, 10%);
$md3-primary-20: hsl(260, 50%, 20%);
$md3-primary-25: hsl(260, 50%, 25%);
$md3-primary-30: hsl(260, 50%, 30%);
$md3-primary-35: hsl(260, 50%, 35%);
$md3-primary-40: hsl(260, 50%, 40%);
$md3-primary-50: hsl(260, 50%, 50%);
$md3-primary-60: hsl(260, 50%, 60%);
$md3-primary-70: hsl(260, 50%, 70%);
$md3-primary-80: hsl(260, 50%, 80%);
$md3-primary-90: hsl(260, 50%, 90%);
$md3-primary-95: hsl(260, 50%, 95%);
$md3-primary-99: hsl(260, 50%, 99%);
$md3-primary-100: hsl(260, 50%, 100%);
// === MD3 Secondary Tonal Palette ===
// Desaturated purple for secondary elements
$md3-secondary-0: hsl(260, 15%, 0%);
$md3-secondary-10: hsl(260, 15%, 10%);
$md3-secondary-20: hsl(260, 15%, 20%);
$md3-secondary-25: hsl(260, 15%, 25%);
$md3-secondary-30: hsl(260, 15%, 30%);
$md3-secondary-35: hsl(260, 15%, 35%);
$md3-secondary-40: hsl(260, 15%, 40%);
$md3-secondary-50: hsl(260, 15%, 50%);
$md3-secondary-60: hsl(260, 15%, 60%);
$md3-secondary-70: hsl(260, 15%, 70%);
$md3-secondary-80: hsl(260, 15%, 80%);
$md3-secondary-90: hsl(260, 15%, 90%);
$md3-secondary-95: hsl(260, 15%, 95%);
$md3-secondary-99: hsl(260, 15%, 99%);
$md3-secondary-100: hsl(260, 15%, 100%);
// === MD3 Tertiary Tonal Palette ===
// Cyan/Teal for tertiary accents
$md3-tertiary-0: hsl(180, 50%, 0%);
$md3-tertiary-10: hsl(180, 50%, 10%);
$md3-tertiary-20: hsl(180, 50%, 20%);
$md3-tertiary-25: hsl(180, 50%, 25%);
$md3-tertiary-30: hsl(180, 50%, 30%);
$md3-tertiary-35: hsl(180, 50%, 35%);
$md3-tertiary-40: hsl(180, 50%, 40%);
$md3-tertiary-50: hsl(180, 50%, 50%);
$md3-tertiary-60: hsl(180, 50%, 60%);
$md3-tertiary-70: hsl(180, 50%, 70%);
$md3-tertiary-80: hsl(180, 50%, 80%);
$md3-tertiary-90: hsl(180, 50%, 90%);
$md3-tertiary-95: hsl(180, 50%, 95%);
$md3-tertiary-99: hsl(180, 50%, 99%);
$md3-tertiary-100: hsl(180, 50%, 100%);
// === MD3 Error Tonal Palette ===
$md3-error-0: hsl(0, 75%, 0%);
$md3-error-10: hsl(0, 75%, 10%);
$md3-error-20: hsl(0, 75%, 20%);
$md3-error-30: hsl(0, 75%, 30%);
$md3-error-40: hsl(0, 75%, 40%);
$md3-error-50: hsl(0, 75%, 50%);
$md3-error-60: hsl(0, 75%, 60%);
$md3-error-70: hsl(0, 75%, 70%);
$md3-error-80: hsl(0, 75%, 80%);
$md3-error-90: hsl(0, 75%, 90%);
$md3-error-95: hsl(0, 75%, 95%);
$md3-error-100: hsl(0, 75%, 100%);
// === MD3 Neutral Tonal Palette ===
// For surfaces and backgrounds
$md3-neutral-0: hsl(260, 10%, 0%);
$md3-neutral-4: hsl(260, 10%, 4%);
$md3-neutral-6: hsl(260, 10%, 6%);
$md3-neutral-10: hsl(260, 10%, 10%);
$md3-neutral-12: hsl(260, 10%, 12%);
$md3-neutral-17: hsl(260, 10%, 17%);
$md3-neutral-20: hsl(260, 10%, 20%);
$md3-neutral-22: hsl(260, 10%, 22%);
$md3-neutral-24: hsl(260, 10%, 24%);
$md3-neutral-25: hsl(260, 10%, 25%);
$md3-neutral-30: hsl(260, 10%, 30%);
$md3-neutral-35: hsl(260, 10%, 35%);
$md3-neutral-40: hsl(260, 10%, 40%);
$md3-neutral-50: hsl(260, 10%, 50%);
$md3-neutral-60: hsl(260, 10%, 60%);
$md3-neutral-70: hsl(260, 10%, 70%);
$md3-neutral-80: hsl(260, 10%, 80%);
$md3-neutral-87: hsl(260, 10%, 87%);
$md3-neutral-90: hsl(260, 10%, 90%);
$md3-neutral-92: hsl(260, 10%, 92%);
$md3-neutral-94: hsl(260, 10%, 94%);
$md3-neutral-95: hsl(260, 10%, 95%);
$md3-neutral-96: hsl(260, 10%, 96%);
$md3-neutral-98: hsl(260, 10%, 98%);
$md3-neutral-99: hsl(260, 10%, 99%);
$md3-neutral-100: hsl(260, 10%, 100%);
// === MD3 Neutral Variant Tonal Palette ===
// For outline and surface variants
$md3-neutral-variant-0: hsl(260, 15%, 0%);
$md3-neutral-variant-10: hsl(260, 15%, 10%);
$md3-neutral-variant-20: hsl(260, 15%, 20%);
$md3-neutral-variant-25: hsl(260, 15%, 25%);
$md3-neutral-variant-30: hsl(260, 15%, 30%);
$md3-neutral-variant-35: hsl(260, 15%, 35%);
$md3-neutral-variant-40: hsl(260, 15%, 40%);
$md3-neutral-variant-50: hsl(260, 15%, 50%);
$md3-neutral-variant-60: hsl(260, 15%, 60%);
$md3-neutral-variant-70: hsl(260, 15%, 70%);
$md3-neutral-variant-80: hsl(260, 15%, 80%);
$md3-neutral-variant-90: hsl(260, 15%, 90%);
$md3-neutral-variant-95: hsl(260, 15%, 95%);
$md3-neutral-variant-99: hsl(260, 15%, 99%);
$md3-neutral-variant-100: hsl(260, 15%, 100%);
// === MD3 Semantic Colors (Success, Warning, Info) ===
$md3-success-40: hsl(145, 60%, 35%);
$md3-success-80: hsl(145, 60%, 75%);
$md3-success-90: hsl(145, 60%, 90%);
$md3-warning-40: hsl(40, 80%, 45%);
$md3-warning-80: hsl(40, 80%, 75%);
$md3-warning-90: hsl(40, 80%, 90%);
$md3-info-40: hsl(210, 70%, 45%);
$md3-info-80: hsl(210, 70%, 75%);
$md3-info-90: hsl(210, 70%, 90%);
// ============================================
// MD3 Dark Theme Color Scheme
// ============================================
// Primary colors
$primary: $md3-primary-80;
$primary-foreground: $md3-primary-20;
$on-primary: $md3-primary-20;
$primary-container: $md3-primary-30;
$on-primary-container: $md3-primary-90;
// Secondary colors
$secondary: $md3-secondary-80;
$secondary-foreground: $md3-secondary-20;
$on-secondary: $md3-secondary-20;
$secondary-container: $md3-secondary-30;
$on-secondary-container: $md3-secondary-90;
// Tertiary colors
$accent: $md3-tertiary-80;
$accent-foreground: $md3-tertiary-20;
$on-tertiary: $md3-tertiary-20;
$tertiary-container: $md3-tertiary-30;
$on-tertiary-container: $md3-tertiary-90;
// Error colors
$destructive: $md3-error-80;
$destructive-foreground: $md3-error-20;
$on-error: $md3-error-20;
$error-container: $md3-error-30;
$on-error-container: $md3-error-90;
// Surface colors (MD3 Tonal Elevation)
$background: $md3-neutral-6;
$foreground: $md3-neutral-90;
$on-background: $md3-neutral-90;
$surface: $md3-neutral-6;
$on-surface: $md3-neutral-90;
$surface-dim: $md3-neutral-6;
$surface-bright: $md3-neutral-24;
// Surface containers for MD3 tonal elevation
$surface-container-lowest: $md3-neutral-4;
$surface-container-low: $md3-neutral-10;
$surface-container: $md3-neutral-12;
$surface-container-high: $md3-neutral-17;
$surface-container-highest: $md3-neutral-22;
// Surface variant
$surface-variant: $md3-neutral-variant-30;
$on-surface-variant: $md3-neutral-variant-80;
// Outline colors
$outline: $md3-neutral-variant-60;
$outline-variant: $md3-neutral-variant-30;
// Legacy compatibility mappings
$card: $surface-container-low;
$card-foreground: $on-surface;
$popover: $surface-container-high;
$popover-foreground: $on-surface;
$muted: $surface-container;
$muted-foreground: $on-surface-variant;
$border: $outline-variant;
$input: $surface-container-highest;
$ring: $primary;
// Inverse colors
$inverse-surface: $md3-neutral-90;
$inverse-on-surface: $md3-neutral-20;
$inverse-primary: $md3-primary-40;
// Scrim and shadow
$scrim: $md3-neutral-0;
$shadow: $md3-neutral-0;
// State layer opacities (MD3 spec)
$state-hover-opacity: 0.08;
$state-focus-opacity: 0.12;
$state-pressed-opacity: 0.12;
$state-dragged-opacity: 0.16;
$state-disabled-opacity: 0.38;
$state-disabled-container-opacity: 0.12;
// ============================================
// MD3 Typography Scale
// ============================================
// Display
$font-display-large: 3.5625rem; // 57px
$font-display-medium: 2.8125rem; // 45px
$font-display-small: 2.25rem; // 36px
// Headline
$font-headline-large: 2rem; // 32px
$font-headline-medium: 1.75rem; // 28px
$font-headline-small: 1.5rem; // 24px
// Title
$font-title-large: 1.375rem; // 22px
$font-title-medium: 1rem; // 16px
$font-title-small: 0.875rem; // 14px
// Body
$font-body-large: 1rem; // 16px
$font-body-medium: 0.875rem; // 14px
$font-body-small: 0.75rem; // 12px
// Label
$font-label-large: 0.875rem; // 14px
$font-label-medium: 0.75rem; // 12px
$font-label-small: 0.6875rem; // 11px
// Line heights for MD3
$line-height-display-large: 4rem; // 64px
$line-height-display-medium: 3.25rem; // 52px
$line-height-display-small: 2.75rem; // 44px
$line-height-headline-large: 2.5rem; // 40px
$line-height-headline-medium: 2.25rem; // 36px
$line-height-headline-small: 2rem; // 32px
$line-height-title-large: 1.75rem; // 28px
$line-height-title-medium: 1.5rem; // 24px
$line-height-title-small: 1.25rem; // 20px
$line-height-body-large: 1.5rem; // 24px
$line-height-body-medium: 1.25rem; // 20px
$line-height-body-small: 1rem; // 16px
$line-height-label-large: 1.25rem; // 20px
$line-height-label-medium: 1rem; // 16px
$line-height-label-small: 1rem; // 16px
// Letter spacing for MD3
$letter-spacing-display: -0.25px;
$letter-spacing-headline: 0;
$letter-spacing-title: 0;
$letter-spacing-body: 0.25px;
$letter-spacing-label: 0.1px;
// ============================================
// MD3 Border Radius (Shape Scale)
// ============================================
$radius-factor: 1;
$size-scale: 1;
$radius-none: 0;
$radius-extra-small: calc(4px * $radius-factor); // MD3 extra-small
$radius-small: calc(8px * $radius-factor); // MD3 small
$radius-medium: calc(12px * $radius-factor); // MD3 medium
$radius-large: calc(16px * $radius-factor); // MD3 large
$radius-extra-large: calc(28px * $radius-factor); // MD3 extra-large
$radius-full: 9999px;
// Legacy mappings
$radius-sm: $radius-extra-small;
$radius-md: $radius-small;
$radius-lg: $radius-medium;
$radius-xl: $radius-large;
// ============================================
// MD3 Elevation System
// ============================================
// Elevation shadows (with tonal overlay for dark theme)
$elevation-1: 0 1px 2px rgba($shadow, 0.3), 0 1px 3px 1px rgba($shadow, 0.15);
$elevation-2: 0 1px 2px rgba($shadow, 0.3), 0 2px 6px 2px rgba($shadow, 0.15);
$elevation-3: 0 4px 8px 3px rgba($shadow, 0.15), 0 1px 3px rgba($shadow, 0.3);
$elevation-4: 0 6px 10px 4px rgba($shadow, 0.15), 0 2px 3px rgba($shadow, 0.3);
$elevation-5: 0 8px 12px 6px rgba($shadow, 0.15), 0 4px 4px rgba($shadow, 0.3);
// Tonal elevation overlays (for dark theme surfaces)
$tonal-elevation-1: rgba($primary, 0.05);
$tonal-elevation-2: rgba($primary, 0.08);
$tonal-elevation-3: rgba($primary, 0.11);
$tonal-elevation-4: rgba($primary, 0.12);
$tonal-elevation-5: rgba($primary, 0.14);
// Legacy shadow mappings
$shadow-accent: rgba($primary, 0.08);
$shadow-accent-md: rgba($primary, 0.12);
$shadow-focus: rgba($primary, 0.10);
$accent-hover: $md3-tertiary-70;
// ============================================
// MD3 Motion/Animation
// ============================================
// Duration tokens
$duration-short-1: 50ms;
$duration-short-2: 100ms;
$duration-short-3: 150ms;
$duration-short-4: 200ms;
$duration-medium-1: 250ms;
$duration-medium-2: 300ms;
$duration-medium-3: 350ms;
$duration-medium-4: 400ms;
$duration-long-1: 450ms;
$duration-long-2: 500ms;
$duration-long-3: 550ms;
$duration-long-4: 600ms;
$duration-extra-long-1: 700ms;
$duration-extra-long-2: 800ms;
$duration-extra-long-3: 900ms;
$duration-extra-long-4: 1000ms;
// Easing tokens (MD3 curves)
$easing-standard: cubic-bezier(0.2, 0, 0, 1);
$easing-standard-decelerate: cubic-bezier(0, 0, 0, 1);
$easing-standard-accelerate: cubic-bezier(0.3, 0, 1, 1);
$easing-emphasized: cubic-bezier(0.2, 0, 0, 1);
$easing-emphasized-decelerate: cubic-bezier(0.05, 0.7, 0.1, 1);
$easing-emphasized-accelerate: cubic-bezier(0.3, 0, 0.8, 0.15);
$easing-legacy: cubic-bezier(0.4, 0, 0.2, 1);
// ============================================
// Size Scale (4dp grid system)
// ============================================
$size-0: 0px;
$size-px: 1px;
$size-0-5: calc(0.125rem * $size-scale);
$size-1: calc(0.25rem * $size-scale);
$size-2: calc(0.5rem * $size-scale);
$size-3: calc(0.75rem * $size-scale);
$size-4: calc(1rem * $size-scale);
$size-5: calc(1.25rem * $size-scale);
$size-6: calc(1.5rem * $size-scale);
$size-8: calc(2rem * $size-scale);
$size-10: calc(2.5rem * $size-scale);
$size-12: calc(3rem * $size-scale);
$size-16: calc(4rem * $size-scale);
$size-20: calc(5rem * $size-scale);
$size-24: calc(6rem * $size-scale);
$size-0-5: calc(0.125rem * $size-scale); // 2px
$size-1: calc(0.25rem * $size-scale); // 4px
$size-2: calc(0.5rem * $size-scale); // 8px
$size-3: calc(0.75rem * $size-scale); // 12px
$size-4: calc(1rem * $size-scale); // 16px
$size-5: calc(1.25rem * $size-scale); // 20px
$size-6: calc(1.5rem * $size-scale); // 24px
$size-8: calc(2rem * $size-scale); // 32px
$size-10: calc(2.5rem * $size-scale); // 40px
$size-12: calc(3rem * $size-scale); // 48px
$size-14: calc(3.5rem * $size-scale); // 56px
$size-16: calc(4rem * $size-scale); // 64px
$size-20: calc(5rem * $size-scale); // 80px
$size-24: calc(6rem * $size-scale); // 96px
// Border radii variables (for direct use)
$radius-factor: 1;
$radius-sm: calc(2px * $radius-factor * $size-scale);
$radius-md: calc(6px * $radius-factor * $size-scale);
$radius-lg: calc(8px * $radius-factor * $size-scale);
$radius-xl: calc(12px * $radius-factor * $size-scale);
$radius-full: 9999px;
// ============================================
// Legacy Compatibility
// ============================================
// Spacing Scale
$spacing: (
@@ -81,20 +402,20 @@ $spacing: (
96: 24rem,
);
// Border Radius
// Border Radius (legacy map)
$radius: (
none: 0,
sm: 0.125rem,
base: 0.25rem,
md: 0.375rem,
lg: 0.5rem,
xl: 0.75rem,
2xl: 1rem,
3xl: 1.5rem,
sm: 0.25rem,
base: 0.5rem,
md: 0.75rem,
lg: 1rem,
xl: 1.5rem,
2xl: 1.75rem,
3xl: 2rem,
full: 9999px,
);
// Font Sizes
// Font Sizes (legacy map)
$font-sizes: (
xs: 0.75rem,
sm: 0.875rem,
@@ -143,11 +464,3 @@ $z-index: (
50: 50,
auto: auto,
);
// Shadow values - Material Design 3 elevation system
$shadow-accent: rgba(99, 150, 180, 0.08); // MD3 elevation tint
$shadow-accent-md: rgba(99, 150, 180, 0.12);
$shadow-focus: rgba(99, 150, 180, 0.10);
// Hover colors - MD3 state layers
$accent-hover: hsl(195, 50%, 55%);

View File

@@ -1,92 +1,360 @@
@use '../abstracts' as *;
// Button component styles
// These complement the existing Tailwind-based button components
// ============================================
// Material Design 3 Button Styles
// ============================================
// Base button styles (MD3)
.btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: $size-2;
padding: $size-2 $size-4;
padding: $size-2-5 $size-6;
min-width: 64px;
min-height: 40px;
border: none;
border-radius: $radius-md;
border-radius: $radius-full;
font-family: inherit;
font-weight: 500;
font-size: 0.875rem;
font-size: $font-label-large;
line-height: $line-height-label-large;
letter-spacing: $letter-spacing-label;
cursor: pointer;
transition: all 0.2s ease;
user-select: none;
text-decoration: none;
position: relative;
overflow: hidden;
transition:
background-color $duration-short-4 $easing-standard,
box-shadow $duration-short-4 $easing-standard,
color $duration-short-4 $easing-standard;
outline: none;
&:hover:not(:disabled) {
transform: translateY(-2px);
// State layer
&::before {
content: '';
position: absolute;
inset: 0;
border-radius: inherit;
background-color: currentColor;
opacity: 0;
transition: opacity $duration-short-4 $easing-standard;
pointer-events: none;
}
&:hover::before {
opacity: $state-hover-opacity;
}
&:focus-visible::before {
opacity: $state-focus-opacity;
}
&:active::before {
opacity: $state-pressed-opacity;
}
&:focus-visible {
box-shadow: 0 0 0 3px $secondary, 0 0 0 6px $ring;
outline: 2px solid $ring;
outline-offset: 2px;
}
&:disabled {
opacity: 0.5;
opacity: $state-disabled-opacity;
cursor: not-allowed;
pointer-events: none;
}
// Primary variant - MD3 filled button
&-primary {
background: $primary;
color: $primary-foreground;
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.12); // MD3 elevation-1
svg {
width: 18px;
height: 18px;
flex-shrink: 0;
}
// ============================================
// MD3 Filled Button (Primary)
// ============================================
&-primary,
&-filled {
background-color: $primary;
color: $on-primary;
box-shadow: $elevation-1;
&:hover:not(:disabled) {
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.14); // MD3 elevation-2
background: hsl(260, 45%, 58%); // Slightly lighter on hover
box-shadow: $elevation-2;
}
&:active:not(:disabled) {
transform: translateY(0);
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.12);
box-shadow: $elevation-1;
}
}
// Secondary variant
&-secondary {
background-color: $secondary;
color: $secondary-foreground;
border: 1px solid $border;
// ============================================
// MD3 Filled Tonal Button
// ============================================
&-tonal {
background-color: $secondary-container;
color: $on-secondary-container;
&:hover:not(:disabled) {
background-color: hsl(217.2, 32.6%, 20%);
box-shadow: $elevation-1;
}
}
// Ghost variant
// ============================================
// MD3 Elevated Button
// ============================================
&-elevated {
background-color: $surface-container-low;
color: $primary;
box-shadow: $elevation-1;
&:hover:not(:disabled) {
box-shadow: $elevation-2;
}
&:active:not(:disabled) {
box-shadow: $elevation-1;
}
}
// ============================================
// MD3 Outlined Button
// ============================================
&-outline,
&-outlined {
background-color: transparent;
color: $primary;
border: 1px solid $outline;
&:hover:not(:disabled) {
background-color: rgba($primary, $state-hover-opacity);
}
}
// ============================================
// MD3 Text Button
// ============================================
&-text,
&-ghost {
background-color: transparent;
color: $accent;
border: 1px solid $accent;
color: $primary;
padding-left: $size-3;
padding-right: $size-3;
&:hover:not(:disabled) {
background-color: rgba(99, 242, 255, 0.1);
background-color: rgba($primary, $state-hover-opacity);
}
}
// Outline variant
&-outline {
background-color: transparent;
color: $foreground;
border: 1px solid $border;
// ============================================
// Destructive/Error Button
// ============================================
&-destructive,
&-error {
background-color: $destructive;
color: $on-error;
box-shadow: $elevation-1;
&:hover:not(:disabled) {
background-color: $secondary;
box-shadow: $elevation-2;
}
&:focus-visible {
outline-color: $destructive;
}
}
// Size variants
// ============================================
// Secondary Button (maps to tonal)
// ============================================
&-secondary {
background-color: $secondary-container;
color: $on-secondary-container;
&:hover:not(:disabled) {
box-shadow: $elevation-1;
}
}
// ============================================
// Size Variants
// ============================================
&-sm {
padding: calc($size-1 * 0.75) $size-2;
font-size: 0.75rem;
padding: $size-1-5 $size-4;
min-height: 32px;
font-size: $font-label-medium;
gap: $size-1-5;
svg {
width: 16px;
height: 16px;
}
}
&-lg {
padding: $size-3 $size-6;
font-size: 1rem;
padding: $size-3 $size-8;
min-height: 48px;
font-size: $font-label-large;
svg {
width: 20px;
height: 20px;
}
}
// ============================================
// Icon Button
// ============================================
&-icon {
padding: 0;
min-width: 40px;
width: 40px;
height: 40px;
border-radius: $radius-full;
&.btn-sm {
min-width: 32px;
width: 32px;
height: 32px;
}
&.btn-lg {
min-width: 48px;
width: 48px;
height: 48px;
}
}
}
// ============================================
// MD3 Icon Button (standalone)
// ============================================
.icon-btn {
display: inline-flex;
align-items: center;
justify-content: center;
width: 40px;
height: 40px;
padding: 0;
border: none;
border-radius: $radius-full;
background-color: transparent;
color: $on-surface-variant;
cursor: pointer;
position: relative;
overflow: hidden;
transition: color $duration-short-4 $easing-standard;
outline: none;
&::before {
content: '';
position: absolute;
inset: 0;
border-radius: inherit;
background-color: currentColor;
opacity: 0;
transition: opacity $duration-short-4 $easing-standard;
pointer-events: none;
}
&:hover::before {
opacity: $state-hover-opacity;
}
&:focus-visible::before {
opacity: $state-focus-opacity;
}
&:active::before {
opacity: $state-pressed-opacity;
}
&:focus-visible {
outline: 2px solid $ring;
outline-offset: 2px;
}
&:disabled {
opacity: $state-disabled-opacity;
cursor: not-allowed;
}
svg {
width: 24px;
height: 24px;
}
// Filled variant
&-filled {
background-color: $primary;
color: $on-primary;
}
// Tonal variant
&-tonal {
background-color: $secondary-container;
color: $on-secondary-container;
}
// Outlined variant
&-outlined {
border: 1px solid $outline;
color: $on-surface-variant;
}
}
// ============================================
// Navigation Button (burger menu)
// ============================================
.nav-burger-btn {
display: inline-flex;
align-items: center;
justify-content: center;
width: 48px;
height: 48px;
padding: 0;
border: none;
border-radius: $radius-full;
background-color: transparent;
color: $on-surface;
cursor: pointer;
position: relative;
overflow: hidden;
transition: color $duration-short-4 $easing-standard;
outline: none;
&::before {
content: '';
position: absolute;
inset: 0;
border-radius: inherit;
background-color: currentColor;
opacity: 0;
transition: opacity $duration-short-4 $easing-standard;
pointer-events: none;
}
&:hover::before {
opacity: $state-hover-opacity;
}
&:focus-visible::before {
opacity: $state-focus-opacity;
}
&:active::before {
opacity: $state-pressed-opacity;
}
&:focus-visible {
outline: 2px solid $ring;
outline-offset: 2px;
}
svg {
width: 24px;
height: 24px;
}
}

View File

@@ -1,16 +1,20 @@
@use '../abstracts' as *;
// Dialog/Modal styling - Material Design 3
// ============================================
// Material Design 3 Dialog/Modal Styles
// ============================================
// Scrim/Overlay
[data-slot="dialog-overlay"],
.dialog-overlay {
position: fixed;
inset: 0;
z-index: 50;
background-color: rgba(0, 0, 0, 0.5); // MD3 scrim opacity
backdrop-filter: blur(4px); // Subtle blur
animation: fadeIn 0.2s ease-out;
background-color: rgba($scrim, 0.32); // MD3 scrim opacity
animation: md3-fade-in $duration-medium-2 $easing-emphasized-decelerate;
}
// Dialog Content (MD3 Basic Dialog)
[data-slot="dialog"],
.dialog-content {
position: fixed;
@@ -18,139 +22,239 @@
top: 50%;
z-index: 50;
transform: translate(-50%, -50%);
width: 90%;
max-width: 32rem;
max-height: 85vh;
width: calc(100% - 48px);
max-width: 560px;
max-height: calc(100vh - 96px);
overflow-y: auto;
background: $popover; // MD3 surface-container-high
border: 1px solid rgba($border, 0.3);
border-radius: $radius-xl; // MD3 extra-large shape (28px)
box-shadow:
0 8px 16px rgba(0, 0, 0, 0.18), // MD3 elevation-3
0 4px 8px rgba(0, 0, 0, 0.12);
animation: dialogSlideIn 0.25s cubic-bezier(0.2, 0, 0, 1); // MD3 emphasized easing
background: $surface-container-high;
border: none;
border-radius: $radius-extra-large;
box-shadow: $elevation-3;
padding: $size-6;
animation: md3-dialog-enter $duration-medium-2 $easing-emphasized-decelerate;
&:focus {
outline: none;
}
}
// Dropdown menu styling - Material Design 3
// Dialog Header
[data-slot="dialog-header"],
.dialog-header {
display: flex;
flex-direction: column;
gap: $size-4;
margin-bottom: $size-6;
}
// Dialog Title (MD3 Headline Small)
[data-slot="dialog-title"],
.dialog-title {
font-size: $font-headline-small;
line-height: $line-height-headline-small;
font-weight: 400;
color: $on-surface;
margin: 0;
}
// Dialog Description (MD3 Body Medium)
[data-slot="dialog-description"],
.dialog-description {
font-size: $font-body-medium;
line-height: $line-height-body-medium;
color: $on-surface-variant;
margin: 0;
}
// Dialog Footer (Actions)
[data-slot="dialog-footer"],
.dialog-footer {
display: flex;
justify-content: flex-end;
gap: $size-2;
margin-top: $size-6;
}
// Dialog Close Button
[data-slot="dialog-close"],
.dialog-close {
position: absolute;
top: $size-4;
right: $size-4;
width: 40px;
height: 40px;
display: flex;
align-items: center;
justify-content: center;
border-radius: $radius-full;
border: none;
background: transparent;
color: $on-surface-variant;
cursor: pointer;
transition: background-color $duration-short-4 $easing-standard;
&:hover {
background-color: rgba($on-surface, $state-hover-opacity);
}
&:focus-visible {
background-color: rgba($on-surface, $state-focus-opacity);
outline: none;
}
}
// ============================================
// MD3 Dropdown Menu
// ============================================
[data-slot="dropdown-menu-content"],
.dropdown-content {
z-index: 50;
min-width: 12rem;
min-width: 112px;
max-width: 280px;
overflow: hidden;
border-radius: $radius-lg; // MD3 large shape
border: 1px solid rgba($border, 0.3);
background: $popover;
box-shadow:
0 4px 8px rgba(0, 0, 0, 0.14), // MD3 elevation-2
0 2px 4px rgba(0, 0, 0, 0.10);
padding: 0.5rem;
animation: dropdownSlideIn 0.2s cubic-bezier(0.2, 0, 0, 1);
border-radius: $radius-extra-small;
border: none;
background: $surface-container;
box-shadow: $elevation-2;
padding: $size-2 0;
animation: md3-menu-enter $duration-short-4 $easing-emphasized-decelerate;
&:focus {
outline: none;
}
}
// Dropdown Menu Item
[data-slot="dropdown-menu-item"],
.dropdown-item {
position: relative;
display: flex;
align-items: center;
gap: 0.75rem;
padding: 0.625rem 0.75rem;
font-size: 0.875rem;
color: $foreground;
border-radius: $radius-md; // MD3 medium shape
gap: $size-3;
min-height: 48px;
padding: 0 $size-3;
font-size: $font-body-large;
line-height: $line-height-body-large;
color: $on-surface;
cursor: pointer;
transition: background-color 0.15s ease;
transition: background-color $duration-short-4 $easing-standard;
user-select: none;
outline: none;
&:hover {
background-color: rgba($accent, 0.08); // MD3 state layer
background-color: rgba($on-surface, $state-hover-opacity);
}
&:focus {
background-color: rgba($accent, 0.12);
background-color: rgba($on-surface, $state-focus-opacity);
}
&:active {
background-color: rgba($on-surface, $state-pressed-opacity);
}
&[data-disabled] {
pointer-events: none;
opacity: 0.38; // MD3 disabled opacity
opacity: $state-disabled-opacity;
}
svg {
width: 24px;
height: 24px;
color: $on-surface-variant;
}
}
// Popover styling - refined and elegant
// Dropdown Menu Separator
[data-slot="dropdown-menu-separator"],
.dropdown-separator {
height: 1px;
background: $outline-variant;
margin: $size-2 0;
}
// Dropdown Menu Label
[data-slot="dropdown-menu-label"],
.dropdown-label {
padding: $size-2 $size-3;
font-size: $font-label-large;
font-weight: 500;
color: $on-surface-variant;
}
// ============================================
// MD3 Popover
// ============================================
[data-slot="popover-content"],
.popover-content {
z-index: 50;
width: auto;
border-radius: $radius-lg;
border: 1px solid rgba($border, 0.5);
background: rgba($popover, 0.95);
backdrop-filter: blur(12px) saturate(150%);
padding: 1rem;
box-shadow:
0 10px 25px rgba(0, 0, 0, 0.2),
0 4px 10px rgba(0, 0, 0, 0.15);
animation: popoverSlideIn 0.2s cubic-bezier(0.4, 0, 0.2, 1);
min-width: 112px;
border-radius: $radius-medium;
border: none;
background: $surface-container;
box-shadow: $elevation-2;
padding: $size-4;
animation: md3-menu-enter $duration-short-4 $easing-emphasized-decelerate;
&:focus {
outline: none;
}
}
// Select dropdown styling - beautiful custom select
// ============================================
// MD3 Select
// ============================================
[data-slot="select-trigger"],
.select-trigger {
display: flex;
align-items: center;
justify-content: space-between;
width: 100%;
padding: 0.5rem 0.75rem;
font-size: 0.875rem;
border-radius: $radius-md;
border: 1px solid rgba($border, 0.5);
background: rgba($input, 0.5);
color: $foreground;
min-height: 56px;
padding: $size-4;
font-size: $font-body-large;
border-radius: $radius-extra-small;
border: 1px solid $outline;
background: transparent;
color: $on-surface;
cursor: pointer;
transition: all 0.2s ease;
transition: border-color $duration-short-4 $easing-standard;
&:hover {
background: rgba($input, 0.7);
border-color: rgba($accent, 0.5);
border-color: $on-surface;
}
&:focus {
outline: none;
border-color: $ring;
box-shadow: 0 0 0 3px rgba($ring, 0.1);
border-color: $primary;
border-width: 2px;
padding: calc(#{$size-4} - 1px);
}
&[data-disabled] {
pointer-events: none;
opacity: 0.5;
opacity: $state-disabled-opacity;
}
svg {
width: 24px;
height: 24px;
color: $on-surface-variant;
}
}
[data-slot="select-content"],
.select-content {
z-index: 50;
min-width: 8rem;
min-width: 112px;
overflow: hidden;
border-radius: $radius-lg;
border: 1px solid rgba($border, 0.5);
background: rgba($popover, 0.95);
backdrop-filter: blur(12px) saturate(150%);
box-shadow:
0 10px 25px rgba(0, 0, 0, 0.2),
0 4px 10px rgba(0, 0, 0, 0.15);
padding: 0.375rem;
animation: selectSlideIn 0.2s cubic-bezier(0.4, 0, 0.2, 1);
border-radius: $radius-extra-small;
border: none;
background: $surface-container;
box-shadow: $elevation-2;
padding: $size-2 0;
animation: md3-menu-enter $duration-short-4 $easing-emphasized-decelerate;
}
[data-slot="select-item"],
@@ -158,45 +262,46 @@
position: relative;
display: flex;
align-items: center;
padding: 0.5rem 0.75rem 0.5rem 2rem;
font-size: 0.875rem;
color: $foreground;
border-radius: $radius-sm;
min-height: 48px;
padding: 0 $size-3;
font-size: $font-body-large;
color: $on-surface;
cursor: pointer;
transition: all 0.15s ease;
transition: background-color $duration-short-4 $easing-standard;
outline: none;
user-select: none;
&:hover {
background-color: rgba($accent, 0.12);
color: $accent;
background-color: rgba($on-surface, $state-hover-opacity);
}
&:focus {
background-color: rgba($accent, 0.15);
color: $accent;
background-color: rgba($on-surface, $state-focus-opacity);
}
&[data-state="checked"] {
background-color: rgba($accent, 0.1);
color: $accent;
font-weight: 500;
background-color: rgba($primary, 0.12);
&::before {
content: '';
content: '';
position: absolute;
left: 0.5rem;
font-size: 0.875rem;
left: 0;
top: 0;
bottom: 0;
width: 3px;
background: $primary;
}
}
&[data-disabled] {
pointer-events: none;
opacity: 0.5;
opacity: $state-disabled-opacity;
}
}
// Alert Dialog styling - attention-grabbing
// ============================================
// MD3 Alert Dialog
// ============================================
[data-slot="alert-dialog-content"],
.alert-dialog-content {
position: fixed;
@@ -204,24 +309,115 @@
top: 50%;
z-index: 50;
transform: translate(-50%, -50%);
width: 90%;
max-width: 28rem;
background: linear-gradient(135deg, rgba($card, 0.95) 0%, rgba($card, 0.98) 100%);
border: 1px solid rgba($destructive, 0.3);
border-radius: $radius-xl;
box-shadow:
0 20px 40px rgba($destructive, 0.2),
0 10px 20px rgba(0, 0, 0, 0.2);
padding: 1.5rem;
animation: alertSlideIn 0.25s cubic-bezier(0.4, 0, 0.2, 1);
width: calc(100% - 48px);
max-width: 560px;
background: $surface-container-high;
border: none;
border-radius: $radius-extra-large;
box-shadow: $elevation-3;
padding: $size-6;
animation: md3-dialog-enter $duration-medium-2 $easing-emphasized-decelerate;
&:focus {
outline: none;
}
}
// Alert Dialog with icon (optional)
.alert-dialog-icon {
display: flex;
justify-content: center;
margin-bottom: $size-4;
svg {
width: 24px;
height: 24px;
color: $on-surface-variant;
}
&--error svg {
color: $destructive;
}
}
// ============================================
// MD3 Snackbar/Toast
// ============================================
.snackbar,
[data-slot="toast"] {
display: flex;
align-items: center;
gap: $size-2;
min-height: 48px;
padding: $size-3-5 $size-4;
background: $inverse-surface;
color: $inverse-on-surface;
border-radius: $radius-extra-small;
box-shadow: $elevation-3;
font-size: $font-body-medium;
animation: md3-snackbar-enter $duration-medium-1 $easing-emphasized-decelerate;
}
.snackbar-action {
margin-left: auto;
padding: 0 $size-2;
font-size: $font-label-large;
font-weight: 500;
color: $inverse-primary;
background: transparent;
border: none;
cursor: pointer;
transition: opacity $duration-short-4 $easing-standard;
&:hover {
opacity: 0.8;
}
}
// ============================================
// Namespace Selector (MD3 styled)
// ============================================
.namespace-select {
display: inline-flex;
align-items: center;
gap: $size-2;
padding: $size-2 $size-4;
font-size: $font-label-large;
font-weight: 500;
border-radius: $radius-small;
border: 1px solid $outline-variant;
background: $surface-container;
color: $on-surface;
cursor: pointer;
transition: all $duration-short-4 $easing-standard;
svg {
width: 18px;
height: 18px;
color: $on-surface-variant;
transition: color $duration-short-4 $easing-standard;
}
&:hover {
background: $surface-container-high;
border-color: $outline;
svg {
color: $on-surface;
}
}
&:focus {
outline: none;
border-color: $primary;
box-shadow: 0 0 0 2px rgba($primary, 0.2);
}
}
// ============================================
// Animations
@keyframes fadeIn {
// ============================================
@keyframes md3-fade-in {
from {
opacity: 0;
}
@@ -230,10 +426,10 @@
}
}
@keyframes dialogSlideIn {
@keyframes md3-dialog-enter {
from {
opacity: 0;
transform: translate(-50%, -48%) scale(0.96);
transform: translate(-50%, -48%) scale(0.95);
}
to {
opacity: 1;
@@ -241,21 +437,10 @@
}
}
@keyframes dropdownSlideIn {
@keyframes md3-menu-enter {
from {
opacity: 0;
transform: translateY(-8px) scale(0.96);
}
to {
opacity: 1;
transform: translateY(0) scale(1);
}
}
@keyframes popoverSlideIn {
from {
opacity: 0;
transform: scale(0.96);
transform: scale(0.95);
}
to {
opacity: 1;
@@ -263,64 +448,13 @@
}
}
@keyframes selectSlideIn {
@keyframes md3-snackbar-enter {
from {
opacity: 0;
transform: translateY(-4px);
transform: translateY(100%);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@keyframes alertSlideIn {
from {
opacity: 0;
transform: translate(-50%, -45%) scale(0.9);
}
to {
opacity: 1;
transform: translate(-50%, -50%) scale(1);
}
}
// Namespace selector specific styling
.namespace-select {
display: inline-flex;
align-items: center;
gap: 0.5rem;
padding: 0.5rem 1rem;
font-size: 0.875rem;
font-weight: 500;
border-radius: $radius-lg;
border: 1px solid rgba($border, 0.5);
background: rgba($secondary, 0.5);
color: $foreground;
cursor: pointer;
transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
svg {
width: 1rem;
height: 1rem;
color: $muted-foreground;
transition: color 0.2s ease;
}
&:hover {
background: rgba($secondary, 0.7);
border-color: rgba($accent, 0.4);
transform: translateY(-1px);
box-shadow: 0 4px 12px rgba($accent, 0.1);
svg {
color: $accent;
}
}
&:focus {
outline: none;
border-color: $ring;
box-shadow: 0 0 0 3px rgba($ring, 0.1);
}
}