@@ -9,12 +9,16 @@ import {
99 ArrowRightIcon ,
1010} from '@phosphor-icons/react' ;
1111import { Link } from 'react-router-dom' ;
12+ import { useVisualSettings } from '../context/VisualSettingsContext' ;
1213
1314const DISMISSED_BANNERS_KEY = 'dismissed-banners' ;
1415
1516const Banner = ( ) => {
1617 const [ banner , setBanner ] = useState ( null ) ;
1718 const [ isVisible , setIsVisible ] = useState ( true ) ;
19+ const { fezcodexTheme } = useVisualSettings ( ) || { } ;
20+ const isLuxe = fezcodexTheme === 'luxe' ;
21+ const isTerracotta = fezcodexTheme === 'terracotta' ;
1822
1923 useEffect ( ( ) => {
2024 const fetchBanner = async ( ) => {
@@ -92,34 +96,187 @@ const Banner = () => {
9296
9397 if ( ! banner || ! isVisible ) return null ;
9498
95- const getTypeStyles = ( ) => {
96- switch ( banner . type ) {
99+ /* ------------------------------------------------------------------
100+ * per-theme style packs
101+ * ------------------------------------------------------------------ */
102+ const iconFor = ( type , weight = 'bold' , size = 20 ) => {
103+ switch ( type ) {
97104 case 'error' :
98- return {
99- bg : 'bg-red-600' ,
100- text : 'text-white' ,
101- icon : < WarningOctagonIcon size = { 20 } weight = "bold" /> ,
102- accent : 'border-red-400' ,
103- } ;
105+ return < WarningOctagonIcon size = { size } weight = { weight } /> ;
104106 case 'warning' :
105- return {
106- bg : 'bg-amber-500' ,
107- text : 'text-black' ,
108- icon : < WarningIcon size = { 20 } weight = "bold" /> ,
109- accent : 'border-amber-300' ,
110- } ;
107+ return < WarningIcon size = { size } weight = { weight } /> ;
111108 case 'info' :
112109 default :
113- return {
114- bg : 'bg-emerald-600' ,
115- text : 'text-white' ,
116- icon : < InfoIcon size = { 20 } weight = "bold" /> ,
117- accent : 'border-emerald-400' ,
118- } ;
110+ return < InfoIcon size = { size } weight = { weight } /> ;
119111 }
120112 } ;
121113
122- const styles = getTypeStyles ( ) ;
114+ const bannerType = banner . type || 'info' ;
115+ const isExternalLink = banner . link && banner . link . startsWith ( 'http' ) ;
116+
117+ const renderLink = ( className ) => {
118+ if ( ! banner . link ) return null ;
119+ const label = banner . linkText || 'VIEW_DETAILS' ;
120+ if ( isExternalLink ) {
121+ return (
122+ < a href = { banner . link } className = { className } >
123+ { label }
124+ < ArrowRightIcon size = { 12 } weight = "bold" />
125+ </ a >
126+ ) ;
127+ }
128+ return (
129+ < Link to = { banner . link } className = { className } >
130+ { label }
131+ < ArrowRightIcon size = { 12 } weight = "bold" />
132+ </ Link >
133+ ) ;
134+ } ;
135+
136+ /* ============================================================
137+ * TERRACOTTA BANNER — editorial bone strip with terra dot
138+ * ============================================================ */
139+ if ( isTerracotta ) {
140+ const typePalette = ( ( ) => {
141+ switch ( bannerType ) {
142+ case 'error' :
143+ return { dot : '#9E4A2F' , accent : '#9E4A2F' , kicker : 'Erratum' } ;
144+ case 'warning' :
145+ return { dot : '#B88532' , accent : '#B88532' , kicker : 'Caveat' } ;
146+ case 'info' :
147+ default :
148+ return { dot : '#C96442' , accent : '#C96442' , kicker : 'Colophon' } ;
149+ }
150+ } ) ( ) ;
151+
152+ return (
153+ < AnimatePresence >
154+ { isVisible && (
155+ < motion . div
156+ initial = { { height : 0 , opacity : 0 } }
157+ animate = { { height : 'auto' , opacity : 1 } }
158+ exit = { { height : 0 , opacity : 0 } }
159+ className = "relative z-[100] bg-[#E8DECE] border-b border-[#1A161320]"
160+ >
161+ < div className = "max-w-[1800px] mx-auto px-5 md:px-12 py-3 grid grid-cols-[auto_1fr_auto] items-center gap-5" >
162+ < div className = "flex items-center gap-3 min-w-0" >
163+ < span
164+ aria-hidden = "true"
165+ className = "inline-block w-[7px] h-[7px] rounded-full"
166+ style = { { backgroundColor : typePalette . dot } }
167+ />
168+ < span
169+ className = "font-ibm-plex-mono text-[9.5px] tracking-[0.3em] uppercase"
170+ style = { { color : typePalette . accent } }
171+ >
172+ { typePalette . kicker }
173+ </ span >
174+ </ div >
175+
176+ < div className = "flex items-center gap-4 min-w-0" >
177+ < span aria-hidden = "true" className = "hidden md:inline text-[#2E2620]/50" >
178+ { iconFor ( bannerType , 'duotone' , 16 ) }
179+ </ span >
180+ < p className = "font-fraunces italic text-[14px] md:text-[15.5px] leading-snug text-[#1A1613] truncate" >
181+ { banner . text }
182+ </ p >
183+ { renderLink (
184+ 'shrink-0 hidden md:inline-flex items-center gap-1 font-ibm-plex-mono text-[9.5px] tracking-[0.24em] uppercase text-[#1A1613] border border-[#1A161340] px-2.5 py-1 hover:bg-[#1A1613] hover:text-[#F3ECE0] transition-colors' ,
185+ ) }
186+ </ div >
187+
188+ < button
189+ type = "button"
190+ onClick = { handleDismiss }
191+ className = "p-1 text-[#2E2620]/50 hover:text-[#9E4A2F] transition-colors shrink-0"
192+ aria-label = "Dismiss"
193+ >
194+ < XIcon size = { 16 } weight = "bold" />
195+ </ button >
196+ </ div >
197+ </ motion . div >
198+ ) }
199+ </ AnimatePresence >
200+ ) ;
201+ }
202+
203+ /* ============================================================
204+ * LUXE BANNER — quiet cream strip with bronze rule
205+ * ============================================================ */
206+ if ( isLuxe ) {
207+ const typePalette = ( ( ) => {
208+ switch ( bannerType ) {
209+ case 'error' :
210+ return { accent : '#7A2020' , label : 'Alert' } ;
211+ case 'warning' :
212+ return { accent : '#B88532' , label : 'Caveat' } ;
213+ case 'info' :
214+ default :
215+ return { accent : '#8D4004' , label : 'Notice' } ;
216+ }
217+ } ) ( ) ;
218+
219+ return (
220+ < AnimatePresence >
221+ { isVisible && (
222+ < motion . div
223+ initial = { { height : 0 , opacity : 0 } }
224+ animate = { { height : 'auto' , opacity : 1 } }
225+ exit = { { height : 0 , opacity : 0 } }
226+ className = "relative z-[100] bg-[#FAFAF8] border-b border-[#1A1A1A]/10"
227+ >
228+ < span
229+ aria-hidden = "true"
230+ className = "absolute top-0 left-0 right-0 h-[2px]"
231+ style = { { backgroundColor : typePalette . accent , opacity : 0.5 } }
232+ />
233+ < div className = "max-w-7xl mx-auto px-6 py-3 flex items-center justify-between gap-4" >
234+ < div className = "flex items-center gap-4 flex-1 min-w-0" >
235+ < span aria-hidden = "true" style = { { color : typePalette . accent } } >
236+ { iconFor ( bannerType , 'duotone' , 18 ) }
237+ </ span >
238+ < span
239+ className = "font-outfit text-[10px] tracking-[0.24em] uppercase shrink-0 hidden md:inline"
240+ style = { { color : typePalette . accent } }
241+ >
242+ { typePalette . label }
243+ </ span >
244+ < p className = "font-playfairDisplay italic text-[15px] md:text-[17px] leading-snug text-[#1A1A1A] truncate" >
245+ { banner . text }
246+ </ p >
247+ { renderLink (
248+ 'shrink-0 inline-flex items-center gap-1 font-outfit text-[10px] tracking-[0.2em] uppercase px-3 py-1.5 border border-[#1A1A1A]/15 text-[#1A1A1A] hover:bg-[#1A1A1A] hover:text-[#FAFAF8] transition-colors rounded-sm' ,
249+ ) }
250+ </ div >
251+ < button
252+ type = "button"
253+ onClick = { handleDismiss }
254+ className = "p-1 text-[#1A1A1A]/40 hover:text-[#1A1A1A] transition-colors shrink-0"
255+ aria-label = "Dismiss"
256+ >
257+ < XIcon size = { 18 } weight = "bold" />
258+ </ button >
259+ </ div >
260+ </ motion . div >
261+ ) }
262+ </ AnimatePresence >
263+ ) ;
264+ }
265+
266+ /* ============================================================
267+ * BRUTALIST BANNER — legacy mono slab
268+ * ============================================================ */
269+ const brutalistPalette = ( ( ) => {
270+ switch ( bannerType ) {
271+ case 'error' :
272+ return { bg : 'bg-red-600' , text : 'text-white' } ;
273+ case 'warning' :
274+ return { bg : 'bg-amber-500' , text : 'text-black' } ;
275+ case 'info' :
276+ default :
277+ return { bg : 'bg-emerald-600' , text : 'text-white' } ;
278+ }
279+ } ) ( ) ;
123280
124281 return (
125282 < AnimatePresence >
@@ -128,40 +285,18 @@ const Banner = () => {
128285 initial = { { height : 0 , opacity : 0 } }
129286 animate = { { height : 'auto' , opacity : 1 } }
130287 exit = { { height : 0 , opacity : 0 } }
131- className = { `${ styles . bg } ${ styles . text } relative z-[100] border-b-2 border-black selection:bg-white selection:text-black` }
288+ className = { `${ brutalistPalette . bg } ${ brutalistPalette . text } relative z-[100] border-b-2 border-black selection:bg-white selection:text-black` }
132289 >
133290 < div className = "max-w-7xl mx-auto px-4 py-3 flex items-center justify-between gap-4" >
134291 < div className = "flex items-center gap-3 flex-1" >
135- < span className = "shrink-0" > { styles . icon } </ span >
292+ < span className = "shrink-0" > { iconFor ( bannerType ) } </ span >
136293 < p className = "font-mono text-xs md:text-sm font-black uppercase tracking-widest leading-tight" >
137294 { banner . text }
138295 </ p >
139- { banner . link && (
140- banner . link . startsWith ( 'http' ) ? (
141- /*
142- Use native <a> tag for external absolute URLs to bypass React Router.
143- This allows the browser to perform a normal page navigation instead of
144- React Router attempting to resolve it internally which causes a 404.
145- */
146- < a
147- href = { banner . link }
148- className = "shrink-0 inline-flex items-center gap-1 bg-black/20 hover:bg-black/40 px-3 py-1 rounded-sm border border-white/20 transition-all font-bold text-[10px] uppercase"
149- >
150- { banner . linkText || 'VIEW_DETAILS' }
151- < ArrowRightIcon size = { 12 } weight = "bold" />
152- </ a >
153- ) : (
154- < Link
155- to = { banner . link }
156- className = "shrink-0 inline-flex items-center gap-1 bg-black/20 hover:bg-black/40 px-3 py-1 rounded-sm border border-white/20 transition-all font-bold text-[10px] uppercase"
157- >
158- { banner . linkText || 'VIEW_DETAILS' }
159- < ArrowRightIcon size = { 12 } weight = "bold" />
160- </ Link >
161- )
296+ { renderLink (
297+ 'shrink-0 inline-flex items-center gap-1 bg-black/20 hover:bg-black/40 px-3 py-1 rounded-sm border border-white/20 transition-all font-bold text-[10px] uppercase' ,
162298 ) }
163299 </ div >
164-
165300 < button
166301 onClick = { handleDismiss }
167302 className = "p-1 hover:bg-black/20 rounded-sm transition-colors shrink-0"
@@ -170,8 +305,6 @@ const Banner = () => {
170305 < XIcon size = { 20 } weight = "bold" />
171306 </ button >
172307 </ div >
173-
174- { /* Brutalist glitch line at bottom */ }
175308 < div className = "h-0.5 w-full bg-black/10" />
176309 </ motion . div >
177310 ) }
0 commit comments