Skip to content

Commit eb08478

Browse files
committed
feat(terracotta): theme-aware banner + terracotta timeline & launch banner
- Banner: brutalist / luxe / terracotta branches with per-type palettes - Terracotta banner: bone strip, terra dot, Fraunces italic, mono kicker - Luxe banner: cream strip, bronze hairline, Playfair italic - Add 2026-04-22 Fezterracotta timeline entry - Add fezterracotta-launch banner (active through 2026-05-05)
1 parent fd6440f commit eb08478

3 files changed

Lines changed: 200 additions & 49 deletions

File tree

public/banner.piml

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -169,6 +169,16 @@
169169
(link) https://fezcode.com/net_run
170170
(linkText) Play net_run
171171

172+
> (banner)
173+
(id) fezterracotta-launch
174+
(type) info
175+
(from) 2026-04-22T00:00:00Z
176+
(to) 2026-05-05T23:59:59Z
177+
(text) FEZTERRACOTTA IS ONLINE: A WEIGHTED CODEX OF BONE PAPER AND TERRA INK. ENABLE VIA SETTINGS OR COMMAND PALETTE.
178+
(isActive) true
179+
(link) /settings#fezcodex-theme
180+
(linkText) Enable Terracotta
181+
172182
> (banner)
173183
(id) urban-rogue-launch
174184
(type) info

public/timeline/timeline.piml

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,12 @@
11
(timeline)
2+
> (item)
3+
(date) 2026-04-22
4+
(title) Fezterracotta: The Weighted Codex
5+
(description) Deployed 'Fezterracotta', a third design language built around the Plumb identity — bone paper, terra ink, brass and sage accents, Fraunces variable serif and IBM Plex Mono. Every page was rebuilt from scratch as editorial matter: chapter-numbered layouts, dictionary entries, plumb-line hero wordmark, registration-crosshair generative plates, a new Galley Proof blog reader, and theme-aware Mermaid, Toasts, Contact Modal, and Banner.
6+
(type) feature
7+
(icon) PaletteIcon
8+
(link) /settings#fezcodex-theme
9+
210
> (item)
311
(date) 2026-04-19
412
(title) Application Archive Optimization

src/components/Banner.jsx

Lines changed: 182 additions & 49 deletions
Original file line numberDiff line numberDiff line change
@@ -9,12 +9,16 @@ import {
99
ArrowRightIcon,
1010
} from '@phosphor-icons/react';
1111
import { Link } from 'react-router-dom';
12+
import { useVisualSettings } from '../context/VisualSettingsContext';
1213

1314
const DISMISSED_BANNERS_KEY = 'dismissed-banners';
1415

1516
const 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

Comments
 (0)