Compare commits

...
Author SHA1 Message Date
Eva Marco c76988798a 🐛 Hide "Create typography style" button for shapes with missing fonts
The button let users convert a text shape's inline styles into a
typography asset even when the shape's font-id couldn't be resolved
(e.g. a custom/team font that was removed or isn't loaded), silently
baking a missing font into the new typography asset.

Guard the button on the font actually resolving via
app.main.fonts/fontsdb, in addition to the existing checks (no
typography or token already applied, single selection).

Added e2e coverage for all four conditions that must independently
hide the button: missing font, applied typography asset, multiple
selection with differing values, and applied typography token.

AI-assisted-by: claude-sonnet-5
2026-09-07 10:31:55 +02:00
3 changed files with 692 additions and 1 deletions

No files matched your search

@@ -0,0 +1,484 @@
{
"~:features": {
"~#set": [
"fdata/path-data",
"plugins/runtime",
"design-tokens/v1",
"variants/v1",
"layout/grid",
"styles/v2",
"fdata/pointer-map",
"fdata/objects-map",
"render-wasm/v1",
"components/v2",
"fdata/shape-data-type"
]
},
"~:team-id": "~u04868522-3ebf-81e8-8006-306b0c9b5f59",
"~:permissions": {
"~:type": "~:membership",
"~:is-owner": true,
"~:is-admin": true,
"~:can-edit": true,
"~:can-read": true,
"~:is-logged": true
},
"~:has-media-trimmed": false,
"~:comment-thread-seqn": 0,
"~:name": "Text: Custom Fonts",
"~:revn": 13,
"~:modified-at": "~m1750151641034",
"~:vern": 0,
"~:id": "~u434b0541-fa2f-802f-8006-6a827d964a9b",
"~:is-shared": false,
"~:migrations": {
"~#ordered-set": [
"legacy-2",
"legacy-3",
"legacy-5",
"legacy-6",
"legacy-7",
"legacy-8",
"legacy-9",
"legacy-10",
"legacy-11",
"legacy-12",
"legacy-13",
"legacy-14",
"legacy-16",
"legacy-17",
"legacy-18",
"legacy-19",
"legacy-25",
"legacy-26",
"legacy-27",
"legacy-28",
"legacy-29",
"legacy-31",
"legacy-32",
"legacy-33",
"legacy-34",
"legacy-36",
"legacy-37",
"legacy-38",
"legacy-39",
"legacy-40",
"legacy-41",
"legacy-42",
"legacy-43",
"legacy-44",
"legacy-45",
"legacy-46",
"legacy-47",
"legacy-48",
"legacy-49",
"legacy-50",
"legacy-51",
"legacy-52",
"legacy-53",
"legacy-54",
"legacy-55",
"legacy-56",
"legacy-57",
"legacy-59",
"legacy-62",
"legacy-65",
"legacy-66",
"legacy-67",
"0001-remove-tokens-from-groups",
"0002-normalize-bool-content",
"0002-clean-shape-interactions",
"0003-fix-root-shape",
"0003-convert-path-content",
"0004-clean-shadow-and-colors",
"0005-deprecate-image-type",
"0006-fix-old-texts-fills",
"0007-clear-invalid-strokes-and-fills-v2",
"0008-fix-library-colors-opacity",
"0009-add-partial-text-touched-flags"
]
},
"~:version": 67,
"~:project-id": "~u53a7ff09-2228-81d3-8006-4b5ea964593b",
"~:created-at": "~m1750081311326",
"~:data": {
"~:pages": ["~u434b0541-fa2f-802f-8006-6a827d964a9c"],
"~:pages-index": {
"~u434b0541-fa2f-802f-8006-6a827d964a9c": {
"~:objects": {
"~u00000000-0000-0000-0000-000000000000": {
"~#shape": {
"~:y": 0,
"~:hide-fill-on-export": false,
"~:transform": {
"~#matrix": {
"~:a": 1.0,
"~:b": 0.0,
"~:c": 0.0,
"~:d": 1.0,
"~:e": 0.0,
"~:f": 0.0
}
},
"~:rotation": 0,
"~:name": "Root Frame",
"~:width": 0.01,
"~:type": "~:frame",
"~:points": [
{
"~#point": {
"~:x": 0.0,
"~:y": 0.0
}
},
{
"~#point": {
"~:x": 0.01,
"~:y": 0.0
}
},
{
"~#point": {
"~:x": 0.01,
"~:y": 0.01
}
},
{
"~#point": {
"~:x": 0.0,
"~:y": 0.01
}
}
],
"~:r2": 0,
"~:proportion-lock": false,
"~:transform-inverse": {
"~#matrix": {
"~:a": 1.0,
"~:b": 0.0,
"~:c": 0.0,
"~:d": 1.0,
"~:e": 0.0,
"~:f": 0.0
}
},
"~:r3": 0,
"~:r1": 0,
"~:id": "~u00000000-0000-0000-0000-000000000000",
"~:parent-id": "~u00000000-0000-0000-0000-000000000000",
"~:frame-id": "~u00000000-0000-0000-0000-000000000000",
"~:strokes": [],
"~:x": 0,
"~:proportion": 1.0,
"~:r4": 0,
"~:selrect": {
"~#rect": {
"~:x": 0,
"~:y": 0,
"~:width": 0.01,
"~:height": 0.01,
"~:x1": 0,
"~:y1": 0,
"~:x2": 0.01,
"~:y2": 0.01
}
},
"~:fills": [
{
"~:fill-color": "#FFFFFF",
"~:fill-opacity": 1
}
],
"~:flip-x": null,
"~:height": 0.01,
"~:flip-y": null,
"~:shapes": [
"~u7d85a63e-18e7-809f-8006-6a827fe8501e",
"~u7d85a63e-18e7-809f-8006-6a833ef5fcef"
]
}
},
"~u7d85a63e-18e7-809f-8006-6a827fe8501e": {
"~#shape": {
"~:y": 451.9999962296588,
"~:transform": {
"~#matrix": {
"~:a": 1.0,
"~:b": 0.0,
"~:c": 0.0,
"~:d": 1.0,
"~:e": 0.0,
"~:f": 0.0
}
},
"~:rotation": 0,
"~:grow-type": "~:auto-width",
"~:content": {
"~:type": "root",
"~:key": "xgmgu1frox",
"~:children": [
{
"~:type": "paragraph-set",
"~:children": [
{
"~:line-height": "1.2",
"~:font-style": "normal",
"~:children": [
{
"~:line-height": "",
"~:font-style": "normal",
"~:typography-ref-id": null,
"~:text-transform": "none",
"~:font-id": "gfont-rufina",
"~:key": "ee7vl7klqs",
"~:font-size": "72",
"~:font-weight": "400",
"~:typography-ref-file": null,
"~:font-variant-id": "normal-400",
"~:text-decoration": "none",
"~:letter-spacing": "0",
"~:fills": [
{
"~:fill-color": "#000000",
"~:fill-opacity": 1
}
],
"~:font-family": "\"Rufina\"",
"~:text": "Text multiple selection one"
}
],
"~:typography-ref-id": null,
"~:text-transform": "none",
"~:text-align": "center",
"~:font-id": "gfont-rufina",
"~:key": "17bt2f4evfs",
"~:font-size": "72",
"~:font-weight": "400",
"~:typography-ref-file": null,
"~:text-direction": "ltr",
"~:type": "paragraph",
"~:font-variant-id": "normal-400",
"~:text-decoration": "none",
"~:letter-spacing": "0",
"~:fills": [
{
"~:fill-color": "#000000",
"~:fill-opacity": 1
}
],
"~:font-family": "\"Rufina\""
}
]
}
],
"~:vertical-align": "top"
},
"~:hide-in-viewer": false,
"~:name": "Text multiple selection one",
"~:width": 403.99995992417394,
"~:type": "~:text",
"~:points": [
{
"~#point": {
"~:x": 744.0000211580308,
"~:y": 451.9999962296588
}
},
{
"~#point": {
"~:x": 1147.9999810822046,
"~:y": 451.9999962296588
}
},
{
"~#point": {
"~:x": 1147.9999810822046,
"~:y": 537.9999971833331
}
},
{
"~#point": {
"~:x": 744.0000211580308,
"~:y": 537.9999971833331
}
}
],
"~:transform-inverse": {
"~#matrix": {
"~:a": 1.0,
"~:b": 0.0,
"~:c": 0.0,
"~:d": 1.0,
"~:e": 0.0,
"~:f": 0.0
}
},
"~:id": "~u7d85a63e-18e7-809f-8006-6a827fe8501e",
"~:parent-id": "~u00000000-0000-0000-0000-000000000000",
"~:frame-id": "~u00000000-0000-0000-0000-000000000000",
"~:x": 744.0000211580307,
"~:selrect": {
"~#rect": {
"~:x": 744.0000211580307,
"~:y": 451.9999962296588,
"~:width": 403.99995992417394,
"~:height": 86.00000095367432,
"~:x1": 744.0000211580307,
"~:y1": 451.9999962296588,
"~:x2": 1147.9999810822046,
"~:y2": 537.9999971833331
}
},
"~:flip-x": null,
"~:height": 86.00000095367432,
"~:flip-y": null
}
},
"~u7d85a63e-18e7-809f-8006-6a833ef5fcef": {
"~#shape": {
"~:y": 537.9999971833331,
"~:transform": {
"~#matrix": {
"~:a": 1.0,
"~:b": 0.0,
"~:c": 0.0,
"~:d": 1.0,
"~:e": 0.0,
"~:f": 0.0
}
},
"~:rotation": 0,
"~:grow-type": "~:auto-width",
"~:content": {
"~:type": "root",
"~:key": "xgmgu1frox",
"~:children": [
{
"~:type": "paragraph-set",
"~:children": [
{
"~:line-height": "1.2",
"~:font-style": "normal",
"~:children": [
{
"~:line-height": "",
"~:font-style": "normal",
"~:typography-ref-id": null,
"~:text-transform": "none",
"~:font-id": "gfont-rufina",
"~:key": "ee7vl7klqs",
"~:font-size": "36",
"~:font-weight": "500",
"~:typography-ref-file": null,
"~:font-variant-id": "normal-500",
"~:text-decoration": "none",
"~:letter-spacing": "0",
"~:fills": [
{
"~:fill-color": "#000000",
"~:fill-opacity": 1
}
],
"~:font-family": "\"Rufina\"",
"~:text": "Second text, same font"
}
],
"~:typography-ref-id": null,
"~:text-transform": "none",
"~:text-align": "center",
"~:font-id": "gfont-rufina",
"~:key": "17bt2f4evfs",
"~:font-size": "0",
"~:font-weight": "500",
"~:typography-ref-file": null,
"~:text-direction": "ltr",
"~:type": "paragraph",
"~:font-variant-id": "normal-500",
"~:text-decoration": "none",
"~:letter-spacing": "0",
"~:fills": [
{
"~:fill-color": "#000000",
"~:fill-opacity": 1
}
],
"~:font-family": "\"Rufina\""
}
]
}
],
"~:vertical-align": "top"
},
"~:hide-in-viewer": false,
"~:name": "Text multiple selection two",
"~:width": 466.0000131576671,
"~:type": "~:text",
"~:points": [
{
"~#point": {
"~:x": 712.9999941849438,
"~:y": 537.9999971833331
}
},
{
"~#point": {
"~:x": 1179.0000073426108,
"~:y": 537.9999971833331
}
},
{
"~#point": {
"~:x": 1179.0000073426108,
"~:y": 580.9999976601703
}
},
{
"~#point": {
"~:x": 712.9999941849438,
"~:y": 580.9999976601703
}
}
],
"~:transform-inverse": {
"~#matrix": {
"~:a": 1.0,
"~:b": 0.0,
"~:c": 0.0,
"~:d": 1.0,
"~:e": 0.0,
"~:f": 0.0
}
},
"~:id": "~u7d85a63e-18e7-809f-8006-6a833ef5fcef",
"~:parent-id": "~u00000000-0000-0000-0000-000000000000",
"~:frame-id": "~u00000000-0000-0000-0000-000000000000",
"~:x": 712.9999941849437,
"~:selrect": {
"~#rect": {
"~:x": 712.9999941849437,
"~:y": 537.9999971833331,
"~:width": 466.0000131576671,
"~:height": 43.00000047683716,
"~:x1": 712.9999941849437,
"~:y1": 537.9999971833331,
"~:x2": 1179.0000073426108,
"~:y2": 580.9999976601703
}
},
"~:flip-x": null,
"~:height": 43.00000047683716,
"~:flip-y": null
}
}
},
"~:id": "~u434b0541-fa2f-802f-8006-6a827d964a9c",
"~:name": "Page 1"
}
},
"~:id": "~u434b0541-fa2f-802f-8006-6a827d964a9b",
"~:options": {
"~:components-v2": true,
"~:base-font-size": "16px"
}
}
}
@@ -0,0 +1,201 @@
import { test, expect } from "@playwright/test";
import { WorkspacePage } from "../pages/WorkspacePage";
import { WasmWorkspacePage } from "../pages/WasmWorkspacePage";
// ---------------------------------------------------------------------------
// The "Create typography style" button (workspace.options.convert-to-typography)
// in the text options sidebar is only shown when ALL of these hold for the
// selected text shape(s) (src/app/main/ui/workspace/sidebar/options/menus/text.cljs):
// (and (some? font) (not typography) (not multiple?) (not applied-token-name))
// Each test below isolates one condition that must independently hide it:
// - font missing (font-id not registered in app.main.fonts/fontsdb)
// - a typography asset is applied (typography-ref-id set)
// - multiple shapes are selected with differing attributes
// - a typography design token is applied (applied-tokens :typography)
// ---------------------------------------------------------------------------
function convertToTypographyButton(workspace) {
return workspace.rightSidebar.getByRole("button", {
name: "Create typography style",
});
}
test.describe("font missing", () => {
// Fixture render-wasm/get-file-text-custom-fonts.json has a text shape
// ("Penpot & Dragons") using a custom team font-id and no typography/token
// applied - otherwise exactly the state that reveals the button once its
// font resolves. Toggling the get-font-variants mock between "the team owns
// this font" and "empty" simulates the font being present vs. missing.
const FILE = {
id: "434b0541-fa2f-802f-8006-59827d964a9b",
pageId: "434b0541-fa2f-802f-8006-59827d964a9c",
};
test.beforeEach(async ({ page }) => {
await WorkspacePage.init(page);
});
test("Create typography style button is hidden when the shape font is missing", async ({
page,
}) => {
const workspace = new WorkspacePage(page);
await workspace.setupEmptyFile();
await workspace.mockRPC(
/get\-file\?/,
"render-wasm/get-file-text-custom-fonts.json",
);
// The team does not own the shape's custom font, so it can't be resolved.
await workspace.mockRPC(
"get-font-variants?team-id=*",
"workspace/get-font-variants-empty.json",
);
await workspace.goToWorkspace({ fileId: FILE.id, pageId: FILE.pageId });
await workspace.clickLeafLayer("Penpot & Dragons");
await expect(convertToTypographyButton(workspace)).not.toBeVisible();
});
test("Create typography style button is visible once the shape font resolves", async ({
page,
}) => {
const workspace = new WorkspacePage(page);
await workspace.setupEmptyFile();
await workspace.mockRPC(
/get\-file\?/,
"render-wasm/get-file-text-custom-fonts.json",
);
// The team owns the shape's custom font, so it resolves normally.
await workspace.mockRPC(
"get-font-variants?team-id=*",
"render-wasm/get-font-variants-custom-fonts.json",
);
await workspace.goToWorkspace({ fileId: FILE.id, pageId: FILE.pageId });
await workspace.clickLeafLayer("Penpot & Dragons");
await expect(convertToTypographyButton(workspace)).toBeVisible();
});
});
test.describe("typography asset applied", () => {
// multiselection-typography.json: "Text with typography asset one" has a
// typography-ref-id pointing at an in-file typography asset (font
// gfont-agdasima, a built-in Google font that resolves with no extra
// mocking), and is not multi-selected or token-applied.
const FILE = {
id: "1062e0a0-8fe0-80ae-8007-e70b4993f5ef",
pageId: "1062e0a0-8fe0-80ae-8007-e70b4993f5f0",
};
test.beforeEach(async ({ page }) => {
await WorkspacePage.init(page);
});
test("Create typography style button is hidden when a typography asset is applied", async ({
page,
}) => {
const workspace = new WorkspacePage(page);
await workspace.setupEmptyFile();
await workspace.mockRPC(
/get\-file\?/,
"workspace/multiselection-typography.json",
);
await workspace.goToWorkspace({ fileId: FILE.id, pageId: FILE.pageId });
await workspace.clickLeafLayer("Text with typography asset one");
// Sanity check: the text options panel did render for this shape - the
// button is specifically hidden by the applied typography, not because
// the whole panel failed to show up.
await expect(
workspace.rightSidebar.getByRole("region", { name: "Text section" }),
).toBeVisible();
await expect(convertToTypographyButton(workspace)).not.toBeVisible();
});
});
test.describe("multiple selection", () => {
// get-file-text-multiple-selection.json has two text shapes sharing the
// same (resolvable, built-in) font-id but differing font-size, with no
// typography or token applied - so selecting both together isolates
// `multiple?` becoming true without also making the font unresolved.
const FILE = {
id: "434b0541-fa2f-802f-8006-6a827d964a9b",
pageId: "434b0541-fa2f-802f-8006-6a827d964a9c",
};
test.beforeEach(async ({ page }) => {
await WorkspacePage.init(page);
});
test("Create typography style button is hidden when multiple shapes with different values are selected", async ({
page,
}) => {
const workspace = new WorkspacePage(page);
await workspace.setupEmptyFile();
await workspace.mockRPC(
/get\-file\?/,
"workspace/get-file-text-multiple-selection.json",
);
await workspace.goToWorkspace({ fileId: FILE.id, pageId: FILE.pageId });
await workspace.clickLeafLayer("Text multiple selection one");
await expect(convertToTypographyButton(workspace)).toBeVisible();
await workspace.clickLeafLayer("Text multiple selection two", {
modifiers: ["Shift"],
});
await expect(convertToTypographyButton(workspace)).not.toBeVisible();
});
});
test.describe("typography token applied", () => {
// get-file-token-tooltip.json: "Text with token" has a typography design
// token applied (applied-tokens :typography) using font gfont-arizonia (a
// built-in Google font that resolves with no extra mocking).
test.beforeEach(async ({ page }) => {
await WasmWorkspacePage.init(page);
await WasmWorkspacePage.mockRPC(page, "get-teams", "get-teams-tokens.json");
});
test("Create typography style button is hidden when a typography token is applied", async ({
page,
}) => {
const workspace = new WasmWorkspacePage(page);
await workspace.mockConfigFlags(["enable-feature-token-input"]);
await workspace.setupEmptyFile();
await workspace.mockRPC("get-team?id=*", "workspace/get-team-tokens.json");
await workspace.mockRPC(
/get\-file\?/,
"workspace/get-file-token-tooltip.json",
);
await workspace.mockRPC(
/get\-file\-fragment\?/,
"workspace/get-file-fragment-tokens.json",
);
await workspace.mockRPC(
"update-file?id=*",
"workspace/update-file-create-rect.json",
);
await workspace.goToWorkspace({
fileId: "c7ce0794-0992-8105-8004-38f280443849",
pageId: "4530574a-7a0a-807b-8008-0107b2c4628e",
});
await page.getByRole("tab", { name: "Layers" }).click();
await workspace.layers
.getByTestId("layer-row")
.filter({ hasText: "Text with token" })
.click();
// Sanity check: the text options panel did render for this shape - the
// button is specifically hidden by the applied token, not because the
// whole panel failed to show up.
await expect(
workspace.rightSidebar.getByRole("region", { name: "Text section" }),
).toBeVisible();
await expect(convertToTypographyButton(workspace)).not.toBeVisible();
});
});
@@ -20,6 +20,7 @@
[app.main.data.workspace.undo :as dwu]
[app.main.data.workspace.wasm-text :as dwwt]
[app.main.features :as features]
[app.main.fonts :as fonts]
[app.main.refs :as refs]
[app.main.store :as st]
[app.main.ui.components.title-bar :refer [title-bar*]]
@@ -307,6 +308,11 @@
main-menu-open? (:main-menu menu-state)
more-options-open? (:more-options menu-state)
font-id (or (:font-id values) (:font-id txt/default-typography))
fonts (mf/deref fonts/fontsdb)
font (get fonts font-id)
token-dropdown-open* (mf/use-state false)
token-dropdown-open? (deref token-dropdown-open*)
@@ -512,7 +518,7 @@
:on-click toggle-token-dropdown
:tooltip-placement "top-left"
:icon i/tokens}])
(when (and (not typography) (not multiple?) (not applied-token-name))
(when (and (some? font) (not typography) (not multiple?) (not applied-token-name))
[:> icon-button* {:variant "ghost"
:aria-label (tr "workspace.options.convert-to-typography")
:on-click on-convert-to-typography