diff --git a/package.json b/package.json
index 8e42344e7..16dce74e4 100644
--- a/package.json
+++ b/package.json
@@ -87,6 +87,7 @@
"tailwindcss": "^3.4.0"
},
"lint-staged": {
- "*": "prettier --ignore-unknown --write"
+ "python/{api-examples-source,concept-source,tutorial-source}/**/*.{py,js,css,html}": "prettier --ignore-unknown --write --tab-width=4",
+ "!(python/{api-examples-source,concept-source,tutorial-source}/**/*.{py,js,css,html})": "prettier --ignore-unknown --write"
}
}
diff --git a/python/concept-source/components-danger-button/danger_button_component/__init__.py b/python/concept-source/components-danger-button/danger_button_component/__init__.py
new file mode 100644
index 000000000..f39638441
--- /dev/null
+++ b/python/concept-source/components-danger-button/danger_button_component/__init__.py
@@ -0,0 +1,25 @@
+from pathlib import Path
+import streamlit as st
+
+component_dir = Path(__file__).parent
+
+
+@st.cache_data
+def load_component_code():
+ with open(component_dir / "button.css", "r") as f:
+ CSS = f.read()
+ with open(component_dir / "button.html", "r") as f:
+ HTML = f.read()
+ with open(component_dir / "button.js", "r") as f:
+ JS = f.read()
+ return HTML, CSS, JS
+
+
+HTML, CSS, JS = load_component_code()
+
+danger_button = st.components.v2.component(
+ name="hold_to_confirm",
+ html=HTML,
+ css=CSS,
+ js=JS,
+)
diff --git a/python/concept-source/components-danger-button/danger_button_component/button.css b/python/concept-source/components-danger-button/danger_button_component/button.css
new file mode 100644
index 000000000..d1db972db
--- /dev/null
+++ b/python/concept-source/components-danger-button/danger_button_component/button.css
@@ -0,0 +1,175 @@
+.danger-zone {
+ font-family: var(--st-font);
+ padding: 2rem;
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ gap: 1.5rem;
+}
+
+.warning-banner {
+ display: flex;
+ align-items: center;
+ gap: 0.5rem;
+ padding: 0.5rem 1rem;
+ background: var(--st-red-background-color);
+ border: 1px solid var(--st-red-color);
+ border-radius: var(--st-base-radius);
+}
+
+.warning-icon {
+ font-size: 1rem;
+}
+
+.warning-text {
+ font-size: 0.75rem;
+ text-transform: uppercase;
+ letter-spacing: 0.15em;
+ color: var(--st-red-color);
+}
+
+.hold-button {
+ position: relative;
+ width: 7.5rem;
+ height: 7.5rem;
+ padding: 0 2rem;
+ border-radius: 50%;
+ border: 1px solid var(--st-primary-color);
+ background: var(--st-secondary-background-color);
+ cursor: pointer;
+ transition: all 0.3s cubic-bezier(0.34, 1.56, 0.64, 1);
+}
+
+.hold-button:hover {
+ transform: scale(1.05);
+ border-color: var(--st-red-color);
+}
+
+.hold-button:active:not(:disabled) {
+ transform: scale(0.98);
+}
+
+.hold-button:disabled {
+ cursor: not-allowed;
+ opacity: 0.9;
+}
+
+.hold-button.holding {
+ animation: pulse 0.5s ease-in-out infinite;
+ border-color: var(--st-red-color);
+}
+
+.hold-button.triggered {
+ animation: success-burst 0.6s ease-out forwards;
+}
+
+@keyframes pulse {
+ 0%,
+ 100% {
+ box-shadow: 0 0 0 0 var(--st-red-color);
+ }
+ 50% {
+ box-shadow: 0 0 0 15px transparent;
+ }
+}
+
+@keyframes success-burst {
+ 0% {
+ transform: scale(1);
+ }
+ 50% {
+ transform: scale(1.15);
+ background: var(--st-red-background-color);
+ }
+ 100% {
+ transform: scale(1);
+ }
+}
+
+.progress-ring {
+ position: absolute;
+ top: 0;
+ left: 0;
+ width: 100%;
+ height: 100%;
+ transform: rotate(-90deg);
+}
+
+.ring-bg {
+ fill: none;
+ stroke: var(--st-border-color);
+ stroke-width: 4;
+}
+
+.ring-progress {
+ fill: none;
+ stroke: var(--st-red-color);
+ stroke-width: 4;
+ stroke-linecap: round;
+ stroke-dasharray: 283;
+ stroke-dashoffset: 283;
+ transition: stroke-dashoffset 0.1s linear;
+ filter: drop-shadow(0 0 6px var(--st-red-color));
+}
+
+.button-content {
+ position: relative;
+ z-index: 1;
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ gap: 0.25rem;
+}
+
+.icon {
+ font-size: 2rem;
+ transition: transform 0.3s ease;
+}
+
+.hold-button:hover .icon {
+ transform: scale(1.1);
+}
+
+.hold-button.holding .icon {
+ animation: shake 0.15s ease-in-out infinite;
+}
+
+@keyframes shake {
+ 0%,
+ 100% {
+ transform: translateX(0);
+ }
+ 25% {
+ transform: translateX(-2px) rotate(-5deg);
+ }
+ 75% {
+ transform: translateX(2px) rotate(5deg);
+ }
+}
+
+.label {
+ font-size: 0.65rem;
+ text-transform: uppercase;
+ letter-spacing: 0.1em;
+ color: var(--st-text-color);
+ opacity: 0.6;
+ transition: all 0.3s ease;
+}
+
+.hold-button.holding .label {
+ color: var(--st-red-color);
+ opacity: 1;
+}
+
+.hold-button.triggered .icon,
+.hold-button.triggered .label {
+ color: var(--st-primary-color);
+ opacity: 1;
+}
+
+.hint {
+ font-size: 0.7rem;
+ color: var(--st-text-color);
+ opacity: 0.5;
+ margin: 0;
+}
diff --git a/python/concept-source/components-danger-button/danger_button_component/button.html b/python/concept-source/components-danger-button/danger_button_component/button.html
new file mode 100644
index 000000000..74b0eb25b
--- /dev/null
+++ b/python/concept-source/components-danger-button/danger_button_component/button.html
@@ -0,0 +1,25 @@
+
+
+ ⚠️
+ Danger Zone
+
+
+
+
+
Press and hold for 2 seconds to confirm
+
diff --git a/python/concept-source/components-danger-button/danger_button_component/button.js b/python/concept-source/components-danger-button/danger_button_component/button.js
new file mode 100644
index 000000000..a78ab4df3
--- /dev/null
+++ b/python/concept-source/components-danger-button/danger_button_component/button.js
@@ -0,0 +1,111 @@
+const HOLD_DURATION = 2000; // 2 seconds
+const COOLDOWN_DURATION = 1500; // cooldown after trigger
+const CIRCUMFERENCE = 2 * Math.PI * 45; // circle circumference
+
+export default function ({ parentElement, setTriggerValue }) {
+ const button = parentElement.querySelector("#danger-btn");
+ const progress = parentElement.querySelector("#ring-progress");
+ const icon = parentElement.querySelector("#icon");
+ const label = parentElement.querySelector("#label");
+
+ let startTime = null;
+ let animationFrame = null;
+ let isDisabled = false; // Prevent interaction during cooldown
+
+ function updateProgress() {
+ if (!startTime) return;
+
+ const elapsed = Date.now() - startTime;
+ const progressPercent = Math.min(elapsed / HOLD_DURATION, 1);
+ const offset = CIRCUMFERENCE * (1 - progressPercent);
+
+ progress.style.strokeDashoffset = offset;
+
+ if (progressPercent >= 1) {
+ // Triggered!
+ triggerAction();
+ } else {
+ animationFrame = requestAnimationFrame(updateProgress);
+ }
+ }
+
+ function startHold() {
+ if (isDisabled) return; // Ignore if in cooldown
+
+ startTime = Date.now();
+ button.classList.add("holding");
+ label.textContent = "Keep holding...";
+ animationFrame = requestAnimationFrame(updateProgress);
+ }
+
+ function cancelHold() {
+ if (isDisabled) return; // Ignore if in cooldown
+
+ startTime = null;
+ button.classList.remove("holding");
+ label.textContent = "Hold to Delete";
+ progress.style.strokeDashoffset = CIRCUMFERENCE;
+
+ if (animationFrame) {
+ cancelAnimationFrame(animationFrame);
+ animationFrame = null;
+ }
+ }
+
+ function triggerAction() {
+ cancelAnimationFrame(animationFrame);
+ animationFrame = null;
+ startTime = null;
+ isDisabled = true; // Disable during cooldown
+
+ button.classList.remove("holding");
+ button.classList.add("triggered");
+ button.disabled = true;
+
+ icon.textContent = "✓";
+ label.textContent = "Deleted!";
+ progress.style.strokeDashoffset = 0;
+
+ // Send trigger to Python
+ setTriggerValue("confirmed", true);
+
+ // Reset after cooldown
+ setTimeout(() => {
+ button.classList.remove("triggered");
+ button.disabled = false;
+ isDisabled = false;
+ icon.textContent = "🗑️";
+ label.textContent = "Hold to Delete";
+ progress.style.strokeDashoffset = CIRCUMFERENCE;
+ }, COOLDOWN_DURATION);
+ }
+
+ function handleTouchStart(e) {
+ e.preventDefault();
+ startHold();
+ }
+
+ // Mouse events
+ button.addEventListener("mousedown", startHold);
+ button.addEventListener("mouseup", cancelHold);
+ button.addEventListener("mouseleave", cancelHold);
+
+ // Touch events for mobile
+ button.addEventListener("touchstart", handleTouchStart);
+ button.addEventListener("touchend", cancelHold);
+ button.addEventListener("touchcancel", cancelHold);
+
+ return () => {
+ if (animationFrame) cancelAnimationFrame(animationFrame);
+
+ // Remove mouse event listeners
+ button.removeEventListener("mousedown", startHold);
+ button.removeEventListener("mouseup", cancelHold);
+ button.removeEventListener("mouseleave", cancelHold);
+
+ // Remove touch event listeners
+ button.removeEventListener("touchstart", handleTouchStart);
+ button.removeEventListener("touchend", cancelHold);
+ button.removeEventListener("touchcancel", cancelHold);
+ };
+}
diff --git a/python/concept-source/components-danger-button/streamlit_app.py b/python/concept-source/components-danger-button/streamlit_app.py
new file mode 100644
index 000000000..72d97a623
--- /dev/null
+++ b/python/concept-source/components-danger-button/streamlit_app.py
@@ -0,0 +1,27 @@
+import streamlit as st
+from danger_button_component import danger_button
+
+st.title("Hold-to-Confirm Button")
+st.caption("A dangerous action that requires intentional confirmation")
+
+# Track deletion events
+if "deleted_items" not in st.session_state:
+ st.session_state.deleted_items = []
+
+# Callback when deletion is confirmed
+def on_delete_confirmed():
+ st.session_state.deleted_items.append(
+ f"Deleted item #{len(st.session_state.deleted_items) + 1}"
+ )
+ st.toast("🗑️ Item permanently deleted!", icon="⚠️")
+
+
+# Render the component
+result = danger_button(key="danger_btn", on_confirmed_change=on_delete_confirmed)
+
+# Show deletion history
+if st.session_state.deleted_items:
+ st.divider()
+ st.subheader("Deletion Log")
+ for item in reversed(st.session_state.deleted_items[-3:]):
+ st.write(f"• {item}")
\ No newline at end of file
diff --git a/python/concept-source/components-hello-world.py b/python/concept-source/components-hello-world.py
new file mode 100644
index 000000000..8aab3d5ff
--- /dev/null
+++ b/python/concept-source/components-hello-world.py
@@ -0,0 +1,9 @@
+import streamlit as st
+
+hello_component = st.components.v2.component(
+ name="hello_world",
+ html="Hello, World!
",
+ css="h2 { color: var(--st-primary-color); }",
+)
+
+hello_component()
diff --git a/python/concept-source/components-interactive-counter/my_component/__init__.py b/python/concept-source/components-interactive-counter/my_component/__init__.py
new file mode 100644
index 000000000..a26f9d391
--- /dev/null
+++ b/python/concept-source/components-interactive-counter/my_component/__init__.py
@@ -0,0 +1,24 @@
+import streamlit as st
+from pathlib import Path
+
+# Get the current file's directory
+_COMPONENT_DIR = Path(__file__).parent
+
+@st.cache_data
+def load_html():
+ with open(_COMPONENT_DIR / "component.html", "r") as f:
+ return f.read()
+
+@st.cache_data
+def load_css():
+ with open(_COMPONENT_DIR / "component.css", "r") as f:
+ return f.read()
+
+@st.cache_data
+def load_js():
+ with open(_COMPONENT_DIR / "component.js", "r") as f:
+ return f.read()
+
+HTML = load_html()
+CSS = load_css()
+JS = load_js()
diff --git a/python/concept-source/components-interactive-counter/my_component/component.css b/python/concept-source/components-interactive-counter/my_component/component.css
new file mode 100644
index 000000000..6afc58e6e
--- /dev/null
+++ b/python/concept-source/components-interactive-counter/my_component/component.css
@@ -0,0 +1,29 @@
+.counter {
+ padding: 20px;
+ border: 1px solid var(--st-border-color);
+ border-radius: var(--st-base-radius);
+ font-family: var(--st-font);
+ text-align: center;
+}
+
+.buttons {
+ margin-top: 15px;
+}
+
+button {
+ margin: 0 5px;
+ padding: 8px 16px;
+ background: var(--st-primary-color);
+ color: white;
+ border: none;
+ border-radius: var(--st-button-radius);
+ cursor: pointer;
+}
+
+button:hover {
+ opacity: 0.8;
+}
+
+#reset {
+ background: var(--st-red-color);
+}
diff --git a/python/concept-source/components-interactive-counter/my_component/component.html b/python/concept-source/components-interactive-counter/my_component/component.html
new file mode 100644
index 000000000..5b0dc73b8
--- /dev/null
+++ b/python/concept-source/components-interactive-counter/my_component/component.html
@@ -0,0 +1,8 @@
+
+
Count: 0
+
+
+
+
+
+
diff --git a/python/concept-source/components-interactive-counter/my_component/component.js b/python/concept-source/components-interactive-counter/my_component/component.js
new file mode 100644
index 000000000..c44717e1d
--- /dev/null
+++ b/python/concept-source/components-interactive-counter/my_component/component.js
@@ -0,0 +1,43 @@
+export default function ({
+ parentElement,
+ setStateValue,
+ setTriggerValue,
+ data,
+}) {
+ let count = data?.initialCount || 0;
+ const display = parentElement.querySelector("#display");
+ const incrementBtn = parentElement.querySelector("#increment");
+ const decrementBtn = parentElement.querySelector("#decrement");
+ const resetBtn = parentElement.querySelector("#reset");
+
+ const updateDisplay = () => {
+ display.textContent = count;
+ setStateValue("count", count); // Persistent state
+ };
+
+ incrementBtn.onclick = () => {
+ count++;
+ updateDisplay();
+ };
+
+ decrementBtn.onclick = () => {
+ count--;
+ updateDisplay();
+ };
+
+ resetBtn.onclick = () => {
+ count = 0;
+ updateDisplay();
+ setTriggerValue("reset", true); // One-time trigger
+ };
+
+ // Initialize
+ updateDisplay();
+
+ // Cleanup function
+ return () => {
+ incrementBtn.removeEventListener("click", incrementBtn.onclick);
+ decrementBtn.removeEventListener("click", decrementBtn.onclick);
+ resetBtn.removeEventListener("click", resetBtn.onclick);
+ };
+}
diff --git a/python/concept-source/components-interactive-counter/streamlit_app.py b/python/concept-source/components-interactive-counter/streamlit_app.py
new file mode 100644
index 000000000..778c7cf3e
--- /dev/null
+++ b/python/concept-source/components-interactive-counter/streamlit_app.py
@@ -0,0 +1,24 @@
+import streamlit as st
+from my_component import HTML, CSS, JS
+
+# Interactive counter with both state and triggers
+counter = st.components.v2.component(
+ "interactive_counter",
+ html=HTML,
+ css=CSS,
+ js=JS,
+)
+
+# Use with callbacks
+result = counter(
+ data={"initialCount": 0},
+ on_count_change=lambda: None, # Track count state
+ on_reset_change=lambda: None, # Handle reset events
+)
+
+# Display current state
+st.write(f"Current count: {result.count}")
+
+# Show when reset was triggered (only for one rerun)
+if result.reset:
+ st.toast("Counter was reset!")
diff --git a/python/concept-source/components-radial-dial/radial_dial_component/__init__.py b/python/concept-source/components-radial-dial/radial_dial_component/__init__.py
new file mode 100644
index 000000000..78dd41897
--- /dev/null
+++ b/python/concept-source/components-radial-dial/radial_dial_component/__init__.py
@@ -0,0 +1,25 @@
+from pathlib import Path
+import streamlit as st
+
+component_dir = Path(__file__).parent
+
+
+@st.cache_data
+def load_component_code():
+ with open(component_dir / "dial.css", "r") as f:
+ CSS = f.read()
+ with open(component_dir / "dial.html", "r") as f:
+ HTML = f.read()
+ with open(component_dir / "dial.js", "r") as f:
+ JS = f.read()
+ return HTML, CSS, JS
+
+
+HTML, CSS, JS = load_component_code()
+
+radial_dial = st.components.v2.component(
+ name="radial_dial",
+ html=HTML,
+ css=CSS,
+ js=JS,
+)
diff --git a/python/concept-source/components-radial-dial/radial_dial_component/dial.css b/python/concept-source/components-radial-dial/radial_dial_component/dial.css
new file mode 100644
index 000000000..5a6ca37a4
--- /dev/null
+++ b/python/concept-source/components-radial-dial/radial_dial_component/dial.css
@@ -0,0 +1,160 @@
+.dial-container {
+ font-family: var(--st-font);
+ color: var(--st-text-color);
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ padding: 1.5rem;
+ position: relative;
+}
+
+.dial-svg {
+ width: 18rem;
+ height: 12rem;
+}
+
+/* Track (background arc) */
+.dial-track {
+ fill: none;
+ stroke: var(--st-secondary-background-color);
+ stroke-width: 20;
+ stroke-linecap: round;
+}
+
+/* Colored segments */
+.dial-segment {
+ fill: none;
+ stroke-width: 20;
+ stroke-linecap: round;
+ opacity: 0.15;
+}
+
+.segment-low {
+ stroke: var(--st-green-color);
+}
+.segment-mid {
+ stroke: var(--st-yellow-color);
+}
+.segment-high {
+ stroke: var(--st-red-color);
+}
+
+/* Progress arc */
+.dial-progress {
+ fill: none;
+ stroke: var(--st-green-color);
+ stroke-width: 20;
+ stroke-linecap: round;
+ filter: drop-shadow(0 0 8px var(--st-green-color));
+ transition:
+ stroke-dashoffset 0.8s cubic-bezier(0.34, 1.2, 0.64, 1),
+ stroke 0.5s ease;
+}
+
+/* Tick marks */
+.ticks line {
+ stroke: var(--st-text-color);
+ stroke-width: 2;
+ opacity: 0.3;
+}
+
+.ticks line.major {
+ stroke-width: 3;
+ opacity: 0.5;
+}
+
+/* Needle */
+.needle-group {
+ transform-origin: 100px 95px;
+ transition: transform 0.8s cubic-bezier(0.34, 1.2, 0.64, 1);
+}
+
+.needle {
+ fill: var(--st-text-color);
+ filter: drop-shadow(2px 2px 4px rgba(0, 0, 0, 0.3));
+}
+
+.needle-shadow {
+ fill: rgba(0, 0, 0, 0.2);
+ transform: translate(2px, 2px);
+}
+
+.needle-cap {
+ fill: var(--st-secondary-background-color);
+ stroke: var(--st-border-color);
+ stroke-width: 2;
+}
+
+.needle-cap-inner {
+ fill: var(--st-text-color);
+ opacity: 0.8;
+}
+
+/* Value display */
+.value-display {
+ margin-top: -2rem;
+ text-align: center;
+}
+
+.value {
+ font-family: var(--st-code-font);
+ font-size: 2.5rem;
+ font-weight: 700;
+ letter-spacing: -0.02em;
+ transition: color 0.5s ease;
+}
+
+.unit {
+ font-size: 1rem;
+ font-weight: 500;
+ opacity: 0.6;
+ margin-left: 0.25rem;
+}
+
+/* Labels */
+.labels {
+ display: flex;
+ justify-content: space-between;
+ width: 240px;
+ margin-top: 0.5rem;
+}
+
+.label {
+ font-size: 0.75rem;
+ font-weight: 500;
+ opacity: 0.5;
+}
+
+/* Title */
+.title {
+ font-size: 0.8rem;
+ font-weight: 600;
+ text-transform: uppercase;
+ letter-spacing: 0.1em;
+ opacity: 0.7;
+ margin-top: 0.75rem;
+}
+
+/* Color themes based on value */
+.dial-progress.low {
+ stroke: var(--st-green-color);
+ filter: drop-shadow(0 0 8px var(--st-green-color));
+}
+.dial-progress.mid {
+ stroke: var(--st-yellow-color);
+ filter: drop-shadow(0 0 8px var(--st-yellow-color));
+}
+.dial-progress.high {
+ stroke: var(--st-red-color);
+ filter: drop-shadow(0 0 8px var(--st-red-color));
+}
+
+.value.low {
+ color: var(--st-green-color);
+}
+.value.mid {
+ color: var(--st-yellow-color);
+}
+.value.high {
+ color: var(--st-red-color);
+}
diff --git a/python/concept-source/components-radial-dial/radial_dial_component/dial.html b/python/concept-source/components-radial-dial/radial_dial_component/dial.html
new file mode 100644
index 000000000..c673e99e6
--- /dev/null
+++ b/python/concept-source/components-radial-dial/radial_dial_component/dial.html
@@ -0,0 +1,40 @@
+
+
+
+
+
+ 0
+
+
+
+
+
+ 0
+ 100
+
+
+
+
+
diff --git a/python/concept-source/components-radial-dial/radial_dial_component/dial.js b/python/concept-source/components-radial-dial/radial_dial_component/dial.js
new file mode 100644
index 000000000..f230b731c
--- /dev/null
+++ b/python/concept-source/components-radial-dial/radial_dial_component/dial.js
@@ -0,0 +1,133 @@
+// Arc parameters
+const CX = 100;
+const CY = 95;
+const RADIUS = 75;
+const START_ANGLE = -135; // degrees from vertical
+const END_ANGLE = 135;
+const ANGLE_RANGE = END_ANGLE - START_ANGLE; // 270 degrees
+
+function polarToCartesian(angle) {
+ const rad = ((angle - 90) * Math.PI) / 180;
+ return {
+ x: CX + RADIUS * Math.cos(rad),
+ y: CY + RADIUS * Math.sin(rad),
+ };
+}
+
+function describeArc(start, end) {
+ const startPoint = polarToCartesian(start);
+ const endPoint = polarToCartesian(end);
+ const largeArc = end - start > 180 ? 1 : 0;
+ return `M ${startPoint.x} ${startPoint.y} A ${RADIUS} ${RADIUS} 0 ${largeArc} 1 ${endPoint.x} ${endPoint.y}`;
+}
+
+export default function ({ parentElement, data }) {
+ const track = parentElement.querySelector("#track");
+ const progress = parentElement.querySelector("#progress");
+ const segmentLow = parentElement.querySelector("#segment-low");
+ const segmentMid = parentElement.querySelector("#segment-mid");
+ const segmentHigh = parentElement.querySelector("#segment-high");
+ const ticksGroup = parentElement.querySelector("#ticks");
+ const needleGroup = parentElement.querySelector("#needle-group");
+ const valueEl = parentElement.querySelector("#value");
+ const unitEl = parentElement.querySelector("#unit");
+ const minLabel = parentElement.querySelector("#min-label");
+ const maxLabel = parentElement.querySelector("#max-label");
+ const titleEl = parentElement.querySelector("#title");
+
+ // Draw background track
+ track.setAttribute("d", describeArc(START_ANGLE, END_ANGLE));
+
+ // Draw colored segments (thirds)
+ const third = ANGLE_RANGE / 3;
+ segmentLow.setAttribute("d", describeArc(START_ANGLE, START_ANGLE + third));
+ segmentMid.setAttribute(
+ "d",
+ describeArc(START_ANGLE + third, START_ANGLE + 2 * third),
+ );
+ segmentHigh.setAttribute(
+ "d",
+ describeArc(START_ANGLE + 2 * third, END_ANGLE),
+ );
+
+ // Draw tick marks
+ const numTicks = 10;
+ ticksGroup.innerHTML = "";
+ for (let i = 0; i <= numTicks; i++) {
+ const angle = START_ANGLE + (ANGLE_RANGE * i) / numTicks;
+ const isMajor = i % 5 === 0;
+ const innerRadius = isMajor ? RADIUS - 30 : RADIUS - 25;
+ const outerRadius = RADIUS - 20;
+
+ const rad = ((angle - 90) * Math.PI) / 180;
+ const x1 = CX + innerRadius * Math.cos(rad);
+ const y1 = CY + innerRadius * Math.sin(rad);
+ const x2 = CX + outerRadius * Math.cos(rad);
+ const y2 = CY + outerRadius * Math.sin(rad);
+
+ const line = document.createElementNS(
+ "http://www.w3.org/2000/svg",
+ "line",
+ );
+ line.setAttribute("x1", x1);
+ line.setAttribute("y1", y1);
+ line.setAttribute("x2", x2);
+ line.setAttribute("y2", y2);
+ if (isMajor) line.classList.add("major");
+ ticksGroup.appendChild(line);
+ }
+
+ function update(config) {
+ const {
+ value = 0,
+ min = 0,
+ max = 100,
+ min_label = String(min),
+ max_label = String(max),
+ unit = "",
+ title = "",
+ color_zones = true,
+ } = config;
+
+ // Calculate percentage
+ const percent = Math.max(0, Math.min(1, (value - min) / (max - min)));
+
+ // Calculate angle for this value
+ const valueAngle = START_ANGLE + ANGLE_RANGE * percent;
+
+ // Update progress arc
+ progress.setAttribute("d", describeArc(START_ANGLE, valueAngle));
+
+ // Update needle rotation
+ needleGroup.style.transform = `rotate(${valueAngle}deg)`;
+
+ // Determine color zone
+ let zone = "low";
+ if (percent > 0.66) zone = "high";
+ else if (percent > 0.33) zone = "mid";
+
+ // Apply color classes if color_zones enabled
+ progress.classList.remove("low", "mid", "high");
+ valueEl.classList.remove("low", "mid", "high");
+
+ if (color_zones) {
+ progress.classList.add(zone);
+ valueEl.classList.add(zone);
+ }
+
+ // Update text
+ valueEl.textContent =
+ typeof value === "number"
+ ? Number.isInteger(value)
+ ? value
+ : value.toFixed(1)
+ : value;
+ unitEl.textContent = unit;
+ minLabel.textContent = min_label;
+ maxLabel.textContent = max_label;
+ titleEl.textContent = title;
+ }
+
+ // Initial render
+ update(data || {});
+}
diff --git a/python/concept-source/components-radial-dial/streamlit_app.py b/python/concept-source/components-radial-dial/streamlit_app.py
new file mode 100644
index 000000000..a032a0755
--- /dev/null
+++ b/python/concept-source/components-radial-dial/streamlit_app.py
@@ -0,0 +1,20 @@
+import streamlit as st
+from radial_dial_component import radial_dial
+
+st.title("Radial Dial")
+st.caption("A display-only component with smooth transitions")
+
+st.subheader("CPU Temperature")
+temp = st.slider("Adjust temperature", 30, 100, 45)
+radial_dial(
+ key="temp_dial",
+ data={
+ "value": temp,
+ "min": 30,
+ "max": 100,
+ "min_label": "Cool",
+ "max_label": "Hot",
+ "unit": "°C",
+ "title": "CPU Temperature"
+ }
+)
\ No newline at end of file
diff --git a/python/concept-source/components-radial-menu/radial_menu_component/__init__.py b/python/concept-source/components-radial-menu/radial_menu_component/__init__.py
new file mode 100644
index 000000000..52f033809
--- /dev/null
+++ b/python/concept-source/components-radial-menu/radial_menu_component/__init__.py
@@ -0,0 +1,25 @@
+from pathlib import Path
+import streamlit as st
+
+component_dir = Path(__file__).parent
+
+
+@st.cache_data
+def load_component_code():
+ with open(component_dir / "menu.css", "r") as f:
+ CSS = f.read()
+ with open(component_dir / "menu.html", "r") as f:
+ HTML = f.read()
+ with open(component_dir / "menu.js", "r") as f:
+ JS = f.read()
+ return HTML, CSS, JS
+
+
+HTML, CSS, JS = load_component_code()
+
+radial_menu = st.components.v2.component(
+ name="radial_menu",
+ html=HTML,
+ css=CSS,
+ js=JS,
+)
diff --git a/python/concept-source/components-radial-menu/radial_menu_component/menu.css b/python/concept-source/components-radial-menu/radial_menu_component/menu.css
new file mode 100644
index 000000000..e583eafa4
--- /dev/null
+++ b/python/concept-source/components-radial-menu/radial_menu_component/menu.css
@@ -0,0 +1,112 @@
+.radial-menu {
+ position: relative;
+ display: inline-block;
+ font-family: var(--st-font);
+}
+
+/* The circular selector button and menu items*/
+.menu-selector,
+.menu-item {
+ width: 3.25rem;
+ height: 3.25rem;
+ border-radius: 50%;
+ border: 2px solid var(--st-border-color);
+ cursor: pointer;
+ background: var(--st-secondary-background-color);
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ transition: all 0.2s ease;
+ font-size: 1.5rem;
+}
+
+.menu-selector:hover {
+ transform: scale(1.05);
+ border-color: var(--st-primary-color);
+}
+
+/* Overlay container */
+.menu-overlay {
+ position: absolute;
+ top: 50%;
+ left: 50%;
+ transform: translate(-50%, -50%);
+ z-index: 100;
+ pointer-events: none;
+}
+
+/* The ring of menu items */
+.menu-ring {
+ position: relative;
+ width: 13rem;
+ height: 13rem;
+ transform: scale(0);
+ opacity: 0;
+ transition:
+ transform 0.4s cubic-bezier(0.34, 1.56, 0.64, 1),
+ opacity 0.2s ease;
+}
+
+.menu-ring.open {
+ transform: scale(1);
+ opacity: 1;
+ pointer-events: auto;
+}
+
+/* Menu items arranged in a circle (6 items at 60 degree intervals)*/
+.menu-item {
+ --angle: calc(var(--i) * 60deg - 90deg);
+
+ background: var(--st-background-color);
+ position: absolute;
+ top: 50%;
+ left: 50%;
+ margin: -1.6125rem;
+ transform: rotate(var(--angle)) translateX(4rem)
+ rotate(calc(-1 * var(--angle)));
+}
+
+.menu-item:hover {
+ transform: rotate(var(--angle)) translateX(4rem)
+ rotate(calc(-1 * var(--angle))) scale(1.15);
+ border-color: var(--st-primary-color);
+ background: var(--st-secondary-background-color);
+}
+
+.menu-item.selected {
+ border-color: var(--st-primary-color);
+ background: var(--st-secondary-background-color);
+}
+
+/* Backdrop when menu is open */
+.menu-overlay::before {
+ content: "";
+ position: fixed;
+ inset: -100vh -100vw;
+ background: var(--st-background-color);
+ opacity: 0;
+ transition: opacity 0.3s ease;
+ pointer-events: none;
+ z-index: -1;
+}
+
+.menu-overlay.open::before {
+ opacity: 0.7;
+ pointer-events: auto;
+}
+
+/* Center decoration */
+.menu-ring::after {
+ content: "";
+ position: absolute;
+ top: 50%;
+ left: 50%;
+ width: 2rem;
+ height: 2rem;
+ transform: translate(-50%, -50%);
+ border-radius: 50%;
+ background: var(--st-secondary-background-color);
+ border: 2px dashed var(--st-border-color);
+ opacity: 0.6;
+ box-sizing: border-box;
+}
diff --git a/python/concept-source/components-radial-menu/radial_menu_component/menu.html b/python/concept-source/components-radial-menu/radial_menu_component/menu.html
new file mode 100644
index 000000000..4f860918c
--- /dev/null
+++ b/python/concept-source/components-radial-menu/radial_menu_component/menu.html
@@ -0,0 +1,11 @@
+
diff --git a/python/concept-source/components-radial-menu/radial_menu_component/menu.js b/python/concept-source/components-radial-menu/radial_menu_component/menu.js
new file mode 100644
index 000000000..2bca0b4c7
--- /dev/null
+++ b/python/concept-source/components-radial-menu/radial_menu_component/menu.js
@@ -0,0 +1,58 @@
+export default function ({ parentElement, data, setStateValue }) {
+ const selector = parentElement.querySelector("#selector");
+ const selectorIcon = parentElement.querySelector("#selector-icon");
+ const overlay = parentElement.querySelector("#overlay");
+ const ring = parentElement.querySelector("#ring");
+
+ let isOpen = false;
+ const options = data?.options || {};
+ let currentSelection = data?.selection || Object.keys(options)[0];
+
+ // Create the 6 menu items from options
+ Object.entries(options).forEach(([value, icon], index) => {
+ const button = document.createElement("button");
+ button.className = "menu-item";
+ button.dataset.value = value;
+ button.style.setProperty("--i", index);
+ button.textContent = icon;
+
+ button.addEventListener("click", () => {
+ currentSelection = value;
+ updateDisplay();
+ toggleMenu();
+ setStateValue("selection", currentSelection);
+ });
+
+ ring.appendChild(button);
+ });
+
+ // Update the selector icon and highlight selected item
+ function updateDisplay() {
+ selectorIcon.textContent = options[currentSelection] || "?";
+
+ ring.querySelectorAll(".menu-item").forEach((item) => {
+ item.classList.toggle(
+ "selected",
+ item.dataset.value === currentSelection,
+ );
+ });
+ }
+
+ // Toggle menu open/closed
+ function toggleMenu() {
+ isOpen = !isOpen;
+ overlay.classList.toggle("open", isOpen);
+ ring.classList.toggle("open", isOpen);
+ }
+
+ // Initialize display
+ updateDisplay();
+
+ // Selector click toggles menu
+ selector.addEventListener("click", toggleMenu);
+
+ // Click outside closes menu
+ overlay.addEventListener("click", (e) => {
+ if (e.target === overlay) toggleMenu();
+ });
+}
diff --git a/python/concept-source/components-radial-menu/streamlit_app.py b/python/concept-source/components-radial-menu/streamlit_app.py
new file mode 100644
index 000000000..1c18581ad
--- /dev/null
+++ b/python/concept-source/components-radial-menu/streamlit_app.py
@@ -0,0 +1,26 @@
+import streamlit as st
+from radial_menu_component import radial_menu
+
+st.header("Radial Menu Component")
+
+st.write("Click the button to open the menu. Select your favorite food!")
+
+options = {
+ "pizza": "🍕",
+ "burger": "🍔",
+ "taco": "🌮",
+ "ramen": "🍜",
+ "sushi": "🍣",
+ "salad": "🥗",
+}
+
+result = radial_menu(
+ data={"options": options, "selection": "burger"},
+ default={"selection": "burger"},
+ on_selection_change=lambda: None,
+ key="food_menu",
+)
+
+if result.selection:
+ icon = options.get(result.selection, "")
+ st.write(f"You selected: **{icon} {result.selection.title()}**")
\ No newline at end of file
diff --git a/python/concept-source/components-rich-data.py b/python/concept-source/components-rich-data.py
new file mode 100644
index 000000000..ad74b6d05
--- /dev/null
+++ b/python/concept-source/components-rich-data.py
@@ -0,0 +1,61 @@
+import pandas as pd
+import streamlit as st
+import base64
+from pathlib import Path
+
+
+_APP_DIR = Path(__file__).parent
+
+
+# Create sample data
+@st.cache_data
+def create_sample_df():
+ return pd.DataFrame(
+ {
+ "name": ["Alice", "Bob", "Charlie"],
+ "city": ["New York", "London", "Tokyo"],
+ }
+ )
+
+
+df = create_sample_df()
+
+
+# Load an image and convert to b64 string
+@st.cache_data
+def load_image_as_base64(image_path):
+ with open(image_path, "rb") as img_file:
+ img_bytes = img_file.read()
+ return base64.b64encode(img_bytes).decode("utf-8")
+
+
+img_base64 = load_image_as_base64(_APP_DIR / "favi.png")
+
+# Serialization is automatically handled by Streamlit components
+chart_component = st.components.v2.component(
+ "data_display",
+ html="""Loading data...
""",
+ js="""
+ export default function({ data, parentElement }) {
+ const container = parentElement.querySelector("#data-container");
+
+ const df = data.df;
+ const userInfo = data.user_info;
+ const imgBase64 = data.image_base64;
+
+ container.innerHTML = `
+ Dataframe: ${df}
+ User Info: ${userInfo.name}
+
+ `;
+ }
+ """,
+)
+
+chart_component(
+ data={
+ "df": df, # Arrow-serializable dataframe
+ "user_info": {"name": "Alice"}, # JSON-serializable data
+ "image_base64": img_base64, # Image as base64 string
+ }
+)
diff --git a/python/concept-source/components-simple-button.py b/python/concept-source/components-simple-button.py
new file mode 100644
index 000000000..9e6d42224
--- /dev/null
+++ b/python/concept-source/components-simple-button.py
@@ -0,0 +1,37 @@
+import streamlit as st
+
+if "click_count" not in st.session_state:
+ st.session_state.click_count = 0
+
+
+def handle_button_click():
+ st.session_state.click_count += 1
+
+
+my_component = st.components.v2.component(
+ "interactive_button",
+ html="""""",
+ css="""
+ button {
+ border: none;
+ padding: .5rem;
+ border-radius: var(--st-button-radius);
+ background-color: var(--st-primary-color);
+ color: white;
+ }
+ """,
+ js="""
+ export default function(component) {
+ const { setTriggerValue, parentElement } = component;
+
+ parentElement.querySelector("button").onclick = () => {
+ setTriggerValue("action", "button_clicked");
+ };
+ }
+ """,
+)
+
+result = my_component(on_action_change=handle_button_click)
+
+if result.action:
+ st.write(f"Button clicked! Total clicks: {st.session_state.click_count}")
diff --git a/python/concept-source/components-simple-counter/my_component/__init__.py b/python/concept-source/components-simple-counter/my_component/__init__.py
new file mode 100644
index 000000000..a26f9d391
--- /dev/null
+++ b/python/concept-source/components-simple-counter/my_component/__init__.py
@@ -0,0 +1,24 @@
+import streamlit as st
+from pathlib import Path
+
+# Get the current file's directory
+_COMPONENT_DIR = Path(__file__).parent
+
+@st.cache_data
+def load_html():
+ with open(_COMPONENT_DIR / "component.html", "r") as f:
+ return f.read()
+
+@st.cache_data
+def load_css():
+ with open(_COMPONENT_DIR / "component.css", "r") as f:
+ return f.read()
+
+@st.cache_data
+def load_js():
+ with open(_COMPONENT_DIR / "component.js", "r") as f:
+ return f.read()
+
+HTML = load_html()
+CSS = load_css()
+JS = load_js()
diff --git a/python/concept-source/components-simple-counter/my_component/component.css b/python/concept-source/components-simple-counter/my_component/component.css
new file mode 100644
index 000000000..3a16535ae
--- /dev/null
+++ b/python/concept-source/components-simple-counter/my_component/component.css
@@ -0,0 +1,25 @@
+.counter {
+ padding: 0.5rem 0.5rem;
+ border: 1px solid var(--st-border-color);
+ border-radius: var(--st-base-radius);
+ font-family: var(--st-font);
+ font-size: var(--st-base-font-size);
+ color: var(--st-text-color);
+}
+
+#count {
+ padding: 0.75rem;
+}
+
+#increment {
+ background: var(--st-primary-color);
+ color: white;
+ border: none;
+ border-radius: var(--st-button-radius);
+ padding: 0.25rem 0.5rem;
+ margin-left: 0.25rem;
+}
+
+#increment:hover {
+ opacity: 0.8;
+}
diff --git a/python/concept-source/components-simple-counter/my_component/component.html b/python/concept-source/components-simple-counter/my_component/component.html
new file mode 100644
index 000000000..36ce6c0bb
--- /dev/null
+++ b/python/concept-source/components-simple-counter/my_component/component.html
@@ -0,0 +1,4 @@
+
+ 0
+
+
diff --git a/python/concept-source/components-simple-counter/my_component/component.js b/python/concept-source/components-simple-counter/my_component/component.js
new file mode 100644
index 000000000..c4c5f0e61
--- /dev/null
+++ b/python/concept-source/components-simple-counter/my_component/component.js
@@ -0,0 +1,11 @@
+export default function ({ parentElement, setStateValue }) {
+ let count = 0;
+ const display = parentElement.querySelector("#count");
+ const button = parentElement.querySelector("#increment");
+
+ button.onclick = () => {
+ count++;
+ display.textContent = count;
+ setStateValue("count", count);
+ };
+}
diff --git a/python/concept-source/components-simple-counter/streamlit_app.py b/python/concept-source/components-simple-counter/streamlit_app.py
new file mode 100644
index 000000000..2815ab3e7
--- /dev/null
+++ b/python/concept-source/components-simple-counter/streamlit_app.py
@@ -0,0 +1,24 @@
+import streamlit as st
+from my_component import HTML, CSS, JS
+
+counter_component = st.components.v2.component(
+ name="counter", html=HTML, css=CSS, js=JS
+)
+
+
+# Define callback function for the count state value
+def handle_count_change():
+ # Called when the component calls setStateValue("count", value)
+ st.toast("Count was updated!")
+
+
+# Mount the counter component with callback
+result = counter_component(
+ width="content", on_count_change=handle_count_change, key="counter_1"
+)
+
+# Access the current count value
+st.write(f"Current count: {result.count}")
+
+# Access the current count value in Session State
+st.write(f"Current count: {st.session_state.counter_1.count}")
diff --git a/python/concept-source/components-stopwatch/stopwatch_component/__init__.py b/python/concept-source/components-stopwatch/stopwatch_component/__init__.py
new file mode 100644
index 000000000..f922b03a9
--- /dev/null
+++ b/python/concept-source/components-stopwatch/stopwatch_component/__init__.py
@@ -0,0 +1,25 @@
+from pathlib import Path
+import streamlit as st
+
+component_dir = Path(__file__).parent
+
+
+@st.cache_data
+def load_component_code():
+ with open(component_dir / "stopwatch.css", "r") as f:
+ CSS = f.read()
+ with open(component_dir / "stopwatch.html", "r") as f:
+ HTML = f.read()
+ with open(component_dir / "stopwatch.js", "r") as f:
+ JS = f.read()
+ return HTML, CSS, JS
+
+
+HTML, CSS, JS = load_component_code()
+
+stopwatch = st.components.v2.component(
+ name="stopwatch",
+ html=HTML,
+ css=CSS,
+ js=JS
+)
\ No newline at end of file
diff --git a/python/concept-source/components-stopwatch/stopwatch_component/stopwatch.css b/python/concept-source/components-stopwatch/stopwatch_component/stopwatch.css
new file mode 100644
index 000000000..2e15a231a
--- /dev/null
+++ b/python/concept-source/components-stopwatch/stopwatch_component/stopwatch.css
@@ -0,0 +1,205 @@
+.stopwatch {
+ font-family: var(--st-font);
+ color: var(--st-text-color);
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ padding: 2rem;
+ gap: 2rem;
+}
+
+/* Ring Display */
+.display-ring {
+ position: relative;
+ width: 14rem;
+ height: 14rem;
+}
+
+.ring-svg {
+ position: absolute;
+ inset: -0.75rem;
+ padding: 0.75rem;
+ transform: rotate(-90deg);
+ overflow: visible;
+}
+
+.ring-track,
+.ring-progress {
+ fill: none;
+ stroke-width: 6;
+}
+
+.ring-track {
+ stroke: var(--st-secondary-background-color);
+}
+
+.ring-progress {
+ stroke: var(--st-primary-color);
+ stroke-linecap: round;
+ stroke-dasharray: 565.5;
+ stroke-dashoffset: 565.5;
+ transition: stroke-dashoffset 0.1s linear;
+ filter: drop-shadow(0 0 8px var(--st-primary-color));
+}
+
+.ring-progress.running {
+ animation: glow 2s ease-in-out infinite;
+}
+
+@keyframes glow {
+ 0%,
+ 100% {
+ opacity: 0.7;
+ }
+ 50% {
+ opacity: 1;
+ }
+}
+
+/* Time Display */
+.display {
+ position: absolute;
+ top: 50%;
+ left: 50%;
+ transform: translate(-50%, -50%);
+ display: flex;
+ align-items: baseline;
+ gap: 2px;
+ font-family: var(--st-code-font);
+ font-size: 2.5rem;
+ font-weight: 700;
+}
+
+.time-segment {
+ min-width: 2ch;
+ text-align: center;
+ letter-spacing: 0.05em;
+}
+
+.separator {
+ opacity: 0.5;
+}
+
+.time-segment.small,
+.separator.small {
+ font-size: 1.5rem;
+ font-weight: 500;
+}
+
+.time-segment.small {
+ opacity: 0.7;
+}
+
+/* Controls */
+.controls {
+ display: flex;
+ gap: 1rem;
+ align-items: center;
+}
+
+.ctrl-btn {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ gap: 0.25rem;
+ padding: 0.75rem 1.25rem;
+ border: none;
+ border-radius: var(--st-button-radius);
+ cursor: pointer;
+ transition: all 0.2s cubic-bezier(0.34, 1.56, 0.64, 1);
+ min-width: 5rem;
+}
+
+.ctrl-btn:disabled {
+ opacity: 0.4;
+ cursor: not-allowed;
+}
+
+.ctrl-btn:hover:not(:disabled) {
+ transform: scale(1.05);
+}
+
+.ctrl-btn.primary {
+ background: var(--st-primary-color);
+ color: white;
+}
+
+.ctrl-btn.primary:hover:not(:disabled) {
+ filter: brightness(1.1);
+}
+
+.ctrl-btn.secondary {
+ background: var(--st-secondary-background-color);
+ border: 1px solid var(--st-border-color);
+}
+
+.ctrl-btn.secondary:hover:not(:disabled) {
+ border-color: var(--st-primary-color);
+}
+
+.btn-icon {
+ font-size: 1.25rem;
+ line-height: 1;
+}
+
+.btn-label {
+ font-size: 0.7rem;
+ font-weight: 500;
+ text-transform: uppercase;
+ letter-spacing: 0.05em;
+}
+
+/* Lap List */
+.lap-list {
+ width: 100%;
+ max-width: 280px;
+ display: flex;
+ flex-direction: column;
+ gap: 0.5rem;
+ max-height: 150px;
+ overflow-y: auto;
+}
+
+.lap-item {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ padding: 0.5rem 1rem;
+ background: var(--st-secondary-background-color);
+ border-radius: var(--st-base-radius);
+ font-size: 0.85rem;
+ animation: slide-in 0.3s cubic-bezier(0.34, 1.56, 0.64, 1);
+}
+
+@keyframes slide-in {
+ from {
+ opacity: 0;
+ transform: translateY(-10px);
+ }
+ to {
+ opacity: 1;
+ transform: translateY(0);
+ }
+}
+
+.lap-number {
+ color: var(--st-primary-color);
+ font-weight: 600;
+}
+
+.lap-time,
+.lap-delta {
+ font-family: var(--st-code-font);
+ font-size: 0.8rem;
+ opacity: 0.8;
+}
+
+.lap-delta.fastest {
+ color: var(--st-green-color);
+ opacity: 1;
+}
+
+.lap-delta.slowest {
+ color: var(--st-red-color);
+ opacity: 1;
+}
diff --git a/python/concept-source/components-stopwatch/stopwatch_component/stopwatch.html b/python/concept-source/components-stopwatch/stopwatch_component/stopwatch.html
new file mode 100644
index 000000000..614fd6cf8
--- /dev/null
+++ b/python/concept-source/components-stopwatch/stopwatch_component/stopwatch.html
@@ -0,0 +1,38 @@
+
+
+
+
+ 00
+ :
+ 00
+ .
+ 00
+
+
+
+
+
+
+
+
+
+
+
diff --git a/python/concept-source/components-stopwatch/stopwatch_component/stopwatch.js b/python/concept-source/components-stopwatch/stopwatch_component/stopwatch.js
new file mode 100644
index 000000000..9957c64d7
--- /dev/null
+++ b/python/concept-source/components-stopwatch/stopwatch_component/stopwatch.js
@@ -0,0 +1,218 @@
+export default function ({
+ parentElement,
+ data,
+ setStateValue,
+ setTriggerValue,
+}) {
+ const minutes = parentElement.querySelector("#minutes");
+ const seconds = parentElement.querySelector("#seconds");
+ const centiseconds = parentElement.querySelector("#centiseconds");
+ const ringProgress = parentElement.querySelector("#ring-progress");
+ const startBtn = parentElement.querySelector("#start-btn");
+ const lapBtn = parentElement.querySelector("#lap-btn");
+ const resetBtn = parentElement.querySelector("#reset-btn");
+ const lapList = parentElement.querySelector("#lap-list");
+
+ const CIRCUMFERENCE = 2 * Math.PI * 90;
+
+ // Initialize from state or defaults
+ let elapsedMs = data?.elapsed || 0;
+ let isRunning = data?.running || false;
+ let laps = data?.laps || [];
+ let lastTimestamp = null;
+ let animationFrame = null;
+
+ let lastMinute = Math.floor(elapsedMs / 60000);
+ let isTransitioning = false;
+
+ function formatTime(ms) {
+ const totalSeconds = Math.floor(ms / 1000);
+ const mins = Math.floor(totalSeconds / 60);
+ const secs = totalSeconds % 60;
+ const cents = Math.floor((ms % 1000) / 10);
+ return {
+ mins: String(mins).padStart(2, "0"),
+ secs: String(secs).padStart(2, "0"),
+ cents: String(cents).padStart(2, "0"),
+ };
+ }
+
+ function updateDisplay() {
+ const time = formatTime(elapsedMs);
+ minutes.textContent = time.mins;
+ seconds.textContent = time.secs;
+ centiseconds.textContent = time.cents;
+
+ const currentMinute = Math.floor(elapsedMs / 60000);
+ const secondsInMinute = (elapsedMs % 60000) / 1000;
+
+ // Arc length: 0 at second 0, full circle at second 60
+ const arcLength = (secondsInMinute / 60) * CIRCUMFERENCE;
+
+ // Detect minute boundary - quick fade transition
+ if (currentMinute > lastMinute && !isTransitioning) {
+ lastMinute = currentMinute;
+ isTransitioning = true;
+
+ // Quick fade out
+ ringProgress.style.transition = "opacity 0.15s ease-out";
+ ringProgress.style.opacity = "0";
+
+ setTimeout(() => {
+ // Reset to small arc while invisible
+ ringProgress.style.transition = "none";
+ ringProgress.style.strokeDasharray = `${arcLength} ${CIRCUMFERENCE}`;
+ ringProgress.style.strokeDashoffset = 0;
+
+ // Fade back in
+ requestAnimationFrame(() => {
+ ringProgress.style.transition = "opacity 0.15s ease-in";
+ ringProgress.style.opacity = "1";
+
+ setTimeout(() => {
+ ringProgress.style.transition = "";
+ isTransitioning = false;
+ }, 150);
+ });
+ }, 150);
+ }
+
+ // Normal ring update
+ if (!isTransitioning) {
+ ringProgress.style.strokeDasharray = `${arcLength} ${CIRCUMFERENCE}`;
+ ringProgress.style.strokeDashoffset = 0;
+ }
+ }
+
+ function updateButtons() {
+ startBtn.querySelector(".btn-icon").textContent = isRunning
+ ? "⏸"
+ : "▶";
+ startBtn.querySelector(".btn-label").textContent = isRunning
+ ? "Pause"
+ : "Start";
+ startBtn.classList.toggle("running", isRunning);
+ ringProgress.classList.toggle("running", isRunning);
+
+ lapBtn.disabled = !isRunning;
+ resetBtn.disabled = isRunning || elapsedMs === 0;
+ }
+
+ function renderLaps() {
+ lapList.innerHTML = "";
+
+ if (laps.length === 0) return;
+
+ // Calculate deltas and find fastest/slowest
+ const deltas = laps.map((lap, i) => {
+ return i === 0 ? lap : lap - laps[i - 1];
+ });
+
+ const minDelta = Math.min(...deltas);
+ const maxDelta = Math.max(...deltas);
+
+ // Render in reverse (newest first)
+ [...laps].reverse().forEach((lap, reverseIdx) => {
+ const idx = laps.length - 1 - reverseIdx;
+ const delta = deltas[idx];
+ const time = formatTime(lap);
+ const deltaTime = formatTime(delta);
+
+ let deltaClass = "";
+ if (laps.length > 1) {
+ if (delta === minDelta) deltaClass = "fastest";
+ else if (delta === maxDelta) deltaClass = "slowest";
+ }
+
+ const item = document.createElement("div");
+ item.className = "lap-item";
+ item.innerHTML = `
+ Lap ${idx + 1}
+ +${deltaTime.mins}:${deltaTime.secs}.${deltaTime.cents}
+ ${time.mins}:${time.secs}.${time.cents}
+ `;
+ lapList.appendChild(item);
+ });
+ }
+
+ function tick(timestamp) {
+ if (!lastTimestamp) lastTimestamp = timestamp;
+
+ const delta = timestamp - lastTimestamp;
+ lastTimestamp = timestamp;
+
+ elapsedMs += delta;
+ updateDisplay();
+
+ if (isRunning) {
+ animationFrame = requestAnimationFrame(tick);
+ }
+ }
+
+ function start() {
+ isRunning = true;
+ lastTimestamp = null;
+ animationFrame = requestAnimationFrame(tick);
+ updateButtons();
+ setStateValue("running", true);
+ }
+
+ function pause() {
+ isRunning = false;
+ if (animationFrame) {
+ cancelAnimationFrame(animationFrame);
+ animationFrame = null;
+ }
+ updateButtons();
+ setStateValue("running", false);
+ setStateValue("elapsed", elapsedMs);
+ }
+
+ function recordLap() {
+ laps.push(elapsedMs);
+ renderLaps();
+ setStateValue("laps", laps);
+ const t = formatTime(elapsedMs);
+ setTriggerValue("lap", {
+ number: laps.length,
+ time: elapsedMs,
+ formatted: `${t.mins}:${t.secs}.${t.cents}`,
+ });
+ }
+
+ function reset() {
+ elapsedMs = 0;
+ laps = [];
+ updateDisplay();
+ renderLaps();
+ updateButtons();
+ setStateValue("laps", []);
+ setStateValue("elapsed", 0);
+ setStateValue("running", false);
+ setTriggerValue("reset", true);
+ }
+
+ // Event listeners
+ startBtn.addEventListener("click", () => {
+ if (isRunning) pause();
+ else start();
+ });
+
+ lapBtn.addEventListener("click", recordLap);
+ resetBtn.addEventListener("click", reset);
+
+ // Initialize display
+ updateDisplay();
+ updateButtons();
+ renderLaps();
+
+ // Resume if was running
+ if (isRunning) {
+ lastTimestamp = null;
+ animationFrame = requestAnimationFrame(tick);
+ }
+
+ return () => {
+ if (animationFrame) cancelAnimationFrame(animationFrame);
+ };
+}
diff --git a/python/concept-source/components-stopwatch/streamlit_app.py b/python/concept-source/components-stopwatch/streamlit_app.py
new file mode 100644
index 000000000..4161ed875
--- /dev/null
+++ b/python/concept-source/components-stopwatch/streamlit_app.py
@@ -0,0 +1,32 @@
+import streamlit as st
+from stopwatch_component import stopwatch
+
+st.title("Stopwatch with Laps")
+st.caption("Combining state values (time, running) with trigger values (lap, reset)")
+
+# Track laps in Python
+if "laps" not in st.session_state:
+ st.session_state.laps = []
+
+# Render the component
+result = stopwatch(
+ key="stopwatch",
+ on_lap_change=lambda: None,
+ on_reset_change=lambda: None,
+ on_running_change=lambda: None,
+ on_elapsed_change=lambda: None,
+ on_laps_change=lambda: None,
+ default={"elapsed": 0, "running": False, "laps": []},
+)
+
+# Display state info
+col1, col2 = st.columns(2)
+with col1:
+ st.metric("Status", "Running" if result.running else "Paused")
+ elapsed_sec = (result.elapsed or 0) / 1000
+ st.metric("Elapsed", f"{elapsed_sec:.1f}s")
+with col2:
+ st.subheader("Lap Records (Python)")
+ for i, lap_ms in enumerate(result.laps[-5:]):
+ mins, secs = divmod(lap_ms / 1000, 60)
+ st.write(f"**Lap {i+1}**: {int(mins):02d}:{secs:05.2f}")
\ No newline at end of file
diff --git a/python/concept-source/favi.png b/python/concept-source/favi.png
new file mode 100644
index 000000000..7ffb2bce0
Binary files /dev/null and b/python/concept-source/favi.png differ