feat: add sandbox mode with creative filter toolbar

Client-side creative filter system for MPS shops. When enabled, visitors
see a floating toolbar with 32 filter presets (basic, warm, cool, dramatic,
color shifts, Instagram-style, SVG), 7 adjustment sliders, image/video
canvas export, and on-demand MediaPipe face detection (eye glow, face mask).

All processing is browser-side. Server only stores the sandbox_mode toggle.
Filters apply to individual media elements (stacks with shop color_filter).
localStorage persists filter state. Watch mode SPA re-applies on navigation.
This commit is contained in:
russell@unturf.com 2026-02-27 11:41:21 -05:00
parent f0af5c564a
commit fd55a1a5e2
9 changed files with 1436 additions and 0 deletions

View file

@ -150,6 +150,9 @@ class Shop(RBase, Base):
# Email digest subscriptions
subscriptions_enabled = Column(Boolean, default=True)
# Sandbox mode: floating creative filter toolbar for visitors
sandbox_mode = Column(Boolean, default=False)
# Precomputed discovery ring: circular ordering of all public products
json_discovery_ring = Column(UnicodeText, nullable=True)

View file

@ -0,0 +1,36 @@
"""add sandbox_mode to shop
Revision ID: a2c13d3117f2
Revises: f3086e09b052
Create Date: 2026-02-27 11:29:04.358964
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = 'a2c13d3117f2'
down_revision = 'f3086e09b052'
branch_labels = None
depends_on = None
from make_post_sell.models.meta import UUIDType
def _column_exists(table, column):
conn = op.get_bind()
result = conn.execute(sa.text(f"PRAGMA table_info({table})"))
return any(row[1] == column for row in result.fetchall())
def upgrade():
if not _column_exists("mps_shop", "sandbox_mode"):
op.add_column(
"mps_shop",
sa.Column("sandbox_mode", sa.Boolean(), nullable=False, server_default="0"),
)
def downgrade():
op.drop_column("mps_shop", "sandbox_mode")

View file

@ -3542,6 +3542,220 @@ html[data-color-filter="7"] {
filter: grayscale(100%) sepia(100%) hue-rotate(145deg) saturate(300%);
}
/* ---- Sandbox mode toolbar ---- */
.sandbox-toolbar {
position: fixed;
bottom: 20px;
right: 20px;
z-index: 9999;
}
.sandbox-toggle-btn {
padding: 10px 16px;
border: none;
border-radius: 8px;
background: var(--pico-primary);
color: var(--pico-primary-inverse);
cursor: pointer;
font-size: 14px;
box-shadow: 0 2px 8px rgba(0,0,0,0.3);
}
.sandbox-toggle-btn:hover {
opacity: 0.9;
}
.sandbox-panel {
position: fixed;
bottom: 70px;
right: 20px;
width: 340px;
max-height: 70vh;
overflow-y: auto;
background: var(--pico-background-color);
border: 1px solid var(--pico-muted-border-color);
border-radius: 12px;
box-shadow: 0 4px 20px rgba(0,0,0,0.3);
z-index: 9999;
padding: 12px;
}
.sandbox-panel-header {
display: grid;
grid-template-columns: 1fr auto;
align-items: center;
padding-bottom: 8px;
border-bottom: 1px solid var(--pico-muted-border-color);
margin-bottom: 8px;
font-weight: bold;
font-size: 14px;
}
.sandbox-close-btn {
background: none;
border: none;
font-size: 20px;
cursor: pointer;
color: var(--pico-color);
padding: 0 4px;
}
.sandbox-presets {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 6px;
margin-bottom: 12px;
}
.sandbox-preset-btn {
padding: 6px 4px;
border: 1px solid var(--pico-muted-border-color);
border-radius: 6px;
background: var(--pico-background-color);
color: var(--pico-color);
cursor: pointer;
font-size: 11px;
text-align: center;
transition: border-color 0.15s;
}
.sandbox-preset-btn.active {
border-color: var(--pico-primary);
background: var(--pico-primary);
color: var(--pico-primary-inverse);
}
.sandbox-preset-category {
grid-column: 1 / -1;
font-size: 11px;
font-weight: bold;
color: var(--pico-muted-color);
margin-top: 6px;
text-transform: uppercase;
letter-spacing: 0.05em;
}
.sandbox-sliders {
margin-bottom: 12px;
}
.sandbox-slider-row {
display: grid;
grid-template-columns: 80px 1fr 36px;
align-items: center;
gap: 8px;
margin-bottom: 4px;
font-size: 12px;
}
.sandbox-slider-row input[type="range"] {
width: 100%;
margin: 0;
padding: 0;
height: 20px;
}
.sandbox-slider-value {
text-align: right;
font-variant-numeric: tabular-nums;
color: var(--pico-muted-color);
font-size: 11px;
}
.sandbox-actions {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 8px;
margin-top: 8px;
padding-top: 8px;
border-top: 1px solid var(--pico-muted-border-color);
}
.sandbox-action-btn {
padding: 8px;
border: 1px solid var(--pico-muted-border-color);
border-radius: 6px;
background: var(--pico-background-color);
color: var(--pico-color);
cursor: pointer;
font-size: 12px;
text-align: center;
transition: border-color 0.15s;
}
.sandbox-action-btn:hover {
border-color: var(--pico-primary);
}
.sandbox-action-btn.sandbox-export-btn {
grid-column: 1 / -1;
}
.sandbox-face-section {
margin-top: 12px;
padding-top: 8px;
border-top: 1px solid var(--pico-muted-border-color);
}
.sandbox-face-controls {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 6px;
margin-top: 8px;
}
.sandbox-color-row {
display: grid;
grid-template-columns: 80px 1fr;
align-items: center;
gap: 8px;
margin-top: 8px;
font-size: 12px;
}
.sandbox-color-row input[type="color"] {
width: 100%;
height: 28px;
border: 1px solid var(--pico-muted-border-color);
border-radius: 4px;
padding: 2px;
cursor: pointer;
}
.sandbox-status {
font-size: 11px;
color: var(--pico-muted-color);
text-align: center;
padding: 4px 0;
}
/* Sandbox canvas overlay for face effects */
.sandbox-canvas-overlay {
position: absolute;
top: 0;
left: 0;
pointer-events: none;
z-index: 5;
}
/* Mobile: full-width panel */
@media (max-width: 800px) {
.sandbox-toolbar {
bottom: 10px;
right: 10px;
}
.sandbox-panel {
bottom: 60px;
right: 10px;
left: 10px;
width: auto;
max-height: 50vh;
}
.sandbox-presets {
grid-template-columns: repeat(3, 1fr);
}
}
/* Edit page media previews */
.edit-media-preview {
width: auto;

File diff suppressed because it is too large Load diff

View file

@ -828,6 +828,11 @@
}
renderQueue();
// Re-apply sandbox filters to newly loaded media elements
if (window.sandboxReapply) {
window.sandboxReapply();
}
}
function updateRelated(related, commentsEnabled, commentCount) {

View file

@ -182,5 +182,50 @@
{% include 'snippets/footer.j2' %}
{% if request.shop and request.shop.sandbox_mode %}
<svg style="display:none" xmlns="http://www.w3.org/2000/svg">
<defs>
<filter id="sandbox-duotone-blue">
<feColorMatrix type="matrix" values="
0.3 0.3 0.3 0 0
0.1 0.1 0.1 0 0
0.5 0.5 0.5 0 0.2
0 0 0 1 0" />
</filter>
<filter id="sandbox-duotone-green">
<feColorMatrix type="matrix" values="
0.1 0.1 0.1 0 0
0.4 0.4 0.4 0 0.1
0.2 0.2 0.2 0 0
0 0 0 1 0" />
</filter>
<filter id="sandbox-grain">
<feTurbulence type="fractalNoise" baseFrequency="0.65" numOctaves="3" stitchTiles="stitch" result="noise"/>
<feColorMatrix type="saturate" values="0" in="noise" result="mono"/>
<feBlend mode="multiply" in="SourceGraphic" in2="mono"/>
</filter>
<filter id="sandbox-vignette">
<feFlood flood-color="black" flood-opacity="0.4" result="flood"/>
<feComposite operator="out" in="flood" in2="SourceGraphic" result="shadow"/>
<feGaussianBlur stdDeviation="40" in="shadow" result="blur"/>
<feComposite operator="atop" in="blur" in2="SourceGraphic"/>
</filter>
</defs>
</svg>
<div id="sandbox-toolbar" class="sandbox-toolbar js-only">
<button id="sandbox-toggle" class="sandbox-toggle-btn" title="Toggle sandbox filters">&#x1F3A8; Filters</button>
<div id="sandbox-panel" class="sandbox-panel" style="display:none">
<div class="sandbox-panel-header">
<span>Creative Filters</span>
<button id="sandbox-close" class="sandbox-close-btn">&times;</button>
</div>
<div id="sandbox-presets" class="sandbox-presets"></div>
<div id="sandbox-sliders" class="sandbox-sliders"></div>
<div id="sandbox-actions" class="sandbox-actions"></div>
</div>
</div>
<script src="/static/js/sandbox.js"></script>
{% endif %}
</body>
</html>

View file

@ -874,6 +874,21 @@
<br />
<br />
<label>Sandbox Mode</label>
<br />
<input type="radio" name="sandbox_mode" id="sandbox_mode_on" value="1"
{% if request.shop.sandbox_mode %}checked{% endif %} />
<label for="sandbox_mode_on" class="inline-label">Enable sandbox mode (floating creative filter toolbar for visitors)</label>
<br />
<input type="radio" name="sandbox_mode" id="sandbox_mode_off" value="0"
{% if not request.shop.sandbox_mode %}checked{% endif %} />
<label for="sandbox_mode_off" class="inline-label">Disable sandbox mode</label>
<br />
<small class="note-text">When enabled, visitors see a floating toolbar to apply real-time filters to images and video. Includes 30+ presets, adjustment sliders, and image/video export. All processing is client-side — original files are never modified.</small>
<br />
<br />
<input type="submit" name="submit" class="mps-submit" value="Save Settings" />
<br />

View file

@ -3782,6 +3782,77 @@ class TestAnalytics(AuthenticatedFunctionalTests):
res = self.testapp.get(f"/s/{shop.id}/{shop.slug}", status=200)
self.assertNotIn('data-color-filter', res.text)
def test_sandbox_mode_default_off(self):
"""Test that new shops have sandbox mode disabled by default."""
shop = self._create_shop_helper(
user_creds=self.user1_creds, shop_params=self.shop1_params
)
self.dbsession.refresh(shop)
self.assertFalse(shop.sandbox_mode)
def test_sandbox_mode_toggle(self):
"""Test enabling and disabling sandbox mode via settings form."""
shop = self._create_shop_helper(
user_creds=self.user1_creds, shop_params=self.shop1_params
)
# Enable sandbox mode
res = self.testapp.post(
f"/s/{shop.id}/settings",
{
"form_section": "ribbon-settings",
"sandbox_mode": "1",
"submit": "Save Settings",
},
status=302,
)
res = res.follow()
self.assertIn("Sandbox mode is now enabled", res.text)
self.dbsession.expire(shop)
self.assertTrue(shop.sandbox_mode)
# Disable sandbox mode
res = self.testapp.post(
f"/s/{shop.id}/settings",
{
"form_section": "ribbon-settings",
"sandbox_mode": "0",
"submit": "Save Settings",
},
status=302,
)
res = res.follow()
self.assertIn("Sandbox mode is now disabled", res.text)
self.dbsession.expire(shop)
self.assertFalse(shop.sandbox_mode)
def test_sandbox_mode_script_tag_present(self):
"""Test that sandbox.js is loaded when sandbox mode is enabled."""
shop = self._create_shop_helper(
user_creds=self.user1_creds, shop_params=self.shop1_params
)
# Without sandbox mode, script should not be present
res = self.testapp.get(f"/s/{shop.id}/{shop.slug}", status=200)
self.assertNotIn('sandbox.js', res.text)
self.assertNotIn('sandbox-toolbar', res.text)
# Enable sandbox mode
self.testapp.post(
f"/s/{shop.id}/settings",
{
"form_section": "ribbon-settings",
"sandbox_mode": "1",
"submit": "Save Settings",
},
status=302,
)
res = self.testapp.get(f"/s/{shop.id}/{shop.slug}", status=200)
self.assertIn('sandbox.js', res.text)
self.assertIn('sandbox-toolbar', res.text)
@patch("boto3.session.Session")
@patch("smtplib.SMTP")
def test_edit_page_shows_media_preview_for_product_file(self, mock_smtp, mock_session_cls):

View file

@ -649,6 +649,20 @@ def shop_settings(request):
)
)
# Handle sandbox mode setting
sandbox_mode_value = request.params.get("sandbox_mode")
if sandbox_mode_value is not None:
sandbox_mode_enabled = int(sandbox_mode_value) == 1
if shop.sandbox_mode != sandbox_mode_enabled:
shop.sandbox_mode = sandbox_mode_enabled
status = "enabled" if sandbox_mode_enabled else "disabled"
request.session.flash(
(
f"Sandbox mode is now {status}",
"success",
)
)
# Handle stripe settings form
if form_section == "stripe-settings":
# Handle disable action