11import * as React from "react" ;
2+ import { Check , Copy } from "lucide-react" ;
23import {
34 Table ,
45 TableBody ,
@@ -56,7 +57,143 @@ type Swatch = {
5657 needsBorder ?: boolean ;
5758} ;
5859
59- function SwatchCard ( { swatch } : { swatch : Swatch } ) {
60+ type SwatchCopyMode = "hex-only" | "class-and-hex" ;
61+
62+ let colorContext : CanvasRenderingContext2D | null = null ;
63+
64+ function getColorContext ( ) {
65+ if ( typeof document === "undefined" ) {
66+ return null ;
67+ }
68+
69+ if ( ! colorContext ) {
70+ const canvas = document . createElement ( "canvas" ) ;
71+ canvas . width = 1 ;
72+ canvas . height = 1 ;
73+ colorContext = canvas . getContext ( "2d" ) ;
74+ }
75+
76+ return colorContext ;
77+ }
78+
79+ function toHex ( color : string ) {
80+ const ctx = getColorContext ( ) ;
81+ if ( ! ctx ) {
82+ return "" ;
83+ }
84+
85+ try {
86+ ctx . clearRect ( 0 , 0 , 1 , 1 ) ;
87+ ctx . fillStyle = "rgba(0,0,0,0)" ;
88+ ctx . fillStyle = color ;
89+ ctx . fillRect ( 0 , 0 , 1 , 1 ) ;
90+ const [ r , g , b , a ] = ctx . getImageData ( 0 , 0 , 1 , 1 ) . data ;
91+ if ( a < 255 ) {
92+ return `#${ [ r , g , b , a ] . map ( ( n ) => n . toString ( 16 ) . padStart ( 2 , "0" ) ) . join ( "" ) . toUpperCase ( ) } ` ;
93+ }
94+ return `#${ [ r , g , b ] . map ( ( n ) => n . toString ( 16 ) . padStart ( 2 , "0" ) ) . join ( "" ) . toUpperCase ( ) } ` ;
95+ } catch {
96+ return "" ;
97+ }
98+ }
99+
100+ function resolveCssColorHex ( value : string ) {
101+ if ( typeof window === "undefined" ) {
102+ return "" ;
103+ }
104+
105+ const probe = document . createElement ( "span" ) ;
106+ probe . style . color = value ;
107+ probe . style . position = "absolute" ;
108+ probe . style . opacity = "0" ;
109+ probe . style . pointerEvents = "none" ;
110+ document . body . appendChild ( probe ) ;
111+ const computed = getComputedStyle ( probe ) . color ;
112+ document . body . removeChild ( probe ) ;
113+ return toHex ( computed ) ;
114+ }
115+
116+ function resolveTokenHex ( token : string , dark = false ) {
117+ if ( typeof window === "undefined" ) {
118+ return "" ;
119+ }
120+
121+ const mount = document . createElement ( "div" ) ;
122+ mount . className = dark ? "dark" : "" ;
123+ mount . style . position = "absolute" ;
124+ mount . style . opacity = "0" ;
125+ mount . style . pointerEvents = "none" ;
126+
127+ const probe = document . createElement ( "span" ) ;
128+ probe . style . color = `var(--${ token } )` ;
129+ mount . appendChild ( probe ) ;
130+ document . body . appendChild ( mount ) ;
131+
132+ const computed = getComputedStyle ( probe ) . color ;
133+ document . body . removeChild ( mount ) ;
134+ return toHex ( computed ) ;
135+ }
136+
137+ async function copyText ( value : string ) {
138+ if ( ! value ) {
139+ return ;
140+ }
141+
142+ try {
143+ await navigator . clipboard . writeText ( value ) ;
144+ } catch {
145+ const ta = document . createElement ( "textarea" ) ;
146+ ta . value = value ;
147+ ta . style . position = "fixed" ;
148+ ta . style . left = "-9999px" ;
149+ document . body . appendChild ( ta ) ;
150+ ta . focus ( ) ;
151+ ta . select ( ) ;
152+ document . execCommand ( "copy" ) ;
153+ document . body . removeChild ( ta ) ;
154+ }
155+ }
156+
157+ function SwatchCard ( {
158+ swatch,
159+ copyMode,
160+ } : {
161+ swatch : Swatch ;
162+ copyMode : SwatchCopyMode ;
163+ } ) {
164+ const [ lightHex , setLightHex ] = React . useState ( "" ) ;
165+ const [ darkHex , setDarkHex ] = React . useState ( "" ) ;
166+ const [ copied , setCopied ] = React . useState < "light" | "dark" | "class" | "" > ( "" ) ;
167+
168+ React . useEffect ( ( ) => {
169+ setLightHex ( resolveTokenHex ( swatch . token , false ) ) ;
170+ setDarkHex ( resolveTokenHex ( swatch . token , true ) ) ;
171+ } , [ swatch . token ] ) ;
172+
173+ React . useEffect ( ( ) => {
174+ if ( ! copied ) {
175+ return ;
176+ }
177+
178+ const id = window . setTimeout ( ( ) => setCopied ( "" ) , 1200 ) ;
179+ return ( ) => window . clearTimeout ( id ) ;
180+ } , [ copied ] ) ;
181+
182+ const onCopyLightHex = async ( ) => {
183+ await copyText ( lightHex ) ;
184+ setCopied ( "light" ) ;
185+ } ;
186+
187+ const onCopyDarkHex = async ( ) => {
188+ await copyText ( darkHex ) ;
189+ setCopied ( "dark" ) ;
190+ } ;
191+
192+ const onCopyClass = async ( ) => {
193+ await copyText ( swatch . className ) ;
194+ setCopied ( "class" ) ;
195+ } ;
196+
60197 return (
61198 < div className = "border-border bg-card flex flex-col overflow-hidden rounded-lg border" >
62199 < div
@@ -66,14 +203,55 @@ function SwatchCard({ swatch }: { swatch: Swatch }) {
66203 >
67204 Aa
68205 </ div >
69- < div className = "space-y-1 p-4" >
70- < div className = "text-foreground text-sm font-semibold" >
206+ < div className = "space-y-2 p-4" >
207+ < div className = "text-foreground text-sm font-semibold leading-none " >
71208 { swatch . token }
72209 </ div >
73- < code className = "text-muted-foreground block font-mono text-xs" >
74- { swatch . className }
75- </ code >
76- < div className = "text-muted-foreground pt-1 text-xs" >
210+ { copyMode === "class-and-hex" ? (
211+ < button
212+ type = "button"
213+ onClick = { onCopyClass }
214+ aria-label = "Copy class name"
215+ title = { `Copy ${ swatch . className } ` }
216+ className = "border-border text-muted-foreground hover:bg-muted focus-visible:ring-ring/50 inline-flex h-7 items-center gap-1.5 self-start rounded-md border px-2 font-mono text-xs transition-colors focus-visible:ring-2 focus-visible:outline-none"
217+ >
218+ < span className = "tabular-nums" > { swatch . className } </ span >
219+ { copied === "class" ? < Check className = "h-3 w-3" /> : < Copy className = "h-3 w-3" /> }
220+ </ button >
221+ ) : (
222+ < code className = "text-muted-foreground block font-mono text-xs" >
223+ { swatch . className }
224+ </ code >
225+ ) }
226+ { lightHex || darkHex ? (
227+ < div className = "font-mono text-xs" >
228+ < div className = "flex flex-wrap items-center gap-2" >
229+ < button
230+ type = "button"
231+ onClick = { onCopyLightHex }
232+ aria-label = "Copy light hex"
233+ title = { `Copy light ${ lightHex || "—" } ` }
234+ className = "border-border text-muted-foreground hover:bg-muted focus-visible:ring-ring/50 inline-flex h-7 items-center gap-1.5 rounded-md border px-2 tabular-nums transition-colors focus-visible:ring-2 focus-visible:outline-none"
235+ >
236+ < span className = "font-mono" > L</ span >
237+ < span > { lightHex || "—" } </ span >
238+ { copied === "light" ? < Check className = "h-3 w-3" /> : < Copy className = "h-3 w-3" /> }
239+ </ button >
240+ < button
241+ type = "button"
242+ onClick = { onCopyDarkHex }
243+ aria-label = "Copy dark hex"
244+ title = { `Copy dark ${ darkHex || "—" } ` }
245+ className = "border-border text-muted-foreground hover:bg-muted focus-visible:ring-ring/50 inline-flex h-7 items-center gap-1.5 rounded-md border px-2 tabular-nums transition-colors focus-visible:ring-2 focus-visible:outline-none"
246+ >
247+ < span className = "font-mono" > D</ span >
248+ < span > { darkHex || "—" } </ span >
249+ { copied === "dark" ? < Check className = "h-3 w-3" /> : < Copy className = "h-3 w-3" /> }
250+ </ button >
251+ </ div >
252+ </ div >
253+ ) : null }
254+ < div className = "text-muted-foreground text-xs" >
77255 < span className = "font-mono" > L</ span > { swatch . light } { " " }
78256 < span className = "text-foreground/30" > ·</ span > { " " }
79257 < span className = "font-mono" > D</ span > { swatch . dark }
@@ -84,11 +262,17 @@ function SwatchCard({ swatch }: { swatch: Swatch }) {
84262 ) ;
85263}
86264
87- function SwatchGrid ( { items } : { items : Swatch [ ] } ) {
265+ function SwatchGrid ( {
266+ items,
267+ copyMode = "class-and-hex" ,
268+ } : {
269+ items : Swatch [ ] ;
270+ copyMode ?: SwatchCopyMode ;
271+ } ) {
88272 return (
89273 < div className = "mt-6 grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3" >
90274 { items . map ( ( s ) => (
91- < SwatchCard key = { s . token } swatch = { s } />
275+ < SwatchCard key = { s . token } swatch = { s } copyMode = { copyMode } />
92276 ) ) }
93277 </ div >
94278 ) ;
@@ -630,6 +814,45 @@ const RAMP_STEPS = [
630814 "950" ,
631815] ;
632816
817+ function PaletteBox ( {
818+ className,
819+ label,
820+ textClassName,
821+ heightClassName = "h-14" ,
822+ } : {
823+ className : string ;
824+ label : string ;
825+ textClassName : string ;
826+ heightClassName ?: string ;
827+ } ) {
828+ const [ copied , setCopied ] = React . useState ( false ) ;
829+
830+ React . useEffect ( ( ) => {
831+ if ( ! copied ) {
832+ return ;
833+ }
834+
835+ const id = window . setTimeout ( ( ) => setCopied ( false ) , 1000 ) ;
836+ return ( ) => window . clearTimeout ( id ) ;
837+ } , [ copied ] ) ;
838+
839+ const onCopyClass = async ( ) => {
840+ await copyText ( className ) ;
841+ setCopied ( true ) ;
842+ } ;
843+
844+ return (
845+ < button
846+ type = "button"
847+ onClick = { onCopyClass }
848+ title = { `Copy ${ className } ` }
849+ className = { `${ className } ${ textClassName } ${ heightClassName } flex flex-1 cursor-copy items-end justify-center pb-1 font-mono text-[10px] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/50` }
850+ >
851+ { copied ? "Copied" : label }
852+ </ button >
853+ ) ;
854+ }
855+
633856function ColorRamp ( { ramp } : { ramp : RampDef } ) {
634857 return (
635858 < div className = "border-border overflow-hidden rounded-lg border" >
@@ -641,14 +864,12 @@ function ColorRamp({ ramp }: { ramp: RampDef }) {
641864 </ div >
642865 < div className = "flex" >
643866 { ramp . bg . map ( ( cls , i ) => (
644- < div
867+ < PaletteBox
645868 key = { cls }
646- className = { `${ cls } ${
647- i >= 5 ? "text-white" : "text-neutral-950"
648- } flex h-14 flex-1 items-end justify-center pb-1 font-mono text-[10px]`}
649- >
650- { RAMP_STEPS [ i ] }
651- </ div >
869+ className = { cls }
870+ textClassName = { i >= 5 ? "text-white" : "text-neutral-950" }
871+ label = { RAMP_STEPS [ i ] }
872+ />
652873 ) ) }
653874 </ div >
654875 </ div >
@@ -980,12 +1201,13 @@ export function ColorsPage() {
9801201 < div className = "border-border mt-6 overflow-hidden rounded-lg border" >
9811202 < div className = "flex" >
9821203 { PRIMARY_SCALE . map ( ( s ) => (
983- < div
1204+ < PaletteBox
9841205 key = { s . step }
985- className = { `${ s . className } ${ s . onClass } flex h-20 flex-1 items-end justify-center pb-2 font-mono text-[11px]` }
986- >
987- { s . step }
988- </ div >
1206+ className = { s . className }
1207+ textClassName = { s . onClass }
1208+ label = { s . step }
1209+ heightClassName = "h-20"
1210+ />
9891211 ) ) }
9901212 </ div >
9911213 < div className = "bg-card border-border border-t p-4" >
0 commit comments