Skip to content

Commit 7b8acbe

Browse files
committed
banner looks liek a cells executed
1 parent 41f0cff commit 7b8acbe

7 files changed

Lines changed: 943 additions & 0 deletions

File tree

public/build-data-contributors.json

Lines changed: 474 additions & 0 deletions
Large diffs are not rendered by default.

public/build-data-pr.json

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
{
2+
"count": null,
3+
"timestamp": 1780382880766
4+
}

public/build-data-pypi.json

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
{
2+
"version": "9.14.0",
3+
"releaseDate": "May 29, 2026",
4+
"timestamp": 1780382880767
5+
}

src/components/ChristmasTree.tsx

Lines changed: 123 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,123 @@
1+
import React, { useEffect, useRef } from 'react';
2+
import '../styles/christmas-trees.css';
3+
4+
export default function ChristmasTree() {
5+
const containerRef = useRef<HTMLDivElement>(null);
6+
7+
useEffect(() => {
8+
const container = containerRef.current;
9+
if (!container) return;
10+
11+
// Create SVG tree element
12+
const createTreeSVG = (scale: number = 1) => {
13+
const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
14+
svg.setAttribute('viewBox', '0 0 100 140');
15+
svg.setAttribute('width', `${80 * scale}`);
16+
svg.setAttribute('height', `${112 * scale}`);
17+
svg.className = 'dancing-tree';
18+
19+
// Tree foliage layers
20+
const foliage1 = document.createElementNS('http://www.w3.org/2000/svg', 'polygon');
21+
foliage1.setAttribute('points', '50,10 20,60 80,60');
22+
foliage1.setAttribute('fill', '#1b4d2e');
23+
svg.appendChild(foliage1);
24+
25+
const foliage2 = document.createElementNS('http://www.w3.org/2000/svg', 'polygon');
26+
foliage2.setAttribute('points', '50,40 10,90 90,90');
27+
foliage2.setAttribute('fill', '#0d3c1f');
28+
svg.appendChild(foliage2);
29+
30+
const foliage3 = document.createElementNS('http://www.w3.org/2000/svg', 'polygon');
31+
foliage3.setAttribute('points', '50,70 15,120 85,120');
32+
foliage3.setAttribute('fill', '#1b4d2e');
33+
svg.appendChild(foliage3);
34+
35+
// Tree trunk
36+
const trunk = document.createElementNS('http://www.w3.org/2000/svg', 'rect');
37+
trunk.setAttribute('x', '40');
38+
trunk.setAttribute('y', '110');
39+
trunk.setAttribute('width', '20');
40+
trunk.setAttribute('height', '30');
41+
trunk.setAttribute('fill', '#8B4513');
42+
svg.appendChild(trunk);
43+
44+
// Star on top
45+
const star = document.createElementNS('http://www.w3.org/2000/svg', 'polygon');
46+
star.setAttribute('points', '50,0 55,15 70,15 58,25 63,40 50,30 37,40 42,25 30,15 45,15');
47+
star.setAttribute('fill', '#ffd700');
48+
star.className = 'tree-star';
49+
svg.appendChild(star);
50+
51+
// Ornaments
52+
const ornaments = [
53+
{ cx: 35, cy: 40, color: '#c8102e' },
54+
{ cx: 65, cy: 45, color: '#ffd700' },
55+
{ cx: 50, cy: 50, color: '#ffd700' },
56+
{ cx: 25, cy: 75, color: '#c8102e' },
57+
{ cx: 50, cy: 85, color: '#ffd700' },
58+
{ cx: 75, cy: 80, color: '#c8102e' },
59+
{ cx: 35, cy: 105, color: '#ffd700' },
60+
{ cx: 65, cy: 110, color: '#c8102e' },
61+
];
62+
63+
ornaments.forEach((ornament) => {
64+
const circle = document.createElementNS('http://www.w3.org/2000/svg', 'circle');
65+
circle.setAttribute('cx', ornament.cx.toString());
66+
circle.setAttribute('cy', ornament.cy.toString());
67+
circle.setAttribute('r', '3');
68+
circle.setAttribute('fill', ornament.color);
69+
circle.className = 'ornament-light';
70+
svg.appendChild(circle);
71+
});
72+
73+
return svg;
74+
};
75+
76+
// Create tree with wrapper
77+
const createTree = (x: number, y: number, scale: number, opacity: number, delay: number) => {
78+
const wrapper = document.createElement('div');
79+
wrapper.className = 'tree-wrapper';
80+
wrapper.style.position = 'absolute';
81+
wrapper.style.left = `${x}px`;
82+
wrapper.style.top = `${y}px`;
83+
wrapper.style.opacity = `${opacity}`;
84+
wrapper.style.transformOrigin = 'center bottom';
85+
wrapper.style.setProperty('--animation-delay', `${delay}s`);
86+
wrapper.appendChild(createTreeSVG(scale));
87+
return wrapper;
88+
};
89+
90+
const viewportHeight = window.innerHeight || 800;
91+
const treePositions = [
92+
{ x: 10, y: viewportHeight * 0.35, scale: 1.4, opacity: 1, delay: 0 },
93+
{ x: window.innerWidth - 130, y: viewportHeight * 0.25, scale: 1.5, opacity: 1, delay: 0.1 },
94+
{ x: 80, y: viewportHeight * 0.5, scale: 1.1, opacity: 0.85, delay: 0.2 },
95+
{ x: window.innerWidth - 240, y: viewportHeight * 0.55, scale: 1.2, opacity: 0.85, delay: 0.15 },
96+
{ x: window.innerWidth / 2 - 80, y: viewportHeight * 0.45, scale: 1.3, opacity: 0.9, delay: 0.25 },
97+
{ x: 120, y: viewportHeight * 0.15, scale: 0.9, opacity: 0.6, delay: 0.3 },
98+
{ x: window.innerWidth - 200, y: viewportHeight * 0.05, scale: 0.95, opacity: 0.65, delay: 0.35 },
99+
{ x: window.innerWidth / 2 - 150, y: viewportHeight * 0.12, scale: 0.85, opacity: 0.55, delay: 0.4 },
100+
{ x: window.innerWidth / 2 + 100, y: viewportHeight * 0.2, scale: 0.9, opacity: 0.6, delay: 0.45 },
101+
{ x: 200, y: viewportHeight * 0.4, scale: 1.0, opacity: 0.75, delay: 0.05 },
102+
{ x: window.innerWidth - 350, y: viewportHeight * 0.42, scale: 1.0, opacity: 0.75, delay: 0.22 },
103+
];
104+
105+
treePositions.forEach((pos) => {
106+
container.appendChild(createTree(pos.x, pos.y, pos.scale, pos.opacity, pos.delay));
107+
});
108+
}, []);
109+
110+
return (
111+
<div
112+
ref={containerRef}
113+
style={{
114+
position: 'absolute',
115+
width: '100%',
116+
height: '100%',
117+
overflow: 'hidden',
118+
pointerEvents: 'none',
119+
zIndex: 0,
120+
}}
121+
/>
122+
);
123+
}

src/components/SeasonalBanner.tsx

Lines changed: 150 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,150 @@
1+
import { useEffect, useRef, useState } from 'react';
2+
import { getCurrentSeasonalConfig, type SeasonalConfig } from '../lib/themeUtils';
3+
import '../styles/seasonal-banner.css';
4+
5+
const DISMISS_STORAGE_KEY = 'seasonalBannerDismissed';
6+
const COLLAPSE_MS = 500;
7+
8+
// IPython REPL palette, tuned for the dark terminal surface.
9+
const C = {
10+
prompt: '#56d364', // In[]: green
11+
promptNum: '#7ee787', // [n] light green
12+
out: '#ff7b72', // Out[]: red
13+
fn: '#79c0ff', // function name blue
14+
str: '#ffa657', // string literal amber
15+
punc: '#8b949e', // parens / punctuation slate
16+
message: '#f0f6fc', // output text, near-white
17+
};
18+
19+
/** Minimal tokenizer to syntax-highlight the faux command, e.g. celebrate('pride'). */
20+
function highlightCommand(cmd: string) {
21+
const tokens: { text: string; color: string }[] = [];
22+
const re = /('[^']*'|"[^"]*")|([A-Za-z_]\w*)|(\s+)|([^\sA-Za-z_'"]+)/g;
23+
let m: RegExpExecArray | null;
24+
while ((m = re.exec(cmd)) !== null) {
25+
if (m[1]) tokens.push({ text: m[1], color: C.str });
26+
else if (m[2]) tokens.push({ text: m[2], color: C.fn });
27+
else if (m[3]) tokens.push({ text: m[3], color: C.punc });
28+
else tokens.push({ text: m[4], color: C.punc });
29+
}
30+
return tokens;
31+
}
32+
33+
export default function SeasonalBanner() {
34+
const [config, setConfig] = useState<SeasonalConfig | null>(null);
35+
const [open, setOpen] = useState(false);
36+
const dismissKey = useRef<string>('');
37+
const collapseTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
38+
39+
useEffect(() => {
40+
const current = getCurrentSeasonalConfig();
41+
if (!current || !current.banner) return;
42+
43+
// Re-show the banner each year by scoping dismissal to season id + year.
44+
const key = `${current.id}-${new Date().getFullYear()}`;
45+
dismissKey.current = key;
46+
47+
let dismissed = '';
48+
try {
49+
dismissed = localStorage.getItem(DISMISS_STORAGE_KEY) ?? '';
50+
} catch {
51+
dismissed = '';
52+
}
53+
if (dismissed === key) return;
54+
55+
setConfig(current);
56+
// Next frame: flip to open so the height/opacity transition plays.
57+
const raf = requestAnimationFrame(() => setOpen(true));
58+
return () => cancelAnimationFrame(raf);
59+
}, []);
60+
61+
const handleDismiss = () => {
62+
try {
63+
localStorage.setItem(DISMISS_STORAGE_KEY, dismissKey.current);
64+
} catch {
65+
// Ignore storage failures; the banner will simply reappear next load.
66+
}
67+
setOpen(false); // play the collapse animation, then unmount
68+
collapseTimer.current = setTimeout(() => setConfig(null), COLLAPSE_MS);
69+
};
70+
71+
// Escape-to-dismiss, matching the visible `esc` keycap affordance.
72+
useEffect(() => {
73+
if (!config) return;
74+
const onKey = (e: KeyboardEvent) => {
75+
if (e.key === 'Escape') handleDismiss();
76+
};
77+
window.addEventListener('keydown', onKey);
78+
return () => {
79+
window.removeEventListener('keydown', onKey);
80+
if (collapseTimer.current) clearTimeout(collapseTimer.current);
81+
};
82+
}, [config]);
83+
84+
if (!config) return null;
85+
86+
const month = new Date().getMonth() + 1; // 1-12; the cell number, e.g. In [6]
87+
const strip =
88+
config.accentGradient ??
89+
'linear-gradient(to right, var(--theme-primary), var(--theme-secondary), var(--theme-accent))';
90+
91+
return (
92+
<div className={`sb-shell ${open ? 'is-open' : ''}`}>
93+
<div className="sb-clip">
94+
<div
95+
className="sb-bar font-mono text-[13px] leading-none text-white"
96+
role="region"
97+
aria-label="Seasonal announcement"
98+
>
99+
<div className="sb-strip" style={{ background: strip }} aria-hidden="true" />
100+
101+
<div className="mx-auto flex max-w-7xl items-center gap-x-2.5 gap-y-1 px-4 py-2.5 pr-14 sm:px-6 flex-wrap">
102+
{/* In [n]: prompt — the cell number is the month */}
103+
<span className="sb-fade sb-d1 whitespace-nowrap font-semibold tracking-tight">
104+
<span style={{ color: C.prompt }}>In&nbsp;</span>
105+
<span style={{ color: C.punc }}>[</span>
106+
<span style={{ color: C.promptNum }}>{month}</span>
107+
<span style={{ color: C.punc }}>]</span>
108+
<span style={{ color: C.prompt }}>:</span>
109+
</span>
110+
111+
{/* faux command, syntax-highlighted; hidden on the smallest screens */}
112+
{config.command && (
113+
<code className="sb-fade sb-d2 hidden whitespace-nowrap sm:inline">
114+
{highlightCommand(config.command).map((t, i) => (
115+
<span key={i} style={{ color: t.color }}>
116+
{t.text}
117+
</span>
118+
))}
119+
</code>
120+
)}
121+
122+
{/* Out [n]: result prompt, IPython red */}
123+
<span
124+
className="sb-fade sb-d3 hidden whitespace-nowrap font-semibold tracking-tight sm:inline"
125+
style={{ color: C.out }}
126+
aria-hidden="true"
127+
>
128+
Out[{month}]:
129+
</span>
130+
131+
{/* the message itself — the cell's output */}
132+
<span className="sb-fade sb-d3 font-medium" style={{ color: C.message }}>
133+
{config.banner}
134+
<span className="sb-cursor ml-1.5" aria-hidden="true" />
135+
</span>
136+
</div>
137+
138+
<button
139+
onClick={handleDismiss}
140+
aria-label="Dismiss announcement"
141+
title="Dismiss (Esc)"
142+
className="absolute right-3 top-1/2 inline-flex -translate-y-1/2 items-center rounded-md border border-white/25 bg-white/5 px-2 py-1 text-[11px] font-medium leading-none text-white/70 transition-colors hover:border-white/50 hover:bg-white/10 hover:text-white focus:outline-none focus:ring-2 focus:ring-white/50"
143+
>
144+
esc
145+
</button>
146+
</div>
147+
</div>
148+
</div>
149+
);
150+
}

src/styles/christmas-trees.css

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
/* Christmas Tree Dance Animations */
2+
3+
.tree-wrapper {
4+
animation: tree-dance 3s ease-in-out infinite;
5+
animation-delay: var(--animation-delay, 0s);
6+
}
7+
8+
@keyframes tree-dance {
9+
0% {
10+
transform: rotate(0deg) translateY(0px);
11+
}
12+
25% {
13+
transform: rotate(-3deg) translateY(-4px);
14+
}
15+
50% {
16+
transform: rotate(3deg) translateY(-8px);
17+
}
18+
75% {
19+
transform: rotate(-2deg) translateY(-4px);
20+
}
21+
100% {
22+
transform: rotate(0deg) translateY(0px);
23+
}
24+
}
25+
26+
/* Ornament lights twinkling */
27+
.ornament-light {
28+
animation: ornament-twinkle 1.5s ease-in-out infinite;
29+
}
30+
31+
@keyframes ornament-twinkle {
32+
0%, 100% {
33+
opacity: 1;
34+
filter: drop-shadow(0 0 2px currentColor);
35+
}
36+
50% {
37+
opacity: 0.5;
38+
filter: drop-shadow(0 0 6px currentColor);
39+
}
40+
}
41+
42+
/* Tree star twinkling */
43+
.tree-star {
44+
animation: star-twinkle 2s ease-in-out infinite;
45+
transform-origin: center;
46+
}
47+
48+
@keyframes star-twinkle {
49+
0%, 100% {
50+
opacity: 1;
51+
filter: drop-shadow(0 0 3px #ffd700);
52+
}
53+
50% {
54+
opacity: 0.4;
55+
filter: drop-shadow(0 0 10px #ffd700);
56+
}
57+
}
58+
59+
/* Enhanced dancing tree with more complex movement */
60+
.dancing-tree {
61+
animation: dancing-tree-movement 4s ease-in-out infinite;
62+
}
63+
64+
@keyframes dancing-tree-movement {
65+
0% {
66+
transform: scaleY(1);
67+
}
68+
25% {
69+
transform: scaleY(0.98) scaleX(0.99);
70+
}
71+
50% {
72+
transform: scaleY(1.02) scaleX(1.01);
73+
}
74+
75% {
75+
transform: scaleY(0.99) scaleX(0.995);
76+
}
77+
100% {
78+
transform: scaleY(1);
79+
}
80+
}

0 commit comments

Comments
 (0)