Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
f0781da
refactor!: rename Builder Variable to Builder Token
surajshetty3416 Jul 5, 2026
72177f6
fix: single-weight Google Font specimens fail to load
surajshetty3416 Jul 16, 2026
67d36cd
feat: Font design tokens - fontFamily can be var(--token)
surajshetty3416 Jul 5, 2026
3280e58
feat: Design System manager — token tabs for Colors, Fonts, Dimensions
surajshetty3416 Jul 5, 2026
d3df948
feat(builder): token count pill on each Design System tab
surajshetty3416 Jul 5, 2026
94a83e1
feat(builder): copy button on each token row — copies its var(--id) h…
surajshetty3416 Jul 5, 2026
6484eeb
feat(tokens): Font Family field mirrors the color input
surajshetty3416 Jul 6, 2026
8e64723
feat: keep old Builder Variable entry points working
surajshetty3416 Jul 16, 2026
c3429cc
fix: CSV import honors the Type column
surajshetty3416 Jul 16, 2026
4d04197
refactor: use frappe-ui TabButtons and Button in the Design System ma…
surajshetty3416 Jul 16, 2026
473e9f2
fix: token edits bust the variables.css compat cache too
surajshetty3416 Jul 16, 2026
796c9ef
fix: color picker lists only Color tokens
surajshetty3416 Jul 16, 2026
c58b6a1
fix: import pre-rename Builder Variable fixtures as Builder Token
surajshetty3416 Jul 17, 2026
85dc494
Merge branch 'develop' into builder-tokens
surajshetty3416 Jul 26, 2026
17a8d06
fix: run the UUID refactor patch against Builder Token
surajshetty3416 Jul 26, 2026
8f22f2d
fix: rename patch guards on the Builder Token table, not the DocType
surajshetty3416 Jul 26, 2026
bf9277a
Merge branch 'develop' into builder-tokens
surajshetty3416 Jul 26, 2026
37fdcdb
fix: guard against an unloaded user font list in the font picker
surajshetty3416 Jul 26, 2026
a45c2ca
fix: keep pre-rename tokens importable across sites
surajshetty3416 Jul 26, 2026
5c39bc1
fix: finish the token rename in user-facing copy and type handling
surajshetty3416 Jul 26, 2026
206d9bc
fix: stop new token rows from saving mid-keystroke, fix hover row height
stravo1 Jul 27, 2026
49ebc1f
fix: stop blur from swapping a selected token for a same-named one
stravo1 Jul 27, 2026
aa73bc6
fix: restore left rounding on token value cell boxes
stravo1 Jul 27, 2026
34fde8b
fix: add a way to cancel the new-token row besides Esc or reload
stravo1 Jul 27, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions builder/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@
from builder import builder_analytics
from builder.builder.doctype.builder_page.builder_page import BuilderPageRenderer
from builder.builder.doctype.builder_snapshot import builder_snapshot
from builder.utils import compact_json, has_page_read, has_page_write
from builder.utils import compact_json, has_page_read, has_page_write, normalize_renamed_doc


@frappe.whitelist()
Expand Down Expand Up @@ -340,7 +340,8 @@ def create_page_from_bundle(bundle: dict, project_folder: str | None = None) ->
for font in bundle.get("fonts") or []:
import_doc(docdict=font)
for var in bundle.get("variables") or []:
import_doc(docdict=var)
# a hub still on the pre-rename schema sends Builder Variable docs
import_doc(docdict=normalize_renamed_doc(var))
for comp in bundle.get("components") or []:
import_doc(docdict=comp)

Expand Down
26 changes: 20 additions & 6 deletions builder/builder/doctype/builder_page/builder_page.py
Original file line number Diff line number Diff line change
Expand Up @@ -357,11 +357,11 @@ def get_context(self, context):

if context.preview:
context.disable_auto_dark_mode = 0
# /builder_assets/variables.css is a rendered route, not a real file, so
# /builder_assets/tokens.css is a rendered route, not a real file, so
# the preview/PDF generator can't fetch it. Inline the variables instead.
from builder.builder.doctype.builder_variable.builder_variable import get_variables_css
from builder.builder.doctype.builder_token.builder_token import get_variables_css

context.inline_variables_css = get_variables_css()
context.inline_tokens_css = get_variables_css()
# Honour the dark/light mode the editor previews in (canvasDarkMode), so the
# initial server render matches it instead of falling back to the OS scheme.
scheme = frappe.form_dict.get("prefers_color_scheme")
Expand Down Expand Up @@ -1375,10 +1375,24 @@ def append_state_style(style_obj, style_tag, style_class, device="desktop"):


def get_font_family(font: str) -> str:
"""Return the first family from a CSS font stack (e.g. 'Inter, sans-serif' -> 'Inter')."""
"""Return the first family from a CSS font stack (e.g. 'Inter, sans-serif' -> 'Inter').
A Font design token (var(--id)) resolves to its family so the Google Fonts
links include tokenized families."""
font = resolve_font_token(font)
return font.split(",")[0].strip().strip("'\"")


def resolve_font_token(font: str) -> str:
match = re.match(r"\s*var\(\s*(--[^),\s]+)", font or "")
if not match:
return font
from builder.builder.doctype.builder_token.builder_token import get_css_variables

# the cached token map, so a page full of tokenized fonts is not a query each
css_variables, _ = get_css_variables()
return css_variables.get(match.group(1), "")


def set_fonts(styles, font_map, inherited_font=None):
weight_map = {
"thin": "100",
Expand Down Expand Up @@ -1425,8 +1439,8 @@ def set_fonts(styles, font_map, inherited_font=None):
# Use the first family from a fallback list, e.g. "Inter, sans-serif" -> "Inter"
font = get_font_family(font)

# Skip if it is a system font
if font.lower() in system_fonts:
# Skip system fonts and unresolvable font tokens
if not font or font.lower() in system_fonts:
continue

weight = str(style.get("fontWeight") or "400").lower()
Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
// Copyright (c) 2025, Frappe Technologies Pvt Ltd and contributors
// For license information, please see license.txt

frappe.ui.form.on("Builder Variable", {
frappe.ui.form.on("Builder Token", {
refresh: function (frm) {
// Only show is_standard field in developer mode
frm.get_field("is_standard").toggle(frappe.boot.developer_mode);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
"engine": "InnoDB",
"field_order": [
"is_standard",
"variable_name",
"token_name",
"group",
"type",
"value",
Expand All @@ -17,7 +17,7 @@
"fieldname": "type",
"fieldtype": "Select",
"label": "Type",
"options": "Color\nDimension"
"options": "Color\nDimension\nFont"
},
{
"fieldname": "value",
Expand All @@ -28,10 +28,10 @@
"reqd": 1
},
{
"fieldname": "variable_name",
"fieldname": "token_name",
"fieldtype": "Data",
"in_list_view": 1,
"label": "Variable Name",
"label": "Token Name",
"reqd": 1
},
{
Expand Down Expand Up @@ -61,7 +61,7 @@
"modified": "2026-05-24 12:00:00.000000",
"modified_by": "Administrator",
"module": "Builder",
"name": "Builder Variable",
"name": "Builder Token",
"naming_rule": "By script",
"owner": "Administrator",
"permissions": [
Expand Down Expand Up @@ -89,5 +89,5 @@
"sort_field": "creation",
"sort_order": "DESC",
"states": [],
"title_field": "variable_name"
"title_field": "token_name"
}
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
from frappe.website.utils import delete_page_cache


class BuilderVariable(Document):
class BuilderToken(Document):
# begin: auto-generated types
# This code is auto-generated. Do not modify anything in this block.

Expand All @@ -22,58 +22,58 @@ class BuilderVariable(Document):
dark_value: DF.Data | None
group: DF.Data | None
is_standard: DF.Check
type: DF.Literal["Color", "Dimension"]
type: DF.Literal["Color", "Dimension", "Font"]
value: DF.Data
variable_name: DF.Data
token_name: DF.Data
# end: auto-generated types

def autoname(self):
if not self.name:
self.name = str(uuid.uuid4())

def after_insert(self):
clear_builder_variable_cache()
clear_builder_token_cache()

def on_update(self):
clear_builder_variable_cache()
clear_builder_token_cache()
if self.is_standard:
export_to_files(
record_list=[["Builder Variable", self.name, "builder_variable"]], record_module="builder"
record_list=[["Builder Token", self.name, "builder_token"]], record_module="builder"
)

if self.has_value_changed("is_standard") and not self.is_standard:
delete_folder("builder", "builder_variable", self.name)
delete_folder("builder", "builder_token", self.name)

def on_trash(self):
clear_builder_variable_cache()
clear_builder_token_cache()
if self.is_standard:
delete_folder("builder", "builder_variable", self.name)
delete_folder("builder", "builder_token", self.name)


@redis_cache(ttl=10 * 24 * 3600)
def get_css_variables():
builder_variables = frappe.get_all("Builder Variable", fields=["name", "value", "dark_value"])
builder_tokens = frappe.get_all("Builder Token", fields=["name", "value", "dark_value"])
css_variables = {}
dark_mode_css_variables = {}

for builder_variable in builder_variables:
if not builder_variable.value:
for builder_token in builder_tokens:
if not builder_token.value:
continue
key = f"--{builder_variable.name}"
css_variables[key] = builder_variable.value
if builder_variable.dark_value:
dark_mode_css_variables[key] = builder_variable.dark_value
key = f"--{builder_token.name}"
css_variables[key] = builder_token.value
if builder_token.dark_value:
dark_mode_css_variables[key] = builder_token.dark_value

return css_variables, dark_mode_css_variables


def get_variables_css() -> str:
"""Render the CSS variables as an inline `:root {...}` rule.

The /builder_assets/variables.css route is a dynamically rendered page, not a
The /builder_assets/tokens.css route is a dynamically rendered page, not a
real file, so the preview/PDF generator can't fetch it (it blocks access to
non-existent local paths). Preview rendering inlines this string instead of
linking the route. Mirrors www/builder_assets/variables.css."""
linking the route. Mirrors www/builder_assets/tokens.css."""
css_variables, dark_mode_css_variables = get_css_variables()
if not css_variables:
return ""
Expand All @@ -89,7 +89,8 @@ def get_variables_css() -> str:
return ":root {\n" + "\n".join(declarations) + "\n}"


def clear_builder_variable_cache(doc=None, method=None):
def clear_builder_token_cache(doc=None, method=None):
get_css_variables.clear_cache()
# bust the rendered page cache for /builder_assets/variables.css
# bust the rendered page cache for tokens.css and its compat alias variables.css
delete_page_cache("builder_assets/tokens.css")
Comment thread
greptile-apps[bot] marked this conversation as resolved.
delete_page_cache("builder_assets/variables.css")
Original file line number Diff line number Diff line change
Expand Up @@ -11,18 +11,18 @@
IGNORE_TEST_RECORD_DEPENDENCIES = [] # eg. ["User"]


class UnitTestbuilderVariable(UnitTestCase):
class UnitTestbuilderToken(UnitTestCase):
"""
Unit tests for builderVariable.
Unit tests for builderToken.
Use this class for testing individual functions and methods.
"""

pass


class IntegrationTestbuilderVariable(IntegrationTestCase):
class IntegrationTestbuilderToken(IntegrationTestCase):
"""
Integration tests for builderVariable.
Integration tests for builderToken.
Use this class for testing interactions between multiple components.
"""

Expand Down
43 changes: 23 additions & 20 deletions builder/builder/patches/refactor_builder_variables.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,30 +3,33 @@

import frappe

from builder.builder.doctype.builder_variable.builder_variable import get_css_variables
from builder.builder.doctype.builder_token.builder_token import get_css_variables
from builder.utils import camel_case_to_kebab_case

UUID_RE = re.compile(r"^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$")


def execute():
"""Migrate Builder Variables to UUID names.
"""Migrate Builder Tokens to UUID names.

1. Assign a UUID to every variable that doesn't already have one.
1. Assign a UUID to every token that doesn't already have one.
2. Rewrite `var(--old-name)` → `var(--<uuid>)` in page/component blocks.
3. Normalise legacy type "Spacing" → "Dimension".

Runs after rename_builder_variable_to_builder_token (pre_model_sync), so the
doctype is always Builder Token by the time this executes.
"""
if not frappe.db.has_column("Builder Variable", "variable_name"):
if not frappe.db.table_exists("Builder Token"):
return

variables = frappe.get_all("Builder Variable", fields=["name", "variable_name"])
non_uuid = [v for v in variables if not UUID_RE.match(v.name)]
tokens = frappe.get_all("Builder Token", fields=["name", "token_name"])
non_uuid = [t for t in tokens if not UUID_RE.match(t.name)]

if non_uuid:
rename_map, css_rewrite_map = build_maps(non_uuid)
pages_updated = rewrite_doctype_blocks("Builder Page", ["blocks", "draft_blocks"], css_rewrite_map)
components_updated = rewrite_doctype_blocks("Builder Component", ["block"], css_rewrite_map)
renamed = rename_variables(rename_map)
renamed = rename_tokens(rename_map)
print(
f"refactor_builder_variables: renamed={renamed} "
f"pages_updated={pages_updated} components_updated={components_updated}"
Expand All @@ -36,35 +39,35 @@ def execute():
get_css_variables.clear_cache()


def build_maps(variables):
"""Return (rename_map, css_rewrite_map) for the given non-UUID variables."""
def build_maps(tokens):
"""Return (rename_map, css_rewrite_map) for the given non-UUID tokens."""
rename_map = {}
css_rewrite_map = {}

for var in variables:
for token in tokens:
new_id = str(uuid.uuid4())
rename_map[var.name] = new_id
rename_map[token.name] = new_id

# kebab-case of the display label
if var.variable_name:
css_rewrite_map[camel_case_to_kebab_case(var.variable_name, True)] = new_id
if token.token_name:
css_rewrite_map[camel_case_to_kebab_case(token.token_name, True)] = new_id

# snake_case doc names used before this refactor
if "_" in var.name:
css_rewrite_map[var.name.replace("_", "-")] = new_id
if "_" in token.name:
css_rewrite_map[token.name.replace("_", "-")] = new_id

# 10-char hex hashes from an earlier refactor
if re.match(r"^[a-f0-9]{10}$", var.name):
css_rewrite_map[var.name] = new_id
if re.match(r"^[a-f0-9]{10}$", token.name):
css_rewrite_map[token.name] = new_id

return rename_map, css_rewrite_map


def rename_variables(rename_map):
def rename_tokens(rename_map):
renamed = 0
for old_name, new_id in rename_map.items():
try:
frappe.rename_doc("Builder Variable", old_name, new_id, force=True, merge=False)
frappe.rename_doc("Builder Token", old_name, new_id, force=True, merge=False)
renamed += 1
except Exception as e:
frappe.log_error(
Expand All @@ -75,7 +78,7 @@ def rename_variables(rename_map):


def normalise_type_spacing():
frappe.db.sql("UPDATE `tabBuilder Variable` SET type='Dimension' WHERE type='Spacing'")
frappe.db.sql("UPDATE `tabBuilder Token` SET type='Dimension' WHERE type='Spacing'")


def rewrite_doctype_blocks(doctype, fields, css_rewrite_map):
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import frappe
from frappe.model.utils.rename_field import rename_field


def execute():
"""Builder Variable → Builder Token. Only the DocType and the label field are
renamed — token doc names (the CSS `--<id>` handles) are untouched, so every
existing page's var(--id) references keep resolving."""
# guard on the table, not the DocType row: syncing the old model back (a
# downgrade, or migrating on develop) drops the Builder Token DocType but
# leaves tabBuilder Token behind, and rename_doc can't rename onto it
if frappe.db.table_exists("Builder Token"):
merge_stale_builder_variables()
return
if not frappe.db.exists("DocType", "Builder Variable"):
return
frappe.rename_doc("DocType", "Builder Variable", "Builder Token", force=True)
frappe.reload_doc("builder", "doctype", "builder_token")
rename_field("Builder Token", "variable_name", "token_name")


def merge_stale_builder_variables():
"""Both doctypes exist when a site ran the rename and later re-synced the old
model. Keep Builder Token, salvage rows only the old table has, drop the rest."""
if frappe.db.table_exists("Builder Variable"):
frappe.db.sql(
"""INSERT INTO `tabBuilder Token`
(name, creation, modified, modified_by, owner, docstatus,
token_name, type, value, dark_value, is_standard, `group`)
SELECT name, creation, modified, modified_by, owner, docstatus,
variable_name, type, value, dark_value, is_standard, `group`
FROM `tabBuilder Variable` bv
WHERE NOT EXISTS (SELECT 1 FROM `tabBuilder Token` bt WHERE bt.name = bv.name)"""
)
frappe.delete_doc("DocType", "Builder Variable", ignore_missing=True, force=True)
frappe.db.sql_ddl("DROP TABLE IF EXISTS `tabBuilder Variable`")
Loading
Loading