Add grayscale mode: CSS filter renders entire shop in black and white

Server renders data-grayscale="on" on <html> so it works without JS.
Toggle in Ribbon & Layout settings, off by default. Original assets
untouched — purely presentational via filter: grayscale(100%).
This commit is contained in:
russell@unturf.com 2026-02-15 05:25:30 -05:00
parent d07195568b
commit dfb70666b4
7 changed files with 145 additions and 1 deletions

View file

@ -136,6 +136,9 @@ class Shop(RBase, Base):
# Watch mode: sticky video, autoplay, related content sidebar
watch_mode_enabled = Column(Boolean, default=False)
# Grayscale mode: render entire site in black and white
grayscale_mode = Column(Boolean, default=False)
# Email digest subscriptions
subscriptions_enabled = Column(Boolean, default=True)

View file

@ -0,0 +1,36 @@
"""add grayscale_mode to shop
Revision ID: c3b37cb2c9cc
Revises: 3c0996ae20f3
Create Date: 2026-02-14 20:13:18.055029
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = 'c3b37cb2c9cc'
down_revision = '3c0996ae20f3'
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", "grayscale_mode"):
op.add_column(
"mps_shop",
sa.Column("grayscale_mode", sa.Boolean(), nullable=False, server_default="0"),
)
def downgrade():
op.drop_column("mps_shop", "grayscale_mode")

View file

@ -3396,3 +3396,9 @@ textarea {
.price-history-current td {
font-weight: bold;
}
/* Grayscale mode */
html[data-grayscale="on"] {
filter: grayscale(100%);
-webkit-filter: grayscale(100%);
}

View file

@ -14,7 +14,7 @@
{% else %}
{% set theme_preference = 'light' if (shop_default_theme == 1) else 'dark' %}
{% endif %}
<html lang="en" data-theme="{{ theme_preference }}" data-user-theme="{{ user_theme_id or 'null' }}" data-shop-default="{{ shop_default_theme }}">
<html lang="en" data-theme="{{ theme_preference }}" data-user-theme="{{ user_theme_id or 'null' }}" data-shop-default="{{ shop_default_theme }}"{% if request.shop and request.shop.grayscale_mode %} data-grayscale="on"{% endif %}>
<head>
<!-- stylesheet to-end-all stylesheets, pico.fluid.classless.css ! woot -->

View file

@ -755,6 +755,21 @@
<br />
<br />
<label>Grayscale Mode</label>
<br />
<input type="radio" name="grayscale_mode" id="grayscale_mode_on" value="1"
{% if request.shop.grayscale_mode %}checked{% endif %} />
<label for="grayscale_mode_on" class="inline-label">Enable grayscale (render entire site in black and white)</label>
<br />
<input type="radio" name="grayscale_mode" id="grayscale_mode_off" value="0"
{% if not request.shop.grayscale_mode %}checked{% endif %} />
<label for="grayscale_mode_off" class="inline-label">Disable grayscale</label>
<br />
<small class="note-text">When enabled, every element on the site — images, video, text, and UI — renders in black and white. Original files are not modified.</small>
<br />
<br />
<input type="submit" name="submit" class="mps-submit" value="Save Settings" />
<br />

View file

@ -3604,3 +3604,73 @@ class TestAnalytics(AuthenticatedFunctionalTests):
self.assertIn("Price History", body)
self.assertIn("$20.00", body)
self.assertIn("$3.50", body)
def test_grayscale_mode_default_off(self):
"""Test that new shops have grayscale 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.grayscale_mode)
def test_grayscale_mode_toggle(self):
"""Test enabling and disabling grayscale mode via settings form."""
shop = self._create_shop_helper(
user_creds=self.user1_creds, shop_params=self.shop1_params
)
# Enable grayscale mode
res = self.testapp.post(
f"/s/{shop.id}/settings",
{
"form_section": "ribbon-settings",
"grayscale_mode": "1",
"submit": "Save Settings",
},
status=302,
)
res = res.follow()
self.dbsession.expire(shop)
self.assertTrue(shop.grayscale_mode)
# Disable grayscale mode
res = self.testapp.post(
f"/s/{shop.id}/settings",
{
"form_section": "ribbon-settings",
"grayscale_mode": "0",
"submit": "Save Settings",
},
status=302,
)
res = res.follow()
self.dbsession.expire(shop)
self.assertFalse(shop.grayscale_mode)
def test_grayscale_mode_html_attribute(self):
"""Test that data-grayscale attribute appears in HTML when enabled."""
shop = self._create_shop_helper(
user_creds=self.user1_creds, shop_params=self.shop1_params
)
# Without grayscale, attribute should not be present
res = self.testapp.get(f"/s/{shop.id}/{shop.slug}", status=200)
self.assertNotIn('data-grayscale="on"', res.text)
# Enable grayscale mode
self.testapp.post(
f"/s/{shop.id}/settings",
{
"form_section": "ribbon-settings",
"grayscale_mode": "1",
"submit": "Save Settings",
},
status=302,
)
# With grayscale, attribute should be present
res = self.testapp.get(f"/s/{shop.id}/{shop.slug}", status=200)
self.assertIn('data-grayscale="on"', res.text)

View file

@ -624,6 +624,20 @@ def shop_settings(request):
)
)
# Handle grayscale mode setting
grayscale_mode_value = request.params.get("grayscale_mode")
if grayscale_mode_value is not None:
grayscale_mode = int(grayscale_mode_value) == 1
if shop.grayscale_mode != grayscale_mode:
shop.grayscale_mode = grayscale_mode
status = "enabled" if grayscale_mode else "disabled"
request.session.flash(
(
f"Grayscale mode {status}",
"success",
)
)
# Handle stripe settings form
if form_section == "stripe-settings":
# Handle disable action